<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/scripts/pretty-feed-v3.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:h="http://www.w3.org/TR/html4/"><channel><title>Seeridia&apos;s Home</title><description>Seeridia 的小小小的大窝！</description><link>https://blog.seeridia.top</link><item><title>Vitepress 站点提供下载 PDF 的方式</title><link>https://blog.seeridia.top/blog/wiki-download</link><guid isPermaLink="true">https://blog.seeridia.top/blog/wiki-download</guid><description>对文档站点使用 VitePress 构建时，如何为用户提供下载 PDF 版本的文档。</description><pubDate>Tue, 20 Jan 2026 18:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我有另一个关于 &lt;a href=&quot;https://github.com/Seeridia/Chemistry-Note&quot;&gt;化学笔记的项目&lt;/a&gt;，它使用 VitePress 构建文档站点。&lt;/p&gt;
&lt;p&gt;但其实这个项目面向的是高中生，他们更习惯于离线阅读资料（没得手机）。在笔者自己高中的时候，这个就是自己手动去一个个用 Typora 导出 PDF，然后再打包发给同学。一来是麻烦，二来是没办法及时更新。&lt;/p&gt;
&lt;p&gt;现在最近也闲了，也发现当时的这个项目现在看的人突然多了起来，所以一方面用 VitePress 构建文档站点，另一方面也想给用户提供一个下载 PDF 的方式。(其实也不仅局限于 vitepress)&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image/preview.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;所以我想着去彻底解决这个问题——实现在每次我更新文档站点的时候，顺便也能生成一个 PDF 版本的文档的自动化流程。&lt;/p&gt;
&lt;h2&gt;总体思路&lt;/h2&gt;
&lt;p&gt;思路其实很简单，VitePress 本身虽然并不支持直接导出 PDF，但是其构建产物不就是 HTML 页面吗？那我们完全可以使用一个工具，把这些 HTML 页面转换成 PDF。&lt;/p&gt;
&lt;p&gt;关于 PDF 的问题解决了，另一个就是关于下载的问题。由于用户主要都是国内的高中生，速度需要有保证，所以我选择把生成的 PDF 文件放在 &lt;a href=&quot;https://cnb.cool/&quot;&gt;cnb.cool&lt;/a&gt; 上面，这样用户可以直接通过 cnb 提供的文件下载链接来下载最新的 PDF 文件。&lt;/p&gt;
&lt;p&gt;（实际上是看了一些软件的镜像下载就放在 cnb 上，看着挺不错的，也就这样用了）&lt;/p&gt;
&lt;h2&gt;清理页面样式&lt;/h2&gt;
&lt;p&gt;首先是清理一下页面媒体查询在打印时的表现。&lt;/p&gt;
&lt;p&gt;媒体查询这个大家都蛮熟的了，这边放上一个调试方式（噗嗤，以前我还真是傻傻的去 Ctrl + P 预览打印效果，后来才知道有这个东西）。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image/debug.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;好在 VitePress 本身也蛮打印友好的，稍微调整了一下隐藏一些不必要的元素（比如导航栏、侧边栏、页脚等）就好了。对我来说需求就这些，其实也可以在页脚加上一些只在打印时候显示的信息，比如版权页面等，都可以用媒体查询来实现。&lt;/p&gt;
&lt;h2&gt;判断需要导出的页面&lt;/h2&gt;
&lt;p&gt;其实主要是没法每次都全量导出所有页面的 PDF，毕竟文档站点页面多了之后，生成 PDF 的时间会很长。所以我就想着能不能只导出改动过的页面。&lt;/p&gt;
&lt;p&gt;下面直接贴代码，主要就是用 git diff 来判断哪些页面改动了，然后只导出这些页面。&lt;/p&gt;
&lt;p&gt;同时也对一些“全局变更”做了判断，比如说侧边栏配置、主题配置、公共资源等，只要这些变更了，就直接全量导出 PDF。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// scripts/changed-pages.js

import { execFileSync } from &apos;child_process&apos;
import fs from &apos;fs&apos;
import path from &apos;path&apos;
import url from &apos;url&apos;

const __dirname = path.dirname(url.fileURLToPath(import.meta.url))

const args = process.argv.slice(2)
const getArgValue = (flag) =&gt; {
    const index = args.indexOf(flag)
    if (index === -1) {
        return null
    }
    return args[index + 1] || null
}

const base = getArgValue(&apos;--base&apos;) || process.env.GIT_BASE
const head = getArgValue(&apos;--head&apos;) || process.env.GIT_HEAD || &apos;HEAD&apos;
const outDirInput = getArgValue(&apos;--out-dir&apos;)
const outDir = outDirInput
    ? path.resolve(process.cwd(), outDirInput)
    : path.resolve(__dirname, &apos;../.github/changed-pages&apos;)

// 全 0 的 SHA 表示“无基准”，需要全量导出
const zeroSha = /^0+$/
// 判断是否属于需要全量导出的“全局变更”
const isGlobalChange = (filePath) =&gt; {
    if (!filePath) {
        return false
    }
    const normalized = filePath.replace(/\\/g, &apos;/&apos;)
    // 如果是下面这些路径的变更，则直接视为全局变更
    return (
        normalized.startsWith(&apos;.vitepress/&apos;) ||
        normalized.startsWith(&apos;public/&apos;) ||
        normalized.startsWith(&apos;data/&apos;) ||
        normalized === &apos;package.json&apos; ||
        normalized === &apos;package-lock.json&apos; ||
        normalized === &apos;bun.lock&apos;
    )
}

// 将 .md 路径映射为站点输出的 .html 路径
const mdToHtml = (filePath) =&gt; {
    if (!filePath) {
        return null
    }
    const normalized = filePath.replace(/\\/g, &apos;/&apos;)
    if (!/\.md$/i.test(normalized)) {
        return null
    }
    const dir = path.posix.dirname(normalized)
    const baseName = path.posix.basename(normalized)
    const lower = baseName.toLowerCase()
    const targetName =
        lower === &apos;readme.md&apos; || lower === &apos;index.md&apos;
            ? &apos;index.html&apos;
            : baseName.replace(/\.md$/i, &apos;.html&apos;)
    return dir === &apos;.&apos; ? targetName : `${dir}/${targetName}`
}

const outputs = {
    forceAll: false,       // 是否需要全量导出
    changed: new Set(),    // 变更的页面列表
    deleted: new Set()     // 删除的页面列表
}

// base 不存在或是全 0，则直接全量
if (!base || zeroSha.test(base)) {
    outputs.forceAll = true
} else {
    let diffOutput = &apos;&apos;
    try {
        // 使用 git diff 获取变更文件列表（含重命名）
        diffOutput = execFileSync(&apos;git&apos;, [&apos;diff&apos;, &apos;--name-status&apos;, &apos;-z&apos;, `${base}..${head}`], {
            encoding: &apos;utf8&apos;
        }).trim()
    } catch (error) {
        // 失败时兜底为全量
        outputs.forceAll = true
    }

    if (!outputs.forceAll &amp;#x26;&amp;#x26; diffOutput) {
        // -z 模式以 \0 分隔，便于处理包含空格的路径
        const parts = diffOutput.split(&apos;\0&apos;).filter(Boolean)
        for (let index = 0; index &amp;#x3C; parts.length; index += 1) {
            const status = parts[index]
            if (!status) {
                continue
            }
            // 处理重命名：Rxxx 旧路径 新路径
            if (status.startsWith(&apos;R&apos;)) {
                const oldPath = parts[index + 1]
                const newPath = parts[index + 2]
                index += 2
                if (isGlobalChange(oldPath) || isGlobalChange(newPath)) {
                    outputs.forceAll = true
                }
                // 旧路径视为删除，新路径视为新增
                const deleted = mdToHtml(oldPath)
                const added = mdToHtml(newPath)
                if (deleted) {
                    outputs.deleted.add(deleted)
                }
                if (added) {
                    outputs.changed.add(added)
                }
                continue
            }

            const filePath = parts[index + 1]
            index += 1
            if (isGlobalChange(filePath)) {
                outputs.forceAll = true
            }

            // 只跟踪 Markdown 的变更
            const htmlPath = mdToHtml(filePath)
            if (!htmlPath) {
                continue
            }
            if (status === &apos;D&apos;) {
                outputs.deleted.add(htmlPath)
            } else {
                outputs.changed.add(htmlPath)
            }
        }
    }
}

fs.mkdirSync(outDir, { recursive: true })

const changedList = outputs.forceAll ? [] : Array.from(outputs.changed).sort()
const deletedList = Array.from(outputs.deleted).sort()

// 写入结果文件，供后续工作流读取
fs.writeFileSync(path.join(outDir, &apos;export-all.txt&apos;), outputs.forceAll ? &apos;true&apos; : &apos;false&apos;)
fs.writeFileSync(path.join(outDir, &apos;changed-pages.txt&apos;), changedList.join(&apos;\n&apos;))
fs.writeFileSync(path.join(outDir, &apos;deleted-pages.txt&apos;), deletedList.join(&apos;\n&apos;))

// 控制台输出简要统计
console.log(
    JSON.stringify({
        forceAll: outputs.forceAll,
        changed: changedList.length,
        deleted: deletedList.length
    })
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;导出 PDF&lt;/h2&gt;
&lt;p&gt;这边我使用的是 &lt;a href=&quot;https://playwright.dev/&quot;&gt;Playwright&lt;/a&gt; 来做这个工作，因为它本身支持无头浏览器操作，并且可以直接导出 PDF。&lt;/p&gt;
&lt;p&gt;读取刚刚生成的变更页面列表，然后一个个打开页面，直接调用 &lt;code&gt;page.pdf()&lt;/code&gt; 方法导出 PDF 即可。唯一注意的是 CJK 字体的问题，需要注入一些字体样式，确保中文能正确显示。（这边在后续的 GitHub Actions 里也需要安装了 Noto Sans CJK 字体）&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// scripts/export-pdf.js

import { chromium } from &apos;playwright&apos;
import fg from &apos;fast-glob&apos;
import http from &apos;http&apos;
import fs from &apos;fs&apos;
import path from &apos;path&apos;
import url from &apos;url&apos;

const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
const distDir = path.resolve(__dirname, &apos;../.vitepress/dist&apos;)

const args = process.argv.slice(2)
const listIndex = args.indexOf(&apos;--list&apos;)
const outIndex = args.indexOf(&apos;--out-dir&apos;)
const listPath = listIndex &gt;= 0 ? args[listIndex + 1] : null
const outDirArg = outIndex &gt;= 0 ? args[outIndex + 1] : null
if (listIndex &gt;= 0 &amp;#x26;&amp;#x26; !listPath) {
    throw new Error(&apos;Missing value for --list&apos;)
}
if (outIndex &gt;= 0 &amp;#x26;&amp;#x26; !outDirArg) {
    throw new Error(&apos;Missing value for --out-dir&apos;)
}
const outDir = outDirArg
    ? path.resolve(process.cwd(), outDirArg)
    : path.resolve(__dirname, &apos;../pdf&apos;)

fs.mkdirSync(outDir, { recursive: true })

const mimeTypes = {
    &apos;.css&apos;: &apos;text/css; charset=utf-8&apos;,
    &apos;.gif&apos;: &apos;image/gif&apos;,
    &apos;.html&apos;: &apos;text/html; charset=utf-8&apos;,
    &apos;.jpeg&apos;: &apos;image/jpeg&apos;,
    &apos;.jpg&apos;: &apos;image/jpeg&apos;,
    &apos;.js&apos;: &apos;application/javascript; charset=utf-8&apos;,
    &apos;.json&apos;: &apos;application/json; charset=utf-8&apos;,
    &apos;.png&apos;: &apos;image/png&apos;,
    &apos;.svg&apos;: &apos;image/svg+xml&apos;,
    &apos;.webp&apos;: &apos;image/webp&apos;,
    &apos;.woff&apos;: &apos;font/woff&apos;,
    &apos;.woff2&apos;: &apos;font/woff2&apos;
}

// 用本地 HTTP 服务替代 file:// 访问，确保 CSS/JS/图片/字体等资源按正常 URL 规则加载
// 同时避免浏览器对 file:// 的安全限制导致资源无法读取
const server = http.createServer((req, res) =&gt; {
    if (!req.url) {
        res.writeHead(400)
        res.end(&apos;Bad Request&apos;)
        return
    }

    // 去掉查询参数后再做 decode，防止路径中包含中文或空格时无法访问
    const decodedPath = decodeURIComponent(req.url.split(&apos;?&apos;)[0])
    const safePath = decodedPath.replace(/^\/+/, &apos;&apos;)
    const filePath = path.resolve(distDir, safePath || &apos;index.html&apos;)

    if (!filePath.startsWith(distDir)) {
        res.writeHead(403)
        res.end(&apos;Forbidden&apos;)
        return
    }

    fs.stat(filePath, (err, stats) =&gt; {
        if (err || !stats.isFile()) {
            res.writeHead(404)
            res.end(&apos;Not Found&apos;)
            return
        }

        const ext = path.extname(filePath).toLowerCase()
        const contentType = mimeTypes[ext] || &apos;application/octet-stream&apos;
        res.writeHead(200, { &apos;Content-Type&apos;: contentType })
        fs.createReadStream(filePath).pipe(res)
    })
})

let files = []
if (listPath) {
    const raw = fs.readFileSync(listPath, &apos;utf8&apos;)
    files = raw
        .split(&apos;\n&apos;)
        .map((line) =&gt; line.trim())
        .filter((line) =&gt; line &amp;#x26;&amp;#x26; line !== &apos;404.html&apos;)
    files = Array.from(new Set(files))
    files = files.filter((file) =&gt; fs.existsSync(path.join(distDir, file)))
} else {
    files = await fg(&apos;**/*.html&apos;, {
        cwd: distDir,
        ignore: [&apos;404.html&apos;]
    })
}

if (files.length === 0) {
    console.log(&apos;No pages to export.&apos;)
    process.exit(0)
}

const serverPort = await new Promise((resolve) =&gt; {
    server.listen(0, &apos;127.0.0.1&apos;, () =&gt; resolve(server.address().port))
})

const browser = await chromium.launch()
const page = await browser.newPage()
// 注入字体样式，确保中文能好好显示
await page.addStyleTag({
    content:
        &apos;html, body { font-family: &quot;Noto Sans CJK SC&quot;,&quot;Noto Sans SC&quot;,&quot;Source Han Sans SC&quot;,&quot;Microsoft YaHei&quot;,&quot;PingFang SC&quot;,sans-serif !important; }&apos;
})

for (const file of files) {
    const inputPath = path.join(distDir, file)
    const outputPath = path.join(outDir, file.replace(/\.html$/, &apos;.pdf&apos;))

    fs.mkdirSync(path.dirname(outputPath), { recursive: true })

    const urlPath = encodeURI(file.replace(/\\/g, &apos;/&apos;))
    const fileUrl = `http://127.0.0.1:${serverPort}/${urlPath}`

    console.log(&apos;Exporting:&apos;, file)

    await page.goto(fileUrl, { waitUntil: &apos;networkidle&apos; })
    await page.evaluate(() =&gt; (document.fonts ? document.fonts.ready : null))

    await page.pdf({
        path: outputPath,
        format: &apos;A4&apos;,
        printBackground: true,
        margin: {
            top: &apos;15mm&apos;,
            bottom: &apos;15mm&apos;,
            left: &apos;15mm&apos;,
            right: &apos;15mm&apos;
        }
    })
}

await browser.close()
await new Promise((resolve) =&gt; server.close(resolve))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Github Actions 集成&lt;/h2&gt;
&lt;p&gt;到这边我个人的方案是：将上一步已经导出的 PDF 文件 push 到一个单独的仓库上，并且我选择放在 cnb 上面，这样国内用户可以直接通过 cnb 提供的文件下载链接来下载最新的 PDF 文件。&lt;/p&gt;
&lt;p&gt;这边我直接是将每次构建的新文件都放新的分支上，然后强制改名覆盖主分支，这样 cnb 上就只有一次 commit 的历史记录，节省空间和拉取速度。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Deploy to Production Server

on:
  push:
    branches:
      - master

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      # 第一步：迁出代码
      - name: Checkout Code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # 第二步：设置 Bun 环境
      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      # 第三步：安装依赖
      - name: Install Dependencies
        run: bun install

      # 第四步：打包项目
      - name: Build Project
        run: bun run docs:build

      # 第五步：检测变更页面
      - name: Detect changed pages
        id: changed_pages
        run: |
          bun scripts/changed-pages.js --base &quot;${{ github.event.before }}&quot; --head &quot;${{ github.sha }}&quot; --out-dir .github/changed-pages
          export_all=$(cat .github/changed-pages/export-all.txt)
          changed_count=$(grep -c . .github/changed-pages/changed-pages.txt || true)
          deleted_count=$(grep -c . .github/changed-pages/deleted-pages.txt || true)
          echo &quot;export_all=$export_all&quot; &gt;&gt; $GITHUB_OUTPUT
          echo &quot;changed_count=$changed_count&quot; &gt;&gt; $GITHUB_OUTPUT
          echo &quot;deleted_count=$deleted_count&quot; &gt;&gt; $GITHUB_OUTPUT

      # 第六步：上传构建产物供 PDF 导出使用
      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: site-dist
          path: |
            .vitepress/dist
            .github/changed-pages

      # 第七步：部署到服务器（略）

  export-pdfs:
    needs: build-and-deploy
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: site-dist
          path: .

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Install Dependencies
        run: bun install

      # 需要安装 CJK 字体以确保 PDF 导出时中文显示正确
      - name: Install CJK fonts
        run: |
          sudo apt-get update
          sudo apt-get install -y fonts-noto-cjk

      # 读取变更页面信息
      - name: Read changed pages info
        id: changed_pages
        run: |
          export_all=$(cat .github/changed-pages/export-all.txt 2&gt;/dev/null || echo &quot;false&quot;)
          changed_count=$(grep -c . .github/changed-pages/changed-pages.txt 2&gt;/dev/null || true)
          deleted_count=$(grep -c . .github/changed-pages/deleted-pages.txt 2&gt;/dev/null || true)
          echo &quot;export_all=$export_all&quot; &gt;&gt; $GITHUB_OUTPUT
          echo &quot;changed_count=$changed_count&quot; &gt;&gt; $GITHUB_OUTPUT
          echo &quot;deleted_count=$deleted_count&quot; &gt;&gt; $GITHUB_OUTPUT

      # 克隆 PDF 仓库（只需要 clone 1层，不然后面很大）
      - name: Clone CNB PDF Repository
        if: steps.changed_pages.outputs.export_all == &apos;true&apos; || steps.changed_pages.outputs.changed_count != &apos;0&apos; || steps.changed_pages.outputs.deleted_count != &apos;0&apos;
        run: |
          git clone --depth 1 https://cnb:${{ secrets.CNB_TOKEN }}@cnb.cool/Seeridia/Chemistry-Note-File.git pdf-repo

      - name: Install Playwright Browsers
        if: steps.changed_pages.outputs.export_all == &apos;true&apos; || steps.changed_pages.outputs.changed_count != &apos;0&apos;
        run: npx playwright install --with-deps chromium

      - name: Export PDFs (all)
        if: steps.changed_pages.outputs.export_all == &apos;true&apos;
        run: bun scripts/export-pdf.js --out-dir pdf-repo

      - name: Export PDFs (changed)
        if: steps.changed_pages.outputs.export_all != &apos;true&apos; &amp;#x26;&amp;#x26; steps.changed_pages.outputs.changed_count != &apos;0&apos;
        run: bun scripts/export-pdf.js --list .github/changed-pages/changed-pages.txt --out-dir pdf-repo

      - name: Remove deleted PDFs
        if: steps.changed_pages.outputs.deleted_count != &apos;0&apos;
        run: |
          while IFS= read -r file; do
            [ -z &quot;$file&quot; ] &amp;#x26;&amp;#x26; continue
            pdf=&quot;${file%.html}.pdf&quot;
            rm -f &quot;pdf-repo/$pdf&quot;
          done &amp;#x3C; .github/changed-pages/deleted-pages.txt

      - name: Push PDF updates
        if: steps.changed_pages.outputs.export_all == &apos;true&apos; || steps.changed_pages.outputs.changed_count != &apos;0&apos; || steps.changed_pages.outputs.deleted_count != &apos;0&apos;
        run: |
          cd pdf-repo
          git config user.name &quot;github-actions[bot]&quot;
          git config user.email &quot;github-actions[bot]@users.noreply.github.com&quot;
          if [ -z &quot;$(git status --porcelain)&quot; ]; then
            echo &quot;No PDF changes to push.&quot;
            exit 0
          fi
          git checkout --orphan temp-sync
          git add -A
          git commit -m &quot;Sync PDFs from ${{ github.sha }}&quot;
          git branch -M main
          git push --force origin main
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;vitepress 配合&lt;/h2&gt;
&lt;p&gt;最后在 VitePress 站点里加一个下载链接就好了，这一部分可以去 &lt;a href=&quot;https://vitepress.dev/zh/reference/default-theme-nav#nav&quot;&gt;vitepress 官方文档&lt;/a&gt;里看看相关内容&lt;/p&gt;
&lt;p&gt;import { Tabs, TabItem } from &apos;astro-pure/user&apos;;&lt;/p&gt;
&lt;p&gt;const { page } = useData();&lt;/p&gt;
&lt;p&gt;const PDF_BASE_URL =
&quot;https://cnb.cool/Seeridia/Chemistry-Note-File/-/git/raw/main/&quot;;&lt;/p&gt;
&lt;p&gt;// 生成当前页面对应的 PDF 下载链接
const pdfUrl = computed(() =&gt; {
const filePath = page.value.filePath ?? &quot;&quot;;
if (!filePath.endsWith(&quot;.md&quot;)) return &quot;&quot;;
const pdfPath = filePath.replace(/.md$/i, &quot;.pdf&quot;);
return &lt;code&gt;${PDF_BASE_URL}${encodeURI(pdfPath)}?download=true&lt;/code&gt;;
});&lt;/p&gt;
&lt;p&gt;// 只有在文档页面才显示下载 PDF 按钮
const isDocPage = computed(
() =&gt; (page.value.frontmatter?.layout ?? &quot;doc&quot;) === &quot;doc&quot;,
);
const shouldShow = computed(() =&gt; isDocPage.value &amp;#x26;&amp;#x26; pdfUrl.value);&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;   &amp;#x3C;/TabItem&gt;
   &amp;#x3C;TabItem label=&quot;组件注册&quot;&gt;
```ts
// .vitepress/theme/index.ts

import DefaultTheme from &quot;vitepress/theme&quot;;
import type { App } from &quot;vue&quot;;
import layout from &quot;./layout.vue&quot;;
import &quot;./custom.css&quot;;

import CCPdfDownloadButton from &quot;./components/CCPdfDownloadButton.vue&quot;;

export default {
  extends: DefaultTheme,
  Layout: layout,
  enhanceApp({ app }: { app: App }) {
    app.component(&quot;CCPdfDownloadButton&quot;, CCPdfDownloadButton);
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;另外导航栏这边的组件只能在桌面端显示，移动端是放在二级菜单里面，我个人是多处理了一个用插槽来实现移动端显示下载按钮&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-vue&quot;&gt;&amp;#x3C;script setup&gt;
import DefaultTheme from &quot;vitepress/theme&quot;;
import PdfDownloadButton from &quot;./components/CCPdfDownloadButton.vue&quot;;

const { Layout } = DefaultTheme;
&amp;#x3C;/script&gt;

&amp;#x3C;template&gt;
  &amp;#x3C;Layout&gt;
    &amp;#x3C;!-- 只有在移动端显示该 PDF 下载按钮，桌面端的按钮在了导航栏里 --&gt;
    &amp;#x3C;template #nav-bar-content-before&gt;
      &amp;#x3C;div class=&quot;PdfDownloadButton&quot;&gt;
        &amp;#x3C;PdfDownloadButton /&gt;
      &amp;#x3C;/div&gt;
    &amp;#x3C;/template&gt;
  &amp;#x3C;/Layout&gt;
&amp;#x3C;/template&gt;

&amp;#x3C;style scoped&gt;
.PdfDownloadButton {
  display: none;
}

@media (max-width: 768px) {
  .PdfDownloadButton {
    display: block;
  }
}
&amp;#x3C;/style&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;到此，我们实现了&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;PDF 自动构建，实时更新&lt;/li&gt;
&lt;li&gt;在 VitePress 站点提供下载链接&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;具体的实现可以看看原项目&lt;/p&gt;
&lt;p&gt;import { GithubCard } from &apos;astro-pure/advanced&apos;&lt;/p&gt;</content:encoded><h:img src="/_astro/cover.BqqgECAj.webp"/><enclosure url="/_astro/cover.BqqgECAj.webp"/></item><item><title>text-autospace： CJK 字符间距控制</title><link>https://blog.seeridia.top/blog/text-autospace</link><guid isPermaLink="true">https://blog.seeridia.top/blog/text-autospace</guid><description>史诗级的中英文混排间距控制方式</description><pubDate>Thu, 25 Dec 2025 00:17:00 GMT</pubDate><content:encoded>&lt;p&gt;相关文档：&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.mozilla.org/zh-CN/docs/Web/CSS/Reference/Properties/text-autospace&quot;&gt;text-autospace - CSS：层叠样式表 | MDN&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://caniuse.com/?search=text-autospace&quot;&gt;&quot;text-autospace&quot; | Can I use... Support tables for HTML5, CSS3, etc&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Baseline&lt;/strong&gt; 2025 支持 Chrome 140+&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;此功能可以用以实现中英文混排中的间距控制&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;text-autospace: normal;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;:root {
  /* 启用自动混排间距 */
  text-autospace: normal;
}

/* 兼容性判断 */
@supports (text-autospace: normal) {
  :root {
    text-autospace: normal;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;其实这边的兼容性判断可以不用的，因为旧浏览器会自动忽略这个属性&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Example&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/1.D-lpifo2_ZCvB3c.webp&quot; alt=&quot;image.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;text-autospace: no-autospace;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/2.D2ZjT_kI_Z1mUf7t.webp&quot; alt=&quot;image.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;text-autospace: normal;&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;normal&lt;/code&gt;：创建默认行为，自动在 CJK 字符与非 CJK 字符之间以及标点符号周围添加间距。此值的效果等同于同时应用 &lt;code&gt;ideograph-alpha&lt;/code&gt; 和 &lt;code&gt;ideograph-numeric&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;no-autospace&lt;/code&gt;：禁用 CJK 和非 CJK 字符间的自动间距行为。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ideograph-alpha&lt;/code&gt;：仅在表意文字（如片假名和汉字）与非表意字母（如拉丁字母）之间添加间距。不会在表意文字与非表意数字之间添加间距。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ideograph-numeric&lt;/code&gt;仅在表意文字（如片假名和汉字）与非表意数字（如拉丁数字）之间添加间距。不会在表意文字与非表意字母之间添加间距。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;punctuation&lt;/code&gt;根据特定语言的排版规范，在标点符号周围添加不可分割的间距。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;insert&lt;/code&gt;仅当表意文字与非表意文字之间不存在现有空格时，才添加指定的间距。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;replace&lt;/code&gt;将表意文字与非表意文字之间的现有间距（例如 &lt;a href=&quot;https://developer.mozilla.org/zh-CN/docs/Glossary/Whitespace&quot;&gt;U+0020&lt;/a&gt;）替换为指定的间距。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;auto&lt;/code&gt;允许浏览器选择符合排版规范的间距。不同浏览器和平台间的间距可能存在差异。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;或许你看着上面的 &lt;code&gt;repalce&lt;/code&gt; 和&lt;code&gt;insert&lt;/code&gt; 更好用，因为实际上不少人（特别是像我们这样的开发者）现在都已经习惯在中英文之间加上一个空格了（甚至微信输入法有这个自动加空格的功能），所以这个属性显然更友好（因为能同时兼顾不同书写风格群体）&lt;/p&gt;
&lt;p&gt;但是！&lt;/p&gt;
&lt;p&gt;挺遗憾的，目前为止（December 24, 2025），只有 Firefox 支持了&lt;code&gt;insert&lt;/code&gt; ，而&lt;code&gt;replace&lt;/code&gt; 目前就是 0 支持。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;使用场景的一点想法？&lt;/h2&gt;
&lt;p&gt;其实主要并不知道是否是准确的，但是自己是这样想的：&lt;/p&gt;
&lt;p&gt;在展示文本等等的大部分场景中，这个&lt;code&gt;text-autospace: normal;&lt;/code&gt; 开起来确实是能提高整段话的顺眼程度，但是在&lt;strong&gt;编辑&lt;/strong&gt;的场景中，我个人倒是认为这个不能开，因为会误判——「这边是不是误打个空格？」&lt;/p&gt;
&lt;p&gt;不过这边也有一个可以说的，这个间距实际控制得很微妙，其实产生这个误判其实也不是很明显。比如你就回到刚刚上面的那个对比图，怎么说？&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;反正自己的东西也是放在，这种很稀碎的文章，我之后也放进来吧。并打上标签 &lt;code&gt;TIL&lt;/code&gt; (Today I Learned)。&lt;/p&gt;
&lt;p&gt;另，本次更新我也顺便把你现在看到的博客整体布局的 &lt;code&gt;body&lt;/code&gt; 标签里加上了 &lt;code&gt;text-autospace: normal;&lt;/code&gt;，所以你现在看到的博客正文也是开启了这个属性的。&lt;/p&gt;</content:encoded><h:img src="/_astro/cover.IXhnlvnN.webp"/><enclosure url="/_astro/cover.IXhnlvnN.webp"/></item><item><title>我有特别的日程管理技巧</title><link>https://blog.seeridia.top/blog/schedule-management</link><guid isPermaLink="true">https://blog.seeridia.top/blog/schedule-management</guid><description>在大学生活中，最为重要的技能</description><pubDate>Thu, 20 Nov 2025 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;没有之一，日程管理永远是大学生活中最重要的技能之一。&lt;/p&gt;
&lt;p&gt;或许如我刚进大学的时候，不做日程管理，或许也过得很好。要做什么，真就是用脑子想一想就能记住的。作业？截止前自然有人提醒；考试？考前一两周自然老师会反复强调。&lt;/p&gt;
&lt;p&gt;但是，随着时间的推移，任务越来越繁重，单靠记忆已经远远不够了。甚至是经常遇到，今天突然告知我下个月要做啥事，这天才能想得起来）&lt;/p&gt;
&lt;p&gt;而日程的管理，不仅仅解决的是“记住要做什么”的问题，更重要的是“合理安排任务”的问题。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;demonstration.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2&gt;最为基础的方式：使用日历&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;主包目前实际用的方式比较复杂了，在后面的章节里面，但是这个方式还是目前最推荐的，因为简单易行。也因此这边图片不多，主要是文字说明。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;虽说是最简单的，但也是目前最实用的方式。&lt;/p&gt;
&lt;p&gt;我更推荐就直接使用手机自带的日历应用，因为既然我们选择了这种方式，我们就要从简，不仅仅是初次配置的简化，更重要的是后续的使用简化。不能让日历的使用变成一件麻烦事，否则就失去了意义——你坚持不了多久。&lt;/p&gt;
&lt;h3&gt;日历的配置&lt;/h3&gt;
&lt;p&gt;我下面都以我自己的手机（小米家的 HyperOS 3）为例，来说说配置，这边仅供参考，其他手机的日历应用大同小异。&lt;/p&gt;
&lt;p&gt;一个是创建多个日历（日程集，或者说是日程分类），我当时的方案是只有两个：「课程表」和「任务清单」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;课程表&lt;/strong&gt;：我使用的是订阅的方式，比如福州大学的 西二 团队开发的福 uu 课程表，提供了日程订阅的功能，直接订阅就行了。这样就不需要手动添加上课时间了。&lt;/p&gt;
&lt;p&gt;对于其他学校的同学，如果学校没有提供类似的课程表订阅功能，可以考虑自己手动录入，或者不在日历里面做这个日程分类，用你们自家的课程表 App 或是小程序还是 Wakeup 之类的来查看上课时间。我自己的这个主要是为了能在一个表里面看到包含课程表在内的所有日程，方便我去安排，而非日历看一下，自己的 App 看一下，这不优雅～&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;任务清单&lt;/strong&gt;：则是我自己手动添加的，主要是把所有需要做的任务都添加进去。这里的任务包括作业、考试、活动、会议等等一切需要你去做的事情。事情可以无关大小，重要与否，甚至是提醒自己晚饭后要去拿个快递这种小事也可以添加进去。总之，凡是你需要记住的事情，都可以添加进去。&lt;/p&gt;
&lt;p&gt;因为我们在这个阶段的设计目标是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;提醒自己要做什么事&lt;/li&gt;
&lt;li&gt;自己要安排某一个事情的时候知道具体的时间有没有空&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以，这两个日历就足够了。&lt;/p&gt;
&lt;h3&gt;那我们该如何使用呢？&lt;/h3&gt;
&lt;p&gt;我们最重要的是保证你能把日程管理这个给坚持下来。所以，最重要的就是&lt;strong&gt;简单&lt;/strong&gt;。我就是直接「小爱同学，提醒我明天早上 8:00-9:00 做下高级微积分的作业」，这样，这个日程便添加进来了。你也可以直接在日历 App 里面添加，反正就是简单快捷。&lt;/p&gt;
&lt;p&gt;这边也可以给一些可以参考的日程提示（有哪些事情添加进去）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;赚德育分的任务，比如每天要我签到、让我每天拍个照&lt;/li&gt;
&lt;li&gt;课程作业的截止时间&lt;/li&gt;
&lt;li&gt;考试时间&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;再多一点：全平台同步的日历&lt;/h2&gt;
&lt;p&gt;比如我有多个设备，小米、华为、iPhone、Mac、Windows 电脑等等，这些设备上都需要查看日程，甚至或许有在 Google 日历上面使用的需求。我该如何做到呢？&lt;/p&gt;
&lt;p&gt;这个时候，我们就需要一个全平台同步的日历服务了。可以考虑使用 Google 日历、Outlook 日历等服务，它们都提供了跨平台的支持，可以在不同设备上查看和管理日程。&lt;/p&gt;
&lt;p&gt;这边并不是说你需要下载他们对应的 App，而是说你可以把你手机上面的日历和这些服务进行同步。这样，无论你在哪个设备上查看日程，都是一致的。&lt;/p&gt;
&lt;p&gt;而我最推荐的是 iCloud 日历服务，尤其是对于苹果设备用户来说，iCloud 日历的集成度非常高，使用体验也非常好。还有一个好处是，iCloud 可以在任何网络环境下运行，这对于国内用户来说是一个很大的优势。&lt;/p&gt;
&lt;h3&gt;iCloud 日历的配置&lt;/h3&gt;
&lt;p&gt;import { Steps } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;但是我没有 Apple 设备怎么办？

iCloud 日历可以通过网页访问，所以你完全可以在 Windows 或者其他 Android 设备上使用 iCloud 日历。只需要在浏览器中登录你的 Apple ID，然后访问 iCloud.com，就可以使用日历功能了。

&gt; 我们用苹果的 iCloud 日历服务，主要是因为它的稳定性和跨平台支持都非常好，而且在国内的访问速度也比较快。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;3. 生成「App 专用密码」&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;由于我们要在非苹果设备上使用 iCloud 日历，需要生成一个「App 专用密码」来进行登录。这个密码不同于你的 Apple ID 密码，专门用于第三方应用访问你的 iCloud 数据。类似于个人 token。

生成方法如下：

&amp;#x3C;Steps&gt;
1. 登录你的 Apple ID 账户页面：[https://appleid.apple.com/](https://appleid.apple.com/)
2. 在「安全性」部分，找到「生成密码」选项

    ![](apple-app-id.webp)

3. 按照提示生成一个新的「App 专用密码」并记住他，我们将在后续步骤中使用它。
&amp;#x3C;/Steps&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;4. 在手机上添加 iCloud 账户&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;以小米手机的日历为例：

&amp;#x3C;Steps&gt;
1. 打开「日历」应用
2. 右上角菜单进入「设置」页，再进入「日程账号管理」菜单
3. 点击底部的「导入账号」
4. 选择「Caldav 账号」
5. 填入以下信息：
   
    - 账号：你的 Apple ID 邮箱
    - 密码：刚刚生成的 「App 专用密码」
    - 服务器地址：icloud.com
&amp;#x3C;/Steps&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;至此，你的手机日历就已经和 iCloud 日历同步了。你可以在手机上查看和管理你的日程，这些更改也会自动同步到 iCloud 上，使得所有订阅了该日历的设备都能看到最新的日程信息。&lt;/p&gt;
&lt;p&gt;这边也不仅仅是手机，其他设备也是类似的配置方式，只要支持 Caldav 协议的日历应用，都可以通过类似的方式添加 iCloud 账户，实现日程同步，比如：Google 日历、Outlook 日历、Notion 日历、飞书等等。&lt;/p&gt;
&lt;h2&gt;最后一点，将任务管理工具放进来&lt;/h2&gt;
&lt;p&gt;这个较为繁杂，如果你觉得太复杂，可以忽略这一部分，单纯使用日历来管理你的日程也是完全可以的。甚至如果你是刚开始做这样的计划的同学，我更推荐你忽略这一部分，先从日历开始，为了你能坚持下来。&lt;/p&gt;
&lt;h3&gt;有必要吗？&lt;/h3&gt;
&lt;p&gt;如果我们把任务管理工具也放进来，那就更完美了。&lt;/p&gt;
&lt;p&gt;有必要吗？我的想法是，有必要。我在日历中日程的管理，更多的是「时间」的管理，而任务管理工具更多的是「任务」的管理。两者结合起来，能够更好地帮助我们进行时间和任务的双重管理。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;我将饭后取快递这种提醒类的任务放在手机的日历中&lt;/li&gt;
&lt;li&gt;我将每天背单词、去做作业这种任务放在任务管理工具中&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;那我们该如何做呢？&lt;/h3&gt;
&lt;p&gt;如果你已经使用了一些任务管理工具，比如 Todoist、Microsoft To Do、TickTick 等等，这些工具大多也支持与日历同步。你可以将这些任务管理工具中的任务同步到你的日历中，这样就能在日历中看到所有的任务和日程，方便你进行整体的时间管理。&lt;/p&gt;
&lt;p&gt;我这边用的是 TickTick 嘀嗒清单，这边可以生成一个嘀嗒的任务日历订阅链接，然后把这个链接添加到你的日历中，就能实现任务和日程的统一管理。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;ticktick.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;但是&lt;/strong&gt;：你或许会将这个订阅在 iCloud，然后想着「由于我手机上面已经订阅了 iCloud 日历，所以我只要在 iCloud 上面添加这个订阅就行了」，但是实际上并不能这样做，虽然你手机上也正确显示出了对应的日程集，但是会发现有错误，&lt;strong&gt;有些任务是直接没有显示出来的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;正确做法是，在手机上（或是你需要展示任务日历的设备上）直接添加这个订阅链接，这样才能正确显示所有的任务。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/subscription-method.BtIcWi0-_ULvYt.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/subscription-method-2.DMyn0O3D_ZATGN9.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;这边的左图是&lt;strong&gt;不要&lt;/strong&gt;把嘀嗒清单订阅添加到 iCloud 上面去（但是可以添加到 apple 设备的本地日历）&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;虽然我知道这不符合逻辑，但是我疑心觉得这是 iCloud 日历在解析订阅日程时出现的问题，所以只能这样做了。&lt;/p&gt;
&lt;h2&gt;所以&lt;/h2&gt;
&lt;p&gt;以上便是如此，最下面的方案便是我目前在使用的日程管理方案。目前看来还不错，能够满足我的需求。&lt;/p&gt;
&lt;p&gt;（不过冤种的我买了嘀嗒清单的会员，因为实际上貌似这个会员可有可无）&lt;/p&gt;
&lt;p&gt;当然，对于上面的最后一部分，如果你觉得太复杂，完全可以忽略这一部分，单纯使用日历来管理你的日程也是完全可以的。当然如果有更好的方案，也欢迎告诉我～&lt;/p&gt;</content:encoded><h:img src="/_astro/cover.BtRElwsV.webp"/><enclosure url="/_astro/cover.BtRElwsV.webp"/></item><item><title>Surge.sh 预览配置</title><link>https://blog.seeridia.top/blog/surge-preview</link><guid isPermaLink="true">https://blog.seeridia.top/blog/surge-preview</guid><description>为 Surge.sh 网站托管服务配置预览环境，以便在每次提交代码后自动生成预览链接，方便团队协作和反馈。</description><pubDate>Sun, 19 Oct 2025 01:18:00 GMT</pubDate><content:encoded>&lt;p&gt;Surge.sh 是一个静态网站托管服务，开发者可以快速将静态网站部署上线，方便进行预览和分享。通过配置 Surge.sh 预览环境，可以在每次代码提交后自动生成预览链接，比如可以放在 Pull Request 中，极大地方便了团队协作和反馈。&lt;/p&gt;
&lt;p&gt;本文来讲讲如何配置 Surge.sh 预览环境。&lt;/p&gt;
&lt;h2&gt;安装 Surge&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;npm install -g surge

surge --version
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;创建 Surge 账号&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;surge
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;命令执行后会提示：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Email：输入你的注册邮箱。&lt;/li&gt;
&lt;li&gt;Password：第一次会创建账号。&lt;/li&gt;
&lt;li&gt;Project path：默认就是当前目录。&lt;/li&gt;
&lt;li&gt;Domain：你可以使用 .surge.sh 的免费子域名，例如 my-project.surge.sh&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;获取 Surge Token&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;surge token
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;配置 GitHub Actions&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/pr-preview.yml

name: Deploy PR Preview to Surge.sh

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read
  issues: write
  pull-requests: write

jobs:
  deploy-preview:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Bun # 自行根据项目需要更改
        uses: oven-sh/setup-bun@v1
        with:
          bun-version: latest

      - name: Install dependencies
        run: bun install

      - name: Build project
        run: bun run build

      - name: Install Surge CLI
        run: bun install -g surge

      - name: Deploy to Surge.sh
        run: surge --project ./dist --domain Loomi-Lair-HomePage-pr-${{ github.event.number }}.surge.sh
        env:
          SURGE_LOGIN: ${{ secrets.SURGE_LOGIN }}
          SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }}

      - name: Comment PR with preview URL
        uses: peter-evans/create-or-update-comment@v3
        with:
          issue-number: ${{ github.event.number }}
          body: |
            Preview deployed: https://Loomi-Lair-HomePage-pr-${{ github.event.number }}.surge.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;配置 GitHub Secrets&lt;/h2&gt;
&lt;p&gt;在你的 GitHub 仓库中，进入 &lt;code&gt;Settings&lt;/code&gt; -&gt; &lt;code&gt;Secrets and variables&lt;/code&gt; -&gt; &lt;code&gt;Actions&lt;/code&gt;，添加以下 Secrets：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SURGE_LOGIN&lt;/code&gt;：你的 Surge 注册邮箱。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SURGE_TOKEN&lt;/code&gt;：你通过刚刚的 &lt;code&gt;surge token&lt;/code&gt; 命令获取的 Surge Token。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;完成以上配置后，每当有 Pull Request 创建或更新时，GitHub Actions 会自动构建项目并部署到 Surge.sh，然后在 Pull Request 中添加预览链接，方便团队成员查看和反馈。&lt;/p&gt;
&lt;p&gt;Surge.sh 还有其他高级功能，比如自定义域名、SSL 证书等，可以根据需要进行配置（因为我还没有用到这些功能，懒了，不写了）&lt;/p&gt;</content:encoded><h:img src="/_astro/cover.Dg_pvnS8.webp"/><enclosure url="/_astro/cover.Dg_pvnS8.webp"/></item><item><title>打印色彩与文件交付</title><link>https://blog.seeridia.top/blog/print-color-file-delivery</link><guid isPermaLink="true">https://blog.seeridia.top/blog/print-color-file-delivery</guid><description>面向非专业设计的打印色彩与文件交付指南</description><pubDate>Sun, 14 Sep 2025 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;本文主要面向非专业设计人员，比如只是在学校里面做做海报什么的，目标也只是普通的小文印店，而非专业的印刷厂。介绍打印色彩的基础知识以及文件交付的注意事项，帮助大家更好地理解打印色彩，避免或尽力减少在打印过程中出现色差等问题。&lt;/p&gt;
&lt;h2&gt;色彩基础&lt;/h2&gt;
&lt;p&gt;这部分不会太深入，主要介绍一些基础的色彩知识，帮助大家理解打印色彩。&lt;/p&gt;
&lt;h3&gt;sRGB&lt;/h3&gt;
&lt;p&gt;sRGB 是 1996 年由微软和惠普制定的一个标准色彩空间，最初是为了网络和普通显示器而设计的。几乎所有的手机、电脑、平板、网页浏览器默认使用 sRGB 作为显示标准。他是一种加色模型，主要通过红（Red）、绿（Green）、蓝（Blue）三种颜色的光线叠加来显示颜色。&lt;/p&gt;
&lt;h3&gt;CMYK&lt;/h3&gt;
&lt;p&gt;CMYK 是一种减色模型，主要用于印刷领域。CMYK 由下面四种颜色的油墨组合而成：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;青（Cyan）&lt;/li&gt;
&lt;li&gt;品红（Magenta）&lt;/li&gt;
&lt;li&gt;黄（Yellow）&lt;/li&gt;
&lt;li&gt;黑（Key）&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;通过叠加这四种颜色的油墨，可以生成各种颜色。与 RGB 不同，CMYK 是通过吸收光线来显示颜色的。因此在转换过程中可能会出现色差。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;加色模型&lt;/strong&gt;：通过叠加不同颜色的光来产生新的颜色。当三种颜色的光线以不同强度叠加时，可以产生各种颜色，最终叠加到白色光。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;减色模型&lt;/strong&gt;：通过吸收光线来产生新的颜色。在印刷过程中，油墨的叠加会吸收不同波长的光，从而生成各种颜色，最终叠加到黑色。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;sRGB&lt;/strong&gt; 是一种基于 &lt;strong&gt;RGB 模型&lt;/strong&gt;的标准色彩空间，广泛用于显示器、相机、网页等数字设备。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;屏幕上的每个像素由红、绿、蓝三个子像素组成。&lt;/li&gt;
&lt;li&gt;当这些子像素发出不同强度的光并混合时，人眼感知到不同的颜色。&lt;/li&gt;
&lt;li&gt;例如：
&lt;ul&gt;
&lt;li&gt;红光 + 绿光 = 黄光&lt;/li&gt;
&lt;li&gt;红光 + 蓝光 = 品红光&lt;/li&gt;
&lt;li&gt;绿光 + 蓝光 = 青光&lt;/li&gt;
&lt;li&gt;红 + 绿 + 蓝（全开）= 白光&lt;/li&gt;
&lt;li&gt;全关 = 黑色&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CMYK 用于印刷，依赖纸张反射环境光来显色。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;纸张本身是白色的，会反射所有可见光。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;油墨的作用是&lt;strong&gt;吸收（减去）某些颜色的光&lt;/strong&gt;，只反射我们看到的颜色。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;青色油墨吸收红光，反射绿光和蓝光（合成青色）&lt;/li&gt;
&lt;li&gt;品红油墨吸收绿光，反射红光和蓝光（合成品红）&lt;/li&gt;
&lt;li&gt;黄色油墨吸收蓝光，反射红光和绿光（合成黄色）&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;当你叠印油墨时：&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;青 + 品红 = 吸收红和绿，只反射蓝 → 蓝色&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;青 + 黄 = 吸收红和蓝，只反射绿 → 绿色&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;品红 + 黄 = 吸收绿和蓝，只反射红 → 红色&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;青 + 品红 + 黄 = 理论上吸收所有光 → 黑色（但实际上呈深棕，所以加入 K——纯黑油墨）&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;色彩损失&lt;/h2&gt;
&lt;p&gt;所以，我们知道了在电脑屏幕上使用的是 RGB 色彩空间，而打印使用的是 CMYK 色彩空间。而 RGB 色彩空间的颜色范围要大于 CMYK 色彩空间的颜色范围。在将 RGB 颜色转换为 CMYK 颜色时，可能会出现色彩损失，导致打印效果与屏幕显示不一致。&lt;/p&gt;
&lt;p&gt;下图展示了在 CIE 1931 xy 色度图中，sRGB 和 CMYK 色域的对比。可以看到，CMYK 色域明显小于 sRGB 色域：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;CIE1931xy_gamut_comparison.svg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;图来源于 &lt;a href=&quot;https://en.wikipedia.org/wiki/CMYK_color_model#/media/File:CIE1931xy_gamut_comparison.svg&quot;&gt;维基百科&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;同时的，你也可以从图上很明显的看到，RGB → CMYK 的主要差别在鲜艳度和色域范围，典型情况是 &lt;strong&gt;鲜艳的蓝色、绿色、紫色、橙色&lt;/strong&gt;，在这些颜色上，CMYK 都会比 sRGB 差很多。&lt;/p&gt;
&lt;p&gt;下面展示一个个人的死亡案例&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/RGBvsCMYK.CMHhiMoN_Z7MJhh.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;（CMYK 校准后的图片 即 模拟打印设备的打印效果）&lt;/p&gt;
&lt;p&gt;笔者本人很大胆地使用了非常鲜艳的蓝色作为海报的主色调，直到设计完成后，才发现打印出来的效果和屏幕上差别巨大，蓝色变得非常暗淡，完全失去了原本的鲜艳感。&lt;/p&gt;
&lt;h2&gt;色彩空间选取&lt;/h2&gt;
&lt;p&gt;通过上面的描述，我们知道了 RGB 和 CMYK 的区别，以及在转换过程中可能会出现色彩损失。那么，在从设计到交付的全流程中，我们是否是要一直使用 CMYK 色彩空间呢？&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;我认为的答案是在设计阶段 依然选择 sRGB&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;逻辑如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;从文印店的实际情况触发&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;普通的文印店不可能提供 ICC Profile 给你。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它描述了“某种设备/油墨/纸张下，CMYK 数字值对应的真实颜色”。&lt;/p&gt;
&lt;p&gt;如果你用 ICC 转换 RGB → CMYK，就能更准确地模拟印刷效果。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文印店多数是数码打印机（激光/喷墨复印机），不是传统四色胶印。这些数码机内部往往有 多色墨盒（CMYK+额外的浅青、浅洋红，甚至橙/绿，例如拓展的 CMYKcm等等），驱动会自动做色彩转换和优化。&lt;/li&gt;
&lt;/ul&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;&lt;strong&gt;如果你交 sRGB&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;打印机驱动会根据 自身 ICC / 内部算法 做转换，尽量保留饱和度和细节。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;如果机器支持多色墨盒，可能比传统 CMYK 打出来的效果更好（比如蓝色会更亮，绿色会更正）。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;相当于“把选择权交给打印机”，往往比你自己手动转 CMYK 更好，尤其是非专业的文印店。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;&lt;strong&gt;如果你交 CMYK&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;你交的数值就固定了，打印机不会再帮你优化。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;如果你用的是普通 CMYK（比如 ISO Coated v2），就等于人为先把颜色砍掉一部分，可能比直接交 sRGB 更差。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;打印效果 → 蓝、绿、橙、紫更容易显得暗淡。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以在最开始的设计阶段，依然建议使用 sRGB 色彩空间进行设计。当然，在设计过程中，也有一些额外的注意事项。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;但是，在我看来有下面这种情况更适用于使用 CMYK&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果你一开始就明确自己追求打印出来的颜色效果尽可能接近设计稿，并且自己确定不会使用过于鲜艳的蓝色、绿色、紫色、橙色等 CMYK 没有覆盖的颜色，那么你也可以选择在设计阶段就使用 CMYK 色彩空间进行设计。&lt;/p&gt;
&lt;p&gt;当然，如果是只用于在网络上展示的设计稿，那当然肯定是使用 sRGB 色彩空间。&lt;/p&gt;
&lt;h2&gt;设计阶段&lt;/h2&gt;
&lt;p&gt;如果使用 sRGB 色彩空间进行设计，建议注意以下几点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;尽量避免使用过于鲜艳的蓝色、绿色、紫色、橙色等颜色。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;如果必须使用，建议在设计软件中开启 CMYK 预览功能，查看这些颜色在 CMYK 下的表现。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/photoshop_cmyk_preview.CTSLkRPt_2uV1wG.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;如果使用 CMYK 色彩空间进行设计&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;避免使用超出 CMYK 色域的颜色&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/photoshop_out_of_gamut_warning.bSpdSyz5_Ytmz0.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;（Photoshop 会提示当前选取的颜色是否超出 CMYK 色域）&lt;/p&gt;</content:encoded><h:img src="/_astro/cover.BsAMiBlB.webp"/><enclosure url="/_astro/cover.BsAMiBlB.webp"/></item><item><title>犀牛鸟 与 瞬间</title><link>https://blog.seeridia.top/blog/summer-of-code</link><guid isPermaLink="true">https://blog.seeridia.top/blog/summer-of-code</guid><description>记一次（首次）大厂开源活动</description><pubDate>Thu, 11 Sep 2025 01:30:00 GMT</pubDate><content:encoded>&lt;p&gt;一个偶然的机会，从西二那边的 Open-Source-Application 了解到了腾讯的犀牛鸟，便也想参与其中。&lt;/p&gt;
&lt;p&gt;倒不是只是知道这个活动，此前其实也有了解过 OSPP 以及 GSoC 之类的开源活动，只是太菜了，一眼看过去就觉得自己不行，没敢尝试。而犀牛鸟相比就友好得多了，甚至入门的介绍视频是教 Git 的使用的，这甚至是欢迎几乎零基础的同学吗？这倒让我去想着——或许我也行？&lt;/p&gt;
&lt;p&gt;import { LinkPreview } from &apos;astro-pure/advanced&apos;&lt;/p&gt;
&lt;p&gt;就从大前端的三个里面选了一个 Cherry，比较一眼看过去有点水水的？，毕竟看下去全都是样式的改进&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;1.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;这不就是改改 CSS 吗？所在我之前有接触 Typora 的主题的制作（刚好也是 markdown 编辑器），看着项目也颇为熟悉。&lt;/p&gt;
&lt;p&gt;于是很自然的选择了这个项目&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;从一封邮件开始说起&lt;/h2&gt;
&lt;p&gt;我看到这个项目的时候其实早在 6 月中旬我就看到了，而这个活动其实在 7 月 10 日才正式开始 issue 的占用和验收。&lt;/p&gt;
&lt;p&gt;或许确实是有自己的私心吧，因为这个项目看的就是谁抢的快，做的快，因为项目本身确实是没有什么技术含量的，我确实是是会担心可能会有人比我更快上手。所以我自己就想着早点开始准备，早点开始做。自己确实是是在那时候就已经准备好几个 issue 的相关代码了，就等着提交。不过，或许这个也不够，因为不仅仅是速度可能有人更快，质量也或许更高，数量更多。&lt;/p&gt;
&lt;p&gt;所以我自己就想着，我或许可以并不只局限于这几个 issue，而是可以尝试着去做更多的事情，更深度的参与这个项目，于是我自己在解决犀牛鸟 issue 的过程中，去看看这个项目有什么问题，有什么需要的地方。&lt;/p&gt;
&lt;p&gt;我最后是选取了 Cherry 的变量系统来讲的，发现当时的 Cherry 的变量系统并不完善，甚至可以说是非常的混乱。&lt;/p&gt;
&lt;p&gt;所以我给 Cherry 的导师 &lt;a href=&quot;https://github.com/sunsonliu&quot;&gt;@sunsonliu&lt;/a&gt; 发了一封邮件，详细阐述了我的想法和建议，部分邮件内容如下&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;邮件原文&lt;/code&gt; 我在 6 月 23 日 向导师发的邮件&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;项目理解&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我自己学习了一下 Cherry-markdown 的相关的样式，有发现了主要有如下问题：&lt;/p&gt;
&lt;p&gt;虽然我在 variable.css 中看到了基础的变量系统，但是其并不完整，以及大量实际使用中很多都仍然是硬编码&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;没有规定统一的圆角值，实际使用中部分组件有圆角，部分没有&lt;/li&gt;
&lt;li&gt;以 box-shadow 为代表的变量系统虽有定义，但大部分的实际使用也都是硬编码（每次使用都是一个新的阴影），甚至部分地方增加了很多不必要的多层阴影&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;改进方案&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;建立更加丰富的设计标记系统：定义完整的圆角、阴影、间距等的系统&lt;/li&gt;
&lt;li&gt;重构现有的硬编码样式，逐步替换当前的硬编码的样式&lt;/li&gt;
&lt;li&gt;建立组件基础库，例如建立个 cherry-card、cherry-button 这样的，统一规定其圆角、阴影，也方便后续开发减少工作量&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;p&gt;我自己有上述的想法，并也有对应的能力去实现，所以我想请问是否有这样的计划，我能够独立去做出来，但是也有些顾虑——一来是这个工作量确实有点多，而且很多文件的修改会容易打乱同时期提交人的 PR；其次是这个项目据我所知有应用到有商业化的腾讯文档等地方，我不清楚是否会收到影响&lt;/p&gt;
&lt;p&gt;上述意见也终归来自于一个只有些许经验的学生，如有意见，也恳求批评指正。我自己看到这个项目，就有想要参与成为 contributor 成员的冲动，即便或许不是通过犀牛鸟项目去着手这个项目。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;虽然我不方便在这边展示后续的导师回信内容，乃至之后的交流内容，我想说的是 &lt;strong&gt;沟通是非常重要的&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不论是提早和导师沟通，还是在遇到问题时及时寻求帮助，都是非常必要的。通过他，我也得到了很多的帮助和指导，甚至在后续的 PR 评审中也得到了很多的建议。甚至专门开了我上面提到的对应的变量系统的改进的实战任务 Issue&lt;/p&gt;
&lt;h2&gt;Collaborator!&lt;/h2&gt;
&lt;p&gt;从上面这个变量系统重构的 PR 开始，作为一个贡献者，就直接一发不可收拾了，截止整个犀牛鸟的活动结束，我一共参与了 27 个 PR，如果论 Commit 的数量，应该是有将近 200 个了（Squash 前）&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;2.webp&quot; alt=&quot;犀牛鸟项目结题报告的 PPT&quot;&gt;&lt;/p&gt;
&lt;p&gt;（这个是直接拿我最后项目报告里面的 PPT）&lt;/p&gt;
&lt;p&gt;甚至比较惊喜的发现在 Openomy 的数据评估下，我的分数已经达到了该仓库的第四。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.openomy.com/svg?repo=Tencent/cherry-markdown&amp;#x26;chart=list&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;（这个图片我特意换成会更新的，看看我的排名有没有变化？）&lt;/p&gt;
&lt;p&gt;当然，上面的这些都不及一封邮件来得更令我欢喜了几天&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;3.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;这是导师给我发的 Collaborate 邀请&lt;/p&gt;
&lt;p&gt;也从那时候开始，我也正式成为开发组的一员了，是对我过去付出的最大的认可，远超我以往所经历的任何瞬间，在那时，开源的意义也变得更加深刻了——一位位前辈带着我去走向更高的山，或许不知什么时候，我也能成为带领他人的那一位。&lt;/p&gt;
&lt;h2&gt;尾声&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;更新于 11 月 19 日&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;结果也如我所愿，拿到了犀牛鸟全部的奖项，在我可预见的未来中，或许我会一直把这段经历放在简历的最前面，虽然具体的工作其实是蛮简单的，但是也或许是最亮眼最深刻的经历了&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/Winner-List.BHFp9WGo_lJAFc.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;昨天也收到了腾讯那边发来的包裹（终于到了）&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/Awards-Certificate.Cf4c0VOl_aSalf.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;不过奖金怎么还没发，Qwq（因为我至今都不清楚我会拿到多少钱），看着这个 11 月能有吧？&lt;/p&gt;</content:encoded><h:img src="/_astro/cover.D8PjyRxB.webp"/><enclosure url="/_astro/cover.D8PjyRxB.webp"/></item><item><title>上传照片（服务端）</title><link>https://blog.seeridia.top/blog/image-upload</link><guid isPermaLink="true">https://blog.seeridia.top/blog/image-upload</guid><description>如何处理照片上传、存储和处理</description><pubDate>Tue, 02 Sep 2025 08:05:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Steps } from &apos;astro-pure/user&apos;
import { GithubCard } from &apos;astro-pure/advanced&apos;
import { LinkPreview } from &apos;astro-pure/advanced&apos;&lt;/p&gt;
&lt;p&gt;同样源自于大一的大作业 Shotmeld 项目&lt;/p&gt;
&lt;p&gt;先把最终流程摆出来吧&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
    autonumber
    title Shotmeld 照片上传与智能处理流程

    participant Client as 客户端&amp;#x3C;br&gt;(Web / App)
    participant Server as 服务端&amp;#x3C;br&gt;(主控协调)
    participant OSSUploader as OSS同步模块&amp;#x3C;br&gt;(异步上传)
    participant EXIFProcessor as EXIF处理模块&amp;#x3C;br&gt;(坐标+逆地理)
    participant AITagger as AI标签模块&amp;#x3C;br&gt;(图像识别)
    participant AliyunOSS as 阿里云OSS&amp;#x3C;br&gt;(原图存储)
    participant GaodeAPI as 高德API&amp;#x3C;br&gt;(逆地理编码)
    participant ImageAI as 阿里云图像识别API&amp;#x3C;br&gt;(智能打标)

    Client -&gt;&gt; Server: 1. 上传图片 (multipart/form-data)
    activate Server
    Server -&gt;&gt; Server: 2. 关联相册 + 生成缩略图&amp;#x3C;br&gt;快速响应准备
    Server --&gt;&gt; Client: 3. 返回“上传成功”
    deactivate Server

    Note over Client,Server: 第3步返回的图片URL指向&amp;#x3C;strong&gt;服务端本地存储&amp;#x3C;/strong&gt;，&amp;#x3C;br&gt;由服务端直接提供文件访问，确保用户“秒级预览”

    par OSS 上传流程
        Server -&gt;&gt; OSSUploader: 4. 异步：上传原图至OSS
        activate OSSUploader
        OSSUploader -&gt;&gt; AliyunOSS: 5. 上传原始图片
        AliyunOSS --&gt;&gt; OSSUploader: 6. 返回OSS URL
        OSSUploader -&gt;&gt; Server: 7. 更新数据库：图片URL切换至OSS地址
        deactivate OSSUploader
    and EXIF 解析流程
        Server -&gt;&gt; EXIFProcessor: 8. 异步：解析EXIF元数据
        activate EXIFProcessor
        EXIFProcessor -&gt;&gt; EXIFProcessor: 9. 火星坐标 → WGS84&amp;#x3C;br&gt;坐标纠偏
        EXIFProcessor -&gt;&gt; GaodeAPI: 10. 请求逆地理编码&amp;#x3C;br&gt;经纬度 → 省市区街道
        GaodeAPI --&gt;&gt; EXIFProcessor: 11. 返回结构化地址
        EXIFProcessor -&gt;&gt; Server: 12. 更新地理标签与拍摄地点
        deactivate EXIFProcessor
    and AI 标签识别流程
        Server -&gt;&gt; AITagger: 13. 异步：图像内容识别
        activate AITagger
        AITagger -&gt;&gt; ImageAI: 14. 提交图片URL&amp;#x3C;br&gt;物体/场景/OCR识别
        ImageAI --&gt;&gt; AITagger: 15. 返回标签列表&amp;#x3C;br&gt;如：“猫, 窗户, 阳光”
        AITagger -&gt;&gt; Server: 16. 保存AI标签至数据库
        deactivate AITagger
    end

    Note over Server,AliyunOSS: 第7步完成后，&amp;#x3C;strong&gt;所有图片访问切换至阿里云OSS&amp;#x3C;/strong&gt;，&amp;#x3C;br&gt;服务端释放存储与带宽压力，实现“前端快响应，后端稳迁移”

    Note right of Server: 最终成果：&amp;#x3C;br&gt;• 图片通过OSS访问&amp;#x3C;br&gt;• 带地理位置&amp;#x3C;br&gt;• 带AI智能标签&amp;#x3C;br&gt;• 支持搜索与推荐
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;下面开始讲讲思考&lt;/p&gt;
&lt;h2&gt;上传流程设计思路&lt;/h2&gt;
&lt;h3&gt;初版云端上传流程与初步优化&lt;/h3&gt;
&lt;p&gt;最初的上传流程非常简单：&lt;/p&gt;
&lt;p&gt;随着功能迭代，我们意识到首页展示多张图片时，不应直接使用原图，因此增加了缩略图步骤：&lt;/p&gt;
&lt;p&gt;然而，这并未解决原图访问速度慢的根本问题。更重要的是，后期我们集成了更多图片处理功能，使得流程愈发复杂：&lt;/p&gt;
&lt;p&gt;在这一系列同步操作下，处理单张图片的时间飙升至致命的 15 秒。相比之下，这些操作在本地环境中几乎是瞬时完成的。这揭示了一个在纯本地开发时难以预见的性能瓶颈，也促使我们着手解决。&lt;/p&gt;
&lt;h3&gt;引入阿里云 OSS：提升访问速度&lt;/h3&gt;
&lt;p&gt;为解决服务器带宽和存储的瓶颈，我们引入了 阿里云对象存储（OSS）服务。OSS 的以下特性对我们的项目尤为关键：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;海量存储 （Massive Storage）：OSS 采用“桶（Bucket）”和“对象（Object）”的扁平结构，理论上容量无限，可按需自动扩展，无需预估硬盘空间。&lt;/li&gt;
&lt;li&gt;高可靠性 （High Reliability）：阿里云承诺极高的数据持久性（12 个 9），通过多副本冗余机制将数据分散存储于不同物理设备和可用区，有效防止单点故障。同时支持版本控制，防止意外删除或覆盖。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最重要的是，OSS 能够显著提升用户访问图片的速度。经过一番文档研读和在 AI 辅助下的集成，我们将所有文件存储逻辑迁移到了 OSS。&lt;/p&gt;
&lt;p&gt;迁移后效果显著：常规 5MB 大小的图片，访问时间能控制在 0.5 秒以内。&lt;/p&gt;
&lt;h3&gt;OSS 迁移后的新挑战：上传速度恶化&lt;/h3&gt;
&lt;p&gt;尽管访问速度大幅提升，但上传流程变为：&lt;/p&gt;
&lt;p&gt;不难想到的是，尽管用户访问图片能获得更好的体验，由于增加了服务器到 OSS 的中转环节，图片上传时间反而延长至 20 秒，情况变得更糟。&lt;/p&gt;
&lt;h3&gt;最终解决方案：异步处理优化&lt;/h3&gt;
&lt;p&gt;为彻底解决上传耗时问题，我们设计并实施了最终的异步处理工作流：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;客户端上传文件至服务器&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;服务器处理相册关联等即时性操作&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;服务器快速生成缩略图&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;服务器立即向客户端返回上传成功的响应（此时图片暂时由服务器承载，优先保障用户能第一时间查看和操作）。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;后台异步处理阶段：&lt;/p&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;AI 标签模块：
&lt;ol&gt;
&lt;li&gt;异步调用阿里云图像识别 API 获取图片标签&lt;/li&gt;
&lt;li&gt;将获取的标签保存至数据库&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;通过上述优化，对于一张约 2MB 的普通图片，我们成功将上传时间控制在 2 秒以内，同时保持了查看速度在 0.3 秒以内的优异表现。&lt;/p&gt;
&lt;h2&gt;Appendix&lt;/h2&gt;
&lt;h3&gt;地图坐标转换&lt;/h3&gt;
&lt;p&gt;我们在 Exif 解析的时候，想要根据 Exif 中的信息来得到一个结构化的地址（例如 福建省福州市福州大学（旗山校区） 这样的具体名称），而非仅仅是经纬度坐标。为此，我们需要引入高德 API 进行逆地理编码。&lt;/p&gt;
&lt;p&gt;但是在这之前，需要知道的是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;中国大陆境内采用的都是 GCJ-02 坐标系（火星坐标系），而非 WGS-84 坐标系（GPS 坐标系）&lt;/li&gt;
&lt;li&gt;图片 Exif 中存储的经纬度信息是 WGS-84 坐标系&lt;/li&gt;
&lt;li&gt;高德 API 的逆地理编码只支持 GCJ-02 坐标系，因此我们需要将 WGS-84 坐标系转换为 GCJ-02 坐标系&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我自己是直接使用 高德 API 提供的坐标转换接口来完成的，当然也可以直接本地转换&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;/**
 * 将GPS（WGS84）坐标转换为高德（GCJ-02）坐标系并获取位置名称
 * @param {number} longitude - GPS经度
 * @param {number} latitude - GPS纬度
 * @returns {Promise&amp;#x3C;{longitude: number, latitude: number, name: string|null}&gt;} - 包含高德坐标和位置名称的对象
 */
async function convertGPSToAMap(originalLongitude, originalLatitude) {
  try {
    // 验证输入坐标的有效性
    if (typeof originalLongitude !== &apos;number&apos; || typeof originalLatitude !== &apos;number&apos; ||
        isNaN(originalLongitude) || isNaN(originalLatitude)) {
      throw new Error(&apos;无效的坐标值&apos;);
    }

    // 确保有API Key
    if (!config.AMAP_KEY) {
      console.warn(&apos;未配置高德地图API Key，无法进行坐标转换和逆地理编码&apos;);
      return { longitude: originalLongitude, latitude: originalLatitude, name: null };
    }

    let amapLongitude = originalLongitude;
    let amapLatitude = originalLatitude;
    let locationName = null;

    // 1. 坐标转换 (WGS84 to GCJ-02)
    const convertUrl = `https://restapi.amap.com/v3/assistant/coordinate/convert?key=${config.AMAP_KEY}&amp;#x26;locations=${originalLongitude},${originalLatitude}&amp;#x26;coordsys=gps`;
    try {
      const convertResponse = await axios.get(convertUrl);
      if (convertResponse.data &amp;#x26;&amp;#x26; convertResponse.data.status === &apos;1&apos; &amp;#x26;&amp;#x26; convertResponse.data.locations) {
        const [lon, lat] = convertResponse.data.locations.split(&apos;,&apos;);
        amapLongitude = parseFloat(lon);
        amapLatitude = parseFloat(lat);
      } else {
        console.error(&apos;高德地图坐标转换API返回错误:&apos;, convertResponse.data);
      }
    } catch (convertError) {
      console.error(&apos;高德地图坐标转换失败:&apos;, convertError);
    }
    
    // 2. 逆地理编码获取位置名称 (使用转换后的或原始的高德坐标)
    const locationStringForRegeo = `${amapLongitude},${amapLatitude}`;
    const regeoUrl = `https://restapi.amap.com/v3/geocode/regeo?key=${config.AMAP_KEY}&amp;#x26;location=${locationStringForRegeo}&amp;#x26;extensions=base`;
    
    try {
        const regeoResponse = await axios.get(regeoUrl);
        if (regeoResponse.data &amp;#x26;&amp;#x26; regeoResponse.data.status === &apos;1&apos; &amp;#x26;&amp;#x26; regeoResponse.data.regeocode) {
          locationName = regeoResponse.data.regeocode.formatted_address;
        } else {
          console.error(&apos;高德地图逆地理编码API返回错误:&apos;, regeoResponse.data);
        }
    } catch (regeoError) {
        console.error(&apos;高德地图逆地理编码失败:&apos;, regeoError);
    }

    // 返回转换后的高德经纬度和获取到的位置名称
    return {
      longitude: amapLongitude,
      latitude: amapLatitude,
      name: locationName
    };

  } catch (error) {
    console.error(&apos;获取位置名称或坐标转换主流程失败:&apos;, error);
    return { longitude: originalLongitude, latitude: originalLatitude, name: null }; 
  }
}
&lt;/code&gt;&lt;/pre&gt;</content:encoded><h:img src="/_astro/cover.B8q_T-9P.webp"/><enclosure url="/_astro/cover.B8q_T-9P.webp"/></item><item><title>极低成本的“自然语言图像检索”实现</title><link>https://blog.seeridia.top/blog/image-search</link><guid isPermaLink="true">https://blog.seeridia.top/blog/image-search</guid><description>通过 LLM 处理图像检索</description><pubDate>Sun, 24 Aug 2025 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { GithubCard } from &apos;astro-pure/advanced&apos;
import { LinkPreview } from &apos;astro-pure/advanced&apos;
import { MdxRepl } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;该实现方式是我在完成校内大作业的时候想到的，并已经在实际项目中得到了应用。通过将自然语言处理与图像检索相结合，我们能够以极低的成本实现高效的图像搜索功能。&lt;/p&gt;
&lt;p&gt;项目 Github 链接：&lt;/p&gt;
&lt;p&gt;目前常规的“文搜图”基础普遍依赖“向量搜索（Vector Search）”的方式实现，但是势必依赖较好的服务端资源，可惜我们这边当时并没有那么好的条件 QwQ，所以只能使用非常 投机取巧 灵活的方式去实现这个功能。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;常规实现——向量搜索&lt;/h2&gt;
&lt;p&gt;当然，我们用的不是这个方法，我只是这边贴一下，方便顺便学习。不过这个也是最主流，效果更好的方法，如果不差钱，这个是最推荐的。不过我的方法也是在这上面延伸出来的。&lt;/p&gt;
&lt;p&gt;就算是正常的“向量搜索（Vector Search）”也会把“文搜图”工作分成如下几步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;理解图片 (Image Understanding)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;当将照片存入相册或图库时，AI模型（如深度学习中的卷积神经网络CNN或Vision Transformer）会自动分析每一张照片。&lt;/p&gt;
&lt;p&gt;它会识别出照片中的各种元素，例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;物体:&lt;/strong&gt; 猫、狗、汽车、桌子、电脑&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;场景:&lt;/strong&gt; 海滩、城市街道、森林、室内房间&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;动作:&lt;/strong&gt; 跑步、微笑、吃饭、弹吉他&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;属性:&lt;/strong&gt; 蓝色、圆形、大的、模糊的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后，AI会将这些复杂的视觉信息转换成一个数学表示，称为 &lt;strong&gt;“图像嵌入” (Image Embedding)&lt;/strong&gt; 或“特征向量”。这个向量可以被看作是这张照片在AI“大脑”中的一个独一无二的“语义坐标”。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;理解文字 (Text Understanding):&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当在搜索框中输入文字，比如“在海边奔跑的金色小狗”时，另一个AI模型（如BERT或CLIP的文本部分）会来处理这段文字。&lt;/li&gt;
&lt;li&gt;它会分析这段话的语法和语义，理解其中的关键概念（海边、奔跑、金色、小狗）。&lt;/li&gt;
&lt;li&gt;同样地，AI也会将这段文字转换成一个数学表示，称为 &lt;strong&gt;“文本嵌入” (Text Embedding)&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;匹配和搜索 (Matching and Searching):&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;现在，AI有了代表您搜索词的“文本向量”和代表每一张照片的“图像向量”。&lt;/li&gt;
&lt;li&gt;最关键的一步是，这些向量存在于同一个“多模态语义空间”中。在这个空间里，意思相近的文本和图片，它们的向量坐标也相近。&lt;/li&gt;
&lt;li&gt;因此，系统只需要计算您的文本向量与图库中所有图像向量的“距离”，然后将距离最近、最匹配的那些照片作为搜索结果返回给您。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;上面的就是向量搜索的方法，也是业内主流采用的方式&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;奇技淫巧&lt;/h2&gt;
&lt;p&gt;正题开始！&lt;/p&gt;
&lt;p&gt;仿照上面的思路，我将“文搜图”的工作分成了两步：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;图片转描述词&lt;/li&gt;
&lt;li&gt;搜索词匹配描述词&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;图片转描述词这步其实蛮简单的，只要把它理解成“图片打标”，就有许多现成的 API 可以用，比如我当时用的阿里家的&lt;/p&gt;
&lt;p&gt;接下来一步就很神奇，我们从 api 那边拿到了每个图片的标签，我们该如何用这些标签去匹配用户的搜索词呢？当然，正常的方法是词向量匹配，但是由于相同的原因，所以我就想到了用 LLM 来做这个匹配。&lt;/p&gt;
&lt;p&gt;（当时没有使用词向量的原因和上面差不多，但是当时傻，不知道有文本向量特化模型，下面我会说到）&lt;/p&gt;
&lt;p&gt;流程如下&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph TB
    subgraph A [&quot;照片索引阶段 (一次性处理) &quot;]
        direction LR
        Photos([个人照片库]) --&gt; API{{调用图像打标 API, 如: 阿里云}}
        API --&gt; Tags[返回图片标签, 例如: &apos;河流&apos;, &apos;小船&apos;]
        Tags --&gt; DB[(标签数据库)]
        Photos -- 路径/ID --&gt; DB
    end

    A --&gt; B

    subgraph B [&quot;用户搜索阶段 (实时查询) &quot;]
        direction TB
        UserInput([用户输入自然语言: &apos;在河边的日落&apos;]) --&gt; Prompt
        DB -- 提取所有可用标签 --&gt; Prompt
        Prompt{{组合成 Prompt}} --&gt; LLM[请求 LLM 匹配]
        LLM --&gt; MatchedTags[LLM 返回最匹配的标签,例如: &apos;河流&apos;, &apos;日落&apos;]
        MatchedTags --&gt; SearchDB{{查询标签数据库}}
        DB -- 被查询 --&gt; SearchDB
        SearchDB --&gt; Results[找到所有匹配的图片]
        Results --&gt; Display([向用户展示结果])
    end

    %% ====== 样式美化 ======
    style A fill:#fff8dc,stroke:#333,stroke-width:1px,rx:10,ry:10
    style B fill:#e6f2ff,stroke:#333,stroke-width:1px,rx:10,ry:10
    style DB fill:#f9c,stroke:#333,stroke-width:2px
    style LLM fill:#bbf,stroke:#333,stroke-width:2px
    style UserInput fill:#eee,stroke:#666,rx:8,ry:8
    style Display fill:#eee,stroke:#666,rx:8,ry:8

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;流程很好解决，接下来就是一些细节的处理&lt;/p&gt;
&lt;h3&gt;信息设计&lt;/h3&gt;
&lt;h4&gt;输入信息&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;标签库&lt;/strong&gt;：JSON 格式的标签列表，例如&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&quot;tags&quot;: [&quot;旅行&quot;, &quot;时尚&quot;, &quot;化妆&quot;, &quot;风景&quot;, &quot;美食&quot;]}
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;搜索查询&lt;/strong&gt;：用户输入的搜索内容，例如：“旅行”。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;输出要求&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;以 JSON 格式返回匹配标签列表，格式为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&quot;matched_tags&quot;: [&quot;标签1&quot;, &quot;标签2&quot;, ...]}
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;输出必须为合法 JSON，仅包含 &lt;code&gt;matched_tags&lt;/code&gt; 数组。&lt;/li&gt;
&lt;li&gt;不能包含解释、注释或额外文本。&lt;/li&gt;
&lt;li&gt;若无匹配标签，则返回 &lt;code&gt;{&quot;matched_tags&quot;: []}&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;提示词设计&lt;/h3&gt;
&lt;p&gt;这边就需要我们去用心设计一个提示词，因为我们需要保证 LLM 稳定地返回我们所需的内容。当然也有一个大家熟知的技巧，用 LLM 来帮我们写提示词。&lt;/p&gt;
&lt;p&gt;除了规定其输出规则外，我这边也给了其核心匹配原则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;相关性联想：匹配与搜索词在功能、场景或属性上紧密相关的事物。例如，搜索“学习”时，可以关联到“书籍”、“课程”、“考试”等标签。&lt;/li&gt;
&lt;li&gt;场景化匹配：关键在于理解搜索词背后的场景和需求。举个关键例子：如果用户搜索“湖水”，你不仅要匹配“湖”，更需要智能地联想到相关的“河”、“江”、“溪”、“水”等，因为它们都属于“水体”这一核心概念。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;确保 AI 能给我更好的匹配效果&lt;/p&gt;
&lt;p&gt;核心匹配原则 (Core Matching Principles):
你的匹配不应局限于字面上的同义词，而必须扩展到更广泛的语义关联维度：&lt;/p&gt;
&lt;p&gt;相关性联想 (Associative Relevance): 匹配与搜索词在功能、场景或属性上紧密相关的事物。例如，搜索“学习”时，可以关联到“书籍”、“课程”、“考试”等标签。
场景化匹配 (Contextual Matching): 关键在于理解搜索词背后的场景和需求。举个关键例子：如果用户搜索“湖水”，你不仅要匹配“湖”，更需要智能地联想到相关的“河”、“江”、“溪”、“水”等，因为它们都属于“水体”这一核心概念。&lt;/p&gt;
&lt;p&gt;输入信息 (Input Information):&lt;/p&gt;
&lt;p&gt;所有标签的 JSON 文件如下：
${JSON.stringify({ tags: allTags }, null, 2)}&lt;/p&gt;
&lt;p&gt;用户的搜索内容是：
${searchQuery}&lt;/p&gt;
&lt;p&gt;输出要求 (Output Requirements):&lt;/p&gt;
&lt;p&gt;请根据上述原则，匹配出与搜索内容相关的所有标签，并仅以 JSON 格式返回匹配的标签列表。&lt;/p&gt;
&lt;p&gt;返回格式示例：
{ &quot;matched_tags&quot;: [&quot;tag1&quot;, &quot;tag2&quot;] }&lt;/p&gt;
&lt;p&gt;严格遵守以下规则 (Strict Rules):&lt;/p&gt;
&lt;p&gt;只返回 JSON：绝对不要添加任何解释、注释或额外的文字。
结果必须是有效的 JSON 数组。
若无相关标签：返回一个包含空数组的 JSON 对象，即 {&quot;matched_tags&quot;: []}。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;#x3C;/Fragment&gt;
&amp;#x3C;/MdxRepl&gt;

### API 请求示例

&amp;#x3C;MdxRepl width=&apos;100%&apos;&gt;
&amp;#x3C;p&gt;请求示例代码&amp;#x3C;/p&gt;
&amp;#x3C;Fragment slot=&apos;desc&apos;&gt;
```js
import OpenAI from &quot;openai&quot;;

const openai = new OpenAI({
apiKey: process.env.API_KEY,
baseURL: &quot;https://dashscope.aliyuncs.com/compatible-mode/v1&quot;,
});

async function main() {
const tags = [&quot;旅行&quot;, &quot;时尚&quot;, &quot;化妆&quot;, &quot;风景&quot;, &quot;美食&quot;];
const searchQuery = &quot;旅行&quot;;
const prompt = `
 角色与目标 (Role &amp;#x26; Goal):
  你是一位专精于概念关联与语义理解的智能标签匹配引擎。你的核心任务是深入分析用户的搜索意图，并从一个给定的标签库中，广泛地匹配出所有相关的标签。

 核心匹配原则 (Core Matching Principles):
  你的匹配不应局限于字面上的同义词，而必须扩展到更广泛的语义关联维度：
      相关性联想 (Associative Relevance): 匹配与搜索词在功能、场景或属性上紧密相关的事物。例如，搜索“学习”时，可以关联到“书籍”、“课程”、“考试”等标签。
      场景化匹配 (Contextual Matching): 关键在于理解搜索词背后的场景和需求。举个关键例子：如果用户搜索“湖水”，你不仅要匹配“湖”，更需要智能地联想到相关的“河”、“江”、“溪”、“水”等，因为它们都属于“水体”这一核心概念。
 输入信息 (Input Information):
     所有标签的 JSON 文件如下：
      ${JSON.stringify({ tags: allTags }, null, 2)}

      用户的搜索内容是：
      ${searchQuery}

 输出要求 (Output Requirements):
  请根据上述原则，匹配出与搜索内容相关的所有标签，并仅以 JSON 格式返回匹配的标签列表。

 返回格式示例：
  { &quot;matched_tags&quot;: [&quot;tag1&quot;, &quot;tag2&quot;] }

 严格遵守以下规则 (Strict Rules):
  只返回 JSON：绝对不要添加任何解释、注释或额外的文字。
  结果必须是有效的 JSON 数组。
  若无相关标签：返回一个包含空数组的 JSON 对象，即 {&quot;matched_tags&quot;: []}。
   `;

const completion = await openai.chat.completions.create({
  model: &quot;qwen-turbo&quot;,
  messages: [
    { role: &quot;system&quot;, content: &quot;你是一个智能助手。&quot; },
    { role: &quot;user&quot;, content: prompt },
  ],
});

console.log(completion.choices[0].message.content);
}

main();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这边我采用的是 &lt;code&gt;qwen-turbo&lt;/code&gt; 模型，主要看中其速度，十次平均 420ms。&lt;/p&gt;
&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;我虽然当时采用的是 &lt;code&gt;qwen-turbo&lt;/code&gt; ，而实际上我们完全可以使用“阿里通用文本向量-v4(text-embedding-v4)”这类特化模型，他们不是思考，而是纯粹的数学计算，延迟更低，效果更好，可惜当时傻，我有时间会去重新做这一块。&lt;/p&gt;
&lt;p&gt;不过这样上面的 API 请求什么都不一样，可以参考&lt;/p&gt;
&lt;h2&gt;评估&lt;/h2&gt;
&lt;h3&gt;优点&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;成本极低，无论是图像打标还是 LLM 对话&lt;/li&gt;
&lt;li&gt;实现简单&lt;/li&gt;
&lt;li&gt;效果好：利用大语言模型做“语义理解”的中间层，比传统的关键词匹配要智能得多&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;缺点&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;搜索的粒度和深度有限&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;关系丢失: 图像打标API通常返回的是离散的、名词性的标签，它们之间没有关系。比如一张“一个男人在追逐一只狗”的照片，标签可能是&lt;code&gt;[&quot;男人&quot;, &quot;狗&quot;, &quot;奔跑&quot;, &quot;草地&quot;]&lt;/code&gt;。无法通过这些标签搜索“谁在追谁”这种带有关系和动作主体的复杂场景。系统可以找到同时包含这些标签的照片，但无法理解它们之间的逻辑。&lt;/li&gt;
&lt;li&gt;属性和细节丢失: 对于“穿红色裙子的女孩”这样的搜索，如果打标API只返回了&lt;code&gt;[&quot;女孩&quot;, &quot;裙子&quot;]&lt;/code&gt;而没有“红色”这个属性，搜索就会失败。标签的质量完全决定了搜索结果的上限。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;结果排序困难&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这个方案本质上是“布尔搜索”（要么匹配，要么不匹配）。如果多个结果都匹配了AI返回的标签（例如，10张照片都有“河流”和“日落”的标签），很难判断哪一张是&lt;strong&gt;最&lt;/strong&gt;匹配用户描述的。而真正的向量搜索可以根据向量距离进行排序，给出最相关的结果。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;后日谈&lt;/h2&gt;
&lt;p&gt;上面的内容产出都是我在当时做那个项目的时候思考的，也实际使用了，你也可以去试试看效果&lt;/p&gt;
&lt;p&gt;当然我上面也提到了，我当时并不知道阿里云那边也有专门的文本向量模型，我知道词向量，但是由于服务器拉了没法本地跑就没用了。&lt;/p&gt;
&lt;p&gt;而结果是这个模型更便宜，效果更好，延迟更低 QwQ，确实是有点小伤心的，不过在这边也学到蛮多的，没办法，有时间去换一换吧，之后再更新这篇文章&lt;/p&gt;</content:encoded><h:img src="/_astro/cover.fNPAEoPW.webp"/><enclosure url="/_astro/cover.fNPAEoPW.webp"/></item><item><title>从网站的上线开始讲起（二）</title><link>https://blog.seeridia.top/blog/website-launched-2</link><guid isPermaLink="true">https://blog.seeridia.top/blog/website-launched-2</guid><description>域名 与 DNS</description><pubDate>Fri, 22 Aug 2025 22:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我就从 域名 → DNS → CDN → ESA 这条链路开始看看一个网站的上线，特别是在国内的网络环境下，如何保证用户能够快速、稳定地访问。不过本篇只会有域名和DNS，后续会补上CDN（这部分太多了）。&lt;/p&gt;
&lt;p&gt;本文的部分内容和供图来自阿里云的 &lt;a href=&quot;https://help.aliyun.com/zh/dns/basic-concepts-dns2-0&quot;&gt;帮助文档&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;域名（Domain Name）&lt;/h2&gt;
&lt;h3&gt;概念&lt;/h3&gt;
&lt;p&gt;域名就是互联网的“门牌号”，让人们可以用 文字 来访问网站，而不用记一长串 IP 地址。&lt;/p&gt;
&lt;p&gt;举例：&lt;code&gt;seeridia.top&lt;/code&gt; 其实只是一个名字，它背后最终会对应到服务器的 IP（例如 &lt;code&gt;172.31.49.252&lt;/code&gt; ）。&lt;/p&gt;
&lt;p&gt;域名属于 ICANN 统一管理，注册商（阿里云、腾讯云、Namecheap 等）相当于“代理商”。&lt;/p&gt;
&lt;h3&gt;关键知识点&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;域名层级&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;顶级域名（TLD）： &lt;code&gt;.com&lt;/code&gt;, &lt;code&gt;.cn&lt;/code&gt;, &lt;code&gt;.top&lt;/code&gt; 等&lt;/li&gt;
&lt;li&gt;二级域名：&lt;code&gt;seeridia.top&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;子域名：&lt;code&gt;api.seeridia.top&lt;/code&gt;, &lt;code&gt;cdn.seeridia.top&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;注意：每一级域名都可以单独做解析配置。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;域名注册与所有权&lt;/p&gt;
&lt;p&gt;注册域名后，获得一定年限的使用权。必须定期续费，否则可能被别人抢注。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;域名系统依赖 DNS&lt;/p&gt;
&lt;p&gt;域名本身只是名字，必须依赖 DNS（域名系统）才能解析成 IP 地址，否则浏览器没法访问。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/4056354571/CAEQYBiBgMC3qveJxBkiIDY4MGZjNjdiZDM1ZTQzYjliZWM1ZTFjMmIyNTNiNzY23963382_20230830144006.372.svg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2&gt;DNS（Domain Name System）&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;作用&lt;/strong&gt;：把人类可读的域名（&lt;code&gt;seeridia.top&lt;/code&gt;）转换成计算机可用的 IP 地址（&lt;code&gt;172.31.49.252&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;类比&lt;/strong&gt;：就像电话簿，把“张三的名字”查成“张三的电话号码”&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;位置&lt;/strong&gt;：DNS 位于整个访问链路的最开始，用户输入网址时，浏览器必须先通过 DNS 得到 IP 才能发起请求&lt;/p&gt;
&lt;h3&gt;解析流程&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;用户在Web浏览器中输入 &lt;code&gt;example.com&lt;/code&gt; ， 向本地域名服务器发起查询请求。若本地域名服务器存在缓存的解析数据，则直接将域名对应的IP地址返回给Web浏览器，跳至步骤9。若本地域名服务器没有查到缓存的解析数据，则继续步骤2。&lt;/li&gt;
&lt;li&gt;本地域名服务器向根域名服务器进行查询。&lt;/li&gt;
&lt;li&gt;根域名服务器将 &lt;code&gt;.com&lt;/code&gt; 顶级域名服务器的地址，返回给本地域名服务器。&lt;/li&gt;
&lt;li&gt;本地域名服务器向 &lt;code&gt;.com&lt;/code&gt; 顶级域名服务器发起 &lt;code&gt;example.com&lt;/code&gt; 的查询请求。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.com&lt;/code&gt; 顶级域名服务器将为 &lt;code&gt;example.com&lt;/code&gt; 提供权威解析的权威域名服务器地址，返回给本地域名服务器。&lt;/li&gt;
&lt;li&gt;本地域名服务器向权威域名服务器发起查询请求。&lt;/li&gt;
&lt;li&gt;权威域名服务器将域名 &lt;code&gt;example.com&lt;/code&gt; 对应的 IP 地址，返回给本地域名服务器。&lt;/li&gt;
&lt;li&gt;本地域名服务器最后把查询的 IP 地址响应给 Web 浏览器。&lt;/li&gt;
&lt;li&gt;Web 浏览器通过 IP 地址访问网站服务器。&lt;/li&gt;
&lt;li&gt;网站服务器返回网页信息。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/5056354571/CAEQTxiBgIDntoSQjxkiIDllMmQyMGJlYzJlZjRlZWI4ZTAzYzI2ZWI3OGU2MjM13963382_20230830144006.372.svg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3&gt;相关概念&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;递归与迭代查询（非递归）查询&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;递归查询&lt;/strong&gt;：DNS 客户端（如你的电脑或手机）向 DNS 服务器发出请求，并要求该服务器必须返回最终结果。如果该服务器没有缓存答案，它就必须自己去“代表客户端”向其他服务器查询，直到获得最终的 IP 地址，然后返回给客户端。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;例如：你访问 &lt;code&gt;www.example.com&lt;/code&gt;，你的电脑向本地 DNS 服务器（如 8.8.8.8）发送一个递归查询请求，如果 8.8.8.8 没有缓存，它会自己去一步步查（通过迭代方式），最终把结果返回给你。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;本地域名服务器（递归 DNS 服务器），主要来源：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ISP（网络服务提供商）&lt;/strong&gt;：如电信、联通、移动等提供的 DNS 服务器。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;公共 DNS 服务&lt;/strong&gt;：如 Google DNS（8.8.8.8）、Cloudflare（1.1.1.1）、阿里 DNS（223.5.5.5）、腾讯 DNS（119.29.29.29）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;企业/校园内部 DNS&lt;/strong&gt;：公司或学校自建的 DNS 服务器&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;迭代查询&lt;/strong&gt;： DNS 服务器不会替客户端继续查询，而是返回一个“可能知道答案”的其他 DNS 服务器地址，让客户端（或上游服务器）自己去问下一个服务器。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;TTL（Time To Live）&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;本地域名服务器（递归 DNS 服务器）在拿到 IP 后，会把这个结果缓存起来，TTL 就代表域名解析结果可缓存的最长时间，缓存时间到期后本地域名服务器则会删除该解析记录的数据，删除之后，如有用户请求域名，则会重新进行递归查询/迭代查询的过程。&lt;/li&gt;
&lt;li&gt;比如 TTL=600 → 递归 DNS 缓存 10 分钟，10 分钟后才会重新问权威 DNS。&lt;/li&gt;
&lt;li&gt;TTL 太长：改配置要等很久才生效。&lt;/li&gt;
&lt;li&gt;TTL 太短：查询太频繁，延迟增加。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;DNS 缓存&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;本地 DNS 缓存&lt;/strong&gt;：浏览器和操作系统会缓存 DNS 解析结果，减少重复查询。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;递归 DNS 缓存&lt;/strong&gt;：递归 DNS 服务器会缓存查询结果，减少对权威 DNS 的请求。&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;记录类型&lt;/h3&gt;
&lt;p&gt;| 类型      | 作用                           | 示例                                                         | 使用场景                                                     |
| --------- | ------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
| &lt;strong&gt;A&lt;/strong&gt;     | 把域名解析到 IPv4 地址         | &lt;code&gt;seeridia.top → 203.0.113.10&lt;/code&gt;                                | 网站直连服务器，最常见的解析方式                             |
| &lt;strong&gt;AAAA&lt;/strong&gt;  | 把域名解析到 IPv6 地址         | &lt;code&gt;seeridia.top → 2400:3200::1&lt;/code&gt;                                | 网站支持 IPv6 访问                                           |
| &lt;strong&gt;CNAME&lt;/strong&gt; | 把域名解析到另一个域名（别名） | &lt;code&gt;www.seeridia.top → seeridia.top&lt;/code&gt;  &lt;code&gt;cdn.seeridia.top → cdn.edgeone.com&lt;/code&gt; | CDN/ESA 接入、统一指向一个目标域名                           |
| &lt;strong&gt;MX&lt;/strong&gt;    | 指定邮件服务器                 | &lt;code&gt;seeridia.top → mail.seeridia.top （优先级 10）&lt;/code&gt;             | Gmail/Outlook 企业邮箱，自建邮箱                             |
| &lt;strong&gt;TXT&lt;/strong&gt;   | 存放文本信息（验证/策略）      | &lt;code&gt;seeridia.top TXT &quot;v=spf1 include:_spf.google.com ~all&quot;&lt;/code&gt;     | 域名验证、邮件安全（SPF/DKIM/DMARC）                         |
| &lt;strong&gt;NS&lt;/strong&gt;    | 指定权威 DNS 服务器            | &lt;code&gt;seeridia.top → ns1.dnsprovider.com&lt;/code&gt;                         | 决定谁负责解析该域名（域名商 / DNS 服务商），一般用于将子域名交给其他 DNS 服务商解析 |&lt;/p&gt;
&lt;p&gt;另外，还有一些其他类型的记录，如 SRV（服务定位）、PTR（反向解析）、CAA（签发证书）等，但不常用。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;CNAME 与 重定向 并不同&lt;/p&gt;
&lt;p&gt;CNAME 其实是说：这个域名其实是另一个域名的别名&lt;/p&gt;
&lt;p&gt;| 对比项       | CNAME 记录                       | HTTP 重定向 (301/302)               |
| ------------ | -------------------------------- | ----------------------------------- |
| 发生位置     | &lt;strong&gt;DNS 层&lt;/strong&gt;（浏览器还没发请求）   | &lt;strong&gt;HTTP 层&lt;/strong&gt;（浏览器已经访问服务器） |
| 返回内容     | 返回另一个域名，继续解析 IP      | 返回新的 URL，让浏览器重新请求      |
| 用户是否可见 | 对用户不可见，用户输入的域名不变 | 用户地址栏会跳转到新的 URL          |
| 使用目的     | 域名别名、接入 CDN、统一解析     | 网站迁移、SEO、内容跳转             |&lt;/p&gt;
&lt;/blockquote&gt;</content:encoded><h:img src="/_astro/cover.CJNRvIBI.webp"/><enclosure url="/_astro/cover.CJNRvIBI.webp"/></item><item><title>Github Actions 自动化发布</title><link>https://blog.seeridia.top/blog/release-action</link><guid isPermaLink="true">https://blog.seeridia.top/blog/release-action</guid><description>以 构建 Tauri 应用 为例，使用 Github Actions 实现自动化发布</description><pubDate>Sun, 17 Aug 2025 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { GithubCard } from &apos;astro-pure/advanced&apos;&lt;/p&gt;
&lt;p&gt;我以下面这个 Tauri 应用为例，记录如何 Github Actions 实现自动化发布&lt;/p&gt;
&lt;p&gt;我分成两部分，一个是每次 Commit 都自动构建应用，另一个是每次发布 Release 时自动打包应用。&lt;/p&gt;
&lt;h2&gt;推送构建&lt;/h2&gt;
&lt;p&gt;只要在主分支有代码提交，或发起 Pull Request，就会触发构建，产物放在 Actions 的 Artifacts 中&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;# .github/workflows/build-test.yml

name: Build and Test

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        platform: [macos-latest, ubuntu-latest, windows-latest]

    runs-on: ${{ matrix.platform }}

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: &apos;18&apos;
          cache: &apos;npm&apos;

      - name: Setup Rust
        uses: actions-rust-lang/setup-rust-toolchain@v1
        with:
          toolchain: stable

      - name: Install Linux dependencies
        if: runner.os == &apos;Linux&apos;
        run: |
          sudo apt-get update
          sudo apt-get install -y \
            build-essential \
            curl \
            wget \
            file \
            libssl-dev \
            libgtk-3-dev \
            libwebkit2gtk-4.1-dev \
            libayatana-appindicator3-dev \
            librsvg2-dev

      - name: Install frontend dependencies
        run: npm install

      - name: Build Tauri application
        uses: tauri-apps/tauri-action@v0.5.22

      - name: Prepare artifact details
        id: prepare_artifact
        shell: bash
        run: |
          ARCH=$(echo &quot;${{ runner.arch }}&quot; | tr &apos;[:upper:]&apos; &apos;[:lower:]&apos;)
          ARTIFACT_PATH=&quot;&quot;
          ARTIFACT_NAME=&quot;&quot;

          if [ &quot;${{ runner.os }}&quot; == &quot;macOS&quot; ]; then
            ARTIFACT_PATH=$(find src-tauri/target/release/bundle/dmg -name &quot;*.dmg&quot; -print -quit)
            ARTIFACT_NAME=&quot;clipboard-viewer-latest-mac-${ARCH}.dmg&quot;
          elif [ &quot;${{ runner.os }}&quot; == &quot;Windows&quot; ]; then
            ARTIFACT_PATH=$(find src-tauri/target/release/bundle/msi -name &quot;*.msi&quot; -print -quit)
            ARTIFACT_NAME=&quot;clipboard-viewer-latest-win-${ARCH}.msi&quot;
          elif [ &quot;${{ runner.os }}&quot; == &quot;Linux&quot; ]; then
            ARTIFACT_PATH=$(find src-tauri/target/release/bundle/appimage -name &quot;*.AppImage&quot; -print -quit)
            ARTIFACT_NAME=&quot;clipboard-viewer-latest-linux-${ARCH}.AppImage&quot;
          fi
          
          echo &quot;Found artifact at: ${ARTIFACT_PATH}&quot;
          echo &quot;New artifact name: ${ARTIFACT_NAME}&quot;
          echo &quot;path=${ARTIFACT_PATH}&quot; &gt;&gt; $GITHUB_OUTPUT
          echo &quot;name=${ARTIFACT_NAME}&quot; &gt;&gt; $GITHUB_OUTPUT

      - name: Upload Build Artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ steps.prepare_artifact.outputs.name }}
          path: ${{ steps.prepare_artifact.outputs.path }}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;发布构建&lt;/h2&gt;
&lt;p&gt;在推送 Release Tag 时，会自动触发构建并发布应用&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;# .github/workflows/release.yml

name: Create GitHub Release

on:
  push:
    tags:
      - &apos;v[0-9]+.[0-9]+.[0-9]+*&apos;

permissions:
  contents: write
  actions: read

jobs:
  publish-release:
    strategy:
      fail-fast: false
      matrix:
        platform: [macos-latest, ubuntu-latest, windows-latest]

    runs-on: ${{ matrix.platform }}

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: &apos;18&apos;
          cache: &apos;npm&apos;

      - name: Setup Rust
        uses: actions-rust-lang/setup-rust-toolchain@v1
        with:
          toolchain: stable

      - name: Install Linux dependencies
        if: runner.os == &apos;Linux&apos;
        run: |
          sudo apt-get update
          sudo apt-get install -y \
            build-essential \
            curl \
            wget \
            file \
            libssl-dev \
            libgtk-3-dev \
            libwebkit2gtk-4.1-dev \
            libayatana-appindicator3-dev \
            librsvg2-dev

      - name: Install frontend dependencies
        run: npm install

      - name: Build and publish release
        uses: tauri-apps/tauri-action@v0.5.22
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tagName: ${{ github.ref_name }}
          releaseName: &apos;App Version ${{ github.ref_name }}&apos;
          releaseBody: &apos;See the assets to download this version.&apos;
          releaseDraft: false
          prerelease: false
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;注意&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;无需配置 &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; 等，Github Actions 会自动提供。&lt;/li&gt;
&lt;li&gt;需要在 Settings &gt; Actions &gt; General &gt; Workflow permissions 中，选择 &quot;Read and write permissions&quot;&lt;/li&gt;
&lt;/ol&gt;</content:encoded><h:img src="/_astro/cover.CQkEvmcx.webp"/><enclosure url="/_astro/cover.CQkEvmcx.webp"/></item><item><title>AstrBot + NapCat 下的 QQBot</title><link>https://blog.seeridia.top/blog/qqbot</link><guid isPermaLink="true">https://blog.seeridia.top/blog/qqbot</guid><description>得益于 AstrBot 和 NapCat 的强大功能，QQBot 的接入确实是非常方便了</description><pubDate>Sat, 16 Aug 2025 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Steps } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;先贴出文档&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astrbot.app&quot;&gt;AstrBot 文档&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://napcat.napneko.icu&quot;&gt;NapCat 官网&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;得益于 AstrBot 和 NapCat 的强大功能，QQBot 的接入确实是非常方便了&lt;/p&gt;
&lt;h2&gt;NapCat 配置&lt;/h2&gt;
&lt;p&gt;在 Linux 下可以直接使用官方的一键支持脚本&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt; curl -o \
 napcat.sh \
 https://nclatest.znin.net/NapNeko/NapCat-Installer/main/script/install.sh \
 &amp;#x26;&amp;#x26; sudo bash napcat.sh \
 --tui
 ```

 按照指示安装即可，唯一要注意的是在 napcat 的日志里会显示 WebUI 链接，一般端口是 6099

2. 启动

```bash
 # 启动 Napcat (需要图形环境或 Xvfb)
 sudo xvfb-run -a qq --no-sandbox


 # 后台启动 (使用 screen) (请使用 root 账户)
 #   启动
 screen -dmS napcat bash -c &quot;xvfb-run -a qq --no-sandbox&quot;

 #   带账号启动
 screen -dmS napcat bash -c &quot;xvfb-run -a qq --no-sandbox -q QQ号码&quot;

 #   附加到会话
 screen -r napcat

 #   停止会话
 screen -S napcat -X quit
 ```

3. 配置

&amp;#x3C;Steps&gt;
1. 添加网络配置
    
    ![](napcat.webp)

2. 如图配置

    ![](ws.webp)
&amp;#x3C;/Steps&gt;
&amp;#x3C;/Steps&gt;


至此 我们已经完成了 NapCat 的配置


## AstrBot 配置

这边我直接在 1Panel 部署 AstrBot，因为其已经上架了 1Panel 的应用市场。

开放 6158 端口后，我们直接进到 Web UI 进行配置

在 **消息平台 &gt; 新增适配器** 中选择 **接入 QQ 个人号(aiocqhttp)**，按照下图配置即可

![](AstrBot.webp)

至此，我们已经完成了所有的配置，接下来可以在 AstrBot 的 Web UI 中进行测试和使用。

import { Aside } from &apos;astro-pure/user&apos;

&amp;#x3C;Aside&gt;

这边附加一下，由于 Google 的相关 api 的免费额度很大，但是在国内服务器可能无法正常访问，这边有一个临时可用的反代：`https://dynamic-halva-76bb38.netlify.app/`，可以直接代替原本的 API Base URL。

可使用 `gemma-3-27b-it`， 每天免费 14400 次。

&amp;#x3C;/Aside&gt;
&lt;/code&gt;&lt;/pre&gt;</content:encoded><h:img src="/_astro/cover.B7T7_CUN.webp"/><enclosure url="/_astro/cover.B7T7_CUN.webp"/></item><item><title>毛玻璃文字</title><link>https://blog.seeridia.top/blog/frosted-glass-text</link><guid isPermaLink="true">https://blog.seeridia.top/blog/frosted-glass-text</guid><description>对文字本身的颜色实现类似于 backdrop-filter 的毛玻璃效果</description><pubDate>Fri, 15 Aug 2025 23:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Spoiler } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;主要矛盾很早就开始了，我想给文字本身的颜色实现类似于 &lt;code&gt;backdrop-filter&lt;/code&gt; 的毛玻璃效果，但这个效果在文字上并不能直接使用。（可恶）&lt;/p&gt;
&lt;p&gt;后续也问了蛮多认识的佬，大多都是改变透明度、混色方式等等去实现貌似是这样的效果，但都不是我想要的。&lt;/p&gt;
&lt;p&gt;最终想起了 stackoverflow ，还提了一个问题 （虽然最后被指出是问题重复，有人已经提过了，但是根本搜不到好不好 QwQ）&lt;/p&gt;
&lt;p&gt;这边先贴个链接：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://stackoverflow.com/questions/60141217/applying-a-backdrop-filter-blur-to-an-svg-path&quot;&gt;StackOverflow 讨论链接&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也先贴上解决方案：（在 stackoverflow 的基础上进行了改进）&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;img src=&quot;https://picsum.photos/id/99/500/300&quot;&gt;
&amp;#x3C;img class=&quot;clipped&quot; src=&quot;https://picsum.photos/id/99/500/300&quot;&gt;

&amp;#x3C;svg height=&quot;0&quot; width=&quot;0&quot;&gt;
    &amp;#x3C;defs&gt;
        &amp;#x3C;clipPath id=&quot;svgTextPath&quot;&gt;
            &amp;#x3C;text x=&quot;50&quot; y=&quot;200&quot; textLength=&quot;400px&quot; font-family=&quot;Vollkorn&quot; font-size=&quot;200px&quot; font-weight=&quot;700&quot;&gt; Text &amp;#x3C;/text&gt;
        &amp;#x3C;/clipPath&gt;
    &amp;#x3C;/defs&gt;
&amp;#x3C;/svg&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;body {
  margin 10px;
}

.clipped {
  position: absolute;
  top: 10px;
  left: 10px;
  clip-path: url(#svgTextPath);
  filter: blur(10px) brightness(1.2); // 调节模糊和亮度
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;example.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;是不是，超级无敌好的效果！&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;本帖就先这样放着，后续我可能会继续完善这个效果。&lt;/p&gt;</content:encoded><h:img src="/_astro/cover.DdCNO23H.webp"/><enclosure url="/_astro/cover.DdCNO23H.webp"/></item><item><title>从网站的上线开始讲起（一）</title><link>https://blog.seeridia.top/blog/website-launched</link><guid isPermaLink="true">https://blog.seeridia.top/blog/website-launched</guid><description>记录下自己的网站上线过程，也包含 GitHub Actions 下的自动化部署</description><pubDate>Fri, 15 Aug 2025 22:00:00 GMT</pubDate><content:encoded>&lt;p&gt;虽然标题叫做「从网站的上线开始讲起」，，但其实并不是或许你想象中的个人故事，毕竟我还啥都不会，也没什么经历和故事，所以，这章真的是——网站的上线&lt;/p&gt;
&lt;p&gt;本文主要涉及的是一个个人的上线工作流，包含 CI、CD 等等&lt;/p&gt;
&lt;h2&gt;Vercel&lt;/h2&gt;
&lt;p&gt;太过于美妙的前端部署平台，支持 Next.js、React 等几乎所有的框架。在 Vercel 中添加 Github 仓库，Vercel 会自动识别项目类型并进行构建。随后就可以直接设置相关的域名和环境变量等。比如你现在面前所看到的网站就是用 Vercel 部署的&lt;/p&gt;
&lt;p&gt;总结一下优缺点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;优点：
&lt;ul&gt;
&lt;li&gt;简单易用：只需将代码推送到 GitHub，Vercel 会自动构建和部署&lt;/li&gt;
&lt;li&gt;支持多种框架：兼容 Next.js、React 等主流框架&lt;/li&gt;
&lt;li&gt;自动化：内置 CI/CD 流程，无需手动配置&lt;/li&gt;
&lt;li&gt;Free!&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;缺点：
&lt;ul&gt;
&lt;li&gt;访问速度慢：在国内访问速度较慢，尤其是在某些地区（特别指出我所在的福建）&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;其实这一个缺点就足够致命了，尤其是对于需要频繁访问的项目，我对于重要的项目还是会选择部署在国内的服务器上。日后也会把这个博客放过去，目前在 Vercel 上的博客只是一个过渡。&lt;/p&gt;
&lt;h2&gt;Github Actions + 服务器部署&lt;/h2&gt;
&lt;p&gt;这个就麻烦多了&lt;/p&gt;
&lt;h3&gt;第一步：SSH 密钥&lt;/h3&gt;
&lt;p&gt;这个生成一次就好，之后就都用这个&lt;/p&gt;
&lt;h4&gt;1. 生成 SSH 密钥对&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-keygen -t rsa -b 4096 -C &quot;github_actions_deploy_key&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接下来可以一直按回车，密钥会保存在默认位置 &lt;code&gt;~/.ssh/id_rsa&lt;/code&gt; 和 &lt;code&gt;~/.ssh/id_rsa.pub&lt;/code&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;id_rsa&lt;/code&gt;：&lt;strong&gt;私钥&lt;/strong&gt; (Private Key)，这个绝对不能泄露&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id_rsa.pub&lt;/code&gt;：&lt;strong&gt;公钥&lt;/strong&gt; (Public Key)，这个是用来配置在服务器上的&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;2. 授权公钥&lt;/h4&gt;
&lt;p&gt;将生成的公钥 &lt;code&gt;id_rsa.pub&lt;/code&gt; 内容添加到服务器的 &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; 文件中。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 将公钥内容追加到 authorized_keys 文件中
cat ~/.ssh/id_rsa.pub &gt;&gt; ~/.ssh/authorized_keys

# 确保文件权限正确，否则 SSH 服务会拒绝连接
chmod 600 ~/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;3. 获取私钥&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;cat ~/.ssh/id_rsa
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;完整地复制&lt;/strong&gt;终端输出的所有内容，包括 &lt;code&gt;-----BEGIN RSA PRIVATE KEY-----&lt;/code&gt; 和 &lt;code&gt;-----END RSA PRIVATE KEY-----&lt;/code&gt; 这两行。&lt;/p&gt;
&lt;p&gt;这个就是服务器私钥。&lt;/p&gt;
&lt;h3&gt;第二步：服务器配置&lt;/h3&gt;
&lt;p&gt;我最早都是用的 Nginx 来部署静态网站，但是发现网站很多的时候，Nginx 的配置就会变得非常复杂，所以现在直接用 1Panel 来管理服务器 （其实是我不会，每次都叫 AI，确实有点麻烦了）&lt;/p&gt;
&lt;p&gt;使用 1Panel 来管理网站、证书什么的，那叫一个舒适啊，这边点几下就成。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;1panel-website.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;这边要注意一下这边的目录：&lt;code&gt;/opt/1panel/www/sites/example.seeridia.top/index&lt;/code&gt;，我们待会配置 Github Actions 的时候要用到。&lt;/p&gt;
&lt;h3&gt;第三步：Github Actions&lt;/h3&gt;
&lt;p&gt;import { Tabs, TabItem } from &apos;astro-pure/user&apos;;&lt;/p&gt;
&lt;h1&gt;工作流名称&lt;/h1&gt;
&lt;p&gt;name: Deploy to Production Server&lt;/p&gt;
&lt;h1&gt;触发条件：当有代码推送到 main 分支时&lt;/h1&gt;
&lt;p&gt;on:
push:
branches:
- main&lt;/p&gt;
&lt;h1&gt;工作任务&lt;/h1&gt;
&lt;p&gt;jobs:
build-and-deploy:
# 运行环境：使用最新的 Ubuntu
runs-on: ubuntu-latest&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;steps:
  # 第一步：迁出代码
  - name: Checkout Code
    uses: actions/checkout@v4

  # 第二步：设置 Node.js 环境
  - name: Setup Node.js
    uses: actions/setup-node@v4
    with:
      # 使用与你服务器上相同的 Node.js 版本
      node-version: &apos;22&apos;

  # 第三步：安装依赖
  - name: Install Dependencies
    run: npm install 

  # 第四步：打包项目
  - name: Build Project
    run: npm run build

  # 第五步：部署到服务器
  - name: Deploy to Server
    uses: appleboy/scp-action@v1.0.0
    with:
      host: ${{ secrets.SERVER_HOST }}
      username: ${{ secrets.SERVER_USER }}
      key: ${{ secrets.SSH_PRIVATE_KEY }}
      source: &quot;dist/*&quot;
      target: ${{ secrets.TARGET_DIR }}
      strip_components: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;  &amp;#x3C;/TabItem&gt;
  &amp;#x3C;TabItem label=&quot;yarn&quot;&gt;

```yml
# .github/workflows/deploy.yml

# 工作流名称
name: Deploy to Production Server

# 触发条件：当有代码推送到 main 分支时
on:
  push:
    branches:
      - main

# 工作任务
jobs:
  build-and-deploy:
    # 运行环境：使用最新的 Ubuntu
    runs-on: ubuntu-latest

    steps:
      # 第一步：迁出代码
      - name: Checkout Code
        uses: actions/checkout@v4

      # 第二步：设置 Node.js 环境
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          # 使用与你服务器上相同的 Node.js 版本
          node-version: &apos;22&apos;
          cache: &apos;yarn&apos;

      # 第三步：安装依赖
      - name: Install Dependencies
        run: yarn install

      # 第四步：打包项目
      - name: Build Project
        run: yarn build

      # 第五步：部署到服务器
      - name: Deploy to Server
        uses: appleboy/scp-action@v1.0.0
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          source: &quot;dist/*&quot;
          target: ${{ secrets.TARGET_DIR }}
          strip_components: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时我们需要在 Github 仓库的 Secrets 中添加以下变量：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SERVER_HOST&lt;/code&gt;：服务器的 IP 地址&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SERVER_USER&lt;/code&gt;：服务器的用户名&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SSH_PRIVATE_KEY&lt;/code&gt;：服务器的 SSH 私钥（刚刚获取的那个）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TARGET_DIR&lt;/code&gt;：服务器上部署的目标目录&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;OK，就这样，非常轻松的，Github Actions 会在每次代码推送到 &lt;code&gt;main&lt;/code&gt; 分支时自动触发，执行上述步骤，将代码打包并部署到服务器上。&lt;/p&gt;</content:encoded><h:img src="/_astro/cover.CHoLSPP-.webp"/><enclosure url="/_astro/cover.CHoLSPP-.webp"/></item></channel></rss>