chore: 初始化项目仓库
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
useSeoMeta({ title: '404 页面不存在' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[70vh] flex-col items-center justify-center gap-4 px-4 anim-fade-in-up">
|
||||
<h1 class="text-6xl font-bold" style="color: var(--color-text-secondary)">404</h1>
|
||||
<p style="color: var(--color-text-secondary)">页面不存在</p>
|
||||
<NuxtLink to="/" class="btn-pill btn-primary mt-2">返回首页</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { siteConfig } from '~/config/site'
|
||||
|
||||
useSeoMeta({
|
||||
title: `关于 - ${siteConfig.siteName}`,
|
||||
description: `关于 ${siteConfig.siteName} 的介绍`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto max-w-3xl px-4 py-12">
|
||||
|
||||
<div class="mb-12 text-center anim-fade-in-up">
|
||||
<h1 class="mb-4 text-4xl font-bold">关于</h1>
|
||||
<p class="text-lg text-muted-foreground">关于这个站点和我</p>
|
||||
</div>
|
||||
|
||||
<!-- 站点介绍 -->
|
||||
<div class="mb-8 anim-fade-in-up anim-delay-1">
|
||||
<div class="rounded-xl border p-6" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
|
||||
<h2 class="mb-4 text-xl font-semibold">关于本站</h2>
|
||||
<div class="prose">
|
||||
<p>欢迎来到 <strong>{{ siteConfig.siteName }}</strong>!这是一个专注于技术分享与实践的个人博客。</p>
|
||||
<p>在这里,我会记录一些技术学习笔记、踩坑经验和项目实践,希望能帮到同样在学习路上的你。</p>
|
||||
<p>本站基于 <a href="https://nuxt.com" target="_blank" rel="noopener noreferrer">Nuxt 3</a> 构建,使用 <a href="https://tailwindcss.com" target="_blank" rel="noopener noreferrer">Tailwind CSS</a> 作为样式方案,UI 参考了 <a href="https://2x.nz" target="_blank" rel="noopener noreferrer">二叉树树</a> 的设计,感谢大佬的开源精神!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 技术栈 -->
|
||||
<div class="mb-8 anim-fade-in-up anim-delay-2">
|
||||
<div class="rounded-xl border p-6" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
|
||||
<h2 class="mb-4 text-xl font-semibold">技术栈</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div v-for="tech in [
|
||||
{ name: 'Nuxt 3', icon: 'simple-icons:nuxtdotjs', color: '#00DC82' },
|
||||
{ name: 'Vue 3', icon: 'simple-icons:vuedotjs', color: '#4FC08D' },
|
||||
{ name: 'TypeScript', icon: 'simple-icons:typescript', color: '#3178C6' },
|
||||
{ name: 'Tailwind CSS', icon: 'simple-icons:tailwindcss', color: '#06B6D4' },
|
||||
{ name: 'Nuxt Content', icon: 'mdi:file-document-outline', color: '#00DC82' },
|
||||
{ name: 'Giscus', icon: 'simple-icons:github', color: '#333' },
|
||||
{ name: 'Golang', icon: 'simple-icons:go', color: '#00ADD8' },
|
||||
{ name: 'Erlang', icon: 'simple-icons:erlang', color: '#A90533' },
|
||||
]" :key="tech.name"
|
||||
class="flex items-center gap-2 rounded-lg p-3 transition-colors"
|
||||
style="background: var(--color-bg-hover);"
|
||||
>
|
||||
<img
|
||||
:src="`https://api.iconify.design/${tech.icon}.svg?color=${encodeURIComponent(tech.color)}`"
|
||||
:alt="tech.name"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
<span class="text-sm font-medium">{{ tech.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<div class="mb-8 anim-fade-in-up anim-delay-3">
|
||||
<div class="rounded-xl border p-6" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
|
||||
<h2 class="mb-4 text-xl font-semibold">联系我</h2>
|
||||
<p class="mb-4 text-sm text-muted-foreground">如果你有任何问题或建议,欢迎通过以下方式联系我:</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a
|
||||
v-for="link in siteConfig.socialLinks"
|
||||
:key="link.name"
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-pill"
|
||||
>
|
||||
<img
|
||||
:src="`https://api.iconify.design/${link.icon}.svg?color=${encodeURIComponent(link.color || 'currentColor')}`"
|
||||
:alt="link.name"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
<span>{{ link.name }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 免责声明 -->
|
||||
<div class="anim-fade-in-up anim-delay-4">
|
||||
<div class="rounded-xl border p-6" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
|
||||
<h2 class="mb-4 text-xl font-semibold">免责声明</h2>
|
||||
<div class="prose">
|
||||
<p>本站所有文章仅代表个人观点,与任何组织或公司无关。</p>
|
||||
<p>本站部分内容来源于网络,如有侵权请联系删除。</p>
|
||||
<p>本站提供的资源仅供参考学习,请于下载后 24 小时内删除。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
+807
@@ -0,0 +1,807 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { siteConfig } from '~/config/site'
|
||||
|
||||
useHead({ title: `封面制作 - ${siteConfig.siteName}` })
|
||||
|
||||
// 文字
|
||||
const leftText = ref('标题')
|
||||
const rightText = ref('副标题')
|
||||
const fontSize = ref(80)
|
||||
const fontWeight = ref(700)
|
||||
const gap = ref(20)
|
||||
|
||||
// 图标
|
||||
const showIcon = ref(true)
|
||||
const iconName = ref('mdi:star')
|
||||
const iconSvg = ref('')
|
||||
const localIcon = ref<string | null>(null)
|
||||
const iconSize = ref(64)
|
||||
const iconColor = ref('#000000')
|
||||
const useOriginalIconColor = ref(false)
|
||||
const iconRadius = ref(0)
|
||||
const iconPosition = ref<'before' | 'middle' | 'after'>('before')
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<string[]>([])
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 背景
|
||||
const bgImage = ref<string | null>(null)
|
||||
const bgColor = ref('#ffffff')
|
||||
const bgColorOpacity = ref(1)
|
||||
const bgBlur = ref(0)
|
||||
const bgOpacity = ref(1)
|
||||
const isBgDragOver = ref(false)
|
||||
|
||||
// 颜色
|
||||
const textColor = ref('#000000')
|
||||
const linkColor = ref(true)
|
||||
|
||||
// 阴影
|
||||
const textShadow = reactive({ x: 0, y: 0, blur: 0, color: '#000000', alpha: 0 })
|
||||
const iconShadow = reactive({ x: 0, y: 0, blur: 0, color: '#000000', alpha: 0 })
|
||||
|
||||
// 图标背景
|
||||
const iconBgEnabled = ref(false)
|
||||
const iconBgColor = ref('#000000')
|
||||
const iconBgOpacity = ref(0.2)
|
||||
const iconBgPadding = ref(10)
|
||||
const iconBgRadius = ref(20)
|
||||
const iconBgBlur = ref(0)
|
||||
|
||||
// 画布
|
||||
const canvasWidth = ref(1067)
|
||||
const canvasHeight = ref(600)
|
||||
const ratios = reactive([
|
||||
{ label: '16:9', w: 16, h: 9, checked: true },
|
||||
{ label: '4:3', w: 4, h: 3, checked: false },
|
||||
{ label: '1:1', w: 1, h: 1, checked: false },
|
||||
{ label: '21:9', w: 21, h: 9, checked: false },
|
||||
])
|
||||
const linkScale = ref(true)
|
||||
|
||||
// 导出
|
||||
const exportFormat = ref<'png' | 'svg'>('png')
|
||||
const exportScales = ref([1])
|
||||
const exportFilename = ref('cover')
|
||||
const exportTransparentBg = ref(false)
|
||||
|
||||
// 背景图拖拽
|
||||
const bgX = ref(0)
|
||||
const bgY = ref(0)
|
||||
const bgScale = ref(1)
|
||||
const isDragging = ref(false)
|
||||
let dragStartX = 0
|
||||
let dragStartY = 0
|
||||
let dragStartBgX = 0
|
||||
let dragStartBgY = 0
|
||||
|
||||
const svgRef = ref<SVGSVGElement | null>(null)
|
||||
|
||||
// 计算
|
||||
const activeRatios = computed(() => ratios.filter(r => r.checked))
|
||||
const maxRatio = computed(() => activeRatios.value.reduce((max, r) => Math.max(max, r.w / r.h), 0))
|
||||
const computedWidth = computed(() => Math.round(canvasHeight.value * maxRatio.value))
|
||||
|
||||
// 颜色转换
|
||||
function hexToRgba(hex: string, alpha: number) {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
// 图标搜索
|
||||
function onSearchInput(e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
searchQuery.value = val
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
if (!val.trim()) { searchResults.value = []; return }
|
||||
searchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`https://api.iconify.design/search?query=${encodeURIComponent(val)}&limit=20`)
|
||||
const data = await res.json()
|
||||
searchResults.value = data.icons || []
|
||||
} catch { searchResults.value = [] }
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function selectIcon(name: string) {
|
||||
iconName.value = name
|
||||
localIcon.value = null
|
||||
}
|
||||
|
||||
// 加载图标 SVG
|
||||
watch(iconName, async (name) => {
|
||||
if (!name || !name.includes(':') || localIcon.value) { iconSvg.value = ''; return }
|
||||
try {
|
||||
const res = await fetch(`https://api.iconify.design/${name.split(':')[0]}/${name.split(':')[1]}.svg`)
|
||||
if (!res.ok) throw Error()
|
||||
let svg = await res.text()
|
||||
svg = svg
|
||||
.replace(/\s(width|height)="[^"]*"/g, '')
|
||||
.replace(/<svg\b([^>]*)>/, '<svg$1 width="100%" height="100%" preserveAspectRatio="xMidYMid meet">')
|
||||
.replace(/currentColor/gi, iconColor.value)
|
||||
iconSvg.value = svg
|
||||
} catch { iconSvg.value = '' }
|
||||
}, { immediate: true })
|
||||
|
||||
// 颜色同步
|
||||
watch(linkColor, (v) => { if (v) iconColor.value = textColor.value })
|
||||
watch(textColor, (v) => { if (linkColor.value) iconColor.value = v })
|
||||
|
||||
// 本地图标上传
|
||||
function onLocalIconUpload(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
localIcon.value = ev.target?.result as string
|
||||
iconName.value = '本地图片'
|
||||
iconSvg.value = ''
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
// 背景图上传
|
||||
function onBgUpload(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file || !file.type.startsWith('image/')) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
bgImage.value = ev.target?.result as string
|
||||
bgX.value = 0; bgY.value = 0; bgScale.value = 1
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function onBgDragOver(e: DragEvent) { e.preventDefault(); isBgDragOver.value = true }
|
||||
function onBgDragLeave() { isBgDragOver.value = false }
|
||||
function onBgDrop(e: DragEvent) {
|
||||
e.preventDefault(); isBgDragOver.value = false
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file) onBgUpload({ target: { files: [file] } } as any)
|
||||
}
|
||||
|
||||
// 画布拖拽
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!bgImage.value) return
|
||||
e.preventDefault()
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
isDragging.value = true
|
||||
dragStartX = e.clientX; dragStartY = e.clientY
|
||||
dragStartBgX = bgX.value; dragStartBgY = bgY.value
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isDragging.value) return
|
||||
bgX.value = dragStartBgX + (e.clientX - dragStartX) / bgScale.value
|
||||
bgY.value = dragStartBgY + (e.clientY - dragStartY) / bgScale.value
|
||||
}
|
||||
|
||||
function onPointerUp() { isDragging.value = false }
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (!bgImage.value) return
|
||||
e.preventDefault()
|
||||
const factor = 1.1
|
||||
bgScale.value = e.deltaY < 0
|
||||
? Math.min(bgScale.value * factor, 10)
|
||||
: Math.max(bgScale.value / factor, 0.1)
|
||||
}
|
||||
|
||||
// 比例联动
|
||||
function updateWidth() {
|
||||
if (linkScale.value) canvasWidth.value = computedWidth.value
|
||||
}
|
||||
|
||||
watch(() => ratios.map(r => r.checked), updateWidth, { deep: true })
|
||||
watch(maxRatio, updateWidth)
|
||||
|
||||
// 导出
|
||||
async function onExport() {
|
||||
const svgEl = svgRef.value
|
||||
if (!svgEl) return
|
||||
|
||||
// 隐藏辅助线
|
||||
const guides = svgEl.querySelectorAll('.ratio-guide')
|
||||
guides.forEach(g => (g as SVGElement).style.display = 'none')
|
||||
const border = svgEl.querySelector('.canvas-border') as SVGElement | null
|
||||
if (border) border.style.display = 'none'
|
||||
|
||||
const clone = svgEl.cloneNode(true) as SVGSVGElement
|
||||
|
||||
// 恢复
|
||||
guides.forEach(g => (g as SVGElement).style.display = '')
|
||||
if (border) border.style.display = ''
|
||||
|
||||
const exportRatios = activeRatios.value.length > 0 ? activeRatios.value : [ratios[0]]
|
||||
|
||||
for (const ratio of exportRatios) {
|
||||
const w = Math.round(canvasHeight.value * (ratio.w / ratio.h))
|
||||
const offsetX = (computedWidth.value - w) / 2
|
||||
|
||||
const c = clone.cloneNode(true) as SVGSVGElement
|
||||
c.setAttribute('width', w.toString())
|
||||
c.setAttribute('height', canvasHeight.value.toString())
|
||||
c.setAttribute('viewBox', `${offsetX} 0 ${w} ${canvasHeight.value}`)
|
||||
|
||||
const svgStr = new XMLSerializer().serializeToString(c)
|
||||
const fname = exportRatios.length > 1
|
||||
? `${exportFilename.value}-${ratio.label.replace(':', '-')}`
|
||||
: exportFilename.value
|
||||
|
||||
if (exportFormat.value === 'svg') {
|
||||
downloadBlob(new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' }), `${fname}.svg`)
|
||||
} else {
|
||||
const img = new Image()
|
||||
img.src = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgStr)))}`
|
||||
await new Promise<void>(r => { img.onload = () => r() })
|
||||
|
||||
const scales = exportScales.value.length > 0 ? exportScales.value : [1]
|
||||
for (const s of scales) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = w * s; canvas.height = canvasHeight.value * s
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.imageSmoothingEnabled = true
|
||||
ctx.imageSmoothingQuality = 'high'
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||
const suffix = scales.length > 1 ? `@${s}x` : ''
|
||||
downloadDataURL(canvas.toDataURL('image/png'), `${fname}${suffix}.png`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, name: string) {
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = name
|
||||
document.body.appendChild(a); a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
function downloadDataURL(url: string, name: string) {
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = name
|
||||
document.body.appendChild(a); a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
// 移动端 tab
|
||||
const activeTab = ref<'content' | 'style' | 'export'>('content')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto max-w-[1920px] px-4 py-8">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold mb-2">封面制作</h1>
|
||||
<p class="text-muted-foreground">在线生成精美的封面图片</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6 w-full">
|
||||
<!-- 画布 -->
|
||||
<div class="flex-1 lg:max-w-[55%]">
|
||||
<div class="lg:sticky lg:top-20">
|
||||
<div
|
||||
class="w-full overflow-hidden flex justify-center rounded-xl select-none touch-none"
|
||||
style="background-color: var(--color-bg-card); padding: 1rem;"
|
||||
role="button" tabindex="0"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
@pointerleave="onPointerUp"
|
||||
@wheel="onWheel"
|
||||
>
|
||||
<svg
|
||||
ref="svgRef"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
:width="computedWidth"
|
||||
:height="canvasHeight"
|
||||
:viewBox="`0 0 ${computedWidth} ${canvasHeight}`"
|
||||
style="max-width: 100%; height: auto;"
|
||||
:style="{ cursor: bgImage ? (isDragging ? 'grabbing' : 'grab') : 'default' }"
|
||||
>
|
||||
<!-- 棋盘格 -->
|
||||
<defs>
|
||||
<pattern id="checkerboard" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<rect width="10" height="10" fill="#e0e0e0"/>
|
||||
<rect x="10" y="0" width="10" height="10" fill="#ffffff"/>
|
||||
<rect x="0" y="10" width="10" height="10" fill="#ffffff"/>
|
||||
<rect x="10" y="10" width="10" height="10" fill="#e0e0e0"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#checkerboard)"/>
|
||||
|
||||
<!-- 背景色 -->
|
||||
<rect
|
||||
width="100%" height="100%"
|
||||
:fill="hexToRgba(bgColor, bgColorOpacity)"
|
||||
/>
|
||||
|
||||
<!-- 背景图 -->
|
||||
<image
|
||||
v-if="bgImage"
|
||||
:href="bgImage"
|
||||
:x="bgX" :y="bgY"
|
||||
:width="computedWidth" :height="canvasHeight"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
:transform="`scale(${bgScale})`"
|
||||
:style="`transform-origin: 50% 50%; filter: blur(${bgBlur}px); opacity: ${bgOpacity};`"
|
||||
/>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<foreignObject x="0" y="0" width="100%" height="100%" style="pointer-events: none;">
|
||||
<div
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
:style="`
|
||||
width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: ${gap}px;
|
||||
font-family: sans-serif;
|
||||
font-weight: ${fontWeight};
|
||||
`"
|
||||
>
|
||||
<!-- 图标 -->
|
||||
<div
|
||||
v-if="showIcon && (iconSvg || localIcon)"
|
||||
:style="`
|
||||
order: ${iconPosition === 'before' ? 0 : iconPosition === 'middle' ? 1 : 2};
|
||||
width: ${iconSize + iconBgPadding * 2}px;
|
||||
height: ${iconSize + iconBgPadding * 2}px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background-color: ${iconBgEnabled ? hexToRgba(iconBgColor, iconBgOpacity) : 'transparent'};
|
||||
backdrop-filter: ${iconBgEnabled && iconBgBlur > 0 ? `blur(${iconBgBlur}px)` : 'none'};
|
||||
border-radius: ${iconBgEnabled ? iconBgRadius + '%' : '0'};
|
||||
`"
|
||||
>
|
||||
<img
|
||||
v-if="localIcon"
|
||||
:src="localIcon"
|
||||
alt="Icon"
|
||||
:style="`
|
||||
max-width: ${iconSize}px; max-height: ${iconSize}px;
|
||||
flex-shrink: 0; border-radius: ${iconRadius}%;
|
||||
overflow: hidden;
|
||||
filter: drop-shadow(${iconShadow.x}px ${iconShadow.y}px ${iconShadow.blur}px ${hexToRgba(iconShadow.color, iconShadow.alpha)});
|
||||
`"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
v-html="iconSvg"
|
||||
:style="`
|
||||
max-width: ${iconSize}px; max-height: ${iconSize}px;
|
||||
flex-shrink: 0;
|
||||
color: ${useOriginalIconColor ? 'inherit' : iconColor};
|
||||
filter: drop-shadow(${iconShadow.x}px ${iconShadow.y}px ${iconShadow.blur}px ${hexToRgba(iconShadow.color, iconShadow.alpha)});
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: ${iconRadius}%; overflow: hidden;
|
||||
`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 左侧文字 -->
|
||||
<span
|
||||
:style="`
|
||||
order: ${iconPosition === 'before' ? 1 : 0};
|
||||
font-size: ${fontSize}px; color: ${textColor};
|
||||
text-shadow: ${textShadow.x}px ${textShadow.y}px ${textShadow.blur}px ${hexToRgba(textShadow.color, textShadow.alpha)};
|
||||
line-height: 1; white-space: nowrap;
|
||||
`"
|
||||
>{{ leftText }}</span>
|
||||
|
||||
<!-- 右侧文字 -->
|
||||
<span
|
||||
:style="`
|
||||
order: ${iconPosition === 'after' ? 1 : 2};
|
||||
font-size: ${fontSize}px; color: ${textColor};
|
||||
text-shadow: ${textShadow.x}px ${textShadow.y}px ${textShadow.blur}px ${hexToRgba(textShadow.color, textShadow.alpha)};
|
||||
line-height: 1; white-space: nowrap;
|
||||
`"
|
||||
>{{ rightText }}</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
|
||||
<!-- 比例参考线 -->
|
||||
<g
|
||||
v-for="r in activeRatios" :key="r.label"
|
||||
class="ratio-guide"
|
||||
>
|
||||
<rect
|
||||
v-if="canvasHeight * (r.w / r.h) < computedWidth"
|
||||
:x="(computedWidth - canvasHeight * (r.w / r.h)) / 2"
|
||||
y="0"
|
||||
:width="canvasHeight * (r.w / r.h)"
|
||||
:height="canvasHeight"
|
||||
fill="none" stroke="rgba(255,0,0,0.5)" stroke-width="2" stroke-dasharray="10 5"
|
||||
/>
|
||||
<text
|
||||
v-if="canvasHeight * (r.w / r.h) < computedWidth"
|
||||
:x="(computedWidth - canvasHeight * (r.w / r.h)) / 2 + 10"
|
||||
y="30"
|
||||
fill="rgba(255,0,0,0.5)" font-size="20"
|
||||
>{{ r.label }}</text>
|
||||
</g>
|
||||
|
||||
<!-- 画布边框 -->
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="none" stroke="rgba(255,0,0,0.8)" stroke-width="2" class="canvas-border"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制面板 -->
|
||||
<div class="w-full lg:flex-1">
|
||||
<!-- 移动端 tab -->
|
||||
<div class="lg:hidden mb-4">
|
||||
<div class="flex gap-1 border rounded-lg p-1">
|
||||
<button
|
||||
v-for="tab in (['content', 'style', 'export'] as const)"
|
||||
:key="tab"
|
||||
class="flex-1 px-3 py-1.5 rounded text-sm font-medium transition-colors"
|
||||
:class="activeTab === tab ? '' : 'text-muted-foreground'"
|
||||
:style="activeTab === tab ? 'background: var(--color-primary); color: white;' : ''"
|
||||
@click="activeTab = tab"
|
||||
>
|
||||
{{ { content: '内容', style: '样式', export: '导出' }[tab] }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端三列 -->
|
||||
<div class="hidden lg:grid lg:grid-cols-3 gap-6">
|
||||
<!-- 内容列 -->
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-lg font-semibold mb-4">内容</h2>
|
||||
|
||||
<!-- 文本设置 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium">文本设置</h3>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">左侧文字</label>
|
||||
<input v-model="leftText" class="w-full h-9 rounded-lg border bg-transparent px-3 text-sm mt-1" style="border-color: var(--color-border);" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">右侧文字</label>
|
||||
<input v-model="rightText" class="w-full h-9 rounded-lg border bg-transparent px-3 text-sm mt-1" style="border-color: var(--color-border);" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">字体粗细: {{ fontWeight }}</label>
|
||||
<input type="range" v-model.number="fontWeight" min="100" max="900" step="100" class="w-full mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图标设置 -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-medium">图标设置</h3>
|
||||
<label class="flex items-center gap-2 cursor-pointer text-sm">
|
||||
<input type="checkbox" v-model="showIcon" />
|
||||
<span>显示图标</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<input type="file" accept="image/*" class="hidden" id="icon-upload" @change="onLocalIconUpload" />
|
||||
<label for="icon-upload" class="flex items-center justify-center h-10 border-2 border-dashed rounded-lg cursor-pointer hover:border-primary text-sm" style="border-color: var(--color-border);">
|
||||
{{ localIcon ? '更换图片' : '上传图标' }}
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
:value="searchQuery"
|
||||
@input="onSearchInput"
|
||||
placeholder="搜索图标库..."
|
||||
class="w-full h-10 rounded-lg border bg-transparent px-3 text-sm"
|
||||
style="border-color: var(--color-border);"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="searchResults.length > 0" class="max-h-48 overflow-y-auto border rounded-lg p-2" style="border-color: var(--color-border);">
|
||||
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fit, minmax(48px, 1fr));">
|
||||
<button
|
||||
v-for="icon in searchResults" :key="icon"
|
||||
class="aspect-square flex items-center justify-center p-1.5 rounded-md border hover:bg-accent transition-colors"
|
||||
:class="icon === iconName ? 'border-primary' : ''"
|
||||
:style="{ borderColor: icon === iconName ? 'var(--color-primary)' : 'var(--color-border)' }"
|
||||
:title="icon"
|
||||
@click="selectIcon(icon)"
|
||||
>
|
||||
<img :src="`https://api.iconify.design/${icon.split(':')[0]}/${icon.split(':')[1]}.svg`" class="w-full h-full" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
当前: {{ iconName }}
|
||||
<a href="https://icon-sets.iconify.design/" target="_blank" rel="noopener noreferrer" style="border-bottom: 1px solid var(--color-text-secondary); padding-bottom: 1px;" class="hover:opacity-70">浏览图标库</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 背景图片 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium">背景图片</h3>
|
||||
<div>
|
||||
<input type="file" accept="image/*" class="hidden" id="bg-upload" @change="onBgUpload" />
|
||||
<label
|
||||
for="bg-upload"
|
||||
class="flex items-center justify-center w-full h-24 border-2 border-dashed rounded-lg cursor-pointer hover:border-primary transition-colors"
|
||||
:class="isBgDragOver ? 'border-primary' : ''"
|
||||
:style="{ borderColor: isBgDragOver ? 'var(--color-primary)' : 'var(--color-border)' }"
|
||||
@dragover="onBgDragOver"
|
||||
@dragleave="onBgDragLeave"
|
||||
@drop="onBgDrop"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-1 text-muted-foreground">
|
||||
<span class="text-xs">{{ isBgDragOver ? '松开上传' : bgImage ? '点击或拖拽更换' : '点击或拖拽上传' }}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="bgImage" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">模糊: {{ bgBlur }}px</span>
|
||||
<button class="text-red-500 text-sm" @click="bgImage = null">删除</button>
|
||||
</div>
|
||||
<input type="range" v-model.number="bgBlur" min="0" max="20" class="w-full" />
|
||||
<span class="text-sm text-muted-foreground">不透明度: {{ Math.round(bgOpacity * 100) }}%</span>
|
||||
<input type="range" v-model.number="bgOpacity" min="0" max="1" step="0.01" class="w-full" />
|
||||
<p class="text-xs text-muted-foreground">提示: 拖拽移动位置,滚轮缩放大小</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 样式列 -->
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-lg font-semibold mb-4">样式</h2>
|
||||
|
||||
<!-- 尺寸设置 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium">尺寸设置</h3>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">字体大小: {{ fontSize }}px</label>
|
||||
<input type="range" v-model.number="fontSize" min="20" max="700" class="w-full mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">图标大小: {{ iconSize }}px</label>
|
||||
<input type="range" v-model.number="iconSize" min="20" max="700" class="w-full mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">图标圆角: {{ iconRadius }}%</label>
|
||||
<input type="range" v-model.number="iconRadius" min="0" max="50" class="w-full mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">图标位置</label>
|
||||
<div class="flex gap-1 border rounded-lg p-1 mt-1" style="border-color: var(--color-border);">
|
||||
<button
|
||||
v-for="pos in ([{ v: 'before', l: '前面' }, { v: 'middle', l: '中间' }, { v: 'after', l: '后面' }] as const)"
|
||||
:key="pos.v"
|
||||
class="flex-1 px-2 py-1 rounded text-sm font-medium transition-colors"
|
||||
:style="iconPosition === pos.v ? 'background: var(--color-primary); color: white;' : ''"
|
||||
@click="iconPosition = pos.v"
|
||||
>{{ pos.l }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">间距: {{ gap }}px</label>
|
||||
<input type="range" v-model.number="gap" min="0" max="200" class="w-full mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 颜色设置 -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-medium">颜色设置</h3>
|
||||
<label class="flex items-center gap-2 cursor-pointer text-sm">
|
||||
<input type="checkbox" v-model="linkColor" />
|
||||
<span>颜色同步</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">文字颜色</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="textColor" class="w-24 h-8 text-xs rounded border bg-transparent px-2" style="border-color: var(--color-border);" />
|
||||
<input type="color" v-model="textColor" class="w-8 h-8 rounded cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">图标颜色</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="iconColor" :disabled="useOriginalIconColor" class="w-24 h-8 text-xs rounded border bg-transparent px-2" style="border-color: var(--color-border);" />
|
||||
<input type="color" v-model="iconColor" :disabled="useOriginalIconColor" class="w-8 h-8 rounded cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">背景颜色</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="bgColor" class="w-24 h-8 text-xs rounded border bg-transparent px-2" style="border-color: var(--color-border);" />
|
||||
<input type="color" v-model="bgColor" class="w-8 h-8 rounded cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">背景不透明度: {{ Math.round(bgColorOpacity * 100) }}%</label>
|
||||
<input type="range" v-model.number="bgColorOpacity" min="0" max="1" step="0.01" class="w-full mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图标背景 -->
|
||||
<div class="space-y-4 pt-4" style="border-top: 1px solid var(--color-border);">
|
||||
<h3 class="font-medium">图标背景</h3>
|
||||
<label class="flex items-center justify-between cursor-pointer text-sm">
|
||||
<span>启用图标背景</span>
|
||||
<input type="checkbox" v-model="iconBgEnabled" />
|
||||
</label>
|
||||
<div v-if="iconBgEnabled" class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">背景颜色</span>
|
||||
<input type="color" v-model="iconBgColor" class="w-6 h-6 rounded cursor-pointer" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">不透明度: {{ Math.round(iconBgOpacity * 100) }}%</label>
|
||||
<input type="range" v-model.number="iconBgOpacity" min="0" max="1" step="0.01" class="w-full mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">内边距: {{ iconBgPadding }}px</label>
|
||||
<input type="range" v-model.number="iconBgPadding" min="0" max="100" class="w-full mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">圆角: {{ iconBgRadius }}%</label>
|
||||
<input type="range" v-model.number="iconBgRadius" min="0" max="50" class="w-full mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 阴影设置 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium">阴影设置</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">颜色</span>
|
||||
<input type="color" v-model="textShadow.color" class="w-8 h-8 rounded cursor-pointer" />
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-muted-foreground">模糊</label>
|
||||
<input type="number" v-model.number="textShadow.blur" class="w-full h-8 rounded border bg-transparent px-2 text-sm mt-1" style="border-color: var(--color-border);" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-muted-foreground">水平</label>
|
||||
<input type="number" v-model.number="textShadow.x" class="w-full h-8 rounded border bg-transparent px-2 text-sm mt-1" style="border-color: var(--color-border);" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-muted-foreground">垂直</label>
|
||||
<input type="number" v-model.number="textShadow.y" class="w-full h-8 rounded border bg-transparent px-2 text-sm mt-1" style="border-color: var(--color-border);" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">不透明度: {{ Math.round(textShadow.alpha * 100) }}%</label>
|
||||
<input type="range" v-model.number="textShadow.alpha" min="0" max="1" step="0.01" class="w-full mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导出列 -->
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-lg font-semibold mb-4">导出</h2>
|
||||
|
||||
<!-- 画板比例 -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="font-medium">画板比例</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label
|
||||
v-for="r in ratios" :key="r.label"
|
||||
class="flex items-center gap-2 p-2 border rounded-lg cursor-pointer hover:bg-accent"
|
||||
:style="{ borderColor: 'var(--color-border)' }"
|
||||
>
|
||||
<input type="checkbox" v-model="r.checked" />
|
||||
<span class="font-mono text-sm">{{ r.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导出设置 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium">导出设置</h3>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">文件名</label>
|
||||
<input v-model="exportFilename" class="w-full h-9 rounded-lg border bg-transparent px-3 text-sm mt-1" style="border-color: var(--color-border);" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">格式</label>
|
||||
<div class="flex gap-1 border rounded-lg p-1 mt-1" style="border-color: var(--color-border);">
|
||||
<button
|
||||
class="flex-1 px-3 py-1 rounded text-sm font-bold transition-colors"
|
||||
:style="exportFormat === 'png' ? 'background: var(--color-primary); color: white;' : ''"
|
||||
@click="exportFormat = 'png'"
|
||||
>PNG</button>
|
||||
<button
|
||||
class="flex-1 px-3 py-1 rounded text-sm font-bold transition-colors"
|
||||
:style="exportFormat === 'svg' ? 'background: var(--color-primary); color: white;' : ''"
|
||||
@click="exportFormat = 'svg'"
|
||||
>SVG</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="exportFormat === 'png'">
|
||||
<h4 class="text-sm text-muted-foreground mb-2">缩放倍率</h4>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label
|
||||
v-for="s in [1, 2, 3, 4]" :key="s"
|
||||
class="flex items-center gap-2 p-2 border rounded-lg cursor-pointer hover:bg-accent"
|
||||
:style="{ borderColor: 'var(--color-border)' }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="exportScales.includes(s)"
|
||||
@change="exportScales = exportScales.includes(s) ? exportScales.filter(x => x !== s) : [...exportScales, s].sort()"
|
||||
/>
|
||||
<span class="font-mono text-sm">{{ s }}x</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-2">{{ computedWidth }}x{{ canvasHeight }} px</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-pill btn-primary w-full justify-center"
|
||||
:disabled="activeRatios.length === 0"
|
||||
@click="onExport"
|
||||
>
|
||||
导出图片
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端内容 -->
|
||||
<div class="lg:hidden">
|
||||
<div v-if="activeTab === 'content'" class="space-y-6">
|
||||
<!-- 同上"内容列"的全部内容 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium">文本设置</h3>
|
||||
<div><label class="text-sm text-muted-foreground">左侧文字</label><input v-model="leftText" class="w-full h-9 rounded-lg border bg-transparent px-3 text-sm mt-1" style="border-color: var(--color-border);" /></div>
|
||||
<div><label class="text-sm text-muted-foreground">右侧文字</label><input v-model="rightText" class="w-full h-9 rounded-lg border bg-transparent px-3 text-sm mt-1" style="border-color: var(--color-border);" /></div>
|
||||
<div><label class="text-sm text-muted-foreground">字体粗细: {{ fontWeight }}</label><input type="range" v-model.number="fontWeight" min="100" max="900" step="100" class="w-full mt-1" /></div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium">图标设置</h3>
|
||||
<input type="file" accept="image/*" class="hidden" id="icon-upload-mobile" @change="onLocalIconUpload" />
|
||||
<label for="icon-upload-mobile" class="flex items-center justify-center h-10 border-2 border-dashed rounded-lg cursor-pointer text-sm" style="border-color: var(--color-border);">{{ localIcon ? '更换图片' : '上传图标' }}</label>
|
||||
<input :value="searchQuery" @input="onSearchInput" placeholder="搜索图标库..." class="w-full h-9 rounded-lg border bg-transparent px-3 text-sm" style="border-color: var(--color-border);" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
当前: {{ iconName }}
|
||||
<a href="https://icon-sets.iconify.design/" target="_blank" rel="noopener noreferrer" style="border-bottom: 1px solid var(--color-text-secondary); padding-bottom: 1px;" class="hover:opacity-70">浏览图标库</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium">背景图片</h3>
|
||||
<input type="file" accept="image/*" class="hidden" id="bg-upload-mobile" @change="onBgUpload" />
|
||||
<label for="bg-upload-mobile" class="flex items-center justify-center w-full h-24 border-2 border-dashed rounded-lg cursor-pointer text-sm" style="border-color: var(--color-border);">{{ bgImage ? '更换背景' : '上传背景' }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="activeTab === 'style'" class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium">尺寸设置</h3>
|
||||
<div><label class="text-sm text-muted-foreground">字体大小: {{ fontSize }}px</label><input type="range" v-model.number="fontSize" min="20" max="700" class="w-full mt-1" /></div>
|
||||
<div><label class="text-sm text-muted-foreground">图标大小: {{ iconSize }}px</label><input type="range" v-model.number="iconSize" min="20" max="700" class="w-full mt-1" /></div>
|
||||
<div><label class="text-sm text-muted-foreground">间距: {{ gap }}px</label><input type="range" v-model.number="gap" min="0" max="200" class="w-full mt-1" /></div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium">颜色设置</h3>
|
||||
<div class="flex items-center justify-between"><span class="text-sm text-muted-foreground">文字颜色</span><input type="color" v-model="textColor" class="w-8 h-8 rounded cursor-pointer" /></div>
|
||||
<div class="flex items-center justify-between"><span class="text-sm text-muted-foreground">背景颜色</span><input type="color" v-model="bgColor" class="w-8 h-8 rounded cursor-pointer" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<h3 class="font-medium">画板比例</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label v-for="r in ratios" :key="r.label" class="flex items-center gap-2 p-2 border rounded-lg cursor-pointer" style="border-color: var(--color-border);">
|
||||
<input type="checkbox" v-model="r.checked" /><span class="font-mono text-sm">{{ r.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-pill btn-primary w-full justify-center" @click="onExport">导出图片</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { siteConfig } from '~/config/site'
|
||||
|
||||
useSeoMeta({
|
||||
title: `友链 - ${siteConfig.siteName}`,
|
||||
description: '友情链接,欢迎互相访问交流',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto max-w-6xl px-4 py-12">
|
||||
<div class="mb-8 text-center anim-fade-in-up">
|
||||
<h1 class="mb-4 text-4xl font-bold">友情链接</h1>
|
||||
<p class="text-lg text-muted-foreground">这里是我的朋友们,欢迎互相访问交流</p>
|
||||
</div>
|
||||
|
||||
<!-- 申请友链 -->
|
||||
<div class="mb-8 anim-fade-in-up anim-delay-1">
|
||||
<div class="rounded-xl border p-6" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
|
||||
<h2 class="mb-2 text-xl font-semibold">申请友链</h2>
|
||||
<p class="mb-4 text-sm text-muted-foreground">欢迎与我交换友链,请在下方提交你的网站信息</p>
|
||||
<div class="mb-4 space-y-2 text-sm text-muted-foreground">
|
||||
<p><span class="font-semibold text-foreground">网站名称:</span>{{ siteConfig.siteName }}</p>
|
||||
<p><span class="font-semibold text-foreground">网站地址:</span>{{ siteConfig.url }}</p>
|
||||
<p><span class="font-semibold text-foreground">网站描述:</span>{{ siteConfig.description }}</p>
|
||||
<p><span class="font-semibold text-foreground">网站头像:</span>{{ siteConfig.avatar }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-4 text-sm" style="background: var(--color-bg-hover);">
|
||||
<p class="mb-2 font-semibold">申请方式:</p>
|
||||
<ol class="list-decimal list-inside space-y-1 text-muted-foreground">
|
||||
<li>请先在你的网站添加本站友链</li>
|
||||
<li>通过以下任意方式提交你的网站信息(名称、地址、描述、头像)</li>
|
||||
<li>审核通过后会添加到友链列表</li>
|
||||
</ol>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<a
|
||||
v-for="link in siteConfig.socialLinks"
|
||||
:key="link.name"
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-pill"
|
||||
>
|
||||
<img
|
||||
:src="`https://api.iconify.design/${link.icon}.svg?color=${encodeURIComponent(link.color || 'currentColor')}`"
|
||||
:alt="link.name"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span>{{ link.name }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 友链列表 -->
|
||||
<div class="anim-fade-in-up anim-delay-2">
|
||||
<h2 class="mb-6 text-2xl font-bold">友链列表 ({{ siteConfig.friends.length }})</h2>
|
||||
<div v-if="siteConfig.friends.length === 0" class="rounded-xl border p-12 text-center text-muted-foreground" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
|
||||
暂无友链,欢迎成为第一个!
|
||||
</div>
|
||||
<div v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<a
|
||||
v-for="friend in siteConfig.friends"
|
||||
:key="friend.url"
|
||||
:href="friend.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block rounded-xl border p-4 transition-all hover:shadow-lg"
|
||||
style="border-color: var(--color-border); background-color: var(--color-bg-card);"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="shrink-0">
|
||||
<img
|
||||
v-if="friend.avatar"
|
||||
:src="friend.avatar"
|
||||
:alt="friend.name"
|
||||
style="width:64px;height:64px;border-radius:50%;object-fit:cover;display:block"
|
||||
@error="(e) => { e.target.style.display = 'none'; e.target.nextElementSibling.style.display = 'flex' }"
|
||||
/>
|
||||
<div
|
||||
style="width:64px;height:64px;border-radius:50%;background:#e5e7eb;display:none;align-items:center;justify-content:center;font-size:1.5rem;font-weight:700;color:#6b7280"
|
||||
>
|
||||
{{ friend.name.charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="mb-1 truncate font-semibold">{{ friend.name }}</div>
|
||||
<div class="line-clamp-2 text-sm text-muted-foreground">
|
||||
{{ friend.description || '暂无描述' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { siteConfig } from '~/config/site'
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
{ src: '//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js', async: true, defer: true },
|
||||
],
|
||||
})
|
||||
|
||||
// 网易云热评
|
||||
const { data: hitokoto } = await useAsyncData('wyy-comment', () => $fetch<{ user: string; music: string; content: string }>('/api/wyy'))
|
||||
|
||||
onMounted(() => {
|
||||
const el = document.getElementById('busuanzi_value_site_pv')
|
||||
if (!el) return
|
||||
el.textContent = '1'
|
||||
|
||||
const animate = (target: number) => {
|
||||
if (target <= 1) return
|
||||
const duration = 800
|
||||
const start = performance.now()
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min((now - start) / duration, 1)
|
||||
const eased = 1 - Math.pow(1 - progress, 3)
|
||||
el.textContent = Math.floor(1 + (target - 1) * eased).toLocaleString()
|
||||
if (progress < 1) requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const val = parseInt(el.textContent || '0', 10)
|
||||
if (val > 1) {
|
||||
observer.disconnect()
|
||||
animate(val)
|
||||
}
|
||||
})
|
||||
observer.observe(el, { childList: true, characterData: true, subtree: true })
|
||||
|
||||
setTimeout(() => observer.disconnect(), 10000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-8 px-4 pt-4 pb-12">
|
||||
|
||||
<!-- 公告卡片 -->
|
||||
<div class="w-full max-w-2xl text-center anim-fade-in-up">
|
||||
<div class="announcement-happy rounded-xl p-6">
|
||||
<p class="announcement-text text-sm md:text-base">
|
||||
<strong>欢迎来到《{{ siteConfig.siteName }}》~ 用代码创造快乐,用热爱点亮生活 (☞゚ヮ゚)☞</strong>
|
||||
</p>
|
||||
<p class="announcement-text text-sm md:text-base mt-1">
|
||||
遇到问题的话,可以去 <a href="https://github.com/wishesl/SuBlog/issues" target="_blank" rel="noopener noreferrer">Issues</a> 提反馈哦,会尽快修的!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 头像 -->
|
||||
<div class="anim-fade-in-up anim-delay-1">
|
||||
<img
|
||||
:src="siteConfig.avatar"
|
||||
:alt="siteConfig.siteName"
|
||||
class="h-32 w-32 rounded-full shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 标题 + 口号 + 访客 -->
|
||||
<div class="text-center anim-fade-in-up anim-delay-2">
|
||||
<h1 class="text-4xl font-bold mb-2">{{ siteConfig.siteName }}</h1>
|
||||
<p v-if="hitokoto" class="text-sm italic leading-relaxed mb-1" style="color: var(--color-text-secondary)">
|
||||
"{{ hitokoto.content }}"
|
||||
</p>
|
||||
<p v-if="hitokoto" class="text-xs opacity-60 mb-1" style="color: var(--color-text-secondary)">
|
||||
—— {{ hitokoto.user }} · {{ hitokoto.music }}
|
||||
</p>
|
||||
<p class="text-sm mt-6" style="color: var(--color-text-secondary)">
|
||||
您是第 <span id="busuanzi_value_site_pv" class="font-medium" style="color: var(--color-primary)"></span> 个访客
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 社交卡片 -->
|
||||
<div class="w-full max-w-2xl mx-auto anim-fade-in-up anim-delay-3">
|
||||
<div class="card relative overflow-hidden py-6">
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-0">
|
||||
<span class="text-5xl font-black tracking-widest" style="color: var(--color-text); opacity: 0.04;">社交</span>
|
||||
</div>
|
||||
<div class="relative z-10 px-6 flex flex-wrap gap-3 justify-center">
|
||||
<a
|
||||
v-for="link in siteConfig.socialLinks"
|
||||
:key="link.name"
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-pill"
|
||||
>
|
||||
<img
|
||||
:src="`https://api.iconify.design/${link.icon}.svg?color=${encodeURIComponent(link.color || 'currentColor')}`"
|
||||
:alt="link.name"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
<span>{{ link.name }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导航卡片 -->
|
||||
<div class="w-full max-w-2xl mx-auto anim-fade-in-up anim-delay-4">
|
||||
<div class="card relative overflow-hidden py-6">
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-0">
|
||||
<span class="text-5xl font-black tracking-widest" style="color: var(--color-text); opacity: 0.04;">导航</span>
|
||||
</div>
|
||||
<div class="relative z-10 px-6 flex flex-wrap gap-3 justify-center">
|
||||
<NuxtLink
|
||||
v-for="link in siteConfig.navLinks"
|
||||
:key="link.href"
|
||||
:to="link.href"
|
||||
class="btn-pill"
|
||||
>
|
||||
<Icon :icon="link.icon" class="w-5 h-5" />
|
||||
<span>{{ link.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes rainbow-flow {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 200% 50%; }
|
||||
}
|
||||
|
||||
.announcement-happy {
|
||||
animation: rainbow-flow 3s linear infinite;
|
||||
}
|
||||
|
||||
.announcement-text,
|
||||
.announcement-text * {
|
||||
background: linear-gradient(90deg,
|
||||
oklch(0.78 0.18 0),
|
||||
oklch(0.78 0.18 45),
|
||||
oklch(0.78 0.18 90),
|
||||
oklch(0.78 0.18 135),
|
||||
oklch(0.78 0.18 180),
|
||||
oklch(0.78 0.18 225),
|
||||
oklch(0.78 0.18 270),
|
||||
oklch(0.78 0.18 315),
|
||||
oklch(0.78 0.18 360)
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: rainbow-flow 3s linear infinite;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { siteConfig } from '~/config/site'
|
||||
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const { data: post } = await useAsyncData(`post-${slug}`, () =>
|
||||
queryCollection('posts')
|
||||
.path(`/posts/${slug}`)
|
||||
.first()
|
||||
)
|
||||
|
||||
if (!post.value) {
|
||||
throw createError({ statusCode: 404, message: '文章不存在' })
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: post.value.title || slug,
|
||||
description: post.value.description || '',
|
||||
ogType: 'article',
|
||||
ogTitle: post.value.title || slug,
|
||||
ogDescription: post.value.description || '',
|
||||
ogImage: post.value.image || siteConfig.ogImage,
|
||||
twitterCard: 'summary_large_image',
|
||||
twitterTitle: post.value.title || slug,
|
||||
twitterDescription: post.value.description || '',
|
||||
twitterImage: post.value.image || siteConfig.ogImage,
|
||||
})
|
||||
|
||||
function formatDate(date: string) {
|
||||
return new Date(date).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article v-if="post" class="container mx-auto max-w-3xl px-4 py-12">
|
||||
|
||||
<header class="mb-8 anim-fade-in-up">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<time v-if="post.published" class="text-sm text-muted-foreground">
|
||||
{{ formatDate(post.published) }}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-4 text-4xl font-bold">{{ post.title }}</h1>
|
||||
|
||||
<p v-if="post.description" class="text-lg text-muted-foreground">
|
||||
{{ post.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="post.image" class="mt-6">
|
||||
<img
|
||||
:src="post.image"
|
||||
:alt="post.title"
|
||||
class="w-full rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="prose prose-neutral dark:prose-invert max-w-none break-words
|
||||
prose-headings:text-foreground prose-headings:scroll-mt-14
|
||||
prose-p:text-foreground
|
||||
prose-strong:text-foreground
|
||||
prose-a:text-primary prose-a:underline prose-a:underline-offset-4 prose-a:break-all prose-a:transition-opacity prose-a:hover:opacity-80
|
||||
prose-blockquote:border-l-primary prose-blockquote:text-muted-foreground
|
||||
prose-code:bg-muted prose-code:text-foreground prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:before:content-none prose-code:after:content-none
|
||||
prose-pre:bg-muted prose-pre:px-4 prose-pre:py-2 prose-pre:text-foreground prose-pre:overflow-x-auto
|
||||
prose-hr:border-border
|
||||
prose-th:border prose-th:border-border prose-th:bg-muted
|
||||
prose-td:border prose-td:border-border
|
||||
prose-img:rounded-lg
|
||||
anim-fade-in-up anim-delay-1">
|
||||
<ContentRenderer :value="post" />
|
||||
</div>
|
||||
|
||||
<div class="anim-fade-in-up anim-delay-2">
|
||||
<BlogGiscus class="mt-16" />
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 anim-fade-in-up anim-delay-3" style="border-top: 1px solid var(--color-border)">
|
||||
<NuxtLink to="/posts" class="text-sm hover:underline" style="color: var(--color-primary)">
|
||||
← 返回文章列表
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 右侧目录导航:从 body AST 提取所有标题,SSR 阶段全量渲染 -->
|
||||
<BlogPostToc :body="post.body" />
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { siteConfig } from '~/config/site'
|
||||
|
||||
useSeoMeta({
|
||||
title: '博客文章',
|
||||
description: `${siteConfig.siteName} 的所有文章`,
|
||||
})
|
||||
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const postsPerPage = 10
|
||||
|
||||
const searchFilters = ref({
|
||||
title: true,
|
||||
description: true,
|
||||
content: true,
|
||||
tags: true,
|
||||
})
|
||||
|
||||
// 高频标签
|
||||
const topTags = computed(() => {
|
||||
if (!allPosts.value) return []
|
||||
const count: Record<string, number> = {}
|
||||
for (const post of allPosts.value) {
|
||||
for (const tag of (post.tags || [])) {
|
||||
count[tag] = (count[tag] || 0) + 1
|
||||
}
|
||||
}
|
||||
return Object.entries(count)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 26)
|
||||
.map(([tag, count]) => ({ tag, count }))
|
||||
})
|
||||
|
||||
function searchByTag(tag: string) {
|
||||
searchQuery.value = tag
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const { data: allPosts } = await useAsyncData('posts-list', () =>
|
||||
queryCollection('posts')
|
||||
.order('pinned', 'DESC')
|
||||
.order('published', 'DESC')
|
||||
.all()
|
||||
)
|
||||
|
||||
const { searchResults, filteredPosts, highlight } = usePostSearch(allPosts, searchQuery, searchFilters)
|
||||
|
||||
const hasAnyFilter = computed(() => searchFilters.value.title || searchFilters.value.description || searchFilters.value.content)
|
||||
|
||||
const totalPages = computed(() => Math.ceil(searchResults.value.length / postsPerPage))
|
||||
|
||||
const paginatedResults = computed(() => {
|
||||
const start = (currentPage.value - 1) * postsPerPage
|
||||
return searchResults.value.slice(start, start + postsPerPage)
|
||||
})
|
||||
|
||||
watch(() => searchFilters.value, () => {
|
||||
currentPage.value = 1
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto max-w-4xl px-4 py-12">
|
||||
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-12 text-center anim-fade-in-up">
|
||||
<h1 class="mb-4 text-4xl font-bold">博客文章</h1>
|
||||
<p class="text-muted-foreground">分享技术、想法和经验</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
共 {{ allPosts?.length || 0 }} 篇文章
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="mb-8 anim-fade-in-up anim-delay-1">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索文章标题、描述或内容..."
|
||||
class="w-full h-10 rounded-full border bg-transparent pl-10 pr-4 text-sm outline-none transition-all focus:ring-2"
|
||||
style="border-color: var(--color-border); --tw-ring-color: var(--color-border);"
|
||||
@input="currentPage = 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none text-sm text-muted-foreground">
|
||||
<input type="checkbox" v-model="searchFilters.title" class="w-4 h-4 rounded" style="accent-color: var(--color-text);" />
|
||||
<span>标题</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none text-sm text-muted-foreground">
|
||||
<input type="checkbox" v-model="searchFilters.description" class="w-4 h-4 rounded" style="accent-color: var(--color-text);" />
|
||||
<span>简介</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none text-sm text-muted-foreground">
|
||||
<input type="checkbox" v-model="searchFilters.content" class="w-4 h-4 rounded" style="accent-color: var(--color-text);" />
|
||||
<span>正文</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none text-sm text-muted-foreground">
|
||||
<input type="checkbox" v-model="searchFilters.tags" class="w-4 h-4 rounded" style="accent-color: var(--color-text);" />
|
||||
<span>标签</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 高频标签 -->
|
||||
<div v-if="topTags.length > 0" class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="{ tag, count } in topTags"
|
||||
:key="tag"
|
||||
class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors cursor-pointer"
|
||||
:style="searchQuery === tag
|
||||
? 'background: var(--color-primary); color: white;'
|
||||
: 'background: var(--color-bg-hover); color: var(--color-text-secondary);'"
|
||||
@click="searchByTag(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
<span class="opacity-60">{{ count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="searchQuery" class="mt-2 min-h-[20px]">
|
||||
<p v-if="!hasAnyFilter" class="text-sm text-red-500">请至少选择一个搜索范围</p>
|
||||
<p v-else-if="filteredPosts.length === 0" class="text-sm text-muted-foreground">未找到匹配的文章</p>
|
||||
<p v-else class="text-sm text-muted-foreground">找到 {{ filteredPosts.length }} 篇文章</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章列表 -->
|
||||
<div class="space-y-6 anim-fade-in-up anim-delay-2">
|
||||
<BlogPostCard
|
||||
v-for="{ post, matchedLines } in paginatedResults"
|
||||
:key="post.id"
|
||||
:post="post"
|
||||
:matched-lines="matchedLines"
|
||||
:highlight="highlight"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="paginatedResults.length === 0" class="text-center py-12 text-muted-foreground">
|
||||
{{ searchQuery ? '未找到匹配的文章' : '暂无文章' }}
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="totalPages > 1" class="mt-8 flex flex-col items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
:disabled="currentPage <= 1"
|
||||
class="btn-pill disabled:opacity-40"
|
||||
@click="currentPage--"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ currentPage }} / {{ totalPages }}
|
||||
</span>
|
||||
<button
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="btn-pill disabled:opacity-40"
|
||||
@click="currentPage++"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
共 {{ totalPages }} 页 / {{ filteredPosts.length }} 个
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { siteConfig } from '~/config/site'
|
||||
|
||||
useSeoMeta({
|
||||
title: `赞助 - ${siteConfig.siteName}`,
|
||||
description: '感谢您的支持,您的赞助将帮助我持续创作优质内容',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto max-w-4xl px-4 py-12">
|
||||
<div class="mb-8 text-center anim-fade-in-up">
|
||||
<h1 class="mb-4 text-4xl font-bold">赞助支持</h1>
|
||||
<p class="text-lg text-muted-foreground">感谢您的支持,您的赞助将帮助我持续创作优质内容</p>
|
||||
</div>
|
||||
|
||||
<!-- 微信支付 -->
|
||||
<div class="mb-8 rounded-xl border p-6 text-center anim-fade-in-up anim-delay-1" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
|
||||
<h2 class="mb-2 text-xl font-semibold">微信支付</h2>
|
||||
<p class="mb-4 text-sm text-muted-foreground">扫描下方二维码向我赞助</p>
|
||||
<div class="flex justify-center py-4">
|
||||
<img
|
||||
:src="siteConfig.sponsors.qrcode.wechat"
|
||||
alt="微信支付二维码"
|
||||
class="h-64 w-64 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 支付宝 -->
|
||||
<div class="mb-12 rounded-xl border p-6 text-center anim-fade-in-up anim-delay-2" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
|
||||
<h2 class="mb-2 text-xl font-semibold">支付宝</h2>
|
||||
<p class="text-lg text-muted-foreground py-8">敬请期待 ✧</p>
|
||||
</div>
|
||||
|
||||
<!-- 赞助名单 -->
|
||||
<div class="anim-fade-in-up anim-delay-3">
|
||||
<h2 class="mb-6 text-2xl font-bold">赞助名单</h2>
|
||||
<div v-if="siteConfig.sponsors.list.length === 0" class="rounded-xl border p-12 text-center text-muted-foreground" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
|
||||
暂无赞助记录,期待你的出现 ✧
|
||||
</div>
|
||||
<div v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="sponsor in siteConfig.sponsors.list"
|
||||
:key="sponsor.name"
|
||||
class="rounded-xl border p-4"
|
||||
style="border-color: var(--color-border); background-color: var(--color-bg-card);"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-lg font-bold" style="background: var(--color-bg-hover); color: var(--color-text-secondary);">
|
||||
{{ sponsor.name.charAt(0) }}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold">{{ sponsor.name }}</div>
|
||||
<div class="text-sm text-muted-foreground">{{ sponsor.date }}</div>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-primary">{{ sponsor.amount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user