chore: 初始化项目仓库
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -0,0 +1,3 @@
|
|||||||
|
# 站点配置(可选,config/site.ts 中已有默认值)
|
||||||
|
NUXT_PUBLIC_SITE_URL=https://2x.nz
|
||||||
|
NUXT_PUBLIC_SITE_NAME=《二叉树树》官方网站
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
SuBlog(尼克的小窝)— 个人技术博客,基于 Nuxt 3 + TypeScript,使用 @nuxt/content v3 管理 Markdown 文章。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # 启动开发服务器
|
||||||
|
pnpm build # 构建生产版本
|
||||||
|
pnpm generate # 静态站点生成
|
||||||
|
pnpm preview # 预览构建产物
|
||||||
|
pnpm new-post <name> # 创建新文章(自动生成 frontmatter 和图片目录)
|
||||||
|
```
|
||||||
|
|
||||||
|
项目使用 pnpm 作为包管理器。
|
||||||
|
|
||||||
|
## 架构要点
|
||||||
|
|
||||||
|
### 内容系统
|
||||||
|
- 文章放在 `content/posts/<slug>/index.md`,图片放在同级 `img/` 目录
|
||||||
|
- 使用 @nuxt/content v3 的 `queryCollection('posts')` API 查询文章
|
||||||
|
- frontmatter 字段:`title`, `published`(日期), `description`, `image`, `draft`, `tags`, `pinned`
|
||||||
|
- `pinned` 字段控制文章置顶排序
|
||||||
|
- 构建时 `nuxt.config.ts` 中的 hook 自动将 `content/posts/<slug>/img/` 复制到产物目录
|
||||||
|
- 开发模式图片通过 `server/routes/posts/[slug]/img/[...file].ts` 读取源文件
|
||||||
|
|
||||||
|
### 页面路由
|
||||||
|
- `pages/index.vue` — 首页(社交卡片、导航、网易云热评、不蒜子计数)
|
||||||
|
- `pages/posts/index.vue` — 文章列表(搜索、标签过滤、分页)
|
||||||
|
- `pages/posts/[slug].vue` — 文章详情(Giscus 评论、右侧 TOC 目录)
|
||||||
|
- `pages/[...slug].vue` — 兜底路由
|
||||||
|
- 其他页面:cover(封面制作)、friends(友链)、sponsor(赞助)、about(关于)
|
||||||
|
|
||||||
|
### 组件分层
|
||||||
|
- `components/layout/` — NavBar(面包屑导航 + 头像旋转)、Footer、ThemeToggle
|
||||||
|
- `components/blog/` — PostCard、PostToc(基于 IntersectionObserver 的目录)、Giscus
|
||||||
|
- `components/content/ProseImg.vue` — 覆盖 @nuxt/content 默认图片组件,支持相对路径解析
|
||||||
|
|
||||||
|
### 工具层
|
||||||
|
- `composables/usePostSearch.ts` — 客户端搜索 composable,支持标题/描述/正文/标签多维过滤和高亮
|
||||||
|
- `utils/content-ast.ts` — Nuxt Content v3 AST 工具函数(文本提取、标题提取、slugify、去重)
|
||||||
|
- `config/site.ts` — 全站配置(站点信息、导航、社交链接、友链、赞助、Giscus)
|
||||||
|
|
||||||
|
### 主题与样式
|
||||||
|
- Tailwind CSS + `@tailwindcss/typography`,暗色模式使用 `class` 策略
|
||||||
|
- CSS 变量定义在 `assets/css/main.css`(`--color-bg`, `--color-primary` 等)
|
||||||
|
- 暗色模式通过 `.dark` class 切换
|
||||||
|
- 动画类:`anim-fade-in-up`、`anim-delay-N`
|
||||||
|
|
||||||
|
### 服务端
|
||||||
|
- `server/routes/api/wyy.ts` — 网易云热评 API 代理
|
||||||
|
|
||||||
|
## 开发规范
|
||||||
|
|
||||||
|
### 工作流程
|
||||||
|
- 效率至上:快速单元式开发
|
||||||
|
- 改完即退:完成代码修改后立即退出,用户会手动测试
|
||||||
|
- 单元提交:每个功能/修改单独提交到 Git
|
||||||
|
- 非用户要求不输出多余内容
|
||||||
|
|
||||||
|
### 编辑规则
|
||||||
|
- 编辑文件仅使用 Read 和 Edit 工具,不使用 Python/PowerShell/Bash 修改文件
|
||||||
|
- Edit 工具使用函数签名等唯一字符串做锚点,避免 tab 缩进匹配问题
|
||||||
|
|
||||||
|
### Git 提交规范
|
||||||
|
- `feat:` 新功能 | `fix:` 修复 | `refactor:` 重构 | `style:` 样式 | `perf:` 性能 | `chore:` 构建/工具/配置
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Sublog - Nuxt 3 博客
|
||||||
|
|
||||||
|
基于 Nuxt 3 的静态博客站点,使用 @nuxt/content 管理 Markdown 文章。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **Nuxt 3** - Vue 3 全栈框架
|
||||||
|
- **@nuxt/content** - Markdown 内容管理
|
||||||
|
- **Tailwind CSS** - 样式框架
|
||||||
|
- **TypeScript** - 类型安全
|
||||||
|
- **Giscus** - GitHub Discussions 评论系统
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
### 启动开发服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建静态站点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run generate
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物输出到 `.output/public`,文章图片会自动复制。
|
||||||
|
|
||||||
|
### 预览构建结果
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 新建文章
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run new-post <slug>
|
||||||
|
```
|
||||||
|
|
||||||
|
会在 `content/posts/<slug>/` 下创建 `index.md` 和 `img/` 目录。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── config/site.ts # 站点配置(改这一个文件定制全站)
|
||||||
|
├── content/posts/ # Markdown 文章
|
||||||
|
│ └── <slug>/
|
||||||
|
│ ├── index.md # 文章内容
|
||||||
|
│ └── img/ # 文章图片
|
||||||
|
├── components/
|
||||||
|
│ ├── layout/ # 布局组件(NavBar, Footer)
|
||||||
|
│ └── blog/ # 博客组件(PostCard, PostToc, Giscus)
|
||||||
|
├── composables/ # 组合式函数
|
||||||
|
├── pages/ # 页面路由
|
||||||
|
├── layouts/ # 布局模板
|
||||||
|
├── server/routes/ # API 路由
|
||||||
|
└── utils/ # 工具函数
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
站点信息集中在 `config/site.ts`:
|
||||||
|
|
||||||
|
- 站点名称、描述、关键词
|
||||||
|
- 头像、favicon
|
||||||
|
- 社交链接
|
||||||
|
- 导航菜单
|
||||||
|
- Giscus 评论配置
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
构建后将 `.output/public` 目录部署到任意静态托管服务(Vercel、Netlify、Cloudflare Pages 等)。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 修复 Nuxt 客户端导航时 Tailwind CSS 未加载导致的链接下划线闪烁 */
|
||||||
|
/* 文章正文 (.prose) 内的链接保留原始样式 */
|
||||||
|
a:not(.prose a) {
|
||||||
|
text-decoration-line: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* 公告链接下划线 */
|
||||||
|
.announcement-text a {
|
||||||
|
text-decoration: none !important;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.3) !important;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
.dark .announcement-text a {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题变量 */
|
||||||
|
:root {
|
||||||
|
--color-bg: #ffffff;
|
||||||
|
--color-bg-card: #ffffff;
|
||||||
|
--color-bg-hover: #f3f4f6;
|
||||||
|
--color-text: #111827;
|
||||||
|
--color-text-secondary: #6b7280;
|
||||||
|
--color-border: #e5e7eb;
|
||||||
|
--color-primary: #3b82f6;
|
||||||
|
--color-ring: rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--color-bg: #0a0a0a;
|
||||||
|
--color-bg-card: #171717;
|
||||||
|
--color-bg-hover: #262626;
|
||||||
|
--color-text: #f3f4f6;
|
||||||
|
--color-text-secondary: #9ca3af;
|
||||||
|
--color-border: #2e2e2e;
|
||||||
|
--color-primary: #60a5fa;
|
||||||
|
--color-ring: rgba(96, 165, 250, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tailwind-like 工具类 */
|
||||||
|
.text-muted-foreground {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-foreground {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-muted {
|
||||||
|
background-color: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-border {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-primary {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
--tw-prose-links: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .prose {
|
||||||
|
--tw-prose-body: var(--color-text);
|
||||||
|
--tw-prose-headings: var(--color-text);
|
||||||
|
--tw-prose-links: var(--color-primary);
|
||||||
|
--tw-prose-bold: var(--color-text);
|
||||||
|
--tw-prose-code: var(--color-text);
|
||||||
|
--tw-prose-quotes: var(--color-text-secondary);
|
||||||
|
--tw-prose-quote-borders: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 入场动画 */
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.anim-fade-in-up {
|
||||||
|
animation: fade-in-up 0.6s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anim-fade-in {
|
||||||
|
animation: fade-in 0.5s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anim-delay-1 { animation-delay: 0.1s; }
|
||||||
|
.anim-delay-2 { animation-delay: 0.2s; }
|
||||||
|
.anim-delay-3 { animation-delay: 0.3s; }
|
||||||
|
.anim-delay-4 { animation-delay: 0.4s; }
|
||||||
|
.anim-delay-5 { animation-delay: 0.5s; }
|
||||||
|
|
||||||
|
/* 卡片 */
|
||||||
|
.card {
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮 */
|
||||||
|
.btn-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pill:hover {
|
||||||
|
background-color: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 代码块 */
|
||||||
|
pre {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 代码高亮背景色(Shiki 不设置 pre 背景,需手动补) */
|
||||||
|
pre.shiki,
|
||||||
|
pre:has(> .shiki) {
|
||||||
|
background-color: #fff !important;
|
||||||
|
}
|
||||||
|
pre.shiki code {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
.dark pre.shiki,
|
||||||
|
.dark pre:has(> .shiki) {
|
||||||
|
background-color: #24292e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏标题锚点链接样式(保留功能,不显示为可点击) */
|
||||||
|
.prose h2 > a,
|
||||||
|
.prose h3 > a,
|
||||||
|
.prose h4 > a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.prose h2 > a:hover,
|
||||||
|
.prose h3 > a:hover,
|
||||||
|
.prose h4 > a:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { siteConfig } from '~/config/site'
|
||||||
|
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
const container = ref<HTMLElement>()
|
||||||
|
|
||||||
|
function loadGiscus() {
|
||||||
|
if (!container.value) return
|
||||||
|
container.value.innerHTML = ''
|
||||||
|
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = 'https://giscus.app/client.js'
|
||||||
|
script.setAttribute('data-repo', siteConfig.giscus.repo)
|
||||||
|
script.setAttribute('data-repo-id', siteConfig.giscus.repoId)
|
||||||
|
script.setAttribute('data-category', siteConfig.giscus.category)
|
||||||
|
script.setAttribute('data-category-id', siteConfig.giscus.categoryId)
|
||||||
|
script.setAttribute('data-mapping', 'pathname')
|
||||||
|
script.setAttribute('data-strict', '0')
|
||||||
|
script.setAttribute('data-reactions-enabled', '1')
|
||||||
|
script.setAttribute('data-emit-metadata', '0')
|
||||||
|
script.setAttribute('data-input-position', 'bottom')
|
||||||
|
script.setAttribute('data-theme', colorMode.value === 'dark' ? 'dark' : 'light')
|
||||||
|
script.setAttribute('data-lang', 'zh-CN')
|
||||||
|
script.setAttribute('data-loading', 'lazy')
|
||||||
|
script.setAttribute('crossorigin', 'anonymous')
|
||||||
|
script.async = true
|
||||||
|
|
||||||
|
container.value.appendChild(script)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadGiscus()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => colorMode.value, () => {
|
||||||
|
const frame = document.querySelector<HTMLIFrameElement>('iframe.giscus-frame')
|
||||||
|
if (frame?.contentWindow) {
|
||||||
|
frame.contentWindow.postMessage(
|
||||||
|
{ giscus: { setConfig: { theme: colorMode.value === 'dark' ? 'dark' : 'light' } } },
|
||||||
|
'https://giscus.app'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold mb-4">评论</h2>
|
||||||
|
<div ref="container" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
post: any
|
||||||
|
matchedLines?: string[]
|
||||||
|
highlight?: (text: string) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const expanded = ref(false)
|
||||||
|
|
||||||
|
function formatDate(date: string) {
|
||||||
|
return new Date(date).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWordCount(post: any): number {
|
||||||
|
const text = JSON.stringify(post.body || '').replace(/<[^>]*>/g, '')
|
||||||
|
const chinese = text.match(/[一-龥]/g) || []
|
||||||
|
const english = text.match(/[a-zA-Z]+/g) || []
|
||||||
|
return chinese.length + english.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReadTime(count: number): number {
|
||||||
|
return Math.max(1, Math.ceil(count / 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordCount = computed(() => getWordCount(props.post))
|
||||||
|
const readTime = computed(() => getReadTime(wordCount.value))
|
||||||
|
|
||||||
|
function renderTitle(): string {
|
||||||
|
const text = props.post.title || '无标题'
|
||||||
|
return props.highlight ? props.highlight(text) : text
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDesc(): string {
|
||||||
|
if (!props.post.description) return ''
|
||||||
|
return props.highlight ? props.highlight(props.post.description) : props.post.description
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLink :to="post.path" class="block">
|
||||||
|
<div class="group transition-all hover:shadow-lg rounded-2xl py-6 overflow-hidden" style="border: 1px solid var(--color-border);">
|
||||||
|
<div class="px-6">
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row">
|
||||||
|
<div v-if="post.image" class="md:w-48 md:flex-shrink-0">
|
||||||
|
<img
|
||||||
|
:src="post.image"
|
||||||
|
:alt="post.title"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
class="h-48 w-full rounded-md object-cover md:h-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-0">
|
||||||
|
<span
|
||||||
|
v-if="post.pinned"
|
||||||
|
class="shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
|
style="background: var(--color-text); color: var(--color-bg);"
|
||||||
|
>
|
||||||
|
置顶
|
||||||
|
</span>
|
||||||
|
<time v-if="post.published" class="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
{{ formatDate(post.published) }}
|
||||||
|
</time>
|
||||||
|
<span class="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
· {{ wordCount }} 字
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
· 约 {{ readTime }} 分钟
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2
|
||||||
|
class="mb-2 text-2xl font-semibold group-hover:opacity-70 transition-opacity"
|
||||||
|
v-html="renderTitle()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="post.description"
|
||||||
|
class="text-muted-foreground"
|
||||||
|
v-html="renderDesc()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="matchedLines && matchedLines.length > 0" class="mt-3 space-y-1.5">
|
||||||
|
<div
|
||||||
|
v-for="(line, idx) in (expanded ? matchedLines : matchedLines.slice(0, 3))"
|
||||||
|
:key="idx"
|
||||||
|
class="text-sm text-muted-foreground border-l-2 pl-3"
|
||||||
|
style="border-color: var(--color-border);"
|
||||||
|
v-html="highlight ? highlight(line) : line"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="matchedLines.length > 3"
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-muted-foreground hover:opacity-70 transition-opacity mt-1"
|
||||||
|
@click.prevent.stop="expanded = !expanded"
|
||||||
|
>
|
||||||
|
{{ expanded ? '收起' : `展开 (还有 ${matchedLines.length - 3} 条)` }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { getHeadingsFromBody } from '~/utils/content-ast'
|
||||||
|
import type { Heading } from '~/utils/content-ast'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
body?: Record<string, any> | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const headings = ref<Heading[]>([])
|
||||||
|
const activeId = ref('')
|
||||||
|
let observer: IntersectionObserver | undefined
|
||||||
|
|
||||||
|
const NAV_HEIGHT = 40
|
||||||
|
|
||||||
|
const minLevel = computed(() => {
|
||||||
|
if (!headings.value.length) return 1
|
||||||
|
return Math.min(...headings.value.map((h) => h.level))
|
||||||
|
})
|
||||||
|
|
||||||
|
function indentClass(level: number): string {
|
||||||
|
const depth = Math.max(0, level - minLevel.value)
|
||||||
|
return ['pl-3', 'pl-6', 'pl-9', 'pl-12', 'pl-14', 'pl-16'][depth] || 'pl-16'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHeadings() {
|
||||||
|
headings.value = getHeadingsFromBody(props.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startObserver() {
|
||||||
|
observer?.disconnect()
|
||||||
|
if (!headings.value.length) return
|
||||||
|
nextTick(() => {
|
||||||
|
const els = headings.value
|
||||||
|
.map((h) => document.getElementById(h.id))
|
||||||
|
.filter(Boolean) as HTMLElement[]
|
||||||
|
if (!els.length) return
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter((e) => e.isIntersecting)
|
||||||
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||||
|
if (visible.length > 0) {
|
||||||
|
activeId.value = (visible[0].target as HTMLElement).id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: `${-(NAV_HEIGHT + 24)}px 0px -60% 0px`, threshold: 0 }
|
||||||
|
)
|
||||||
|
for (const el of els) observer.observe(el)
|
||||||
|
const firstVisible = els.find((el) => {
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
return r.bottom > NAV_HEIGHT + 24
|
||||||
|
})
|
||||||
|
activeId.value = (firstVisible || els[0]).id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
buildHeadings()
|
||||||
|
|
||||||
|
onMounted(() => { startObserver() })
|
||||||
|
|
||||||
|
watch(() => props.body, () => {
|
||||||
|
buildHeadings()
|
||||||
|
if (headings.value.length) startObserver()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
onUnmounted(() => { observer?.disconnect() })
|
||||||
|
|
||||||
|
function handleClick(e: MouseEvent, id: string) {
|
||||||
|
const el = document.getElementById(id)
|
||||||
|
if (!el) return
|
||||||
|
e.preventDefault()
|
||||||
|
activeId.value = id
|
||||||
|
const top = el.getBoundingClientRect().top + window.scrollY - (NAV_HEIGHT + 16)
|
||||||
|
window.scrollTo({ top, behavior: 'instant' })
|
||||||
|
history.replaceState(null, '', `#${id}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
v-if="headings.length > 0"
|
||||||
|
class="hidden xl:block fixed top-24 right-[max(1rem,calc((100vw-48rem)/2-18rem))] w-56 max-h-[calc(100vh-8rem)] overflow-y-auto pr-2 text-sm z-30"
|
||||||
|
aria-label="目录"
|
||||||
|
>
|
||||||
|
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">目录</div>
|
||||||
|
<ul class="space-y-1 border-l border-border">
|
||||||
|
<li v-for="h in headings" :key="h.id">
|
||||||
|
<a
|
||||||
|
:href="`#${h.id}`"
|
||||||
|
class="block py-1 pr-2 border-l-2 -ml-px transition-colors"
|
||||||
|
:class="[
|
||||||
|
indentClass(h.level),
|
||||||
|
activeId === h.id
|
||||||
|
? 'border-primary text-foreground font-medium'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/40',
|
||||||
|
]"
|
||||||
|
@click="handleClick($event, h.id)"
|
||||||
|
>
|
||||||
|
{{ h.text }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
src?: string
|
||||||
|
alt?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const resolvedSrc = computed(() => {
|
||||||
|
if (!props.src) return ''
|
||||||
|
// 绝对路径直接用
|
||||||
|
if (props.src.startsWith('/') || props.src.startsWith('http')) return props.src
|
||||||
|
// 相对路径:基于当前路由拼接
|
||||||
|
const base = route.path.replace(/\/$/, '')
|
||||||
|
return `${base}/${props.src}`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<img :src="resolvedSrc" :alt="alt" loading="lazy" decoding="async" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { siteConfig } from '~/config/site'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<footer class="py-6 text-center text-sm border-t" style="color: var(--color-text-secondary); border-color: var(--color-border);">
|
||||||
|
<p>
|
||||||
|
© {{ new Date().getFullYear() }} {{ siteConfig.siteName }}
|
||||||
|
·
|
||||||
|
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" class="hover:opacity-70" style="border-bottom: 1px solid var(--color-text-secondary); padding-bottom: 1px;">蜀ICP备2026028044号</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { siteConfig } from '~/config/site'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
const path = route.path
|
||||||
|
if (path === '/') return []
|
||||||
|
|
||||||
|
const parts = path.split('/').filter(Boolean)
|
||||||
|
const result: Array<{ label: string; path: string }> = []
|
||||||
|
|
||||||
|
let currentPath = ''
|
||||||
|
for (const part of parts) {
|
||||||
|
currentPath += `/${part}`
|
||||||
|
result.push({
|
||||||
|
label: part,
|
||||||
|
path: currentPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="sticky top-0 z-40 w-full backdrop-blur-sm border-b"
|
||||||
|
style="background-color: color-mix(in srgb, var(--color-bg) 80%, transparent); border-color: var(--color-border);">
|
||||||
|
<div class="relative flex h-10 items-center justify-between px-2">
|
||||||
|
<!-- 最左边:头像 + 面包屑 -->
|
||||||
|
<div class="flex items-center gap-1 min-w-0">
|
||||||
|
<NuxtLink to="/" class="shrink-0 hover:opacity-80 transition-opacity">
|
||||||
|
<img
|
||||||
|
:src="siteConfig.avatar"
|
||||||
|
:alt="siteConfig.siteName"
|
||||||
|
class="h-6 w-6 rounded-full nav-avatar"
|
||||||
|
/>
|
||||||
|
</NuxtLink>
|
||||||
|
<template v-for="(crumb, i) in breadcrumbs" :key="crumb.path">
|
||||||
|
<span class="shrink-0 text-muted-foreground/40">/</span>
|
||||||
|
<NuxtLink
|
||||||
|
:to="crumb.path"
|
||||||
|
class="text-xs font-medium truncate shrink min-w-0 hover:opacity-80 transition-opacity"
|
||||||
|
:class="i === breadcrumbs.length - 1 ? 'text-foreground' : 'text-muted-foreground'"
|
||||||
|
>
|
||||||
|
{{ crumb.label }}
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最右边:主题切换 -->
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<LayoutThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-avatar {
|
||||||
|
animation: spin 2s linear infinite;
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-avatar:hover {
|
||||||
|
animation-play-state: running;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
|
const modes = ['light', 'dark', 'system'] as const
|
||||||
|
type Mode = typeof modes[number]
|
||||||
|
|
||||||
|
const iconMap: Record<Mode, string> = {
|
||||||
|
light: 'mdi:weather-sunny',
|
||||||
|
dark: 'mdi:weather-night',
|
||||||
|
system: 'mdi:monitor',
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelMap: Record<Mode, string> = {
|
||||||
|
light: '浅色模式',
|
||||||
|
dark: '深色模式',
|
||||||
|
system: '跟随系统',
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMode = computed(() => colorMode.preference as Mode)
|
||||||
|
const currentIcon = computed(() => iconMap[currentMode.value] || iconMap.system)
|
||||||
|
const currentLabel = computed(() => labelMap[currentMode.value] || labelMap.system)
|
||||||
|
|
||||||
|
function cycle() {
|
||||||
|
const idx = modes.indexOf(currentMode.value)
|
||||||
|
colorMode.preference = modes[(idx + 1) % modes.length]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
@click="cycle"
|
||||||
|
class="inline-flex items-center justify-center rounded-md w-8 h-8 transition-colors"
|
||||||
|
style="color: var(--color-text-secondary)"
|
||||||
|
:class="[
|
||||||
|
'hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]',
|
||||||
|
]"
|
||||||
|
:aria-label="currentLabel"
|
||||||
|
:title="currentLabel"
|
||||||
|
>
|
||||||
|
<Icon :icon="currentIcon" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { extractTextFromAst } from '~/utils/content-ast'
|
||||||
|
|
||||||
|
export interface PostSearchFilters {
|
||||||
|
title: boolean
|
||||||
|
description: boolean
|
||||||
|
content: boolean
|
||||||
|
tags: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostSearchResult {
|
||||||
|
post: any
|
||||||
|
matchedLines: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQueryTerms(query: string): string[] {
|
||||||
|
const terms: string[] = []
|
||||||
|
const re = /"([^"]+)"|(\S+)/g
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = re.exec(query)) !== null) {
|
||||||
|
const t = (m[1] ?? m[2] ?? '').trim().toLowerCase()
|
||||||
|
if (t) terms.push(t)
|
||||||
|
}
|
||||||
|
return terms
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightText(text: string, terms: string[]): string {
|
||||||
|
if (!terms.length) return text
|
||||||
|
const escaped = terms
|
||||||
|
.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||||
|
.sort((a, b) => b.length - a.length)
|
||||||
|
const regex = new RegExp(`(${escaped.join('|')})`, 'gi')
|
||||||
|
return text.replace(regex, '<mark class="search-highlight">$1</mark>')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMatchedLines(plainText: string, terms: string[]): string[] {
|
||||||
|
if (!terms.length || !plainText) return []
|
||||||
|
const lines = plainText.split('\n')
|
||||||
|
const matched: string[] = []
|
||||||
|
for (const line of lines) {
|
||||||
|
const lower = line.toLowerCase()
|
||||||
|
if (terms.every((t) => lower.includes(t))) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (
|
||||||
|
trimmed
|
||||||
|
&& trimmed.length > 10
|
||||||
|
&& !trimmed.startsWith('#')
|
||||||
|
&& !trimmed.startsWith('.')
|
||||||
|
&& !trimmed.includes('{')
|
||||||
|
&& !trimmed.includes('}')
|
||||||
|
&& !trimmed.includes(':')
|
||||||
|
&& !trimmed.includes(';')
|
||||||
|
&& !trimmed.match(/^[a-z][\w-]*[\s]*\{/)
|
||||||
|
&& !trimmed.match(/^\.shiki/)
|
||||||
|
&& !trimmed.match(/^var\(--/)
|
||||||
|
&& !trimmed.match(/^html\b/)
|
||||||
|
&& !trimmed.match(/^pre\b/)
|
||||||
|
&& !trimmed.match(/^@/)
|
||||||
|
) {
|
||||||
|
matched.push(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePostSearch(
|
||||||
|
allPosts: Ref<any[] | null>,
|
||||||
|
searchQuery: Ref<string>,
|
||||||
|
filters: Ref<PostSearchFilters>,
|
||||||
|
) {
|
||||||
|
const searchResults = computed<PostSearchResult[]>(() => {
|
||||||
|
if (!allPosts.value || !searchQuery.value.trim()) {
|
||||||
|
return allPosts.value?.map((p) => ({ post: p, matchedLines: [] })) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const terms = parseQueryTerms(searchQuery.value)
|
||||||
|
if (!terms.length) return allPosts.value.map((p) => ({ post: p, matchedLines: [] }))
|
||||||
|
|
||||||
|
const results: PostSearchResult[] = []
|
||||||
|
|
||||||
|
for (const post of allPosts.value) {
|
||||||
|
const title = (post.title || '').toLowerCase()
|
||||||
|
const desc = (post.description || '').toLowerCase()
|
||||||
|
|
||||||
|
const matchTitle = filters.value.title && terms.some((t) => title.includes(t))
|
||||||
|
const matchDesc = filters.value.description && terms.some((t) => desc.includes(t))
|
||||||
|
const matchTags = filters.value.tags && (post.tags || []).some((tag: string) => terms.some((t) => tag.toLowerCase().includes(t)))
|
||||||
|
|
||||||
|
let matchContent = false
|
||||||
|
let matchedLines: string[] = []
|
||||||
|
if (filters.value.content) {
|
||||||
|
const plainText = extractTextFromAst(post.body?.value || post.body || null).toLowerCase()
|
||||||
|
matchContent = terms.some((t) => plainText.includes(t))
|
||||||
|
if (matchContent) {
|
||||||
|
matchedLines = getMatchedLines(
|
||||||
|
extractTextFromAst(post.body?.value || post.body || null),
|
||||||
|
terms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchTitle || matchDesc || matchContent || matchTags) {
|
||||||
|
results.push({ post, matchedLines })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredPosts = computed(() => searchResults.value.map((r) => r.post))
|
||||||
|
|
||||||
|
function highlight(text: string): string {
|
||||||
|
if (!searchQuery.value.trim()) return text
|
||||||
|
return highlightText(text, parseQueryTerms(searchQuery.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTerms(): string[] {
|
||||||
|
return parseQueryTerms(searchQuery.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchResults,
|
||||||
|
filteredPosts,
|
||||||
|
highlight,
|
||||||
|
getTerms,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export const siteConfig = {
|
||||||
|
// 站点信息(改这里定制全站)
|
||||||
|
name: 'Nixus',
|
||||||
|
siteName: '尼克的小窝',
|
||||||
|
title: '《尼克的小窝》官方网站',
|
||||||
|
subtitle: 'Nixus',
|
||||||
|
description: '专注于IT/互联网技术分享与实践的个人技术博客',
|
||||||
|
url: 'https://nixus.top/',
|
||||||
|
keywords: ['尼克', '博客', 'Nixus Blog', '技术博客'],
|
||||||
|
|
||||||
|
// 头像和图标
|
||||||
|
avatar: 'https://q2.qlogo.cn/headimg_dl?dst_uin=1042864399&spec=100',
|
||||||
|
favicon: 'https://q2.qlogo.cn/headimg_dl?dst_uin=1042864399&spec=100',
|
||||||
|
ogImage: 'https://q2.qlogo.cn/headimg_dl?dst_uin=1042864399&spec=100',
|
||||||
|
|
||||||
|
// 作者信息
|
||||||
|
author: {
|
||||||
|
name: 'Nixus',
|
||||||
|
url: 'https://nixus.top/',
|
||||||
|
},
|
||||||
|
bio: 'Protect What You Love.',
|
||||||
|
|
||||||
|
// 社交链接
|
||||||
|
socialLinks: [
|
||||||
|
{ name: 'B站', icon: 'simple-icons:bilibili', url: 'https://space.bilibili.com/296124701', color: '#fb7299' },
|
||||||
|
{ name: 'QQ', icon: 'simple-icons:qq', url: 'https://qm.qq.com/cgi-bin/qm/qr?k=placeholder&group_code=1042864399', color: '#12B7F5' },
|
||||||
|
{ name: 'GitHub', icon: 'simple-icons:github', url: 'https://github.com/wishesl', color: '#333333' },
|
||||||
|
{ name: 'Gitee', icon: 'simple-icons:gitee', url: 'https://gitee.com/blushes', color: '#c71d23' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 导航菜单
|
||||||
|
navLinks: [
|
||||||
|
{ label: '博客', icon: 'mdi:post-outline', href: '/posts' },
|
||||||
|
{ label: '封面制作', icon: 'mdi:image-edit-outline', href: '/cover' },
|
||||||
|
{ label: '友链', icon: 'mdi:link-variant', href: '/friends' },
|
||||||
|
{ label: '赞助', icon: 'mdi:heart-outline', href: '/sponsor' },
|
||||||
|
{ label: '关于', icon: 'mdi:information-outline', href: '/about' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 友链
|
||||||
|
friends: [
|
||||||
|
{ name: '二叉树树', url: 'https://2x.nz', avatar: 'https://q2.qlogo.cn/headimg_dl?dst_uin=2726730791&spec=100', description: '专注于IT/互联网技术分享与实践的个人技术博客' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 赞助
|
||||||
|
sponsors: {
|
||||||
|
qrcode: {
|
||||||
|
wechat: '/sponsors/qrcode/wechat.png',
|
||||||
|
},
|
||||||
|
list: [] as { name: string; date: string; amount: string }[],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Giscus 评论配置
|
||||||
|
giscus: {
|
||||||
|
repo: 'wishesl/SuBlog',
|
||||||
|
repoId: 'R_kgDOSsypJg',
|
||||||
|
category: 'Announcements',
|
||||||
|
categoryId: 'DIC_kwDOSsypJs4C-Mks',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SiteConfig = typeof siteConfig
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
|
||||||
|
|
||||||
|
export default defineContentConfig({
|
||||||
|
collections: {
|
||||||
|
posts: defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: '**/*.md',
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
published: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
image: z.string().optional(),
|
||||||
|
draft: z.boolean().optional(),
|
||||||
|
pinned: z.boolean().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
author: z.string().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1067" height="600" viewBox="0 0 1067 600" style="max-width: 100%; height: auto; cursor: 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="rgba(255, 255, 255, 1)"/><!----><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: 22px; font-family: sans-serif; font-weight: 600;"><div style="order: 1; width: 123px; height: 123px; display: flex; align-items: center; justify-content: center; background-color: transparent; backdrop-filter: none; border-radius: 0px;"><div style="max-width: 103px; max-height: 103px; flex-shrink: 0; color: rgb(0, 0, 0); filter: drop-shadow(rgba(0, 0, 0, 0) 0px 0px 0px); display: flex; align-items: center; justify-content: center; border-radius: 6%; overflow: hidden;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 185" width="100%" height="100%" preserveAspectRatio="xMidYMid meet"><path fill="#2396ed" d="M250.716 70.497c-5.765-4-18.976-5.5-29.304-3.5c-1.2-10-6.725-18.749-16.333-26.499l-5.524-4l-3.844 5.75c-4.803 7.5-7.205 18-6.485 28c.24 3.499 1.441 9.749 5.044 15.249c-3.362 2-10.328 4.5-19.455 4.5H1.155l-.48 2c-1.682 9.999-1.682 41.248 18.014 65.247c14.892 18.249 36.99 27.499 66.053 27.499c62.93 0 109.528-30.25 131.386-84.997c8.647.25 27.142 0 36.51-18.75c.24-.5.72-1.5 2.401-5.249l.961-2zM139.986 0h-26.42v24.999h26.42zm0 29.999h-26.42v24.999h26.42zm-31.225 0h-26.42v24.999h26.42zm-31.225 0H51.115v24.999h26.421zM46.311 59.998H19.89v24.999h26.42zm31.225 0H51.115v24.999h26.421zm31.225 0h-26.42v24.999h26.42zm31.226 0h-26.422v24.999h26.422zm31.225 0H144.79v24.999h26.422z"/></svg></div></div><span style="order: 0; font-size: 86px; color: rgb(0, 0, 0); text-shadow: rgba(0, 0, 0, 0.09) 0px 0px 0px; line-height: 1; white-space: nowrap;">编译 musl</span><span style="order: 2; font-size: 86px; color: rgb(0, 0, 0); text-shadow: rgba(0, 0, 0, 0.09) 0px 0px 0px; line-height: 1; white-space: nowrap;">可执行文件</span></div></foreignObject><g class="ratio-guide" style="display: none;"><rect x="0.16666666666674246" y="0" width="1066.6666666666665" height="600" fill="none" stroke="rgba(255,0,0,0.5)" stroke-width="2" stroke-dasharray="10 5"/><text x="10.166666666666742" y="30" fill="rgba(255,0,0,0.5)" font-size="20">16:9</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" style="display: none;"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,132 @@
|
|||||||
|
---
|
||||||
|
title: Docker 编译 musl 可执行文件
|
||||||
|
published: 2025-03-27
|
||||||
|
image: /posts/docker-musl-build/img/cover.svg
|
||||||
|
description: 解决 Go 语言跨平台编译问题,使用 Docker + musl 编译静态链接的可执行文件。
|
||||||
|
tags: ['Docker', 'Golang', '运维']
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## 解决问题
|
||||||
|
|
||||||
|
用 Go 进行跨平台编译时往往步骤复杂,用 Docker 可以轻松解决。但 Docker 的当前最小镜像 Alpine 其动态链接库采用 musl,与主流的 glibc 不同,往往不能方便执行。
|
||||||
|
|
||||||
|
## 查阅资料
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动容器并挂载当前目录到 `/app`
|
||||||
|
docker run -it --rm -v "$PWD":/app -w /app ubuntu:20.04 bash
|
||||||
|
|
||||||
|
# 在容器内执行以下操作:
|
||||||
|
apt-get update && apt-get install -y musl-tools golang-go # 安装 musl 和 Go
|
||||||
|
export CC=musl-gcc # 指定 musl 编译器
|
||||||
|
export CGO_ENABLED=1 # 启用 CGO
|
||||||
|
# 设置代理
|
||||||
|
go env -w GOPROXY=https://goproxy.cn,direct
|
||||||
|
go build -ldflags '-linkmode external -extldflags "-static"' -o myapp # 静态编译
|
||||||
|
exit # 退出容器
|
||||||
|
|
||||||
|
# 验证结果(在宿主机执行)
|
||||||
|
file myapp # 应显示 "statically linked"
|
||||||
|
ldd myapp # 应显示 "not a dynamic executable"
|
||||||
|
|
||||||
|
wget https://go.dev/dl/go1.21.linux-amd64.tar.gz
|
||||||
|
|
||||||
|
ENV PATH="/data/go/bin:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 遇到问题
|
||||||
|
|
||||||
|
1. `go mod tidy` 时报错 `tls: failed to verify certificate: x509: certificate`
|
||||||
|
|
||||||
|
解决:`RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*`
|
||||||
|
|
||||||
|
基础镜像的证书未更新,导致 https 的请求出现类似错误。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
2. 直接安装 `golang-go` 其版本过低
|
||||||
|
|
||||||
|
解决: `PATH="/data/go/bin:$PATH"` 手动指定环境
|
||||||
|
|
||||||
|
## 最终方案
|
||||||
|
|
||||||
|
1. 下载 `go1.24.1.linux-amd64.tar.gz` 解压
|
||||||
|
2. 编写 Dockerfile
|
||||||
|
3. 执行 `docker build -t go-build:v1 .`
|
||||||
|
|
||||||
|
Dockerfile 内容:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM ubuntu:20.04
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.authors="sutong"
|
||||||
|
|
||||||
|
COPY go/ /go/
|
||||||
|
|
||||||
|
ENV PATH="/go/bin:$PATH"
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y musl-tools
|
||||||
|
|
||||||
|
## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate
|
||||||
|
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
# 指定 musl 编译器
|
||||||
|
ENV CC=musl-gcc
|
||||||
|
# 启用 CGO
|
||||||
|
ENV CGO_ENABLED=1
|
||||||
|
|
||||||
|
VOLUME /data
|
||||||
|
|
||||||
|
WORKDIR /data
|
||||||
|
|
||||||
|
CMD ["/bin/bash"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# docker build -t go-build:v1 .
|
||||||
|
|
||||||
|
# 直接执行编译 编译后删除
|
||||||
|
docker run --rm -v .:/data go-build:v1 go build -o app_musl
|
||||||
|
|
||||||
|
# 构建编译环境
|
||||||
|
docker run -it -d -v .:/data --name <容器名称> go-build:v1 /bin/bash
|
||||||
|
# eg:docker run -it -d -v .:/data --name logbus-build go-build:v1 /bin/bash
|
||||||
|
|
||||||
|
docker exec -it <容器名称> go build -o <myapp>
|
||||||
|
# eg:docker exec -it logbus-build go build -o logbus_musl_1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 额外注意
|
||||||
|
|
||||||
|
若没科学上网需要设置代理:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it <容器名称> /bin/bash
|
||||||
|
go env -w GOPROXY=https://goproxy.cn,direct
|
||||||
|
go build -ldflags '-linkmode external -extldflags "-static"' -o myapp # 静态编译
|
||||||
|
go build # musl 库编译
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行环境示例
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.authors="sutong"
|
||||||
|
|
||||||
|
COPY glog_musl /app/
|
||||||
|
|
||||||
|
COPY conf.ini /data/
|
||||||
|
COPY ipname.ini /data/
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN chmod 777 /app/glog_musl
|
||||||
|
|
||||||
|
CMD ["./glog_musl", "logsweb", "/data/conf.ini"]
|
||||||
|
|
||||||
|
# docker build -t glog:v1 .
|
||||||
|
# docker run -p 6801:6801 glog:v1
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1067" height="600" viewBox="0 0 1067 600" style="max-width: 100%; height: auto; cursor: 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="rgba(255, 255, 255, 1)"/><!----><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: 22px; font-family: sans-serif; font-weight: 600;"><div style="order: 1; width: 123px; height: 123px; display: flex; align-items: center; justify-content: center; background-color: transparent; backdrop-filter: none; border-radius: 0px;"><div style="max-width: 103px; max-height: 103px; flex-shrink: 0; color: rgb(0, 0, 0); filter: drop-shadow(rgba(0, 0, 0, 0) 0px 0px 0px); display: flex; align-items: center; justify-content: center; border-radius: 6%; overflow: hidden;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 185" width="100%" height="100%" preserveAspectRatio="xMidYMid meet"><path fill="#2396ed" d="M250.716 70.497c-5.765-4-18.976-5.5-29.304-3.5c-1.2-10-6.725-18.749-16.333-26.499l-5.524-4l-3.844 5.75c-4.803 7.5-7.205 18-6.485 28c.24 3.499 1.441 9.749 5.044 15.249c-3.362 2-10.328 4.5-19.455 4.5H1.155l-.48 2c-1.682 9.999-1.682 41.248 18.014 65.247c14.892 18.249 36.99 27.499 66.053 27.499c62.93 0 109.528-30.25 131.386-84.997c8.647.25 27.142 0 36.51-18.75c.24-.5.72-1.5 2.401-5.249l.961-2zM139.986 0h-26.42v24.999h26.42zm0 29.999h-26.42v24.999h26.42zm-31.225 0h-26.42v24.999h26.42zm-31.225 0H51.115v24.999h26.421zM46.311 59.998H19.89v24.999h26.42zm31.225 0H51.115v24.999h26.421zm31.225 0h-26.42v24.999h26.42zm31.226 0h-26.422v24.999h26.422zm31.225 0H144.79v24.999h26.422z"/></svg></div></div><span style="order: 0; font-size: 86px; color: rgb(0, 0, 0); text-shadow: rgba(0, 0, 0, 0.09) 0px 0px 0px; line-height: 1; white-space: nowrap;">构建时</span><span style="order: 2; font-size: 86px; color: rgb(0, 0, 0); text-shadow: rgba(0, 0, 0, 0.09) 0px 0px 0px; line-height: 1; white-space: nowrap;">指定用户启动</span></div></foreignObject><g class="ratio-guide" style="display: none;"><rect x="0.16666666666674246" y="0" width="1066.6666666666665" height="600" fill="none" stroke="rgba(255,0,0,0.5)" stroke-width="2" stroke-dasharray="10 5"/><text x="10.166666666666742" y="30" fill="rgba(255,0,0,0.5)" font-size="20">16:9</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" style="display: none;"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
title: Docker 构建时指定用户启动
|
||||||
|
published: 2025-04-01
|
||||||
|
image: /posts/docker-user-start/img/cover.svg
|
||||||
|
description: 解决 Docker 容器默认以 root 用户运行导致的权限问题,指定非 root 用户启动应用。
|
||||||
|
tags: ['Docker', '运维', 'Linux']
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前言
|
||||||
|
|
||||||
|
我们在 Linux 使用应用时,往往不会直接使用 root 用户作为应用启动项,但 Dockerfile 默认就是 root 用户,导致挂载生成的文件路径往往是 root 权限,非 root 用户访问就很不方便。
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
### 1. 安装用户管理工具
|
||||||
|
|
||||||
|
若镜像非自带用户管理:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# 权限管理
|
||||||
|
RUN apk add --no-cache shadow
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 创建用户并修改权限
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# 创建用户组和用户
|
||||||
|
RUN addgroup -S <app_user_group> && adduser -S -G <app_user_group> <app_user>
|
||||||
|
|
||||||
|
# 修改目录权限
|
||||||
|
RUN chown -R <app_user_group>:<app_user> /data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 指定用户启动
|
||||||
|
|
||||||
|
执行 docker 时加 `--user` 参数:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it --user <app_user> <app_image>:<image_version>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# 国内源
|
||||||
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
|
||||||
|
|
||||||
|
RUN apk update --no-cache
|
||||||
|
# 权限管理
|
||||||
|
RUN apk add --no-cache shadow
|
||||||
|
# 调试
|
||||||
|
RUN apk add --no-cache bash
|
||||||
|
# 设置时区
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# 创建用户组和用户
|
||||||
|
RUN addgroup -S glog && adduser -S -G glog glog
|
||||||
|
|
||||||
|
COPY ./glog_static_musl_1 /app/
|
||||||
|
|
||||||
|
RUN mkdir /data
|
||||||
|
|
||||||
|
VOLUME [ "/data" ]
|
||||||
|
WORKDIR /data
|
||||||
|
|
||||||
|
# 修改权限
|
||||||
|
RUN chown -R glog:glog /data
|
||||||
|
RUN chown -R glog:glog /app
|
||||||
|
|
||||||
|
# 指定用户启动
|
||||||
|
USER glog
|
||||||
|
CMD [ "/app/glog_static_musl_1", "logsweb", "./conf.ini" ]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
关键步骤:
|
||||||
|
|
||||||
|
1. `apk add --no-cache shadow` - 安装用户管理工具
|
||||||
|
2. `addgroup` + `adduser` - 创建用户
|
||||||
|
3. `chown -R` - 修改目录权限
|
||||||
|
4. `USER <username>` - 指定运行用户
|
||||||
|
|
||||||
|
这样容器内的应用就不再以 root 身份运行,更加安全。
|
||||||
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
title: Hello World - 欢迎来到尼克的小窝
|
||||||
|
published: 2026-05-31T08:00:00
|
||||||
|
description: 博客上线啦!这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。
|
||||||
|
image: /posts/hello-world/img/cover.jpg
|
||||||
|
pinned: true
|
||||||
|
tags: ['博客', 'Nuxt']
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## 博客上线啦!
|
||||||
|
|
||||||
|
Hello World!欢迎来到**尼克的小窝**,这是我的第一篇博客文章。
|
||||||
|
|
||||||
|
经过一段时间的折腾,这个基于 Nuxt 3 的个人博客终于上线了。在这里记录一下这个项目的技术选型和功能特性。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
本站使用了以下技术:
|
||||||
|
|
||||||
|
| 技术 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| **Nuxt 3** | Vue 3 全栈框架,提供 SSG、路由、API 等能力 |
|
||||||
|
| **TypeScript** | 类型安全,提升开发体验 |
|
||||||
|
| **Tailwind CSS v4** | 原子化 CSS,快速构建 UI |
|
||||||
|
| **@nuxt/content** | Markdown 驱动的内容管理 |
|
||||||
|
| **Shiki** | 代码语法高亮 |
|
||||||
|
| **Giscus** | 基于 GitHub Discussions 的评论系统 |
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
目前已实现的功能:
|
||||||
|
|
||||||
|
- **博客系统** - Markdown 文章,支持代码高亮、目录导航、搜索
|
||||||
|
- **暗色模式** - 跟随系统或手动切换
|
||||||
|
- **文章搜索** - 支持标题、描述、正文、标签多维度搜索
|
||||||
|
- **标签系统** - 文章标签分类,高频标签快速筛选
|
||||||
|
- **友链页面** - 展示友情链接,支持申请
|
||||||
|
- **赞助页面** - 微信收款码展示
|
||||||
|
- **封面制作** - 在线生成封面图片工具
|
||||||
|
- **网易云热评** - 首页随机展示音乐评论
|
||||||
|
- **访客统计** - 不蒜子计数器,带数字滚动动画
|
||||||
|
- **SEO 优化** - 自动生成站点地图、Open Graph 标签
|
||||||
|
|
||||||
|
## 代码示例
|
||||||
|
|
||||||
|
本站的配置非常集中,一个文件搞定全站定制:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// config/site.ts
|
||||||
|
export const siteConfig = {
|
||||||
|
siteName: '尼克的小窝',
|
||||||
|
url: 'https://nixus.top/',
|
||||||
|
avatar: 'https://q2.qlogo.cn/headimg_dl?dst_uin=xxx&spec=100',
|
||||||
|
// ... 改这一个文件,全站生效
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
文章的 frontmatter 也很简洁:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: 文章标题
|
||||||
|
published: 2026-05-31T08:00:00
|
||||||
|
description: 文章简介
|
||||||
|
tags: ['标签1', '标签2']
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
本站使用 SSG 静态生成,构建后直接部署到静态托管即可:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx nuxi generate
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物在 `.output/public` 目录下,可以直接丢到 Nginx、Vercel、Netlify 等平台。
|
||||||
|
|
||||||
|
## 后续计划
|
||||||
|
|
||||||
|
- [ ] RSS 订阅
|
||||||
|
- [ ] 文章目录优化
|
||||||
|
- [ ] 更多小工具
|
||||||
|
- [ ] 持续输出内容
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
感谢 [二叉树树](https://2x.nz) 的开源项目,本站的 UI 设计参考了他的项目。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**感谢阅读!如果觉得不错,欢迎点个 Star 或者留个评论 (☞゚ヮ゚)☞**
|
||||||
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,162 @@
|
|||||||
|
---
|
||||||
|
title: Nuxt Content v3 踩坑记录:目录、图片与那些反直觉的设计
|
||||||
|
published: 2026-05-31T14:00:00
|
||||||
|
description: 从 SvelteKit 迁移到 Nuxt 3 的过程中,遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。
|
||||||
|
image: /posts/nuxt-content-lessons/img/cover.jpg
|
||||||
|
tags: ['Nuxt', '踩坑', 'Nuxt Content']
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# 背景
|
||||||
|
|
||||||
|
最近在用 Nuxt 3 + Nuxt Content v3 搭建一个个人博客,参考了一个 SvelteKit 项目(svaf)的架构。原以为换个框架只是语法差异,没想到在几个关键功能上踩了不少坑。
|
||||||
|
|
||||||
|
这篇文章记录了三个核心问题的排查过程和最终方案。
|
||||||
|
|
||||||
|
# 问题一:目录导航(TOC)只有 h2
|
||||||
|
|
||||||
|
## 现象
|
||||||
|
|
||||||
|
Nuxt Content 内置了 TOC 数据,挂在 `post.body.toc` 上。直接用它渲染目录,发现**只有 h2 标题,h3 全部丢失**。
|
||||||
|
|
||||||
|
## 排查
|
||||||
|
|
||||||
|
查了官方文档,发现 `build.markdown.toc.depth` 可以控制深度:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// nuxt.config.ts
|
||||||
|
content: {
|
||||||
|
build: {
|
||||||
|
markdown: {
|
||||||
|
toc: { depth: 3 } // 包含 h3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
配置改了,清了缓存重启,**依然只有 h2**。这个配置似乎不起作用。
|
||||||
|
|
||||||
|
## 最终方案
|
||||||
|
|
||||||
|
放弃依赖 Nuxt Content 的 toc,直接从 `post.body` 的 AST 里递归提取所有标题:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function extractHeadings(node: any, list: Heading[] = []): Heading[] {
|
||||||
|
if (!node) return list
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
// minimark 格式:["h2", {id: "xxx"}, "文本"]
|
||||||
|
if (typeof node[0] === 'string' && /^h[1-6]$/.test(node[0])) {
|
||||||
|
const level = Number(node[0].charAt(1))
|
||||||
|
const text = extractText(node.slice(2)).trim()
|
||||||
|
list.push({ id: node[1]?.id || slugify(text), text, level })
|
||||||
|
}
|
||||||
|
for (const child of node) extractHeadings(child, list)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
关键点:Nuxt Content v3 的 body 使用 **minimark** 格式,标题节点是数组 `["h2", {id: "xxx"}, "标题文本"]`,不是对象。
|
||||||
|
|
||||||
|
# 问题二:图片路径解析错误
|
||||||
|
|
||||||
|
## 现象
|
||||||
|
|
||||||
|
markdown 里用相对路径引用图片:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
HTML 输出的 `<img src="img/sshfs.avif">` 看起来没问题,但浏览器请求的却是 `/posts/img/sshfs.avif` 而不是 `/posts/sshfs/img/sshfs.avif`。
|
||||||
|
|
||||||
|
## 排查
|
||||||
|
|
||||||
|
1. 检查了 `<base>` 标签 —— 没有
|
||||||
|
2. 检查了重定向 —— 没有
|
||||||
|
3. 直接访问 `/posts/sshfs/img/sshfs.avif` —— 返回 200 OK
|
||||||
|
4. 直接访问 `/posts/img/sshfs.avif` —— 404
|
||||||
|
|
||||||
|
服务器路由没问题,问题是**浏览器解析相对路径时基于了错误的基准 URL**。
|
||||||
|
|
||||||
|
这可能和 Nuxt 的客户端路由有关:SPA 导航时,浏览器的"当前页面"和 Vue Router 的"当前路由"不同步,导致相对路径解析出错。
|
||||||
|
|
||||||
|
## 最终方案
|
||||||
|
|
||||||
|
两步解决:
|
||||||
|
|
||||||
|
**第一步:自定义 ProseImg 组件**
|
||||||
|
|
||||||
|
创建 `components/content/ProseImg.vue`,覆盖 Nuxt Content 默认的图片渲染:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup>
|
||||||
|
const route = useRoute()
|
||||||
|
const resolvedSrc = computed(() => {
|
||||||
|
if (!props.src) return ''
|
||||||
|
if (props.src.startsWith('/') || props.src.startsWith('http')) return props.src
|
||||||
|
const base = route.path.replace(/\/$/, '')
|
||||||
|
return `${base}/${props.src}`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<img :src="resolvedSrc" :alt="alt" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**第二步:启用 MDC prose 组件**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// nuxt.config.ts
|
||||||
|
mdc: {
|
||||||
|
components: {
|
||||||
|
prose: true // 让 Nuxt Content 使用自定义 ProseImg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 问题三:content 目录的图片如何服务
|
||||||
|
|
||||||
|
## 现象
|
||||||
|
|
||||||
|
原站(SvelteKit)的图片放在 `content/posts/{slug}/img/` 下,markdown 用相对路径引用,一切正常。Nuxt 项目里,content 目录的文件不会被当成静态资源服务。
|
||||||
|
|
||||||
|
## 方案对比
|
||||||
|
|
||||||
|
| 方案 | 优点 | 缺点 |
|
||||||
|
|------|------|------|
|
||||||
|
| 复制到 public/ | 简单 | 文件重复,需同步 |
|
||||||
|
| Vite 插件拦截 | 原站方案 | Nuxt 不直接支持 |
|
||||||
|
| Server Route | 不复制文件 | 需要手动写路由 |
|
||||||
|
|
||||||
|
最终选择 **Server Route**,和原站的 Vite 插件思路一致:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server/routes/posts/[slug]/img/[...file].ts
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const url = getRequestURL(event)
|
||||||
|
const match = url.pathname.match(/^\/posts\/([^/]+)\/img\/(.+)$/)
|
||||||
|
if (!match) return
|
||||||
|
|
||||||
|
const [, slug, filename] = match
|
||||||
|
const filePath = resolve('content/posts', slug, 'img', filename)
|
||||||
|
const data = await readFile(filePath)
|
||||||
|
setResponseHeader(event, 'content-type', MIME[ext])
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
请求 `/posts/sshfs/img/sshfs.avif` → 读取 `content/posts/sshfs/img/sshfs.avif` → 返回图片。
|
||||||
|
|
||||||
|
# 总结
|
||||||
|
|
||||||
|
| 问题 | 根因 | 方案 |
|
||||||
|
|------|------|------|
|
||||||
|
| TOC 只有 h2 | `toc.depth` 配置不生效 | 从 body AST 手动提取 |
|
||||||
|
| 图片路径错误 | SPA 路由导致相对路径解析基准错误 | ProseImg 组件 + mdc.prose 配置 |
|
||||||
|
| content 图片 404 | content 目录不暴露给浏览器 | Server Route 直接读取返回 |
|
||||||
|
|
||||||
|
Nuxt Content v3 的设计理念是"图片放 public/,用绝对路径"。如果想像 SvelteKit 项目那样把图片和文章放一起,需要额外做不少工作。
|
||||||
|
|
||||||
|
不过一旦这些坑都踩过,整体开发体验还是很流畅的。Nuxt 的生态系统、自动导入、模块系统都比 SvelteKit 成熟很多。
|
||||||
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
title: Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题
|
||||||
|
published: 2026-05-31T18:47:00
|
||||||
|
image: /posts/nuxt-tailwind-underline-fix/img/cover.png
|
||||||
|
description: 记录 Nuxt 3 配合 Tailwind CSS 时,客户端导航导致链接文字出现下划线闪烁的问题及解决方案。
|
||||||
|
tags: ['Nuxt', 'Tailwind CSS', 'CSS', '踩坑']
|
||||||
|
author: Nixus
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
在 Nuxt 3 项目中使用 Tailwind CSS 时,发现一个奇怪的现象:
|
||||||
|
|
||||||
|
- 首次通过客户端导航(点击 `<NuxtLink>`)跳转到某个页面时,**所有链接文字都会出现下划线**
|
||||||
|
- 手动刷新页面后,下划线消失,样式恢复正常
|
||||||
|
|
||||||
|
这个问题在开发模式和生产构建后都会出现。
|
||||||
|
|
||||||
|
## 原因分析
|
||||||
|
|
||||||
|
Tailwind CSS 的 Preflight(基于 modern-normalize)会重置链接样式:
|
||||||
|
|
||||||
|
```css
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
正常情况下,这会让链接继承父元素的 `text-decoration`(通常是 `none`),从而去掉浏览器默认的下划线。
|
||||||
|
|
||||||
|
问题出在 **Nuxt 的客户端导航机制**:
|
||||||
|
|
||||||
|
1. 用户点击 `<NuxtLink>`,Vue Router 拦截导航
|
||||||
|
2. 新页面组件异步加载并渲染
|
||||||
|
3. **在 CSS 完全加载/应用之前,页面已经渲染了**
|
||||||
|
4. 此时浏览器使用默认样式(链接带下划线)
|
||||||
|
5. CSS 加载完成后,下划线消失
|
||||||
|
|
||||||
|
这就是典型的 **FOUC(Flash of Unstyled Content)** 问题。
|
||||||
|
|
||||||
|
## 为什么刷新就正常了?
|
||||||
|
|
||||||
|
刷新页面时,浏览器会等待所有 CSS 加载完成后再渲染页面(SSR 模式下 HTML 和 CSS 是一起返回的)。所以刷新后样式是正确的。
|
||||||
|
|
||||||
|
而客户端导航是 JavaScript 驱动的,CSS 的加载和页面的渲染是异步的,就出现了时序差。
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
尝试了多种方案:
|
||||||
|
|
||||||
|
- `@layer base` 覆盖 — 无效,CSS layers 的优先级问题
|
||||||
|
- `corePlugins.preflight: false` — 无效,且破坏其他基础样式
|
||||||
|
- `features.inlineStyles: true` — 无效
|
||||||
|
- 页面过渡动画 — 只是掩盖,不能根治
|
||||||
|
|
||||||
|
最终有效的方案是在 `app.vue` 中用 `!important` 强制覆盖:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<style>
|
||||||
|
a:not(.prose a) {
|
||||||
|
text-decoration-line: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键点:**
|
||||||
|
- `!important` 是为了对抗 Tailwind CSS layers 的优先级
|
||||||
|
- `:not(.prose a)` 排除文章正文区域,保留文章内链接的下划线(可读性需要)
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
这是 Nuxt 3 + Tailwind CSS 的一个已知问题,本质上是 CSS 加载时序导致的 FOUC。`!important` 虽然不够优雅,但在这个场景下是务实的解决方案。
|
||||||
|
|
||||||
|
如果你有更好的方案,欢迎在 [Issues](https://github.com/wishesl/SuBlog/issues) 提出!
|
||||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
title: SvelteKit 入门:为什么它比你想象的更简单
|
||||||
|
published: 2026-05-30
|
||||||
|
description: SvelteKit 是一个全栈 Svelte 框架,这篇文章带你了解它的核心概念和优势。
|
||||||
|
image: /posts/sveltekit-intro/img/cover.jpg
|
||||||
|
tags: ['SvelteKit', '前端框架']
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## 什么是 SvelteKit?
|
||||||
|
|
||||||
|
SvelteKit 是 Svelte 的官方全栈框架,类似于 React 之于 Next.js 的关系。它提供了路由、SSR、SSG 等能力,让开发者可以快速构建现代 Web 应用。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 核心特点
|
||||||
|
|
||||||
|
### 1. 编译时框架
|
||||||
|
|
||||||
|
Svelte 和 React/Vue 最大的区别在于:**它在编译阶段就把组件转成原生 JS**,运行时没有虚拟 DOM。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Svelte 组件(编译后变成直接的 DOM 操作)
|
||||||
|
let count = 0
|
||||||
|
function increment() {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 文件路由
|
||||||
|
|
||||||
|
`src/routes/` 目录结构即 URL,不需要手动配置路由表:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/routes/
|
||||||
|
├── +page.svelte → /
|
||||||
|
├── about/
|
||||||
|
│ └── +page.svelte → /about
|
||||||
|
└── posts/
|
||||||
|
├── +page.svelte → /posts
|
||||||
|
└── [slug]/
|
||||||
|
└── +page.svelte → /posts/:slug
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 全栈能力
|
||||||
|
|
||||||
|
SvelteKit 内置了服务端渲染(SSR)、静态站点生成(SSG)、API 路由等功能,开箱即用。
|
||||||
|
|
||||||
|
## 和其他框架对比
|
||||||
|
|
||||||
|
| 特性 | SvelteKit | Next.js | Nuxt |
|
||||||
|
|------|-----------|---------|------|
|
||||||
|
| 学习曲线 | 低 | 中 | 中 |
|
||||||
|
| 包体积 | 小 | 大 | 中 |
|
||||||
|
| 运行时开销 | 无 | 有 | 有 |
|
||||||
|
| 生态 | 小 | 大 | 大 |
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
如果你追求**简洁的代码**和**极致的性能**,SvelteKit 是一个非常值得尝试的选择。它的学习曲线平缓,写起来就像在写原生 HTML/CSS/JS。
|
||||||
|
|
||||||
|

|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { siteConfig } from '~/config/site'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: (title) => title ? `${title} - ${siteConfig.siteName}` : siteConfig.title,
|
||||||
|
link: [
|
||||||
|
{ rel: 'icon', href: siteConfig.favicon },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: siteConfig.title,
|
||||||
|
description: siteConfig.description,
|
||||||
|
keywords: siteConfig.keywords.join(','),
|
||||||
|
ogTitle: siteConfig.title,
|
||||||
|
ogDescription: siteConfig.description,
|
||||||
|
ogImage: siteConfig.ogImage,
|
||||||
|
ogUrl: siteConfig.url,
|
||||||
|
twitterCard: 'summary_large_image',
|
||||||
|
twitterTitle: siteConfig.title,
|
||||||
|
twitterDescription: siteConfig.description,
|
||||||
|
twitterImage: siteConfig.ogImage,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col">
|
||||||
|
<LayoutNavBar />
|
||||||
|
<main class="flex-1">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<LayoutFooter />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { cpSync, existsSync, mkdirSync, readdirSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { siteConfig } from './config/site'
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-01-01',
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
css: ['~/assets/css/main.css'],
|
||||||
|
|
||||||
|
modules: [
|
||||||
|
'@nuxtjs/tailwindcss',
|
||||||
|
'@nuxtjs/color-mode',
|
||||||
|
'@nuxt/content',
|
||||||
|
'@nuxtjs/sitemap',
|
||||||
|
],
|
||||||
|
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
htmlAttrs: { lang: 'zh-CN' },
|
||||||
|
charset: 'utf-8',
|
||||||
|
viewport: 'width=device-width, initial-scale=1',
|
||||||
|
meta: [
|
||||||
|
{ name: 'description', content: siteConfig.description },
|
||||||
|
{ property: 'og:type', content: 'website' },
|
||||||
|
{ property: 'og:locale', content: 'zh_CN' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
colorMode: {
|
||||||
|
classSuffix: '',
|
||||||
|
preference: 'system',
|
||||||
|
fallback: 'light',
|
||||||
|
},
|
||||||
|
|
||||||
|
content: {
|
||||||
|
build: {
|
||||||
|
markdown: {
|
||||||
|
toc: {
|
||||||
|
depth: 3,
|
||||||
|
},
|
||||||
|
highlight: {
|
||||||
|
theme: {
|
||||||
|
light: 'github-light',
|
||||||
|
dark: 'github-dark',
|
||||||
|
},
|
||||||
|
langs: [
|
||||||
|
'javascript', 'js', 'cjs', 'mjs', 'jsx',
|
||||||
|
'typescript', 'ts', 'cts', 'mts', 'tsx',
|
||||||
|
'vue', 'css', 'html', 'json',
|
||||||
|
'bash', 'sh', 'shell', 'zsh', 'shellscript',
|
||||||
|
'python', 'py', 'go', 'rust', 'java', 'c', 'cpp', 'csharp',
|
||||||
|
'ruby', 'php', 'swift', 'kotlin', 'scala',
|
||||||
|
'sql', 'graphql', 'protobuf',
|
||||||
|
'yaml', 'yml', 'toml', 'xml', 'ini',
|
||||||
|
'markdown', 'md', 'mdc',
|
||||||
|
'dockerfile', 'docker', 'makefile', 'nginx', 'apache',
|
||||||
|
'diff', 'git-commit', 'git-rebase',
|
||||||
|
'powershell', 'batch', 'lua', 'perl', 'r', 'dart', 'elixir', 'erlang', 'haskell'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mdc: {
|
||||||
|
components: {
|
||||||
|
prose: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
site: {
|
||||||
|
url: siteConfig.url,
|
||||||
|
name: siteConfig.title,
|
||||||
|
},
|
||||||
|
|
||||||
|
nitro: {
|
||||||
|
prerender: {
|
||||||
|
routes: ['/sitemap.xml'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
hooks: {
|
||||||
|
'nitro:build:public-assets'(nitro: any) {
|
||||||
|
const root = process.cwd()
|
||||||
|
const contentDir = join(root, 'content', 'posts')
|
||||||
|
if (!existsSync(contentDir)) return
|
||||||
|
|
||||||
|
const publicDir = join(root, '.output', 'public', 'posts')
|
||||||
|
|
||||||
|
const posts = readdirSync(contentDir, { withFileTypes: true })
|
||||||
|
.filter(d => d.isDirectory())
|
||||||
|
.map(d => d.name)
|
||||||
|
|
||||||
|
let copied = 0
|
||||||
|
for (const slug of posts) {
|
||||||
|
const imgDir = join(contentDir, slug, 'img')
|
||||||
|
if (!existsSync(imgDir)) continue
|
||||||
|
|
||||||
|
const destDir = join(publicDir, slug, 'img')
|
||||||
|
mkdirSync(destDir, { recursive: true })
|
||||||
|
cpSync(imgDir, destDir, { recursive: true })
|
||||||
|
copied++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copied > 0) {
|
||||||
|
console.log(`[content-assets] Copied images for ${copied} posts`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "sublog",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare",
|
||||||
|
"new-post": "node scripts/new-post.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"nuxt": "^3.17.0",
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify/vue": "^5.0.1",
|
||||||
|
"@nuxt/content": "^3.14.0",
|
||||||
|
"@nuxtjs/color-mode": "^4.0.0",
|
||||||
|
"@nuxtjs/sitemap": "^8.0.0",
|
||||||
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"feed": "^4.2.2",
|
||||||
|
"typescript": "^5.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,867 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg width="540" height="540" viewBox="0 0 540 540" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<rect width="540" height="540" fill="#ffffff"/>
|
||||||
|
<defs>
|
||||||
|
<rect id="p" width="12" height="12" />
|
||||||
|
</defs>
|
||||||
|
<g>
|
||||||
|
<use x="24" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { mkdirSync, writeFileSync, existsSync } from 'node:fs'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
|
||||||
|
const name = process.argv[2]
|
||||||
|
if (!name) {
|
||||||
|
console.error('Usage: pnpm new-post <name>')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const date = now.toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const dir = resolve('content/posts', name)
|
||||||
|
const file = join(dir, 'index.md')
|
||||||
|
const imgDir = join(dir, 'img')
|
||||||
|
|
||||||
|
if (existsSync(file)) {
|
||||||
|
console.error(`Already exists: ${file}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
mkdirSync(imgDir, { recursive: true })
|
||||||
|
|
||||||
|
const content = `---
|
||||||
|
title: ${name}
|
||||||
|
published: ${date}
|
||||||
|
description:
|
||||||
|
image: /posts/${name}/img/cover.jpg
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
在这里写文章内容...
|
||||||
|
`
|
||||||
|
|
||||||
|
writeFileSync(file, content, 'utf8')
|
||||||
|
console.log(`Created: ${file}`)
|
||||||
|
console.log(`Image dir: ${imgDir}`)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export default defineEventHandler(async () => {
|
||||||
|
try {
|
||||||
|
const raw = await $fetch<string>('https://keai.icu/apiwyy/api', { responseType: 'text' })
|
||||||
|
return JSON.parse(raw as string)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { resolve, extname } from 'node:path'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const url = getRequestURL(event)
|
||||||
|
const pathMatch = url.pathname.match(/^\/posts\/([a-z0-9-]+)\/img\/(.+)$/)
|
||||||
|
if (!pathMatch) return
|
||||||
|
|
||||||
|
const [, slug, filename] = pathMatch
|
||||||
|
|
||||||
|
// 防止路径遍历攻击
|
||||||
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||||
|
throw createError({ statusCode: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = extname(filename).toLowerCase()
|
||||||
|
if (!MIME[ext]) return
|
||||||
|
|
||||||
|
const filePath = resolve('content/posts', slug, 'img', filename)
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
throw createError({ statusCode: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await readFile(filePath)
|
||||||
|
setResponseHeader(event, 'content-type', MIME[ext])
|
||||||
|
setResponseHeader(event, 'cache-control', 'public, max-age=31536000')
|
||||||
|
return data
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: [
|
||||||
|
'./components/**/*.{vue,ts}',
|
||||||
|
'./layouts/**/*.vue',
|
||||||
|
'./pages/**/*.vue',
|
||||||
|
'./composables/**/*.ts',
|
||||||
|
'./config/**/*.ts',
|
||||||
|
'./app.vue',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
require('@tailwindcss/typography'),
|
||||||
|
],
|
||||||
|
} satisfies Config
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Nuxt Content v3 (minimark) AST 工具函数
|
||||||
|
* 集中管理 body AST 的文本提取、标题提取、slugify 等操作
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- 文本提取 ---
|
||||||
|
|
||||||
|
const SKIP_TAGS = new Set(['style', 'script', 'pre', 'code', 'head', 'meta', 'link'])
|
||||||
|
|
||||||
|
export function extractTextFromAst(node: any): string {
|
||||||
|
if (!node) return ''
|
||||||
|
if (typeof node === 'string') return node
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
const tag = typeof node[0] === 'string' ? node[0] : ''
|
||||||
|
if (SKIP_TAGS.has(tag)) return ''
|
||||||
|
const start = typeof node[1] === 'object' && !Array.isArray(node[1]) && node[1] !== null ? 2 : 1
|
||||||
|
return node.slice(start).map(extractTextFromAst).join('')
|
||||||
|
}
|
||||||
|
if (typeof node === 'object') {
|
||||||
|
if (typeof node.value === 'string') return node.value
|
||||||
|
if (node.children) return extractTextFromAst(node.children)
|
||||||
|
if (node.value) return extractTextFromAst(node.value)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 标题提取 ---
|
||||||
|
|
||||||
|
export interface Heading {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
level: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextFromNode(node: any): string {
|
||||||
|
if (!node) return ''
|
||||||
|
if (typeof node === 'string') return node
|
||||||
|
if (Array.isArray(node)) return node.map(extractTextFromNode).join('')
|
||||||
|
if (typeof node === 'object') {
|
||||||
|
if (typeof node.value === 'string') return node.value
|
||||||
|
if (node.children) return extractTextFromNode(node.children)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractHeadings(node: any, list: Heading[] = []): Heading[] {
|
||||||
|
if (!node) return list
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
if (typeof node[0] === 'string' && /^h[1-6]$/.test(node[0])) {
|
||||||
|
const level = Number(node[0].charAt(1))
|
||||||
|
const props = typeof node[1] === 'object' && !Array.isArray(node[1]) ? node[1] : {}
|
||||||
|
const childStart = typeof node[1] === 'object' && !Array.isArray(node[1]) ? 2 : 1
|
||||||
|
const text = node.slice(childStart).map(extractTextFromNode).join('').trim()
|
||||||
|
if (text) {
|
||||||
|
list.push({ id: props.id || slugify(text), text, level })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const child of node) {
|
||||||
|
if (Array.isArray(child) || (typeof child === 'object' && child !== null)) {
|
||||||
|
extractHeadings(child, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
if (typeof node === 'object') {
|
||||||
|
const tag = node.tag || node.type
|
||||||
|
if (tag && /^h[1-6]$/.test(tag)) {
|
||||||
|
const level = Number(tag.charAt(1))
|
||||||
|
const text = extractTextFromNode(node).trim()
|
||||||
|
if (text) {
|
||||||
|
list.push({ id: node.props?.id || slugify(text), text, level })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.children) extractHeadings(node.children, list)
|
||||||
|
if (node.value) extractHeadings(node.value, list)
|
||||||
|
if (node.body) extractHeadings(node.body, list)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- slugify ---
|
||||||
|
|
||||||
|
export function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s ]+/g, '-')
|
||||||
|
.replace(/[^\p{L}\p{N}\-]/gu, '')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '') || 'section'
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 去重(给 heading id 加后缀)---
|
||||||
|
|
||||||
|
export function deduplicateIds(list: Heading[]): Heading[] {
|
||||||
|
const seen = new Map<string, number>()
|
||||||
|
for (const h of list) {
|
||||||
|
if (seen.has(h.id)) {
|
||||||
|
const n = (seen.get(h.id) || 1) + 1
|
||||||
|
seen.set(h.id, n)
|
||||||
|
h.id = `${h.id}-${n}`
|
||||||
|
} else {
|
||||||
|
seen.set(h.id, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 从 body 提取所有标题(去重后)---
|
||||||
|
|
||||||
|
export function getHeadingsFromBody(body: Record<string, any> | null | undefined): Heading[] {
|
||||||
|
if (!body) return []
|
||||||
|
const list: Heading[] = []
|
||||||
|
extractHeadings(body.value || body, list)
|
||||||
|
return deduplicateIds(list)
|
||||||
|
}
|
||||||