<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>喵洛阁</title>
    <link>https://kemeow0815.github.io/</link>
    <description>每一段旅行，都有终点。</description>
    <language>zh-cn</language>
    <generator>Gridea Pro</generator>
    <lastBuildDate>Tue, 14 Apr 2026 16:29:02 +0800</lastBuildDate>
    <atom:link href="https://kemeow0815.github.io/feed.xml" rel="self" type="application/rss+xml"></atom:link>
    <item>
      <title>Astro 博客课程表页面实现教程</title>
      <link>https://kemeow0815.github.io/post/timetable-guide/</link>
      <guid isPermaLink="true">https://kemeow0815.github.io/post/timetable-guide/</guid>
      <pubDate>Mon, 13 Apr 2026 10:28:51 +0800</pubDate>
      <description><![CDATA[<h1 id="astro-">Astro 博客课程表页面实现教程</h1>
<h2 id="heading">前言</h2>
<p>本文详细讲解如何使用 Astro + TypeScript 构建一个功能完整的课程表页面，包括数据结构设计、数据解析、组件架构等核心知识点。</p>
<h2 id="heading-1">整体架构</h2>
<p>课程表页面采用分层设计：</p>
<ol>
<li><strong>数据层</strong> - JSON 文件存储课程数据</li>
<li><strong>解析层</strong> - TypeScript 工具函数解析 JSON 并计算当前周次</li>
<li><strong>类型层</strong> - 定义完整的数据类型系统</li>
<li><strong>组件层</strong> - Astro 组件负责渲染页面结构</li>
<li><strong>交互层</strong> - 客户端脚本实现实时状态更新</li>
</ol>
<h2 id="heading-2">数据结构设计</h2>
<h3 id="json-">JSON 数据格式</h3>
<p>课程数据存储在一个 JSON 文件中，包含以下字段：</p>
<pre><code class="language-json">{
  &quot;config&quot;: {
    &quot;courseLen&quot;: 2,
    &quot;id&quot;: 1,
    &quot;name&quot;: &quot;默认配置&quot;
  },
  &quot;nodeTimes&quot;: [{ &quot;node&quot;: 1, &quot;startTime&quot;: &quot;08:00&quot;, &quot;endTime&quot;: &quot;09:40&quot;, &quot;timeTable&quot;: 1 }],
  &quot;meta&quot;: {
    &quot;id&quot;: 1,
    &quot;tableName&quot;: &quot;大三下&quot;,
    &quot;maxWeek&quot;: 20,
    &quot;nodes&quot;: 10,
    &quot;startDate&quot;: &quot;2026-3-2&quot;,
    &quot;timeTable&quot;: 1,
    &quot;showSat&quot;: false,
    &quot;showSun&quot;: false
  },
  &quot;courseDefinitions&quot;: [{ &quot;id&quot;: 1, &quot;courseName&quot;: &quot;计算机网络&quot;, &quot;color&quot;: &quot;#FF6B6B&quot; }],
  &quot;arrangements&quot;: [
    {
      &quot;id&quot;: 1,
      &quot;day&quot;: 1,
      &quot;startNode&quot;: 1,
      &quot;step&quot;: 2,
      &quot;startWeek&quot;: 1,
      &quot;endWeek&quot;: 16,
      &quot;teacher&quot;: &quot;张老师&quot;,
      &quot;room&quot;: &quot;A101&quot;
    }
  ]
}
</code></pre>
<h3 id="typescript-">TypeScript 类型定义</h3>
<pre><code class="language-typescript">// 配置段
interface TimetableConfigSegment {
  courseLen: number
  id: number
  name: string
}

// 节次时间
interface TimetableNodeTime {
  node: number
  startTime: string
  endTime: string
  timeTable: number
}

// 元数据
interface TimetableMetaSegment {
  id: number
  tableName: string
  maxWeek: number
  nodes: number
  startDate: string
  timeTable: number
  showSat?: boolean
  showSun?: boolean
}

// 课程定义
interface TimetableCourseDefinition {
  id: number
  courseName: string
  color?: string
}

// 课程安排
interface TimetableCourseArrangement {
  id: number
  day: number
  startNode: number
  step: number
  startWeek: number
  endWeek: number
  teacher?: string
  room?: string
}

// 解析后的完整数据
interface ParsedTimetableData {
  config: TimetableConfigSegment
  nodeTimes: TimetableNodeTime[]
  meta: TimetableMetaSegment
  courseDefinitions: TimetableCourseDefinition[]
  arrangements: TimetableCourseArrangement[]
}
</code></pre>
<h2 id="heading-3">数据解析层</h2>
<h3 id="json--1">JSON 解析器</h3>
<pre><code class="language-typescript">const EXPECTED_SEGMENT_COUNT = 5

function parseJsonLine&lt;T&gt;(line: string, lineNumber: number): T {
  const normalizedLine = line.endsWith(',') ? line.slice(0, -1) : line
  try {
    return JSON.parse(normalizedLine) as T
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error)
    throw new Error(`课表数据解析失败：第 ${lineNumber} 行不是合法 JSON（${message}）`)
  }
}

export function parseTimetableText(rawText: string): ParsedTimetableData {
  const lines = rawText
    .split(/\r?\n/)
    .map((line) =&gt; line.trim())
    .filter((line) =&gt; line.length &gt; 0)

  if (lines.length !== EXPECTED_SEGMENT_COUNT) {
    throw new Error(`课表数据结构错误：必须恰好包含 ${EXPECTED_SEGMENT_COUNT} 段 JSON`)
  }

  const segments = lines.map((line, index) =&gt; parseJsonLine&lt;unknown&gt;(line, index + 1))
  const [config, nodeTimes, meta, courseDefinitions, arrangements] = segments

  return {
    config: config as TimetableConfigSegment,
    nodeTimes: nodeTimes as TimetableNodeTime[],
    meta: meta as TimetableMetaSegment,
    courseDefinitions: courseDefinitions as TimetableCourseDefinition[],
    arrangements: arrangements as TimetableCourseArrangement[],
  }
}
</code></pre>
<h3 id="heading-4">当前周计算</h3>
<pre><code class="language-typescript">function parseDateFromYmd(ymd: string): Date | null {
  const parts = ymd.split('-').map((part) =&gt; Number(part))
  if (parts.length !== 3 || parts.some((part) =&gt; !Number.isFinite(part))) {
    return null
  }
  const [year, month, day] = parts
  return new Date(year, month - 1, day)
}

export function resolveCurrentWeek(
  startDateText: string,
  maxWeek: number,
  now: Date = new Date(),
): number {
  const startDate = parseDateFromYmd(startDateText)
  if (!startDate) {
    return 1
  }

  const msPerDay = 24 * 60 * 60 * 1000
  const diffDays = Math.floor((now.getTime() - startDate.getTime()) / msPerDay)
  const week = Math.floor(diffDays / 7) + 1

  if (week &lt; 1) return 1
  if (week &gt; maxWeek) return 1
  return week
}
</code></pre>
<h2 id="heading-5">页面路由设计</h2>
<h3 id="heading-6">首页路由</h3>
<pre><code class="language-astro">---
import fs from 'node:fs'
import path from 'node:path'
import type { TimetableViewModel } from '@/types/timetable'
import TimetablePageContent from '@components/timetable/TimetablePageContent.astro'
import MainGridLayout from '@layouts/MainGridLayout.astro'
import { buildTimetableViewModel, resolveCurrentWeek } from '@utils/timetable-normalizer'
import { parseTimetableFile } from '@utils/timetable-parser-server'

const filePath = 'src/data/timetable/大三下.json'
const absoluteFilePath = path.join(process.cwd(), filePath)

let viewModel: TimetableViewModel | null = null
let loadError = ''
let isCurrentWeek = false
let baselineText = ''

try {
  baselineText = fs.readFileSync(absoluteFilePath, 'utf-8')
  const parsedData = parseTimetableFile(filePath)
  const currentWeek = resolveCurrentWeek(parsedData.meta.startDate, parsedData.meta.maxWeek)
  viewModel = buildTimetableViewModel(parsedData, currentWeek)
  isCurrentWeek = viewModel.currentWeek === currentWeek
} catch (error) {
  loadError = error instanceof Error ? error.message : '课表数据加载失败'
}
---

&lt;MainGridLayout title={viewModel ? `课表 - 第${viewModel.currentWeek}周` : '课表'}&gt;
  {
    viewModel ? (
      &lt;TimetablePageContent
        viewModel={viewModel}
        isCurrentWeek={isCurrentWeek}
        baselineText={baselineText}
      /&gt;
    ) : (
      &lt;div class=&quot;card-base p-6 md:p-8&quot;&gt;
        &lt;div class=&quot;text-red-500&quot;&gt;课表加载失败：{loadError}&lt;/div&gt;
      &lt;/div&gt;
    )
  }
&lt;/MainGridLayout&gt;
</code></pre>
<h3 id="heading-7">动态路由</h3>
<pre><code class="language-astro">---
const weekParam = Number(Astro.params.week)

// 使用 URL 参数中的周次，如果无效则使用当前周
const selectedWeek =
  Number.isFinite(weekParam) &amp;&amp; weekParam &gt;= 1 ? Math.floor(weekParam) : currentWeek

// 生成所有周次的静态路径
export function getStaticPaths() {
  const parsedData = parseTimetableFile('src/data/timetable/大三下.json')
  const maxWeek = Math.max(1, parsedData.meta.maxWeek || 1)
  return Array.from({ length: maxWeek }, (_, index) =&gt; ({
    params: { week: String(index + 1) },
  }))
}
---
</code></pre>
<h2 id="heading-8">组件层实现</h2>
<h3 id="heading-9">页面容器组件</h3>
<pre><code class="language-astro">---
import type { TimetableViewModel } from '@/types/timetable'
import LiveTimetableStatus from '@components/timetable/LiveTimetableStatus.astro'
import TimetableDayList from '@components/timetable/TimetableDayList.astro'
import TimetableGrid from '@components/timetable/TimetableGrid.astro'

interface Props {
  viewModel: TimetableViewModel
  isCurrentWeek?: boolean
  baselineText: string
}

const { viewModel, isCurrentWeek = false, baselineText } = Astro.props

const liveStatusPayload = {
  coursesByDay: viewModel.coursesByDay,
}
---

&lt;div class=&quot;card-base p-6 md:p-8&quot;&gt;
  &lt;!-- 页面头部 --&gt;
  &lt;div class=&quot;flex items-center justify-between mb-6&quot;&gt;
    &lt;h1 class=&quot;text-2xl font-bold&quot;&gt;{viewModel.tableName}&lt;/h1&gt;
    &lt;span class=&quot;text-sm text-white/60&quot;&gt;共 {viewModel.maxWeek} 周&lt;/span&gt;
  &lt;/div&gt;

  &lt;!-- 周次导航 --&gt;
  &lt;div class=&quot;flex items-center gap-3 mb-5&quot;&gt;
    &lt;a href={`/timetable/${Math.max(1, viewModel.currentWeek - 1)}/`}&gt; 上一周 &lt;/a&gt;
    &lt;span class=&quot;min-w-[4.5rem] text-center font-medium&quot;&gt;
      第 {viewModel.currentWeek} 周
    &lt;/span&gt;
    &lt;a href={`/timetable/${Math.min(viewModel.maxWeek, viewModel.currentWeek + 1)}/`}&gt; 下一周 &lt;/a&gt;
    {
      isCurrentWeek &amp;&amp; (
        &lt;span class=&quot;rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-2.5 py-1 text-sm font-medium text-emerald-200&quot;&gt;
          当前周
        &lt;/span&gt;
      )
    }
  &lt;/div&gt;

  &lt;!-- 实时状态 --&gt;
  &lt;LiveTimetableStatus payload={liveStatusPayload} class=&quot;mb-4&quot; /&gt;

  &lt;!-- 桌面端网格 --&gt;
  &lt;TimetableGrid viewModel={viewModel} /&gt;

  &lt;!-- 移动端列表 --&gt;
  &lt;TimetableDayList viewModel={viewModel} /&gt;
&lt;/div&gt;
</code></pre>
<h3 id="heading-10">桌面端网格组件</h3>
<pre><code class="language-astro">---
import type { TimetableCourseView } from '@/types/timetable'
import TimetableCourseCard from '@components/timetable/TimetableCourseCard.astro'

interface Props {
  viewModel: TimetableViewModel
}

const { viewModel } = Astro.props

const dayIndexes = viewModel.dayColumns.map((day) =&gt; day.day)
const dayCount = viewModel.dayColumns.length

// 构建课程查找表
const courseMapByDayAndNode = new Map&lt;string, TimetableCourseView[]&gt;()

for (const day of dayIndexes) {
  for (const course of viewModel.coursesByDay[day] ?? []) {
    const pairStartNode = course.startNode % 2 === 1 ? course.startNode : course.startNode - 1
    const key = `${day}-${Math.max(1, pairStartNode)}`
    const list = courseMapByDayAndNode.get(key) ?? []
    list.push(course)
    courseMapByDayAndNode.set(key, list)
  }
}
---

&lt;div class=&quot;hidden md:block card-base w-full overflow-hidden&quot; style={`--day-count: ${dayCount}`}&gt;
  &lt;!-- 表头 --&gt;
  &lt;div class=&quot;timetable-header-grid border-b border-white/10&quot;&gt;
    &lt;div class=&quot;px-3 py-2 text-xs border-r border-white/10&quot;&gt;节次&lt;/div&gt;
    {
      viewModel.dayColumns.map((day) =&gt; (
        &lt;div class=&quot;px-3 py-2 text-sm font-semibold border-r last:border-r-0 border-white/10&quot;&gt;
          {day.label}
        &lt;/div&gt;
      ))
    }
  &lt;/div&gt;

  &lt;!-- 课程行 --&gt;
  {
    viewModel.nodeRows
      .filter((row) =&gt; row.node % 2 === 1)
      .map((row) =&gt; (
        &lt;div class=&quot;timetable-row-grid border-b last:border-b-0 border-white/10&quot;&gt;
          &lt;div class=&quot;px-3 py-3 border-r border-white/10&quot;&gt;
            &lt;p class=&quot;text-xs font-medium&quot;&gt;
              第 {row.node}-{Math.min(row.node + 1, viewModel.nodeRows.length)} 节
            &lt;/p&gt;
            &lt;p class=&quot;text-[11px] text-white/80 mt-1&quot;&gt;{row.startTime}&lt;/p&gt;
          &lt;/div&gt;

          {viewModel.dayColumns.map((day) =&gt; {
            const key = `${day.day}-${row.node}`
            const courses = courseMapByDayAndNode.get(key) ?? []
            return (
              &lt;div class=&quot;px-2 py-2 border-r last:border-r-0 border-white/10 align-top min-h-[88px]&quot;&gt;
                {courses.length &gt; 0 ? (
                  &lt;div class=&quot;space-y-2&quot;&gt;
                    {courses.map((course) =&gt; (
                      &lt;TimetableCourseCard course={course} compact={true} /&gt;
                    ))}
                  &lt;/div&gt;
                ) : (
                  &lt;div class=&quot;h-full flex items-center justify-center text-[11px] text-white/60&quot;&gt;
                    —
                  &lt;/div&gt;
                )}
              &lt;/div&gt;
            )
          })}
        &lt;/div&gt;
      ))
  }
&lt;/div&gt;

&lt;style&gt;
  .timetable-header-grid,
  .timetable-row-grid {
    display: grid;
    grid-template-columns: 120px repeat(var(--day-count), minmax(180px, 1fr));
  }
&lt;/style&gt;
</code></pre>
<h3 id="heading-11">实时状态组件</h3>
<pre><code class="language-astro">---
import type { TimetableCourseView } from '@/types/timetable'

interface Props {
  payload: {
    coursesByDay: Record&lt;number, TimetableCourseView[]&gt;
  }
}

const { payload } = Astro.props
const payloadText = encodeURIComponent(JSON.stringify(payload))
---

&lt;div data-live-status-root data-live-payload={payloadText}&gt;
  &lt;p data-live-status class=&quot;text-sm font-semibold&quot;&gt;状态计算中...&lt;/p&gt;
  &lt;p class=&quot;mt-1 text-sm&quot;&gt;
    &lt;span&gt;下一堂课：&lt;/span&gt;
    &lt;span data-live-next-detail class=&quot;font-medium&quot;&gt;--&lt;/span&gt;
    &lt;span data-live-next-tail&gt;&lt;/span&gt;
  &lt;/p&gt;
&lt;/div&gt;

&lt;script is:inline&gt;
  function parseTimeToMinute(text) {
    const parts = String(text || '').split(':')
    if (parts.length !== 2) return null
    return Number(parts[0]) * 60 + Number(parts[1])
  }

  function resolveLiveState(payload) {
    const now = new Date()
    const currentMinute = now.getHours() * 60 + now.getMinutes()
    const day = now.getDay() === 0 ? 7 : now.getDay()

    if (day &gt;= 6) {
      return { status: '周末', nextDetail: '--', nextTail: '' }
    }

    const courses = (payload?.coursesByDay?.[day] || [])
      .map((course) =&gt; {
        const match = course.timeText.match(/(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})/)
        if (!match) return null
        return {
          ...course,
          startMinute: parseTimeToMinute(match[1]),
          endMinute: parseTimeToMinute(match[2]),
        }
      })
      .filter(Boolean)
      .sort((a, b) =&gt; a.startMinute - b.startMinute)

    if (courses.length === 0) {
      return { status: '无课', nextDetail: '--', nextTail: '' }
    }

    let status = '无课'
    for (let i = 0; i &lt; courses.length; i++) {
      const current = courses[i]
      if (currentMinute &gt;= current.startMinute &amp;&amp; currentMinute &lt; current.endMinute) {
        status = `上课（${current.courseName}）`
        break
      }
      const next = courses[i + 1]
      if (next &amp;&amp; currentMinute &gt;= current.endMinute &amp;&amp; currentMinute &lt; next.startMinute) {
        status = `课间（下一节：${next.courseName}）`
        break
      }
    }

    const nextCourse = courses.find((c) =&gt; c.startMinute &gt; currentMinute)
    if (!nextCourse) {
      return { status, nextDetail: '--', nextTail: '' }
    }

    const remainMinutes = nextCourse.startMinute - currentMinute
    const hours = Math.floor(remainMinutes / 60)
    const minutes = remainMinutes % 60
    const timeText = hours &gt; 0 ? `${hours}小时${minutes}分钟` : `${minutes}分钟`

    return {
      status,
      nextDetail: `${nextCourse.courseName} - ${nextCourse.room || '未填写'}`,
      nextTail: `（${timeText}后）`,
      nextColor: nextCourse.color,
    }
  }

  function updateLiveStatus(root) {
    const payloadRaw = decodeURIComponent(root.dataset.livePayload || '%7B%7D')
    const payload = JSON.parse(payloadRaw)
    const state = resolveLiveState(payload)

    root.querySelector('[data-live-status]').textContent = state.status
    root.querySelector('[data-live-next-detail]').textContent = state.nextDetail
    root.querySelector('[data-live-next-tail]').textContent = state.nextTail
    root.querySelector('[data-live-next-detail]').style.color = state.nextColor || ''
  }

  function setupLiveStatus() {
    document.querySelectorAll('[data-live-status-root]').forEach(updateLiveStatus)
    setInterval(() =&gt; {
      document.querySelectorAll('[data-live-status-root]').forEach(updateLiveStatus)
    }, 30 * 1000)
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', setupLiveStatus)
  } else {
    setupLiveStatus()
  }
&lt;/script&gt;
</code></pre>
<h2 id="heading-12">样式与交互</h2>
<h3 id="heading-13">响应式设计</h3>
<p>课表页面使用了响应式设计，桌面端显示网格，移动端显示列表：</p>
<pre><code class="language-astro">&lt;!-- 桌面端网格 --&gt;
&lt;TimetableGrid viewModel={viewModel} /&gt;

&lt;!-- 移动端列表 --&gt;
&lt;TimetableDayList viewModel={viewModel} /&gt;
</code></pre>
<p>在组件内部使用 Tailwind 的响应式类控制显示：</p>
<pre><code class="language-astro">&lt;!-- TimetableGrid.astro --&gt;
&lt;div class=&quot;hidden md:block&quot;&gt;...&lt;/div&gt;

&lt;!-- TimetableDayList.astro --&gt;
&lt;div class=&quot;md:hidden&quot;&gt;...&lt;/div&gt;
</code></pre>
<h3 id="heading-14">颜色生成</h3>
<p>每个课程卡片都有一个唯一的颜色，通过哈希函数生成：</p>
<pre><code class="language-typescript">function hashText(input: string): number {
  let hash = 0
  for (let i = 0; i &lt; input.length; i += 1) {
    hash = (hash * 31 + input.charCodeAt(i)) | 0
  }
  return Math.abs(hash)
}

function buildCourseColor(courseName: string, courseId: number): string {
  const seed = hashText(`${courseName}-${courseId}`)
  const hue = seed % 360
  return `hsl(${hue} 78% 68%)`
}
</code></pre>
<p>使用 HSL 颜色模式，固定饱和度和亮度，只改变色相，这样生成的颜色既多样又和谐。</p>
<h2 id="heading-15">可视化编辑器（进阶）</h2>
<p>TimetableVisualEditor.svelte 是一个功能完整的可视化编辑器，让用户可以直接在浏览器里增删改课程。</p>
<h3 id="heading-16">核心功能</h3>
<ol>
<li><strong>双栏布局</strong> - 左侧课程列表，右侧属性编辑面板</li>
<li><strong>编辑模式</strong> - 提供&quot;编辑课表&quot;和&quot;退出编辑&quot;按钮</li>
<li><strong>数据验证</strong> - 实时验证课程数据的合法性</li>
<li><strong>导出功能</strong> - 将编辑后的数据导出为 JSON 文件</li>
</ol>
<h3 id="heading-17">状态管理</h3>
<pre><code class="language-typescript">let editMode = false // 是否处于编辑模式
let draftParsed: ParsedTimetableData // 编辑中的数据副本
let selectedArrangementRef: number | null // 当前选中的课程索引
let validationError = '' // 验证错误信息
let isDirty = false // 数据是否有修改
let creatingCourse = false // 是否正在创建新课程
</code></pre>
<h3 id="heading-18">表单验证</h3>
<pre><code class="language-typescript">function validateDraft(data: ParsedTimetableData): string {
  for (let index = 0; index &lt; data.arrangements.length; index += 1) {
    const arrangement = data.arrangements[index]

    if (arrangement.day &lt; 1 || arrangement.day &gt; 7) {
      return `第 ${index + 1} 条课程安排的星期超出范围（1-7）`
    }

    if (arrangement.startNode &lt; 1 || arrangement.startNode &gt; maxNode) {
      return `第 ${index + 1} 条课程安排的起始节次超出范围`
    }

    if (arrangement.startWeek &gt; arrangement.endWeek) {
      return `第 ${index + 1} 条课程安排的起止周非法`
    }

    const courseDef = data.courseDefinitions.find((c) =&gt; c.id === arrangement.id)
    if (!courseDef || !courseDef.courseName?.trim()) {
      return `第 ${index + 1} 条课程安排关联课程名为空`
    }
  }
  return ''
}
</code></pre>
<h3 id="heading-19">数据导出</h3>
<pre><code class="language-typescript">function exportJson() {
  const error = validateDraft(draftParsed)
  if (error) {
    validationError = error
    return
  }

  const text = serializeTimetableDataToFileText(draftParsed)
  const blob = new Blob([text], { type: 'application/json;charset=utf-8' })
  const url = URL.createObjectURL(blob)

  const link = document.createElement('a')
  link.href = url
  link.download = `${baselineParsed.meta.tableName || 'timetable'}.json`
  document.body.appendChild(link)
  link.click()
  document.body.removeChild(link)
  URL.revokeObjectURL(url)
}
</code></pre>
<h2 id="heading-20">总结</h2>
<p>通过这篇文章，我们详细解析了一个完整的课程表页面的实现过程：</p>
<p><strong>数据层</strong> - 设计了清晰的 JSON 数据结构，分离配置、定义和安排</p>
<p><strong>类型层</strong> - 使用 TypeScript 定义完整类型，确保类型安全</p>
<p><strong>解析层</strong> - 实现 JSON 解析、数据转换和当前周计算</p>
<p><strong>路由层</strong> - 使用 Astro 动态路由生成所有周次页面</p>
<p><strong>组件层</strong> - 拆分多个组件，实现网格布局、列表布局、课程卡片</p>
<p><strong>交互层</strong> - 客户端脚本实现实时状态更新</p>
<p><strong>编辑层</strong> - 可视化编辑器支持增删改课程</p>
<h3 id="heading-21">可扩展的功能</h3>
<p>如果你想进一步完善这个课表页面，可以考虑：</p>
<ol>
<li><strong>多课表支持</strong> - 支持切换不同学期的课表</li>
<li><strong>课程提醒</strong> - 上课前发送浏览器通知</li>
<li><strong>导出功能</strong> - 导出为 ICS 日历文件</li>
<li><strong>数据同步</strong> - 从教务系统自动同步课程</li>
</ol>
<p>希望这篇教程对你有帮助！</p>
]]></description>
      <category>博客</category>
      <category>页面</category>
      <enclosure url="https://imgbed.050815.xyz/file/cover/timetable/timetable__1_.webp" length="0" type="image/webp"></enclosure>
    </item>
    <item>
      <title>waline使用neon数据库和在vercel部署</title>
      <link>https://kemeow0815.github.io/post/use-neon-database-with-waline-on-vercel/</link>
      <guid isPermaLink="true">https://kemeow0815.github.io/post/use-neon-database-with-waline-on-vercel/</guid>
      <pubDate>Sat, 11 Apr 2026 17:09:31 +0800</pubDate>
      <description><![CDATA[<p>文章介绍了使用Waline和neon数据库在Vercel上部署博客的过程。首先，需要创建数据库并配置存储服务，然后定义数据库名称并执行创建表操作。接下来，通过点击顶部按钮重新部署，使数据库服务生效。最后，添加环境变量并进入项目页面进行&quot;Redeploy&quot;操作，使变量生效。</p>
<!-- more -->
<h1 id="walineneonvercel">waline使用neon数据库和在vercel部署</h1>
<blockquote>
<p>由于<a href="https://console.leancloud.app/">Leancloud</a>已于26年年初开始逐步停止服务，并在明年年初将停运，于是，使用neon数据库来替代，基于Waline的<a href="https://waline.js.org/guide/get-started/#%E5%88%9B%E5%BB%BA%E6%95%B0%E6%8D%AE%E5%BA%93">官方文档</a>来进行部署，但是遇到了问题，再参考<a href="https://www.dearom.com/blog/20260222-waline-neon-database-guide">文章</a>来完善部署方案。</p>
</blockquote>
<p>在部署博客的过程中，我选取Waline作为评论系统，于是根据文档来部署：</p>
<p align="center">
  <a href="https://vercel.com/button">
    <img src="https://vercel.com/button" alt="Deploy to Vercel" />
  </a>
</p>
<blockquote>
<p>点击上方按钮来部署</p>
</blockquote>
<p>但是目前部署会出现下图的问题</p>
<p><img src="https://cdn.jsdmirror.com/gh/zsxcoder/github-img@main/img/waline-1.avif" alt="" /></p>
<h2 id="heading">创建数据库</h2>
<ol>
<li>
<p>点击顶部的 <code>Storage</code> 进入存储服务配置页，选择 <code>Create Database</code> 创建数据库。<code>Marketplace Database Providers</code> 数据库服务选择 <code>Neon</code>，点击 <code>Continue</code> 进行下一步。<br />
<img src="https://cdn.jsdmirror.com/gh/zsxcoder/github-img@main/img/waline-2.avif" alt="" /></p>
</li>
<li>
<p>此时会让你创建一个 <code>Neon</code> 账号，此时选择 <code>Accept and Create</code> 接受并创建。后续选择数据库的套餐配置，包括地区和额度。这里可以什么都不操作直接选择 <code>Continue</code> 下一步。<br />
<img src="https://cdn.jsdmirror.com/gh/zsxcoder/github-img@main/img/waline-3.avif" alt="" /></p>
</li>
<li>
<p>此时会让你定义数据库名称，这里也可以不用修改直接 <code>Continue</code> 进行下一步。<br />
<img src="https://cdn.jsdmirror.com/gh/zsxcoder/github-img@main/img/waline-4.avif" alt="" /></p>
</li>
<li>
<p>这时候 <code>Storage</code> 下就有你创建的数据库服务了，点击进去选择 <code>Open in Neon</code> 跳转到 <code>Neon</code>。在 <code>Neon</code> 界面左侧选择 <code>SQL Editor</code>，将 <code>waline.pgsql</code> 中的 <code>SQL</code> 语句粘贴进编辑器中，点击 <code>Run</code> 执行创建表操作。<br />
<img src="https://cdn.jsdmirror.com/gh/zsxcoder/github-img@main/img/waline-5.avif" alt="" /></p>
</li>
</ol>
<p><img src="https://cdn.jsdmirror.com/gh/zsxcoder/github-img@main/img/waline-6.avif" alt="" /></p>
<ol start="4">
<li>
<p>稍等片刻之后会告知你创建成功。此时回到 Vercel，点击顶部的 <code>Deployments</code> 点击顶部最新的一次部署右侧的 <code>Redeploy</code> 按钮进行重新部署。该步骤是为了让刚才配置的数据库服务生效。<br />
<img src="https://cdn.jsdmirror.com/gh/zsxcoder/github-img@main/img/waline-7.avif" alt="" /></p>
</li>
<li>
<p>此时会跳转到 <code>Overview</code> 界面开始部署，等待片刻后 <code>STATUS</code> 会变成 <code>Ready</code>。此时请点击 <code>Visit</code> ，即可跳转到部署好的网站地址，此地址即为你的服务端地址。<br />
<img src="https://cdn.jsdmirror.com/gh/zsxcoder/github-img@main/img/waline-8.avif" alt="" /></p>
</li>
</ol>
<p>以上就是官方文档相关部分，接下来是补充：</p>
<p><strong>Neon数据库配置信息</strong></p>
<p><img src="https://cdn.jsdmirror.com/gh/zsxcoder/github-img@main/img/waline-9.avif" alt="" /></p>
<p>在上图部分复制数据库信息：</p>
<table>
<thead>
<tr>
<th>变量</th>
<th>值</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>PG_HOST</code></td>
<td>取<code>PGHOST_UNPOOLED</code>的值</td>
</tr>
<tr>
<td><code>PG_DB</code></td>
<td>取<code>PGDATABASE</code>的值</td>
</tr>
<tr>
<td><code>PG_USER</code></td>
<td>取<code>POSTGRES_USER</code>的值</td>
</tr>
<tr>
<td><code>PG_PASSWORD</code></td>
<td>取<code>PGPASSWORD</code>的值</td>
</tr>
<tr>
<td><code>PG_SSL</code></td>
<td>填<code>true</code></td>
</tr>
</tbody>
</table>
<p><strong>Vercel环境变量设置</strong></p>
<p><img src="https://cdn.jsdmirror.com/gh/zsxcoder/github-img@main/img/waline-10.avif" alt="" /></p>
<blockquote>
<p>提示： 添加环境变量后，需要进入<code>Vercel</code>项目的<code>Deployments</code>页面，找到最新部署点击 &ldquo;Redeploy&rdquo;，变量才会生效。</p>
</blockquote>
<!-- more -->]]></description>
      <category>waline评论</category>
      <category>博客</category>
      <enclosure url="https://cdn.jsdmirror.com/gh/zsxcoder/github-img@main/img/waline-neon-deploy.avif" length="0" type="image/jpeg"></enclosure>
    </item>
    <item>
      <title>新文章通知记录</title>
      <link>https://kemeow0815.github.io/post/post-diff-guide/</link>
      <guid isPermaLink="true">https://kemeow0815.github.io/post/post-diff-guide/</guid>
      <pubDate>Sat, 11 Apr 2026 17:07:00 +0800</pubDate>
      <description><![CDATA[<p>文章介绍了博客新文章通知功能的实现原理，包括 RSS 数据获取、IndexedDB 存储、文章变更检测、差异对比算法等核心逻辑。通过对比新旧文章数据，自动检测新增和更新的文章，并以动画弹窗形式通知用户。</p>
<!-- more -->
<h1 id="heading">新文章通知记录</h1>
<blockquote>
<p>有时候，发现新内容的那一刻，正是阅读最好的时机。</p>
</blockquote>
<h2 id="heading-1">功能概述</h2>
<p>新文章通知功能是一个自动检测博客文章更新的客户端组件，能够在用户访问网站时，自动对比当前 RSS 数据与上次访问时的数据，发现新增或更新的文章，并以优雅的弹窗形式通知用户。</p>
<p><strong>核心特性</strong></p>
<ul>
<li><strong>自动检测</strong>：基于 RSS 数据自动检测文章变更</li>
<li><strong>差异对比</strong>：使用 diff 算法精确识别内容变化</li>
<li><strong>本地存储</strong>：使用 IndexedDB 持久化存储文章数据</li>
<li><strong>智能防误报</strong>：过滤全量更新等特殊情况</li>
</ul>
<h2 id="heading-2">实现逻辑</h2>
<h3 id="1-">1. 数据获取与存储</h3>
<p><strong>RSS 获取</strong>：通过 <code>fetch('/rss.xml')</code> 获取当前博客文章列表</p>
<p><strong>数据解析</strong>：解析 XML，提取标题、链接、GUID、发布时间、描述和正文内容</p>
<p><strong>IndexedDB 存储</strong>：将文章数据存储在浏览器的 IndexedDB 中，支持离线访问</p>
<h3 id="2-">2. 变更检测机制</h3>
<p>系统通过对比新旧数据检测以下变更：</p>
<table>
<thead>
<tr>
<th>变更类型</th>
<th>检测条件</th>
<th>处理方式</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>新文章</strong></td>
<td>GUID 在旧数据中不存在</td>
<td>标记为 <code>isUpdated: false</code></td>
</tr>
<tr>
<td><strong>标题变更</strong></td>
<td><code>post.title !== stored.title</code></td>
<td>使用 <code>diffLines</code> 对比差异</td>
</tr>
<tr>
<td><strong>描述变更</strong></td>
<td><code>post.description !== stored.description</code></td>
<td>使用 <code>diffLines</code> 对比差异</td>
</tr>
<tr>
<td><strong>内容变更</strong></td>
<td><code>post.content !== stored.content</code></td>
<td>使用 <code>diffLines</code> 对比差异</td>
</tr>
</tbody>
</table>
<h3 id="3-">3. 智能防误报策略</h3>
<p>为避免以下情况产生误报：</p>
<ol>
<li><strong>首次访问</strong>：所有文章都是&quot;新&quot;的，不应提示</li>
<li><strong>全量更新</strong>：RSS 源重建导致所有文章 GUID 变化</li>
<li><strong>全部更新</strong>：所有文章同时被标记为更新</li>
</ol>
<p>系统采用以下策略：</p>
<pre><code class="language-typescript">// 检测是否全为新文章或全为更新
const isAllNew = detectedChanges.length === currentPosts.length &amp;&amp; 
                 detectedChanges.every((p) =&gt; !p.isUpdated)
const isAllUpdated = detectedChanges.length === currentPosts.length &amp;&amp; 
                     detectedChanges.every((p) =&gt; p.isUpdated)

if (isAllNew || isAllUpdated) {
  // 重置存储，不显示通知
  await clearStore(db, 'posts')
  await savePosts(db, 'posts', currentPosts)
  showNotification([], Date.now(), false, initTime)
  return
}
</code></pre>
<h2 id="heading-3">核心代码结构</h2>
<h3 id="heading-4">组件结构</h3>
<p><strong>NewPostNotification.astro</strong></p>
<p>组件采用三层架构：</p>
<ol>
<li><strong>UI 层</strong>：圆形铃铛图标 → 展开面板 → 文章列表</li>
<li><strong>动画层</strong>：CSS 过渡动画实现平滑展开/收起</li>
<li><strong>逻辑层</strong>：TypeScript 处理数据对比和状态管理</li>
</ol>
<pre><code class="language-astro">&lt;!-- 折叠状态 --&gt;
&lt;div id=&quot;notification-panel&quot; class=&quot;size-10 rounded-full ...&quot;&gt;
  &lt;Icon name=&quot;lucide:bell&quot; /&gt;
&lt;/div&gt;

&lt;!-- 展开状态 --&gt;
&lt;div id=&quot;notification-content&quot; class=&quot;flex flex-col p-5&quot;&gt;
  &lt;!-- 文章列表 --&gt;
&lt;/div&gt;
</code></pre>
<h3 id="heading-5">数据库操作</h3>
<p><strong>notification-db.ts</strong></p>
<p>数据库操作封装：</p>
<table>
<thead>
<tr>
<th>函数</th>
<th>功能</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>openDB()</code></td>
<td>打开 IndexedDB 连接</td>
</tr>
<tr>
<td><code>getStoredPosts()</code></td>
<td>获取存储的文章列表</td>
</tr>
<tr>
<td><code>savePosts()</code></td>
<td>保存文章到数据库</td>
</tr>
<tr>
<td><code>clearStore()</code></td>
<td>清空数据存储</td>
</tr>
<tr>
<td><code>fetchRSS()</code></td>
<td>获取并解析 RSS 数据</td>
</tr>
<tr>
<td><code>generateId()</code></td>
<td>生成带作用域的唯一 ID</td>
</tr>
</tbody>
</table>
<pre><code class="language-typescript">// 数据库配置
const DB_NAME = 'blog-rss-store-v2'
const STORE_OLD = 'posts'
const STORE_NEW = 'posts_new'
</code></pre>
<h3 id="heading-6">差异对比算法</h3>
<p>使用 <code>diff</code> 库进行行级对比：</p>
<pre><code class="language-typescript">import * as Diff from 'diff'

function computeDiff(oldText: string, newText: string) {
  // 标准化处理：统一换行符、去除行尾空格
  const normalizeRaw = (text: string) =&gt; {
    return text
      .replace(/\r\n/g, '\n')
      .replace(/\r/g, '\n')
      .split('\n')
      .map(line =&gt; line.replace(/[ \t]+$/g, ''))
      .join('\n')
      .trim()
  }
  
  const diffs = Diff.diffLines(normalizeRaw(oldText), normalizeRaw(newText))
  return diffs.some(part =&gt; part.added || part.removed) ? diffs : null
}
</code></pre>
<h2 id="heading-7">食用方法</h2>
<p><strong>使用说明</strong>：当检测到新文章或文章更新时，页面右下角会出现铃铛图标，点击即可查看变更列表。</p>
<h3 id="heading-8">功能入口</h3>
<p>新文章通知组件 - 位于页面右下角的铃铛图标</p>
<h3 id="heading-9">交互说明</h3>
<ol>
<li><strong>首次访问</strong>：自动记录当前文章状态，不显示通知</li>
<li><strong>发现更新</strong>：铃铛图标出现红点并脉动动画，1.5秒后自动展开</li>
<li><strong>查看变更</strong>：点击文章标题可跳转到对应文章</li>
<li><strong>标记已读</strong>：点击&quot;清空通知&quot;按钮重置状态</li>
<li><strong>手动关闭</strong>：点击&quot;隐藏&quot;按钮收起面板</li>
</ol>
<h2 id="heading-10">注意事项</h2>
<h3 id="1--1">1. 数据存储限制</h3>
<ul>
<li><strong>IndexedDB 容量</strong>：浏览器通常限制在 50MB 左右</li>
<li><strong>存储结构</strong>：文章正文可能较大，注意监控存储空间</li>
<li><strong>清理策略</strong>：目前无自动清理，长期可能积累大量数据</li>
</ul>
<h3 id="2-rss-">2. RSS 源依赖</h3>
<ul>
<li><strong>实时性</strong>：依赖 RSS 生成时机，可能有延迟</li>
<li><strong>完整性</strong>：确保 RSS 包含完整文章内容（<code>content:encoded</code>）</li>
<li><strong>跨域问题</strong>：RSS 需与网站同域或通过代理访问</li>
</ul>
<h3 id="3--1">3. 性能优化</h3>
<ul>
<li><strong>防抖处理</strong>：对比操作在客户端执行，大量文章时可能卡顿</li>
<li><strong>异步加载</strong>：RSS 获取为异步，不影响页面加载</li>
<li><strong>内存管理</strong>：文章数据缓存在内存中，注意内存占用</li>
</ul>
<h3 id="4-">4. 兼容性</h3>
<ul>
<li><strong>浏览器支持</strong>：需要支持 IndexedDB 的现代浏览器</li>
<li><strong>隐私模式</strong>：部分浏览器隐私模式下 IndexedDB 不可用</li>
<li><strong>SSR 适配</strong>：组件使用 <code>client:only</code> 指令，避免服务端渲染问题</li>
</ul>
<h2 id="heading-11">技术栈</h2>
<table>
<thead>
<tr>
<th>技术</th>
<th>用途</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Astro</strong></td>
<td>组件框架</td>
</tr>
<tr>
<td><strong>TypeScript</strong></td>
<td>类型安全</td>
</tr>
<tr>
<td><strong>IndexedDB</strong></td>
<td>本地数据持久化</td>
</tr>
<tr>
<td><strong>diff</strong></td>
<td>文本差异对比</td>
</tr>
<tr>
<td><strong>DOMParser</strong></td>
<td>RSS XML 解析</td>
</tr>
<tr>
<td><strong>Tailwind CSS</strong></td>
<td>样式设计</td>
</tr>
</tbody>
</table>
<h2 id="heading-12">总结</h2>
<p>新文章通知功能通过 RSS 数据对比和 IndexedDB 存储，实现了客户端的文章变更检测。核心优势在于：</p>
<ol>
<li><strong>无需后端</strong>：纯前端实现，不依赖服务器推送</li>
<li><strong>精准检测</strong>：使用 diff 算法精确识别内容变化</li>
<li><strong>智能防误报</strong>：过滤首次访问和全量更新等特殊情况</li>
<li><strong>优雅交互</strong>：平滑动画和清晰的信息展示</li>
</ol>
<p><strong>未来优化方向</strong></p>
<ul>
<li>支持 Web Push 推送通知</li>
<li>添加文章更新详情页（diff 可视化）</li>
<li>实现自动清理过期数据</li>
<li>支持多 RSS 源聚合</li>
</ul>
]]></description>
      <category>博客</category>
      <category>功能</category>
      <enclosure url="https://cdn.jsdmirror.com/gh/zsxcoder/github-img@main/img/post-diff-update.avif" length="0" type="image/jpeg"></enclosure>
    </item>
  </channel>
</rss>