1. 增加了几种模板

This commit is contained in:
李琦
2026-08-24 13:06:04 +08:00
parent 1617baec19
commit 287e428e87
124 changed files with 11330 additions and 142 deletions

View File

@@ -0,0 +1,151 @@
# 2026-08-24
## 博客后台可替换模板子系统(一期完整落地)
按批准计划cosmic-pulse-einstein.md实现"后台可替换模板"classic 经典 / ubuntu-terminal 终端 / custom 自定义布局包三类模板。
### 后端Go/Gin
- 新增 `models/template.go`Template 模型)、`repositories/template_repository.go`CRUD + GetCurrentSiteTemplateSlug + ActivateTemplate
- `repositories/migration.go` 新增 `MigrateTemplates()`:建 templates 表 + 种入内置模板classic/ubuntu-terminal+ 种 site_template=classic setting。
- 新增 `handlers/template.go`:公开 `GET /api/templates``GET /api/templates/:slug`;后台 uploadzip 严格校验10MB/200 条目/50MB 解压/扩展名白名单禁 js·html·svg/路径穿越防护/manifest 校验、CRUD、activate。权限复用 settings 组。
- `handlers/setting.go` publicKeys 加 site_template`main.go` 注册路由 + 调用迁移。
- 内置模板 Slug 保留字classic / ubuntu-terminal自定义 slug 需 `^[a-z0-9][a-z0-9-]{0,31}$`
### 前端Vue3
- `App.vue` 改造为布局分发器templateMode = classic/terminal/custom支持 `?preview_template=slug` 预览覆盖admin/login/presentation(PPT) 绕过分发。
- 新增 `layouts/ClassicLayout.vue`(从 App.vue 抽出)、`layouts/TerminalLayout.vue``layouts/CustomLayoutRenderer.vue`
- 终端模板 `terminal/`TerminalShell输入/↑↓历史/Tab 补全/自动滚动)+ commands.tsls/cd/cat/search/works/columns/snippets/videos/services/about/tags/help/pwd/whoami/date/echo/banner/clear支持 &&+ output/ 四个渲染组件;深链映射 route.path→命令会话Ubuntu 紫黑配色Ubuntu Mono 风格)。
- 自定义渲染引擎 `layouts/`blocks/basic.tsheader/nav/hero/footer/image/link/divider/static_html+ BlockDataList/BlockPostDetail/BlockAboutBrief + blocks/index.ts 白名单映射resolvers.tssettings bindings + 数据源加载 + HTML 清洗);皮肤 CSS 注入 `data-template` 隔离、卸载移除。
- `api.ts` 新增模板接口fetchTemplates/fetchTemplateDescriptor/getAdminTemplates/uploadTemplate/updateTemplate/activateTemplate/deleteTemplatePublicSettings 加 site_template。
- 后台:`pages/admin/TemplateManage.vue`(上传/列表/设为当前/预览/停用/删除)+ 菜单「模板管理」+ Settings.vue「模板」分组下拉选择schema 加 template-select 类型)。
### 验证
- 后端 go build 通过scripts/ 目录有项目原有 main 冲突,与本次无关)。
- 前端 vue-tsc --noEmit 通过、vite build 通过。
- 说明:本地 8081 后端为旧进程PID 56808**需重启后** /api/templates、site_template 才生效;终端模板可用 `/?preview_template=ubuntu-terminal` 无需后端新接口直接预览dev server 8888 已启动)。
### 交付物
- 示例自定义布局包:`examples/custom-template-demo/`manifest+layout+skin及打包 `examples/minimal-demo.zip`,可直接后台上传测试。
### 环境注意
- vite build 会被安全删除保护拦截(清空 dist 超 50 文件),先把 dist 移到仓库外临时目录再 build。
## 模板系统收尾优化(四项,均已落地)
1. **内置模板完全锁定**`server/handlers/template.go` 的 AdminUpdateTemplate 增加 IsSystem 校验——内置模板不可改名400、不可停用400description 可改;删除保护原有双保险。
2. **模板管理页增强**
- 后端新增 `GET /api/admin/templates/:id` 详情接口AdminGetTemplateDetail返回 manifest/layout 全文 + assetsBase + isCurrent
- 前端新增 `components/admin/TemplatePreviewModal.vue`iframe 预览 `/?preview_template=`+ `TemplateDetailDrawer.vue`(概览/manifest/layout JSON Tab + 复制TemplateManage.vue 接入(预览改内嵌模态框、加详情按钮、上传区 📦→Icon
- api.ts 新增 TemplateDetail 类型 + getTemplateDetail。
3. **文档**:新建根 `README.md`(项目概览/技术栈/快速开始/目录结构/模板概述)与 `docs/TEMPLATE-DEV.md`(模板开发手册:体系/终端命令表/URL 深链映射/zip 规范/manifest+layout schema/block 白名单/bindings/skin 隔离/操作流程/扩展指南)。
4. **后台 emoji → lucide 图标**AdminMenuItem.vue 图标改 `<Icon :name>` 渲染AdminLayout 30 个菜单 emoji 映射为图标名bar-chart-3/trending-up/theater/presentation 等,已核对 lucide 0.446 存在pages/admin 18 文件 40 处 + components/admin 3 文件AccessLogModal/PostHistoryModal/UserPickerPopover批量替换脚本 emoji2icon.py 在临时目录,按容器智能 size 28/14自动注入 import Icon。后台已无 emoji 残留(前台 pages/Categories.vue 与 TerminalLayout 的 ✕ 不在范围内未动)。
验证go build OK、vue-tsc --noEmit OK、vite build OK。后端 8081 仍为旧进程需重启后新接口/锁定逻辑生效。
## 模板卡片单选 + 终端交互/配色增强Craft 完成)
1. **站点模板卡片单选**`client/src/pages/admin/Settings.vue` 的 template-select 由 select 改为卡片单选网格(模板名/类型徽标/slug/选中对勾active 高亮边框),引入 Iconcheck 图标)。样式 scoped 追加 template-picker 系列。
2. **终端配色可配置localStorage**
- 新增 `client/src/terminal/theme.ts`THEME_PRESETSubuntu 紫/经典绿/白底/现代深灰)+ 自定义颜色 10 项bg/windowBg/titlebarBg/titlebarText/text/muted/prompt/accent/error/bannerload/save 存 localStorage key `terminal-theme`JSON {preset, colors}themeToVars 生成 CSS 变量。
- 新增 `client/src/terminal/TerminalThemePanel.vue`:预设色卡 + 10 项 color input + 恢复默认/关闭。
- `TerminalLayout.vue` 根容器 :style 应用 --term-* 变量标题栏新增调色板按钮打开面板scoped 样式与 `terminal.css` 全部改 var(--term-*)color-mix 做混合色)。
3. **终端命令/导航交互增强**commands.ts + types.ts
- 新命令:`man <cmd>``history [-c]``back`(浏览器后退)、`open <url>``theme`(开面板)、`reload``help [命令]` 支持单个命令详情;`cd -` 返回上一目录ctx 增 getLastDir/setLastDir
- CommandContext 扩展getLastDir/setLastDir/getHistory/clearHistory/openThemePanelTerminalLayout 维护 lastDir 与 history ref。
- PostDetailOutput 顶部加「返回」按钮emit back → router.back()TerminalShell 转发 back 事件。
4. **文档**docs/TEMPLATE-DEV.md 命令表更新(新命令 + cd -+ 新增 §2.3 终端配色(预设/自定义/localStorage/变量约定README 终端描述补配色说明。
验证vue-tsc --noEmit OK、vite build OK后台 admin 目录 grep 无 emoji 残留。dev server 8888 已重启(之前进程超时退出)。
## 修复:终端输入框/骨架样式未还原scoped 穿透问题)
根因TerminalShell.vue 内部元素的骨架样式(.term-body/.term-line/.term-prompt/.term-cmd-text/.term-info/.term-error/.term-banner/.term-input 等)写在了 TerminalLayout.vue 的 `<style scoped>` 里,**scoped 样式不穿透子组件** → 全部回落到浏览器默认样式(输入框带原生边框/白色背景/系统字体)。
修复:把这套终端骨架样式整体迁移到全局 `client/src/terminal/terminal.css`(追加 "Terminal shell skeleton" 段落),并强化 .term-inputbackground/border/outline/box-shadow 全 !important 清除、appearance none、继承 mono 字体、caret 色、placeholder/selection 主题化);删除 TerminalLayout.vue 的 scoped style 块210-391 行)。教训:跨组件共享的"外观类"必须放全局样式或组件自身 scoped不能放在父组件 scoped 里。
验证vue-tsc OK、vite build OK、dev 8888 HMR 即时生效。
## 终端模板补齐作品/片段/视频/服务/专栏支持
之前 videos/services 只有提示文字、snippets/tags 只读列表。现已全模块打通:
- types.tsTermOutputKind 增 work/snippet/videopayload 增 WorkPayload/SnippetPayload/VideoPayload。
- commands.tscat 支持路径解析blog/works/snippets/videos/columns 前缀,`cat 12` 默认文章);新增 cmdWorkDetail/cmdSnippetDetail/cmdVideoDetail/cmdColumnPostscmdVideos 完整实现videos 列分类+全部视频、videos <分类> 列专辑+视频、videos albums/<id> 列专辑内视频cmdServices 展示客户评价+合作伙伴;注册表新增 work/snippet/video 便捷命令(复用详情 handler
- 新输出组件output/WorkDetailOutput.vue标题/分类/年份/描述/技术栈/链接、SnippetDetailOutput.vue代码经 renderMarkdown 走 hljs 高亮、VideoOutput.vue内嵌 <video controls> 播放 + 封面/时长ListOutput 增 videos variant时长/分类,点击跳 /videos/:id
- TerminalShell渲染 work/snippet/video/videos 分支onOpenList videos → /videos/:id。
- TerminalLayout深链映射扩展/works/:id、/columns/:id、/snippets/:id、/videos/:id、/videos/albums/:id → 对应命令)。
- 注意:/snippets/:id 无前端独立路由,终端内用 snippet <id> / cat snippets/<id> 查看,列表不跳转。
验证vue-tsc OK、vite build OKdev server 8888 重启后台任务会超时退出需重建。docs/TEMPLATE-DEV.md 命令表与深链映射同步更新。
## 终端右侧内容预览面板
用户反馈 cat 长文在终端流里不好看 → 实现双栏预览:
- types.tsPreviewPayloadkind: post/work/snippet/video + title + dataCommandContext 增 openPreview/closePreview。
- commands.tscmdPostDetail/cmdWorkDetail/cmdSnippetDetail/cmdVideoDetail 改为 fetch 数据 → ctx.openPreview右侧面板终端只输出一行 info"已打开…close 关闭");新增 close 命令。
- 新组件 terminal/PreviewPanel.vue标题栏类型徽标/标题/✕)+ 独立滚动内容区,复用 PostDetailOutput/WorkDetailOutput/SnippetDetailOutput/VideoOutput加了 hideBack prop面板内隐藏返回按钮
- TerminalLayout双栏布局.term-main flex 行:.term-shell-col flex:1 + .term-preview-col 55%/max60%preview ref + ctx 接线resetSession 关闭面板。
- terminal.css双栏样式 + 窄屏(<880px)面板全屏覆盖。
验证vue-tsc OK、vite build OK、dev 8888 重启后台任务易超时退出需重建。docs/TEMPLATE-DEV.md 增 §2.4 右侧预览面板。
## 多模板扩展7 套新模板)+ 后台优化
用户确认:新增 7 套内置模板(共 9 套classic/ubuntu-terminal/pixel/magazine/newspaper/bento/glass/retro/guofeng全部页面逐页重写排版差异化非换色、canvas 主题预览、Settings tab 记忆、终端面板 max-height。
- 修复terminal.css .term-preview-col 加 max-height 720/height 100%/overflow hidden与 .term-body 对齐),窄屏覆盖模式 max-height 100vh。
- Settings.vueactiveTab localStorage 记忆key admin-settings-active-tabloadInitialTab 校验合法值)。
- canvas 预览config/templatePreviews.ts9 套 draw 函数200x120 原生 canvas+ components/admin/TemplatePreviewCanvas.vueDPR 缩放);接入 TemplateManage 卡片 + Settings template-opt 卡片。
- 后端template_repository.go 加 7 个 slug 常量migration.go MigrateTemplates 追加 7 个 ensureBuiltinTemplatesortOrder 2-8handlers/template.go 内置保留字 map 扩展。
- 模板页面系统关键架构templates/registry.tsTEMPLATE_LAYOUTS 9 套懒加载App.vue 改 <component :is="layoutComponent">slug→注册表未知 slug 走 CustomLayoutRenderer每套模板 = templates/<slug>/{Layout.vue, <slug>.css, pages/index.ts, pages/*.vue}Layout 内 <component :is="pages[route.name] ?? RouterView">(未实现页回退经典不白屏);前台 13 个路由 name 为映射键。
- 7 套模板完成度pixel 13 页全量magazine/newspaper/bento/glass/retro/guofeng 各 Layout+css+核心 3 页home/blog/blogDetail其余页回退经典架构支持后续补齐
- 字体pnpm add 被环境 safe-delete 机制拦截trash 中止),改字体栈回退方案('Press Start 2P'/'Noto Serif SC' 未安装时回退 JetBrains Mono / 系统宋体 Songti SC+SimSunmain.ts 不导入。
- 验证go build OK、vue-tsc OK、vite build OK9 套 ?preview_template= 全部 200。
## 终端预览改为右侧独立窗口
用户反馈:预览不应在终端窗口内(双栏挤压),要在终端右边另开一个窗口,终端左移缩小。
- terminal.css删除 .term-main/.term-shell-col/.term-preview-col 旧双栏;新增 .term-desktopflex 行居中 gap 24max-width 1420+ .term-window 改 flex:0 1 560px缩小+ .term-preview-windowflex:1 1 560px max-width 760独立窗口标题栏 .term-preview-window-bar类型徽标/标题/✕)+ body 独立滚动);<1120px 上下堆叠;<880px 预览窗口全屏覆盖。
- TerminalLayout.vue模板改双窗口桌面终端窗口 + 预览窗口),预览窗口标题栏显示 previewKindLabel文章/作品/代码/视频)+ 标题 + ✕PreviewPanel.vue 移除内部头部(标题栏上移外层窗口),保留内容 body 渲染。
- docs/TEMPLATE-DEV.md §2.4 更新为"右侧预览窗口(独立窗口)"。
验证vue-tsc OK、vite build OK、dev 8888 重启。
## 终端窗口高度 + 模板 CSS 未加载修复
1. 窗口超视口:.term-page padding 3rem→1.5rem 1rem.term-desktop height 改 calc(100vh - 3rem)(去掉 min(760, ...).term-body/.term-body-wrap 已是 flex:1+min-height:0+overflow-y:auto窗口内滚动预览窗口 body 同理 → 总高=100vh 不超视口。
2. 新模板 css 未加载7 套 LayoutPixel/Magazine/Newspaper/Bento/Glass/Retro/GuofengLayout.vue从未 import 自己的 css → 脚本在 script setup 首 import 前注入 `import './<slug>.css'`(模板 Layout 懒加载时 css 一并加载。教训Vue 组件文件内的 .css 必须显式 import 才会生效。
验证vue-tsc OK、vite build OK、dev 8888用 nohup & 方式重启,任务方式易超时)。
## 模板质感打磨(中文化 + 详情页特殊样式)
用户批评模板粗糙、英文文案、详情无设计 → 打磨:
- pixel 全套Layout 导航/页脚中文化(博客/作品/专栏/视频/代码/合作/关于Home 重写(◇欢迎/★精选文章/◆最新作品/▣快速选择中文按钮Blog 中文化(搜索/全部/上一页/下一页);其余 10 页 sed 中文化(暂无数据/加载中/返回/代码库/客户评价/合作伙伴/技能树 等pixel.css 字体栈去掉未安装的 Press Start 2P → 'JetBrains Mono','Noto Sans SC'(中文像素感用等宽)。
- 7 套 BlogDetail 全部重做特殊样式:
- pixel游戏机状态栏分类/日期/阅读 像素框)+ 像素绿标题 + 像素分隔条(绿橙方块)+ END 落款
- magazine刊头信息行年糕杂志·VOL.x·日期+ 特大标题 + 作者行(文/年糕)+ 首字下沉 + 尾注「— 完 —」
- newspaper报纸版头年糕日报·第xxx期·分类+ 头条大标题 + 引题行 + np-cols 双栏正文
- bento宫格头部卡标题 + 分类/日期/阅读 彩色标签)+ 内容卡
- glass毛玻璃居中头部卡标题 + meta 光晕)+ 内容卡
- retro印刷刊头编号+ 烫金大标题 + 日期印章框 + 「— 刊毕 —」
- guofeng竖排日期框日/年月)+ 标题 + 印章 + 分隔线 + 落款「谨记于…·作者 识」
- 各套 Home 英文文案中文化magazine 编者按/Latest Work、newspaper 头版头条、bento/glass Featured/Latest Work 等)。
验证vue-tsc OK、vite build OK、dev 8888 OK。
## 模板文字颜色适配 + 详情页去最大宽度
1. 文字颜色适配:全局 --art-* 是 RGB 三元组rgb(var(--art-accent)) 用法。7 套模板 css 根类(.pixel-root 等)注入 --art-bg/-surface/-text/-muted/-accent/-border 覆盖每套按自身配色pixel 深绿黑/57 255 20、magazine 米白/200 16 46、newspaper 纸色/163 59 46、bento 深紫蓝/108 92 231、glass 深紫/124 220 255、retro 牛皮纸/184 134 11、guofeng 宣纸/178 58 46未重写组件回退经典页自动适配模板配色。
2. 详情页去 max-width各套 Layout 的 main 加 :class="{ full: isDetail }"route.name==='blog-detail'css 追加 .xxx-main.full { max-width: none; },详情页全宽显示。
验证vue-tsc OK、vite build OK、dev 8888 OK。
## 终端作品详情排查与增强
用户反馈"终端的作品无法查看内容详情"。用 playwright-cli 真实浏览器逐段验证works 命令 → ListOutput 渲染 ✓ → 点击行 → navigate /works/:id → 深链 cat works/:id → openPreview 预览窗口 ✓(全链路正常,含长 id work_1768828710998326100
定位感知问题WorkDetailOutput 只渲染标题+描述(作品数据 techStack/links 常为 null无封面/画廊 → 详情显得空。增强 WorkDetailOutput封面 heroImg 大图error 置空防裂图、gallery 画廊网格、技术栈/链接/下一篇,文案中文化(技术栈/作品展示/链接/在线访问/代码仓库)。
- 经验playwright-cli 全局装于 C:/Users/admin/.workbuddy/binaries/node/versions/22.22.2/playwright-clitype 对 Vue input 不生效v-model 需 bubbles:true 的 input 事件),用 eval 派发 input+keydown Enter 模拟;截图/snapshot 输出到 .playwright-cli/ 且部分为空,用 eval 取文本更可靠;测试完 .playwright-cli 移出仓库。
验证vue-tsc OK、vite build OK、dev 8888 OK。
## 终端窗口宽度 + 预览窗口无最大宽度
用户截图终端窗口在窄视口被预览窗口压窄flex 收缩)。修复:
- .term-windowflex 0 1 560px min-width 0 → flex 0 0 520px min-width 480px max-width 600px默认 520px最小 480px 不被压垮)。
- .term-preview-window去 max-width:760"不限制最大宽度"flex 1 1 auto min-width 340填充剩余空间视口宽时可达 ~820px+)。
验证vue-tsc OK、vite build OK、dev 8888 OK。
## 模板详情宽度修复full 类名 bug+ 终端无预览加宽
1. 用户反馈"其他模板文章宽度也不要设置宽度"——查因:脚本注入的详情全宽规则类名写错(.magazine-main.full 等,而 Layout 实际类是 mag-main/np-main/bt-main/gl-main/rt-main/gf-main只有 pixel 生效。修正 6 套 full 类名 → .xxx-main.full 全部正确blog-detail 时 main 全宽。
2. 终端无预览时太窄:.term-window 默认 flex 1 1 auto max-width 960px无预览占满桌面TerminalLayout .term-desktop 加 :class has-preview有预览时 .term-window flex 0 0 520px max 600 收窄。教训脚本生成类名必须与真实模板类名一致缩写类mag-main与 slugmagazine不同。
验证vue-tsc OK、vite build OK、dev 8888 OK。
## 6 套模板视觉差异化重构用户4 套纸感模板同质化、bento 简陋、glass 不高级)
1. magazine→现代编辑杂志纯白底 #f8f8f6 + 墨黑 + 朱红 #e63946;无衬线(Inter)为主 + Playfair 标题;刊头粗红条;文章卡大号编号(::before data-index深色代码块h2 红条前缀。
2. newspaper→旧报纸黄纸 #ece2c8 + 浓黑 + 砖红;宋体正文、报头双线框 + 日期栏、黑体大标题、双栏正文、描边按钮。
3. retro→活字印刷深牛皮纸 #d9c8a3 + 深棕 + 烫金渐变标题;贴纸角标(.rt-stamp 旋转)、art-deco 菱形装饰线(.rt-deco)、粗描边阴影卡。
4. guofeng→水墨意境近白宣纸 #f7f2e7 + 墨色 + 朱砂;超大书法标题(.gf-huge clamp 84px)、大留白(56px top padding)、墨线远山、印章。
5. bento 升级:卡片渐变底(四色渐变)、顶部内高光线(::before)、hover 阴影、.bt-card-icon 图标容器Home 加 lucide 图标 sparkles/file-text/palette/layout-grid
6. glass 升级:背景加第四层光斑;卡片渐变玻璃(linear-gradient 150deg)、顶部弧光(::before radial)、内高光 inset、hover 蓝辉光 0 0 40px。
每套重写后补回 --art-* 覆盖与 .xxx-main.full 规则。magazine Blog 文章卡补 data-index 编号。
验证vue-tsc OK、vite build OK、6 套预览 200。

76
README.md Normal file
View File

@@ -0,0 +1,76 @@
# 年糕博客art-code
个人博客系统,前后端分离。内置多套前台页面模板,支持后台一键切换与自定义布局包上传。
## 功能特性
- 内容管理:文章、分类、专栏、作品、视频、代码片段、标签
- 站点配置全局设置、首页内容、菜单、SEO 均由后台管理
- **多模板体系**:前台页面(首页/博客/作品/关于等全部公开页)可在后台切换模板
- `classic` 经典布局(默认)
- `ubuntu-terminal` Ubuntu 终端风格(`ls` / `cat` / `search` 等命令交互,配色可在终端内自定义并保存)
- 自定义布局包(后台上传 zipJSON 描述区块结构 + 皮肤 CSS
- PPT 演示:文章一键生成 PPT
## 技术栈
| 端 | 技术 |
|---|---|
| 前端 `client/` | Vue 3 + TypeScript + Vite 6 + Tailwind CSS + lucide-vue-next |
| 后端 `server/` | Go + Gin + GORM + MySQL |
| 数据库 | MySQL 5.7+(表结构随启动自动迁移) |
## 快速开始
```bash
# 后端server/
# 1. 复制 .env.example 为 .env 并填写 MySQL 连接
# 2. 启动(数据库迁移在 main.go 启动时自动执行)
cd server
go run main.go # 监听 :8081
# 前端client/
cd client
pnpm install
pnpm dev # 监听 :8888/api 代理到 8081
```
生产构建:`cd client && pnpm build`(产物在 `client/dist/`)。
## 目录结构
```
├── server/ # Go/Gin 后端
│ ├── main.go # 路由注册 + 启动迁移
│ ├── handlers/ # 接口处理器
│ ├── repositories/ # 数据访问 + 表迁移
│ ├── models/ # GORM 模型
│ └── utils/ # 统一响应等
├── client/ # Vue3 前端
│ ├── src/pages/ # 前台页面Home/Blog/Works/About…
│ ├── src/pages/admin/ # 后台管理页面
│ ├── src/layouts/ # 模板布局ClassicLayout / TerminalLayout / CustomLayoutRenderer
│ ├── src/terminal/ # 终端模板引擎(命令解析、输出组件)
│ ├── src/layouts/blocks/ # 自定义布局渲染引擎(白名单块组件)
│ └── src/services/api.ts # API 封装
├── docs/TEMPLATE-DEV.md # 模板开发手册(必读)
└── examples/ # 示例自定义布局包
```
## 模板系统概述
前台模板由后台配置项 `site_template`settings 表)控制,前端 `App.vue` 作为布局分发器按模板渲染:
- `classic``layouts/ClassicLayout.vue`(现有经典布局)
- `ubuntu-terminal``layouts/TerminalLayout.vue`(终端交互)
- 其他 slug → `layouts/CustomLayoutRenderer.vue`(按布局 JSON 渲染自定义包)
**预览不落库**:访问 `/?preview_template=<slug>` 可临时预览任意模板,适合后台确认后再切换。
后台入口:系统设置 → 模板管理(上传/启用/设为当前/预览/详情/删除)。
> 内置模板classic / ubuntu-terminal不可删除、不可改名、不可停用自定义模板可删除当前使用中的除外
## 文档
- **[模板开发手册](docs/TEMPLATE-DEV.md)**模板体系、终端命令表、URL 深链映射、自定义布局包规范manifest / layout schema、zip 限制、skin 隔离约定)、后台操作流程与前端扩展指南

View File

@@ -1,64 +1,37 @@
<template>
<!--
CRITICAL FIX: Removed 'overflow-hidden' from this root div.
'overflow-hidden' on an ancestor element breaks 'position: sticky'
in child components (like BlogDetail TOC) because it changes the
scrolling context.
Root layout dispatcher: routes through registered template layouts
based on site_template setting (or ?preview_template= override).
Admin/Login/Presentation pages bypass template dispatch entirely.
-->
<div class="min-h-screen bg-art-bg text-art-text relative flex flex-col">
<!-- Ambient canvas: dark stars / light aurora+dust -->
<StarBackground v-if="showFrontendChrome" class="z-0" />
<!-- Light-mode static corner washes -->
<div v-if="showFrontendChrome" class="light-atmosphere" aria-hidden="true"></div>
<!-- Global Noise (Only for frontend) opacity/blend via theme CSS vars -->
<div v-if="showFrontendChrome" class="site-noise fixed inset-0 pointer-events-none z-[60] bg-noise"></div>
<!-- Toast Container -->
<!-- Toast Container (global, incl. admin) -->
<div id="toast-container" class="toast-container relative z-[70]"></div>
<!-- Header (Hide on Admin & Login) -->
<Header v-if="showFrontendChrome" class="z-50" />
<!-- Mobile Menu (Hide on Admin & Login) -->
<MobileMenu v-if="showFrontendChrome" class="z-50" />
<!-- Main Content -->
<!-- Conditional classes: Apply max-w-7xl only for frontend pages -->
<main :class="[
isPresentationPage ? 'w-full flex-1 relative z-10 min-h-0' :
isFullWidthPage ? 'w-full flex-1 relative z-10' : 'flex-1 pt-32 pb-20 px-6 max-w-7xl mx-auto relative z-10'
]">
<router-view />
</main>
<!-- Footer (Hide on Admin & Login) -->
<Footer v-if="showFrontendChrome" class="relative z-10" />
<!-- Snippet Modal (Hide on Admin & Login) -->
<!-- SnippetModal is managed by Snippets.vue page, not here -->
<!-- Inquiry Modal (Hide on Admin & Login) -->
<InquiryModal v-if="showFrontendChrome" class="relative z-[80]" />
<!-- User Profile Modal (Global) -->
<!-- User Profile Modal (global) -->
<UserProfileModal
:is-open="profileModalOpen"
:user-id="profileUserId"
@close="closeUserProfile"
/>
<!-- Pages that bypass template dispatch: admin / login / presentation (PPT preview) -->
<template v-if="bypassTemplateLayout">
<main :class="bypassMainClass">
<router-view />
</main>
</template>
<!-- Frontend pages: render per active template layout -->
<component :is="layoutComponent" v-else />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
import { useRoute } from 'vue-router'
import Header from './components/Header.vue'
import MobileMenu from './components/MobileMenu.vue'
import Footer from './components/Footer.vue'
import InquiryModal from './components/InquiryModal.vue'
import StarBackground from './components/StarBackground.vue'
import CustomLayoutRenderer from './layouts/CustomLayoutRenderer.vue'
import { TEMPLATE_LAYOUTS } from './templates/registry'
import UserProfileModal from './components/UserProfileModal.vue'
import { useUserProfileModal } from './composables/useUserProfileModal'
import { loadPublicSettings } from './composables/usePublicSettings'
@@ -81,18 +54,43 @@ watch(isAdminOrLogin, (forced) => setForceDark(forced), { immediate: true })
// 沉浸式演示页PPT 预览等)
const isPresentationPage = computed(() => route.meta.presentation === true)
const showFrontendChrome = computed(() => !isAdminOrLogin.value && !isPresentationPage.value)
// 这些页面绕过模板分发,保持固定渲染
const bypassTemplateLayout = computed(() => isAdminOrLogin.value || isPresentationPage.value)
// 全宽布局页面(不受 max-w-7xl 限制)
const isFullWidthPage = computed(() => {
const path = route.path
return path.startsWith('/admin') || path === '/login'
|| path.startsWith('/blog/')
|| path.startsWith('/works/')
|| path.startsWith('/columns')
|| path.startsWith('/videos/')
const bypassMainClass = computed(() => {
if (isPresentationPage.value) return 'w-full flex-1 relative z-10 min-h-0'
return 'w-full flex-1 relative z-10'
})
// 当前模板 slug
const templateSlug = ref('classic')
// 布局组件:注册表懒加载;未知 slug自定义上传走自定义渲染引擎
const layoutComponent = computed<Component>(() => {
const loader = TEMPLATE_LAYOUTS[templateSlug.value]
if (loader) return defineAsyncComponent(loader)
return CustomLayoutRenderer
})
// ?preview_template= 查询参数覆盖(后台预览用,不落库)
const previewTemplate = computed(() => {
const q = route.query.preview_template
return typeof q === 'string' ? q : undefined
})
const applyTemplateMode = async (force = false) => {
try {
const settings = await loadPublicSettings(force)
templateSlug.value = previewTemplate.value || settings.site_template || 'classic'
} catch {
templateSlug.value = 'classic'
}
}
const handleSettingsChanged = () => {
void applyTemplateMode(true)
}
// 应用网站配置到页面标题和meta标签
const applySiteSettings = async () => {
try {
@@ -137,9 +135,20 @@ const applySiteSettings = async () => {
}
onMounted(() => {
void applyTemplateMode()
// 只在非管理页面应用设置
if (!isAdminOrLogin.value) {
applySiteSettings()
}
window.addEventListener('public-settings-changed', handleSettingsChanged)
})
</script>
onUnmounted(() => {
window.removeEventListener('public-settings-changed', handleSettingsChanged)
})
// 预览参数变化时(如在站点内导航保留 query重新解析模板
watch(() => route.query.preview_template, () => {
void applyTemplateMode()
})
</script>

View File

@@ -16,7 +16,7 @@
</div>
<div v-else-if="logs.list.length === 0" class="flex flex-col items-center justify-center py-12">
<div class="text-4xl mb-4">📊</div>
<div class="text-4xl mb-4"><Icon name="bar-chart-3" :size="36" /></div>
<p class="text-white/60">暂无访问记录</p>
</div>
@@ -78,6 +78,7 @@
</template>
<script setup lang="ts">
import Icon from '../Icon.vue'
import { ref, computed, watch } from 'vue'
import { getPostAccessLogs, PostAccessLog, PaginationResponse } from '../../services/api'
import { useToast } from '../../composables/useToast'

View File

@@ -172,90 +172,91 @@ interface MenuItemWithState extends MenuItem {}
const menuItems = ref<MenuItemWithState[]>([
{
title: '概览',
icon: '📊',
icon: 'bar-chart-3',
path: '/admin/dashboard'
},
{
title: '数据分析',
icon: '📈',
icon: 'trending-up',
path: '/admin/analytics'
},
{
title: '内容管理',
icon: '📚',
icon: 'book-open',
isOpen: true,
children: [
{ title: '文章管理', path: '/admin/posts', icon: '📝' },
{ title: '分类管理', path: '/admin/categories', icon: '📂' },
{ title: '专栏管理', path: '/admin/columns', icon: '📚' },
{ title: '作品管理', path: '/admin/works', icon: '🎨' },
{ title: '文章管理', path: '/admin/posts', icon: 'file-text' },
{ title: '分类管理', path: '/admin/categories', icon: 'folder-open' },
{ title: '专栏管理', path: '/admin/columns', icon: 'library' },
{ title: '作品管理', path: '/admin/works', icon: 'palette' },
{
title: '视频管理',
isOpen: false,
children: [
{ title: '视频分类', path: '/admin/video-categories', icon: '📁' },
{ title: '视频专辑', path: '/admin/video-albums', icon: '📀' },
{ title: '视频列表', path: '/admin/videos', icon: '🎬' },
{ title: '视频分类', path: '/admin/video-categories', icon: 'folder' },
{ title: '视频专辑', path: '/admin/video-albums', icon: 'disc' },
{ title: '视频列表', path: '/admin/videos', icon: 'clapperboard' },
]
},
{
title: '代码管理',
isOpen: false,
children: [
{ title: '代码片段', path: '/admin/snippets', icon: '💻' },
{ title: '代码分类', path: '/admin/code-types', icon: '🏷️' }
{ title: '代码片段', path: '/admin/snippets', icon: 'code' },
{ title: '代码分类', path: '/admin/code-types', icon: 'tag' }
]
},
{ title: '标签管理', path: '/admin/tags', icon: '🏷️' }
{ title: '标签管理', path: '/admin/tags', icon: 'tag' }
]
},
{
title: '页面配置',
icon: '🔧',
icon: 'sliders-horizontal',
isOpen: false,
children: [
{ title: '关于页面', path: '/admin/about', icon: '👤' },
{ title: '客户评价', path: '/admin/testimonials', icon: '💬' },
{ title: '合作伙伴', path: '/admin/partners', icon: '🤝' }
{ title: '关于页面', path: '/admin/about', icon: 'user' },
{ title: '客户评价', path: '/admin/testimonials', icon: 'message-square' },
{ title: '合作伙伴', path: '/admin/partners', icon: 'handshake' }
]
},
{
title: '业务中心',
icon: '💼',
icon: 'briefcase',
isOpen: false,
children: [
{ title: '合作咨询', path: '/admin/inquiries', icon: '📩' },
{ title: '邮箱配置', path: '/admin/email-suffixes', icon: '📧' }
{ title: '合作咨询', path: '/admin/inquiries', icon: 'inbox' },
{ title: '邮箱配置', path: '/admin/email-suffixes', icon: 'mail' }
]
},
{
title: '用户权限',
icon: '🛡️',
icon: 'shield',
isOpen: false,
children: [
{ title: '用户管理', path: '/admin/users', icon: '👥' },
{ title: '角色管理', path: '/admin/roles', icon: '🔒' }
{ title: '用户管理', path: '/admin/users', icon: 'users' },
{ title: '角色管理', path: '/admin/roles', icon: 'lock' }
]
},
{
title: '附件管理',
icon: '📎',
icon: 'paperclip',
isOpen: false,
children: [
{ title: '附件库', path: '/admin/attachments', icon: '📦' },
{ title: '附件分类管理', path: '/admin/attachment-categories', icon: '📂' },
{ title: 'OSS配置', path: '/admin/oss-configs', icon: '☁️' }
{ title: '附件库', path: '/admin/attachments', icon: 'package' },
{ title: '附件分类管理', path: '/admin/attachment-categories', icon: 'folder' },
{ title: 'OSS配置', path: '/admin/oss-configs', icon: 'cloud' }
]
},
{
title: '系统设置',
icon: '⚙️',
icon: 'settings',
isOpen: false,
children: [
{ title: '全局配置', path: '/admin/settings', icon: '🛠️' },
{ title: 'PPT 模板', path: '/admin/ppt-templates', icon: '📽️' },
{ title: '操作日志', path: '/admin/logs', icon: '📋' },
{ title: '访问日志', path: '/admin/access-logs', icon: '🌐' }
{ title: '全局配置', path: '/admin/settings', icon: 'wrench' },
{ title: '模板管理', path: '/admin/templates', icon: 'theater' },
{ title: 'PPT 模板', path: '/admin/ppt-templates', icon: 'presentation' },
{ title: '操作日志', path: '/admin/logs', icon: 'clipboard-list' },
{ title: '访问日志', path: '/admin/access-logs', icon: 'globe' }
]
}
])

View File

@@ -9,11 +9,13 @@
@click="handleToggle"
>
<div class="flex items-center gap-3 min-w-0">
<span
<Icon
v-if="item.icon && depth === 0"
class="text-lg shrink-0 group-hover:text-art-accent transition-colors"
:name="item.icon"
:size="18"
class="shrink-0 group-hover:text-art-accent transition-colors"
:class="isItemActive ? 'text-art-accent' : ''"
>{{ item.icon }}</span>
/>
<span
class="text-sm truncate transition-opacity duration-300"
:class="[
@@ -62,10 +64,12 @@
class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 rounded-full bg-art-accent"
:class="depth >= 2 ? 'h-3' : 'h-4'"
></div>
<span
<Icon
v-if="item.icon && depth === 0"
class="text-lg shrink-0 group-hover:text-art-accent transition-colors"
>{{ item.icon }}</span>
:name="item.icon"
:size="18"
class="shrink-0 group-hover:text-art-accent transition-colors"
/>
<span
class="text-sm truncate transition-opacity duration-300"
:class="[
@@ -79,6 +83,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import AdminMenuItem from './AdminMenuItem.vue'
import Icon from '../Icon.vue'
import type { MenuItem } from './adminMenuTypes'
const props = defineProps<{

View File

@@ -11,7 +11,7 @@
<h3 class="text-lg font-medium text-white">历史版本</h3>
<p class="text-xs text-art-muted mt-1">每次保存后生成新版本vN 表示第 N 次保存后的内容</p>
</div>
<button type="button" class="text-art-muted hover:text-white" @click="emit('close')"></button>
<button type="button" class="text-art-muted hover:text-white" @click="emit('close')"><Icon name="x" :size="14" /></button>
</div>
<div class="flex items-center gap-3 px-6 py-3 border-b border-white/10 shrink-0 bg-white/[0.02]">
@@ -282,6 +282,7 @@
</template>
<script setup lang="ts">
import Icon from '../Icon.vue'
import { computed, ref, watch } from 'vue'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'

View File

@@ -0,0 +1,290 @@
<template>
<Teleport to="body">
<div v-if="open" class="tdd-overlay" @click.self="close">
<div class="tdd-drawer">
<div class="tdd-head">
<span class="tdd-title">模板详情</span>
<button class="tdd-close" title="关闭" @click="close">
<Icon name="x" :size="16" />
</button>
</div>
<div v-if="loading" class="tdd-loading">加载中</div>
<template v-else-if="detail">
<!-- Tabs -->
<div class="tdd-tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="tdd-tab"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<div class="tdd-body">
<!-- 概览 -->
<div v-if="activeTab === 'overview'" class="tdd-overview">
<div class="tdd-row"><span class="tdd-k">名称</span><span class="tdd-v">{{ detail.name }}</span></div>
<div class="tdd-row"><span class="tdd-k">Slug</span><span class="tdd-v font-mono">{{ detail.slug }}</span></div>
<div class="tdd-row"><span class="tdd-k">类型</span><span class="tdd-v">{{ detail.type === 'builtin' ? '内置' : '自定义' }}</span></div>
<div class="tdd-row"><span class="tdd-k">状态</span>
<span class="tdd-v">
{{ detail.isActive ? '已启用' : '已停用' }}
<span v-if="detail.isCurrent" class="tdd-current">当前使用中</span>
</span>
</div>
<div class="tdd-row"><span class="tdd-k">描述</span><span class="tdd-v">{{ detail.description || '—' }}</span></div>
<div v-if="detail.skinCssPath" class="tdd-row"><span class="tdd-k">皮肤 CSS</span><span class="tdd-v font-mono" style="font-size: 0.75rem;">{{ detail.skinCssPath }}</span></div>
<div v-if="detail.assetsBase" class="tdd-row"><span class="tdd-k">资源目录</span><span class="tdd-v font-mono" style="font-size: 0.75rem;">{{ detail.assetsBase }}</span></div>
<div v-if="detail.createdAt" class="tdd-row"><span class="tdd-k">上传时间</span><span class="tdd-v">{{ formatTime(detail.createdAt) }}</span></div>
</div>
<!-- manifest / layout JSON -->
<div v-else class="tdd-json">
<div v-if="jsonText" class="tdd-json-wrap">
<button class="tdd-copy" @click="copyJson">复制</button>
<pre class="tdd-pre">{{ jsonText }}</pre>
</div>
<div v-else class="tdd-empty">由前端 layouts/ 直接实现 {{ activeTab }} 内容</div>
</div>
</div>
</template>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import Icon from '../Icon.vue'
import { getTemplateDetail, type TemplateDetail, type TemplateMeta } from '../../services/api'
const props = defineProps<{
open: boolean
template: TemplateMeta | null
}>()
const emit = defineEmits<{ (e: 'close'): void }>()
const loading = ref(false)
const detail = ref<TemplateDetail | null>(null)
const activeTab = ref<'overview' | 'manifest' | 'layout'>('overview')
const tabs = [
{ id: 'overview' as const, label: '概览' },
{ id: 'manifest' as const, label: 'manifest.json' },
{ id: 'layout' as const, label: 'layout.json' },
]
const jsonText = computed(() => {
const raw = activeTab.value === 'manifest' ? detail.value?.manifest : detail.value?.layout
if (!raw) return ''
try {
return JSON.stringify(raw, null, 2)
} catch {
return String(raw)
}
})
watch(() => props.open, (v) => {
if (!v || !props.template) return
activeTab.value = 'overview'
detail.value = null
loading.value = true
getTemplateDetail(props.template.id)
.then((d) => { detail.value = d })
.catch(() => { detail.value = null })
.finally(() => { loading.value = false })
})
function formatTime(ts: number): string {
const d = new Date(ts * 1000)
return d.toLocaleString()
}
function copyJson() {
const text = jsonText.value
if (!text) return
navigator.clipboard?.writeText(text).catch(() => { /* ignore */ })
}
function close() {
emit('close')
}
</script>
<style scoped>
.tdd-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 1000;
}
.tdd-drawer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(560px, 92vw);
background: #121214;
border-left: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
box-shadow: -20px 0 60px rgba(0, 0, 0, 0.5);
}
.tdd-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.9rem 1.2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.tdd-title {
color: #fff;
font-weight: 600;
font-size: 1rem;
}
.tdd-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.15s;
}
.tdd-close:hover {
background: rgba(255, 80, 80, 0.2);
color: #ff8080;
}
.tdd-loading {
padding: 3rem;
text-align: center;
color: rgba(255, 255, 255, 0.5);
}
.tdd-tabs {
display: flex;
gap: 0.2rem;
padding: 0.6rem 1.2rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.tdd-tab {
padding: 0.5rem 0.9rem;
font-size: 0.82rem;
color: rgba(255, 255, 255, 0.55);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.15s;
}
.tdd-tab:hover {
color: #fff;
}
.tdd-tab.active {
color: rgb(var(--art-accent));
border-bottom-color: rgb(var(--art-accent));
}
.tdd-body {
flex: 1;
overflow-y: auto;
padding: 1.2rem;
}
.tdd-row {
display: flex;
gap: 0.8rem;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 0.85rem;
}
.tdd-k {
color: rgba(255, 255, 255, 0.45);
flex-shrink: 0;
width: 5.5rem;
}
.tdd-v {
color: rgba(255, 255, 255, 0.9);
word-break: break-all;
}
.tdd-current {
display: inline-block;
margin-left: 0.5rem;
background: rgb(var(--art-accent));
color: #000;
font-size: 0.7rem;
padding: 1px 8px;
border-radius: 9999px;
font-weight: 600;
}
.tdd-json {
position: relative;
}
.tdd-json-wrap {
position: relative;
}
.tdd-copy {
position: sticky;
top: 0;
float: right;
z-index: 2;
background: rgb(var(--art-accent));
color: #000;
border: none;
padding: 0.25rem 0.8rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
margin-bottom: 0.5rem;
}
.tdd-copy:hover {
opacity: 0.9;
}
.tdd-pre {
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 1rem;
font-size: 0.75rem;
line-height: 1.6;
color: #c9d1d9;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.tdd-empty {
color: rgba(255, 255, 255, 0.45);
font-size: 0.85rem;
padding: 1.5rem 0;
text-align: center;
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<canvas ref="canvasRef" class="tpc-canvas" :width="cssW * dpr" :height="cssH * dpr" :style="{ width: cssW + 'px', height: cssH + 'px' }"></canvas>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { drawTemplatePreview } from '../../config/templatePreviews'
const props = withDefaults(defineProps<{
slug: string
width?: number
height?: number
}>(), { width: 200, height: 120 })
const canvasRef = ref<HTMLCanvasElement | null>(null)
const cssW = props.width
const cssH = props.height
const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 2) : 1
function render() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, cssW, cssH)
drawTemplatePreview(props.slug, ctx, cssW, cssH)
}
onMounted(render)
watch(() => props.slug, render)
</script>
<style scoped>
.tpc-canvas {
display: block;
border-radius: 8px;
max-width: 100%;
}
</style>

View File

@@ -0,0 +1,149 @@
<template>
<Teleport to="body">
<div v-if="open" class="tpm-overlay" @click.self="close">
<div class="tpm-modal">
<div class="tpm-head">
<span class="tpm-title">预览{{ name }}</span>
<div class="tpm-actions">
<button class="tpm-btn" @click="openInNewTab">新窗口打开</button>
<button class="tpm-btn tpm-close" title="关闭" @click="close">
<Icon name="x" :size="16" />
</button>
</div>
</div>
<div class="tpm-body">
<iframe
v-if="open"
:src="previewSrc"
class="tpm-frame"
@load="loaded = true"
/>
<div v-if="!loaded" class="tpm-loading">模板加载中</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import Icon from '../Icon.vue'
const props = defineProps<{
open: boolean
slug: string
name: string
}>()
const emit = defineEmits<{ (e: 'close'): void }>()
const loaded = ref(false)
const previewSrc = computed(() => `/?preview_template=${encodeURIComponent(props.slug)}`)
watch(() => props.open, (v) => {
if (v) loaded.value = false
})
function close() {
emit('close')
}
function openInNewTab() {
window.open(previewSrc.value, '_blank')
}
</script>
<style scoped>
.tpm-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(6px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.tpm-modal {
width: min(1100px, 100%);
height: 88vh;
background: #101012;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.8);
}
.tpm-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
}
.tpm-title {
color: #fff;
font-size: 0.9rem;
font-weight: 600;
}
.tpm-actions {
display: flex;
gap: 0.4rem;
}
.tpm-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.85);
padding: 0.3rem 0.7rem;
border-radius: 6px;
font-size: 0.78rem;
cursor: pointer;
transition: all 0.15s;
}
.tpm-btn:hover {
background: rgba(255, 255, 255, 0.14);
}
.tpm-close:hover {
background: rgba(255, 80, 80, 0.2);
border-color: rgba(255, 80, 80, 0.4);
color: #ff8080;
}
.tpm-body {
flex: 1;
position: relative;
min-height: 0;
}
.tpm-frame {
width: 100%;
height: 100%;
border: none;
background: #fff;
}
.tpm-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.5);
font-size: 0.9rem;
background: #101012;
}
</style>

View File

@@ -3,7 +3,7 @@
<button type="button" class="picker-trigger" @click.stop="toggleOpen">
<span v-if="selectedUser" class="selected-text">{{ selectedUser.username }}</span>
<span v-else class="placeholder">选择操作人</span>
<span v-if="selectedUser" class="clear-btn" @click.stop="clearSelection"></span>
<span v-if="selectedUser" class="clear-btn" @click.stop="clearSelection"><Icon name="x" :size="14" /></span>
</button>
<div v-if="isOpen" class="popover-card" @click.stop>
@@ -38,6 +38,7 @@
</template>
<script setup lang="ts">
import Icon from '../Icon.vue'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { getUsersPaginated, type User } from '../../services/api'

View File

@@ -1,12 +1,12 @@
import { DEFAULT_HOMEPAGE_CONFIG_JSON } from './homepageConfig'
export type SettingFieldType = 'text' | 'textarea' | 'number' | 'menu-checkboxes' | 'email' | 'homepage-config'
export type SettingFieldType = 'text' | 'textarea' | 'number' | 'menu-checkboxes' | 'email' | 'homepage-config' | 'template-select'
export interface SettingSchemaItem {
key: string
label: string
type: SettingFieldType
group: 'site' | 'seo' | 'navigation' | 'pagination' | 'contact' | 'homepage'
group: 'site' | 'seo' | 'navigation' | 'pagination' | 'contact' | 'homepage' | 'template'
public: boolean
default: string
description: string
@@ -19,6 +19,7 @@ export const SETTING_GROUPS: { id: SettingSchemaItem['group']; label: string }[]
{ id: 'homepage', label: '首页内容' },
{ id: 'pagination', label: '分页设置' },
{ id: 'contact', label: '联系方式' },
{ id: 'template', label: '模板' },
]
export const SETTINGS_SCHEMA: SettingSchemaItem[] = [
@@ -121,6 +122,15 @@ export const SETTINGS_SCHEMA: SettingSchemaItem[] = [
default: '',
description: '公开展示的联系邮箱',
},
{
key: 'site_template',
label: '站点模板',
type: 'template-select',
group: 'template',
public: true,
default: 'classic',
description: '前台公开页面使用的模板classic 经典 / ubuntu-terminal 终端 / 自定义布局包)',
},
]
export const MENU_OPTIONS = [

View File

@@ -0,0 +1,315 @@
// 模板 mini 预览绘制配置:每套模板用原生 canvas 画迷你示意图
// draw(ctx, w, h)w=200 h=120 逻辑坐标,调用方负责 DPR 缩放
type Ctx = CanvasRenderingContext2D
export interface TemplatePreviewSpec {
slug: string
draw: (ctx: Ctx, w: number, h: number) => void
}
function rr(ctx: Ctx, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.arcTo(x + w, y, x + w, y + h, r)
ctx.arcTo(x + w, y + h, x, y + h, r)
ctx.arcTo(x, y + h, x, y, r)
ctx.arcTo(x, y, x + w, y, r)
ctx.closePath()
}
// ---------- classic 经典 ----------
function drawClassic(ctx: Ctx, w: number, h: number) {
ctx.fillStyle = '#0d0f14'
ctx.fillRect(0, 0, w, h)
// 顶部导航
ctx.fillStyle = 'rgba(255,255,255,0.08)'
ctx.fillRect(0, 0, w, 14)
ctx.fillStyle = '#d4b383'
ctx.fillRect(8, 4, 20, 6)
// hero
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.fillRect(8, 26, 120, 8)
ctx.fillStyle = 'rgba(255,255,255,0.4)'
ctx.fillRect(8, 40, 90, 4)
// 卡片行
for (let i = 0; i < 3; i++) {
ctx.fillStyle = 'rgba(255,255,255,0.1)'
rr(ctx, 8 + i * 44, 58, 38, 52, 4)
ctx.fill()
ctx.fillStyle = '#d4b383'
ctx.fillRect(12 + i * 44, 66, 30, 3)
ctx.fillStyle = 'rgba(255,255,255,0.3)'
ctx.fillRect(12 + i * 44, 74, 26, 3)
}
}
// ---------- ubuntu-terminal 终端 ----------
function drawTerminal(ctx: Ctx, w: number, h: number) {
const g = ctx.createLinearGradient(0, 0, 0, h)
g.addColorStop(0, '#300a24')
g.addColorStop(1, '#1a0517')
ctx.fillStyle = g
ctx.fillRect(0, 0, w, h)
// 标题栏
ctx.fillStyle = '#3b1d38'
ctx.fillRect(0, 0, w, 14)
ctx.fillStyle = '#e95420'
ctx.beginPath()
ctx.arc(12, 7, 3, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = 'rgba(255,255,255,0.7)'
ctx.font = '8px monospace'
ctx.fillText('nian-gao@blog:~$', 24, 10)
// 文本行
ctx.fillStyle = '#87d700'
ctx.font = '8px monospace'
ctx.fillText('$ ls blog', 12, 34)
ctx.fillStyle = 'rgba(232,232,232,0.85)'
ctx.fillText('分类:前端/设计/技术', 12, 48)
ctx.fillText('1 文章标题 [前端]', 12, 60)
ctx.fillText('2 另一篇 [设计]', 12, 72)
ctx.fillStyle = '#87d700'
ctx.fillText('$ cat 1', 12, 90)
// 光标
ctx.fillStyle = 'rgba(232,232,232,0.9)'
ctx.fillRect(52, 92, 4, 8)
}
// ---------- pixel 像素 ----------
function drawPixel(ctx: Ctx, w: number, h: number) {
ctx.fillStyle = '#0a0a0a'
ctx.fillRect(0, 0, w, h)
// 扫描线
ctx.fillStyle = 'rgba(255,255,255,0.03)'
for (let y = 0; y < h; y += 4) ctx.fillRect(0, y, w, 1)
// 8-bit 标题
ctx.fillStyle = '#39ff14'
ctx.font = 'bold 16px monospace'
ctx.fillText('BLOG', 8, 30)
ctx.fillStyle = '#ff9f00'
ctx.fillText('STAGE SELECT', 8, 44)
// 关卡方块
const colors = ['#39ff14', '#ff9f00', '#e95420', '#4fc3f7']
for (let i = 0; i < 4; i++) {
const x = 8 + i * 36
ctx.fillStyle = '#111'
ctx.fillRect(x, 56, 28, 44)
ctx.fillStyle = colors[i]
ctx.strokeStyle = colors[i]
ctx.lineWidth = 2
ctx.strokeRect(x + 2, 58, 24, 40)
ctx.fillRect(x + 6, 64, 16, 6)
ctx.fillStyle = 'rgba(255,255,255,0.7)'
ctx.font = '7px monospace'
ctx.fillText(`1-${i + 1}`, x + 8, 88)
}
}
// ---------- magazine 杂志 ----------
function drawMagazine(ctx: Ctx, w: number, h: number) {
ctx.fillStyle = '#f5f0e6'
ctx.fillRect(0, 0, w, h)
// 报头
ctx.fillStyle = '#1a1a1a'
ctx.font = 'bold 20px serif'
ctx.fillText('年 糕', w / 2 - 24, 28)
ctx.fillStyle = '#c8102e'
ctx.fillRect(0, 34, w, 2)
// 大标题
ctx.fillStyle = '#1a1a1a'
ctx.font = 'bold 13px serif'
ctx.fillText('设计与技术的', 10, 54)
ctx.fillText('深度思考', 10, 66)
// 三栏
const colW = (w - 30) / 3
for (let i = 0; i < 3; i++) {
const x = 10 + i * (colW + 5)
ctx.fillStyle = '#c8102e'
ctx.fillRect(x, 76, 22, 3)
ctx.fillStyle = 'rgba(26,26,26,0.85)'
ctx.fillRect(x, 84, colW, 3)
ctx.fillRect(x, 90, colW - 6, 3)
ctx.fillRect(x, 96, colW - 2, 3)
ctx.fillStyle = 'rgba(26,26,26,0.5)'
ctx.fillRect(x, 102, colW, 3)
}
}
// ---------- newspaper 报纸 ----------
function drawNewspaper(ctx: Ctx, w: number, h: number) {
ctx.fillStyle = '#f2ecdc'
ctx.fillRect(0, 0, w, h)
// 报头框
ctx.strokeStyle = '#222'
ctx.lineWidth = 2
ctx.strokeRect(6, 6, w - 12, 26)
ctx.fillStyle = '#222'
ctx.font = 'bold 17px serif'
ctx.fillText('年糕日报', w / 2 - 30, 25)
// 头条
ctx.fillStyle = '#222'
ctx.font = 'bold 13px serif'
ctx.fillText('科技专栏 · 头版', 10, 48)
ctx.fillRect(10, 52, w - 20, 2)
// 两栏文本
ctx.fillStyle = 'rgba(34,34,34,0.8)'
for (let i = 0; i < 4; i++) {
ctx.fillRect(10, 60 + i * 8, 80, 3)
}
ctx.fillStyle = '#222'
ctx.fillRect(100, 60, 4, 40)
ctx.fillStyle = 'rgba(34,34,34,0.7)'
for (let i = 0; i < 5; i++) {
ctx.fillRect(110, 60 + i * 8, 80, 3)
}
}
// ---------- bento 宫格 ----------
function drawBento(ctx: Ctx, w: number, h: number) {
ctx.fillStyle = '#0f1115'
ctx.fillRect(0, 0, w, h)
const cells = [
{ x: 8, y: 8, w: 104, h: 104, c: '#6c5ce7' },
{ x: 118, y: 8, w: 74, h: 50, c: '#ff6b6b' },
{ x: 118, y: 62, w: 74, h: 50, c: '#4ecdc4' },
]
for (const c of cells) {
ctx.fillStyle = c.c
rr(ctx, c.x, c.y, c.w, c.h, 12)
ctx.fill()
ctx.fillStyle = 'rgba(255,255,255,0.35)'
rr(ctx, c.x + 10, c.y + 10, c.w - 20, 8, 4)
ctx.fill()
}
}
// ---------- glass 玻璃拟态 ----------
function drawGlass(ctx: Ctx, w: number, h: number) {
const g = ctx.createLinearGradient(0, 0, w, h)
g.addColorStop(0, '#4a2d8a')
g.addColorStop(0.5, '#2d5a8a')
g.addColorStop(1, '#1f8a8a')
ctx.fillStyle = g
ctx.fillRect(0, 0, w, h)
// 光斑
ctx.fillStyle = 'rgba(255,255,255,0.12)'
ctx.beginPath()
ctx.arc(150, 24, 26, 0, Math.PI * 2)
ctx.fill()
// 玻璃卡片
ctx.fillStyle = 'rgba(255,255,255,0.18)'
ctx.strokeStyle = 'rgba(255,255,255,0.4)'
ctx.lineWidth = 1
rr(ctx, 12, 18, 110, 84, 14)
ctx.fill()
ctx.stroke()
rr(ctx, 132, 18, 56, 38, 12)
ctx.fill()
ctx.stroke()
rr(ctx, 132, 64, 56, 38, 12)
ctx.fill()
ctx.stroke()
ctx.fillStyle = 'rgba(255,255,255,0.8)'
ctx.font = '10px sans-serif'
ctx.fillText('Glass', 30, 60)
}
// ---------- retro 复古印刷 ----------
function drawRetro(ctx: Ctx, w: number, h: number) {
ctx.fillStyle = '#e8dcc0'
ctx.fillRect(0, 0, w, h)
// 纸张颗粒
ctx.fillStyle = 'rgba(120,90,40,0.06)'
for (let i = 0; i < 60; i++) {
ctx.fillRect(Math.random() * w, Math.random() * h, 1.5, 1.5)
}
// 烫金标题
const gold = ctx.createLinearGradient(0, 0, w, 0)
gold.addColorStop(0, '#b8860b')
gold.addColorStop(0.5, '#d4a017')
gold.addColorStop(1, '#8a6508')
ctx.fillStyle = gold
ctx.font = 'bold 18px serif'
ctx.fillText('YEAR BOOK', 12, 34)
// 粗边框卡片
ctx.strokeStyle = '#4a3728'
ctx.lineWidth = 3
ctx.strokeRect(10, 48, 84, 56)
ctx.fillStyle = '#4a3728'
ctx.font = 'bold 11px serif'
ctx.fillText('EST. 2024', 18, 66)
ctx.fillStyle = 'rgba(74,55,40,0.8)'
ctx.fillRect(18, 74, 66, 4)
ctx.fillRect(18, 82, 60, 4)
// 右侧印章感方块
ctx.fillStyle = '#8a6508'
ctx.fillRect(130, 60, 40, 40)
ctx.fillStyle = '#e8dcc0'
ctx.fillText('印', 140, 86)
}
// ---------- guofeng 国风水墨 ----------
function drawGuofeng(ctx: Ctx, w: number, h: number) {
ctx.fillStyle = '#f4efe4'
ctx.fillRect(0, 0, w, h)
// 远山
ctx.strokeStyle = 'rgba(40,52,70,0.65)'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(0, 92)
ctx.lineTo(40, 60)
ctx.lineTo(70, 88)
ctx.lineTo(110, 52)
ctx.lineTo(150, 86)
ctx.lineTo(200, 58)
ctx.stroke()
ctx.strokeStyle = 'rgba(40,52,70,0.3)'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(0, 100)
ctx.lineTo(50, 76)
ctx.lineTo(95, 98)
ctx.lineTo(140, 72)
ctx.lineTo(200, 96)
ctx.stroke()
// 墨字
ctx.fillStyle = '#282f33'
ctx.font = 'bold 22px serif'
ctx.fillText('墨', 16, 40)
ctx.fillStyle = 'rgba(40,47,51,0.75)'
ctx.font = 'bold 12px serif'
ctx.fillText('年糕集 · 山水', 46, 34)
// 印章
ctx.fillStyle = '#c8102e'
ctx.fillRect(150, 10, 34, 34)
ctx.fillStyle = '#f4efe4'
ctx.font = 'bold 16px serif'
ctx.fillText('年', 160, 34)
}
export const TEMPLATE_PREVIEWS: Record<string, TemplatePreviewSpec> = {
classic: { slug: 'classic', draw: drawClassic },
'ubuntu-terminal': { slug: 'ubuntu-terminal', draw: drawTerminal },
pixel: { slug: 'pixel', draw: drawPixel },
magazine: { slug: 'magazine', draw: drawMagazine },
newspaper: { slug: 'newspaper', draw: drawNewspaper },
bento: { slug: 'bento', draw: drawBento },
glass: { slug: 'glass', draw: drawGlass },
retro: { slug: 'retro', draw: drawRetro },
guofeng: { slug: 'guofeng', draw: drawGuofeng },
}
export function drawTemplatePreview(slug: string, ctx: Ctx, w: number, h: number) {
const spec = TEMPLATE_PREVIEWS[slug]
if (spec) {
spec.draw(ctx, w, h)
} else {
// 未知模板:灰底占位
ctx.fillStyle = '#1a1d24'
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = 'rgba(255,255,255,0.4)'
ctx.font = '12px sans-serif'
ctx.fillText(slug, 12, 60)
}
}

View File

@@ -0,0 +1,52 @@
<template>
<div class="min-h-screen bg-art-bg text-art-text relative flex flex-col">
<!-- Ambient canvas: dark stars / light aurora+dust -->
<StarBackground class="z-0" />
<!-- Light-mode static corner washes -->
<div class="light-atmosphere" aria-hidden="true"></div>
<!-- Global Noise opacity/blend via theme CSS vars -->
<div class="site-noise fixed inset-0 pointer-events-none z-[60] bg-noise"></div>
<!-- Header -->
<Header class="z-50" />
<!-- Mobile Menu -->
<MobileMenu class="z-50" />
<!-- Main Content -->
<main :class="[
isFullWidthPage ? 'w-full flex-1 relative z-10' : 'flex-1 pt-32 pb-20 px-6 max-w-7xl mx-auto relative z-10'
]">
<router-view />
</main>
<!-- Footer -->
<Footer class="relative z-10" />
<!-- Inquiry Modal -->
<InquiryModal class="relative z-[80]" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import Header from '../components/Header.vue'
import MobileMenu from '../components/MobileMenu.vue'
import Footer from '../components/Footer.vue'
import InquiryModal from '../components/InquiryModal.vue'
import StarBackground from '../components/StarBackground.vue'
const route = useRoute()
// 全宽布局页面(不受 max-w-7xl 限制)
const isFullWidthPage = computed(() => {
const path = route.path
return path.startsWith('/blog/')
|| path.startsWith('/works/')
|| path.startsWith('/columns')
|| path.startsWith('/videos/')
})
</script>

View File

@@ -0,0 +1,118 @@
<template>
<div class="custom-layout-root" :data-template="slug">
<div v-if="error" class="custom-layout-error">{{ error }}</div>
<template v-else-if="layoutTree">
<div
:class="layoutTree.className || ''"
:style="treeStyles"
>
<BlockNode v-for="blk in layoutTree.blocks || []" :key="blk.id" :block="blk" />
</div>
</template>
<div v-else class="custom-layout-loading">模板加载中</div>
</div>
</template>
<script setup lang="ts">
import { computed, defineComponent, h, onMounted, onUnmounted, ref, type Component } from 'vue'
import { fetchTemplateDescriptor } from '../services/api'
import { BLOCK_COMPONENTS, isKnownBlockType } from './blocks'
import type { TemplateDescriptor } from '../services/api'
const props = defineProps<{ slug: string }>()
const descriptor = ref<TemplateDescriptor | null>(null)
const error = ref('')
let skinStyleEl: HTMLStyleElement | null = null
const layoutTree = computed(() => descriptor.value?.layout || null)
const treeStyles = computed(() => {
const root = descriptor.value?.layout as Record<string, any> | undefined
return root?.styles || {}
})
// 递归渲染节点layout 容器 → div 递归;其余 → 白名单组件)
const BlockNode = defineComponent({
name: 'BlockNode',
props: {
block: { type: Object, required: true },
},
setup(props) {
const block = props.block as Record<string, any>
const type = String(block.type || '')
const blockStyles = (block.styles || {}) as Record<string, string>
if (!isKnownBlockType(type)) {
console.warn(`[custom-template] 未知块类型 "${type}",已跳过`)
return () => null
}
if (type === 'layout') {
return () => h('div', { class: block.className || '', style: blockStyles }, [
...(block.blocks || []).map((child: Record<string, any>) =>
h(BlockNode, { key: child.id, block: child })
),
])
}
const comp: Component = BLOCK_COMPONENTS[type]
return () => h(comp, { block }, [
...(block.blocks || []).map((child: Record<string, any>) =>
h(BlockNode, { key: child.id, block: child })
),
])
},
})
// 注入皮肤 CSSdata-template 隔离,卸载时移除)
async function loadSkin() {
const skinPath = descriptor.value?.skinCssPath
if (!skinPath) return
try {
const res = await fetch(skinPath)
if (!res.ok) return
const css = await res.text()
skinStyleEl = document.createElement('style')
skinStyleEl.setAttribute('data-template-skin', props.slug)
skinStyleEl.textContent = css
document.head.appendChild(skinStyleEl)
} catch {
/* 皮肤加载失败不阻塞渲染 */
}
}
onMounted(async () => {
try {
descriptor.value = await fetchTemplateDescriptor(props.slug)
await loadSkin()
} catch (e) {
error.value = `模板 "${props.slug}" 加载失败,请检查是否已启用`
console.error('[custom-template] load failed:', e)
}
})
onUnmounted(() => {
if (skinStyleEl) {
skinStyleEl.remove()
skinStyleEl = null
}
})
</script>
<style scoped>
.custom-layout-root {
min-height: 100vh;
}
.custom-layout-loading,
.custom-layout-error {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
color: rgb(var(--art-muted));
font-size: 0.95rem;
padding: 2rem;
}
</style>

View File

@@ -0,0 +1,259 @@
<template>
<div class="term-page" :style="themeVars">
<!-- 双窗口桌面终端窗口缩小+ 内容预览窗口独立窗口 -->
<div class="term-desktop" :class="{ 'has-preview': !!preview }">
<div class="term-window">
<!-- Ubuntu-style title bar -->
<div class="term-titlebar">
<div class="term-titlebar-left">
<span class="term-titlebar-dot"></span>
<span class="term-titlebar-title">{{ user }}@{{ host }}: {{ cwdDisplay }}</span>
</div>
<div class="term-titlebar-right">
<button
class="term-tb-btn"
:class="{ active: themePanelOpen }"
@click="themePanelOpen = !themePanelOpen"
title="终端配色"
>
<Icon name="palette" :size="14" />
</button>
<button class="term-tb-btn" @click="scrollToTop" title="回到顶部"></button>
<button class="term-tb-btn term-tb-close" @click="resetSession" title="重置会话"></button>
</div>
</div>
<div class="term-body-wrap">
<TerminalShell
:lines="lines"
:cwd="cwd"
:user="user"
:host="host"
:site-title="siteTitle"
@command="handleCommand"
@navigate="handleNavigate"
@hint="(t) => push({ kind: 'info', text: t })"
@back="handleBack"
/>
<!-- 配色面板 -->
<TerminalThemePanel
v-if="themePanelOpen"
:theme="theme"
@update="applyTheme"
@close="themePanelOpen = false"
/>
</div>
</div>
<!-- 右侧独立预览窗口 -->
<div v-if="preview" class="term-preview-window">
<div class="term-preview-window-bar">
<span class="term-titlebar-dot" style="background:var(--term-accent);"></span>
<span class="term-preview-window-kind">{{ previewKindLabel }}</span>
<span class="term-preview-window-title">{{ preview.title }}</span>
<button class="term-tb-btn term-tb-close" @click="closePreview" title="关闭(输入 close">
<Icon name="x" :size="14" />
</button>
</div>
<div class="term-preview-window-body">
<PreviewPanel :payload="preview" @close="closePreview" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
import TerminalShell from '../terminal/TerminalShell.vue'
import TerminalThemePanel from '../terminal/TerminalThemePanel.vue'
import PreviewPanel from '../terminal/PreviewPanel.vue'
import Icon from '../components/Icon.vue'
import { executeInput, welcomeLines } from '../terminal/commands'
import { loadPublicSettings } from '../composables/usePublicSettings'
import { loadTerminalTheme, saveTerminalTheme, themeToVars, type TerminalTheme } from '../terminal/theme'
import type { TermLine, PreviewPayload } from '../terminal/types'
const route = useRoute()
const router = useRouter()
const user = 'nian-gao'
const host = 'blog'
const lines = ref<TermLine[]>([])
const cwd = ref('')
const siteTitle = ref('年糕崽崽.Dev')
const siteAuthor = ref('')
const lastDir = ref('')
const history = ref<string[]>([])
// 主题(本地存储)
const theme = ref<TerminalTheme>(loadTerminalTheme())
const themeVars = computed(() => themeToVars(theme.value))
const themePanelOpen = ref(false)
// 右侧预览面板
const preview = ref<PreviewPayload | null>(null)
const openPreview = (p: PreviewPayload) => { preview.value = p }
const closePreview = () => { preview.value = null }
const previewKindLabel = computed(() => {
switch (preview.value?.kind) {
case 'post': return '文章'
case 'work': return '作品'
case 'snippet': return '代码'
case 'video': return '视频'
default: return '内容'
}
})
let seq = 0
const nextId = () => ++seq
function push(line: Omit<TermLine, 'id'>) {
lines.value.push({ ...line, id: nextId() })
}
function pushCmd(raw: string) {
push({ kind: 'cmd', text: raw })
}
function doClear() {
lines.value = []
}
const cwdDisplay = computed(() => (cwd.value ? `/${cwd.value}` : '~'))
function applyTheme(t: TerminalTheme) {
theme.value = t
saveTerminalTheme(t)
}
// ---------- 命令执行 ----------
async function handleCommand(raw: string) {
pushCmd(raw)
if (raw.trim()) {
history.value.push(raw.trim())
}
await executeInput(raw, {
cwd: cwd.value,
setCwd: (d) => { cwd.value = d },
getLastDir: () => lastDir.value,
setLastDir: (d) => { lastDir.value = d },
push,
clear: doClear,
router,
siteTitle: siteTitle.value,
siteAuthor: siteAuthor.value,
homeUrl: '/',
getHistory: () => history.value,
clearHistory: () => { history.value = [] },
openThemePanel: () => { themePanelOpen.value = true },
openPreview,
closePreview,
})
}
function handleBack() {
if (window.history.length > 1) {
router.back()
} else {
push({ kind: 'info', text: '没有可返回的历史记录' })
}
}
// ---------- 深链翻译 ----------
function previewQuery() {
const q = route.query.preview_template
return typeof q === 'string' ? { preview_template: q } : {}
}
function pathToCommand(path: string, query: LocationQuery): string[] | null {
if (path === '/') return ['ls']
const seg = path.split('/').filter(Boolean)
switch (seg[0]) {
case 'blog':
if (seg.length === 1) {
return [query.q ? `search ${String(query.q)}` : 'cd blog && ls']
}
if (seg.length === 2) return [`cat blog/${seg[1]}`]
return null
case 'works':
if (seg.length === 1) return ['works']
if (seg.length === 2) return [`cat works/${seg[1]}`]
return null
case 'columns':
if (seg.length === 1) return ['columns']
if (seg.length === 2) return [`cat columns/${seg[1]}`]
return null
case 'snippets':
if (seg.length === 1) return ['snippets']
if (seg.length === 2) return [`cat snippets/${seg[1]}`]
return null
case 'videos':
if (seg.length === 1) return ['videos']
if (seg[1] === 'albums' && seg.length === 3) return [`videos albums/${seg[2]}`]
if (seg.length === 2) return [`cat videos/${seg[1]}`]
return null
case 'services': return ['services']
case 'about': return ['about']
case 'tags': return ['tags']
default: return null
}
}
async function runPathCommands(path: string, query: LocationQuery) {
const cmds = pathToCommand(path, query)
if (!cmds) return
for (const cmd of cmds) {
await handleCommand(cmd)
}
}
let lastFullPath = ''
// 终端内导航(保留 preview_template 参数)
function handleNavigate(path: string) {
router.push({ path, query: previewQuery() })
}
// 路由变化(点击/前进后退)→ 翻译为命令会话
watch(() => route.fullPath, (fullPath) => {
if (!fullPath || fullPath === lastFullPath) return
lastFullPath = fullPath
void runPathCommands(route.path, route.query)
})
function resetSession() {
lines.value = []
cwd.value = ''
lastDir.value = ''
preview.value = null
void runPathCommands(route.path, route.query)
}
function scrollToTop() {
window.scrollTo({ top: 0 })
}
onMounted(async () => {
// 读取站点信息
try {
const settings = await loadPublicSettings()
siteTitle.value = settings.site_title || '年糕崽崽.Dev'
siteAuthor.value = settings.site_author || '博主'
} catch { /* fallback */ }
// 欢迎横幅 + 初始深链命令
for (const l of welcomeLines(siteTitle.value)) {
push(l)
}
lastFullPath = route.fullPath
await runPathCommands(route.path, route.query)
})
</script>

View File

@@ -0,0 +1,105 @@
<template>
<section class="custom-block custom-about-brief" :style="blockStyles">
<div v-if="loading" class="custom-loading">加载中</div>
<template v-else-if="profile">
<div class="custom-about-head">
<img v-if="profile.avatar" :src="profile.avatar" alt="" class="custom-about-avatar" />
<div>
<h2 class="custom-about-name">{{ profile.name }}</h2>
<div v-if="profile.location" class="custom-about-meta">{{ profile.location }}</div>
</div>
</div>
<div v-if="profile.bio" class="custom-about-bio">{{ profile.bio }}</div>
<div v-if="techStack.length" class="custom-about-tech">
<span v-for="t in techStack" :key="t" class="custom-about-tag">{{ t }}</span>
</div>
</template>
<div v-else class="custom-empty">暂无关于信息</div>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { fetchAboutProfile, type AboutProfile } from '../../services/api'
const props = defineProps<{ block: Record<string, any> }>()
const loading = ref(true)
const profile = ref<AboutProfile | null>(null)
const blockStyles = computed(() => ({
...(props.block.styles || {}),
}))
const techStack = computed(() =>
Array.isArray(profile.value?.techStack) ? profile.value!.techStack : []
)
onMounted(async () => {
try {
profile.value = await fetchAboutProfile()
} catch {
profile.value = null
} finally {
loading.value = false
}
})
</script>
<style scoped>
.custom-about-brief {
padding: 1.5rem;
}
.custom-loading,
.custom-empty {
color: rgb(var(--art-muted));
padding: 1rem 0;
}
.custom-about-head {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.custom-about-avatar {
width: 4.5rem;
height: 4.5rem;
border-radius: 9999px;
object-fit: cover;
border: 1px solid var(--art-border);
}
.custom-about-name {
font-size: 1.4rem;
font-weight: 700;
color: rgb(var(--art-text));
}
.custom-about-meta {
color: rgb(var(--art-muted));
font-size: 0.85rem;
}
.custom-about-bio {
color: rgb(var(--art-muted));
line-height: 1.7;
margin-bottom: 1rem;
}
.custom-about-tech {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.custom-about-tag {
border: 1px solid var(--art-border);
padding: 0.2rem 0.75rem;
border-radius: 9999px;
font-size: 0.8rem;
color: rgb(var(--art-accent));
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div class="custom-block" :style="blockStyles">
<div v-if="loading" class="custom-loading">加载中</div>
<template v-else>
<div
v-for="item in items"
:key="keyOf(item)"
class="custom-list-item"
:class="{ 'is-link': routePrefix }"
@click="onClick(item)"
>
<div class="custom-item-title">{{ titleOf(item) }}</div>
<div v-if="descOf(item)" class="custom-item-desc">{{ descOf(item) }}</div>
<div v-if="metaOf(item)" class="custom-item-meta">{{ metaOf(item) }}</div>
</div>
<div v-if="!items.length" class="custom-empty">{{ emptyText }}</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { loadDataSource } from '../resolvers'
const props = defineProps<{ block: Record<string, any> }>()
const router = useRouter()
const block = props.block
const bindings = (block.bindings || {}) as Record<string, any>
const type = String(block.type || 'posts')
const loading = ref(true)
const items = ref<any[]>([])
const emptyText = computed(() => bindings.emptyText || '暂无内容')
const blockStyles = computed(() => ({
...(block.styles || {}),
}))
// 各类型的字段与路由配置
function fieldOf(item: any, keys: string[]): string {
for (const k of keys) {
const v = item?.[k]
if (v != null && String(v) !== '') return String(v)
}
return ''
}
const titleOf = (item: any) => fieldOf(item, ['title', 'name'])
const descOf = (item: any) => fieldOf(item, ['excerpt', 'desc', 'description', 'bio'])
const metaOf = (item: any) => {
if (type === 'posts') return fieldOf(item, ['categoryName', 'date'])
if (type === 'works') return fieldOf(item, ['category', 'year'])
if (type === 'snippets') return fieldOf(item, ['type'])
return ''
}
const routePrefix = computed(() => {
if (type === 'posts') return '/blog/'
if (type === 'works') return '/works/'
if (type === 'columns') return '/columns/'
return ''
})
const keyOf = (item: any) => String(item?.id ?? Math.random())
function onClick(item: any) {
if (!routePrefix.value) return
router.push(`${routePrefix.value}${item.id}`)
}
onMounted(async () => {
try {
const source = bindings.source || type.replace(/_list$/, '') || 'posts'
const data = await loadDataSource(source as any, bindings)
if (Array.isArray(data)) {
items.value = data
} else if (data && Array.isArray(data.list)) {
items.value = data.list
} else if (data && typeof data === 'object') {
items.value = [data]
}
} catch {
items.value = []
} finally {
loading.value = false
}
})
</script>
<style scoped>
.custom-block {
padding: 0.5rem 0;
}
.custom-loading,
.custom-empty {
color: rgb(var(--art-muted));
font-size: 0.9rem;
padding: 1rem 0;
}
.custom-list-item {
padding: 0.9rem 1rem;
border-bottom: 1px solid var(--art-border);
transition: background 0.2s;
}
.custom-list-item.is-link {
cursor: pointer;
}
.custom-list-item.is-link:hover {
background: rgba(212, 179, 131, 0.06);
}
.custom-item-title {
color: rgb(var(--art-text));
font-weight: 600;
font-size: 1.02rem;
}
.is-link:hover .custom-item-title {
color: rgb(var(--art-accent));
}
.custom-item-desc {
color: rgb(var(--art-muted));
font-size: 0.9rem;
line-height: 1.6;
margin-top: 0.3rem;
}
.custom-item-meta {
color: rgb(var(--art-muted));
font-size: 0.78rem;
margin-top: 0.25rem;
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<article class="custom-block custom-post-detail" :style="blockStyles">
<div v-if="loading" class="custom-loading">加载中</div>
<template v-else-if="post">
<h1 class="custom-post-title">{{ post.title }}</h1>
<div class="custom-post-meta">
{{ post.categoryName || '未分类' }} · {{ post.date }}
<span v-if="post.readCount"> · 阅读 {{ post.readCount }}</span>
</div>
<div class="custom-post-content" v-html="html"></div>
</template>
<div v-else class="custom-empty">文章不存在</div>
</article>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { fetchPost } from '../../services/api'
import { renderMarkdown } from '../../utils/markdownRenderer'
import type { Post } from '../../services/api'
const props = defineProps<{ block: Record<string, any> }>()
const bindings = (props.block.bindings || {}) as Record<string, any>
const loading = ref(true)
const post = ref<Post | null>(null)
const html = ref('')
const blockStyles = computed(() => ({
...(props.block.styles || {}),
}))
onMounted(async () => {
try {
const id = bindings.id
const p = await fetchPost(String(id ?? ''))
if (p && (p.title || p.content)) {
post.value = p
html.value = await renderMarkdown(p.content || p.excerpt || '')
}
} catch {
post.value = null
} finally {
loading.value = false
}
})
</script>
<style scoped>
.custom-post-detail {
padding: 1rem 1.5rem;
}
.custom-loading,
.custom-empty {
color: rgb(var(--art-muted));
padding: 1rem 0;
}
.custom-post-title {
font-size: 1.8rem;
font-weight: 700;
color: rgb(var(--art-text));
margin-bottom: 0.5rem;
}
.custom-post-meta {
color: rgb(var(--art-muted));
font-size: 0.85rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--art-border);
margin-bottom: 1.2rem;
}
.custom-post-content {
color: rgb(var(--art-text));
line-height: 1.85;
font-size: 1.02rem;
}
.custom-post-content :deep(h1),
.custom-post-content :deep(h2),
.custom-post-content :deep(h3) {
margin: 1.3em 0 0.6em;
line-height: 1.4;
}
.custom-post-content :deep(p) {
margin: 0.8em 0;
}
.custom-post-content :deep(a) {
color: rgb(var(--art-accent));
}
.custom-post-content :deep(img) {
max-width: 100%;
border-radius: 0.5rem;
}
.custom-post-content :deep(blockquote) {
border-left: 3px solid rgb(var(--art-accent));
margin: 1em 0;
padding: 0.3em 1.2em;
color: rgb(var(--art-muted));
}
.custom-post-content :deep(pre) {
overflow-x: auto;
}
</style>

View File

@@ -0,0 +1,155 @@
import { defineComponent, h, onMounted, ref } from 'vue'
import { resolveSetting, sanitizeStaticHtml } from '../resolvers'
type BindingMap = Record<string, any>
function useSettingBinding(key: string | undefined) {
const v = ref('')
if (key) {
onMounted(async () => {
v.value = await resolveSetting(key.replace(/^settings\./, ''))
})
}
return v
}
/**
* 简单展示块工厂setupFn 在组件 setup 阶段执行(可注册 hooks
* 返回渲染函数。
*/
export function basicBlock(
setupFn: (bindings: BindingMap) => () => ReturnType<typeof h>
) {
return defineComponent({
name: 'BasicBlock',
props: {
block: { type: Object, required: true },
},
setup(props) {
const bindings = (props.block as any).bindings || {}
return setupFn(bindings)
},
})
}
export const BlockDivider = basicBlock(() => () =>
h('hr', { style: { border: 'none', borderTop: '1px solid var(--art-border)', margin: '1.5rem 0' } })
)
export const BlockImage = basicBlock((b) => () =>
h('img', {
src: b.src || '',
alt: b.alt || '',
style: {
maxWidth: '100%',
borderRadius: '0.5rem',
margin: '1rem 0',
...(b.width ? { width: b.width } : {}),
},
})
)
export const BlockLink = basicBlock((b) => () =>
h('a', {
href: b.href || '#',
target: '_blank',
rel: 'noopener',
style: { color: 'rgb(var(--art-accent))', textDecoration: 'underline' },
}, b.text || b.href || '')
)
export const BlockStaticHtml = basicBlock((b) => () =>
h('div', { innerHTML: sanitizeStaticHtml(String(b.html || '')) })
)
export const BlockHero = basicBlock((b) => {
const title = useSettingBinding(b.title)
const subtitle = useSettingBinding(b.subtitle)
return () => h('div', { style: { textAlign: 'center', padding: '3rem 1rem 2rem' } }, [
h('h1', {
style: {
fontSize: 'clamp(2rem, 5vw, 3.5rem)',
fontWeight: 700,
letterSpacing: '0.02em',
marginBottom: '0.75rem',
color: 'rgb(var(--art-text))',
},
}, title.value || ''),
h('p', {
style: {
color: 'rgb(var(--art-muted))',
fontSize: '1.1rem',
maxWidth: '36rem',
margin: '0 auto',
lineHeight: 1.7,
},
}, subtitle.value || ''),
b.buttonText && b.buttonHref
? h('a', {
href: b.buttonHref,
style: {
display: 'inline-block',
marginTop: '1.5rem',
padding: '0.6rem 1.6rem',
borderRadius: '9999px',
background: 'rgb(var(--art-accent))',
color: '#000',
textDecoration: 'none',
fontWeight: 600,
},
}, b.buttonText)
: null,
])
})
export const BlockHeader = basicBlock((b) => {
const title = useSettingBinding(b.title || 'site_title')
return () => h('header', {
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '1rem 1.5rem',
borderBottom: '1px solid var(--art-border)',
position: 'sticky',
top: 0,
background: 'var(--art-nav-bg)',
backdropFilter: 'blur(20px)',
zIndex: 40,
},
}, [
h('span', { style: { fontWeight: 700, color: 'rgb(var(--art-text))' } }, title.value || ''),
])
})
export const BlockNav = basicBlock((b) => {
const links: Array<{ label: string; href: string }> = Array.isArray(b.links) ? b.links : []
return () => h('nav', {
style: {
display: 'flex',
gap: '1.25rem',
padding: '0.75rem 1.5rem',
borderBottom: '1px solid var(--art-border)',
flexWrap: 'wrap',
},
}, links.map(l =>
h('a', {
href: l.href || '#',
style: { color: 'rgb(var(--art-muted))', textDecoration: 'none', fontSize: '0.9rem' },
}, l.label || l.href)
))
})
export const BlockFooter = basicBlock((b) => {
const text = useSettingBinding(b.text || 'footer_icp')
return () => h('footer', {
style: {
textAlign: 'center',
padding: '2rem 1rem',
color: 'rgb(var(--art-muted))',
fontSize: '0.85rem',
borderTop: '1px solid var(--art-border)',
marginTop: '3rem',
},
}, text.value || '')
})

View File

@@ -0,0 +1,39 @@
import type { Component } from 'vue'
import {
BlockDivider, BlockImage, BlockLink, BlockStaticHtml,
BlockHero, BlockHeader, BlockNav, BlockFooter,
} from './basic'
import BlockDataList from './BlockDataList.vue'
import BlockPostDetail from './BlockPostDetail.vue'
import BlockAboutBrief from './BlockAboutBrief.vue'
/**
* 布局块白名单:类型 → 内部组件。
* 渲染引擎仅允许渲染此映射中的类型,杜绝任意字符串 component :is。
* 'layout' 为容器类型由渲染器特判处理div 递归渲染子块)。
*/
export const BLOCK_COMPONENTS: Record<string, Component> = {
header: BlockHeader,
nav: BlockNav,
hero: BlockHero,
footer: BlockFooter,
image: BlockImage,
link: BlockLink,
divider: BlockDivider,
static_html: BlockStaticHtml,
post_list: BlockDataList,
post_card: BlockDataList,
post_detail: BlockPostDetail,
works_list: BlockDataList,
work_detail: BlockDataList,
columns_list: BlockDataList,
snippets_list: BlockDataList,
tags_list: BlockDataList,
categories_list: BlockDataList,
about_brief: BlockAboutBrief,
}
export function isKnownBlockType(type: string): boolean {
return type === 'layout' || type in BLOCK_COMPONENTS
}

View File

@@ -0,0 +1,53 @@
import { loadPublicSettings } from '../composables/usePublicSettings'
import {
fetchPosts, fetchPost, fetchWorks, fetchCategories,
fetchSnippets, fetchColumns, fetchTags, fetchAboutProfile,
type PublicSettings,
} from '../services/api'
export type DataSource =
| 'posts' | 'post' | 'works' | 'categories'
| 'snippets' | 'columns' | 'tags' | 'about'
// 解析 bindings 中的 settings 值("settings.site_title"
export async function resolveSetting(key: string): Promise<string> {
const s = await loadPublicSettings() as PublicSettings
const v = (s as Record<string, unknown>)[key]
return v == null ? '' : String(v)
}
// 加载数据源
export async function loadDataSource(source: DataSource, params: Record<string, any> = {}): Promise<any> {
switch (source) {
case 'posts': {
const pageSize = Number(params.pageSize) || 10
const res = await fetchPosts(undefined, undefined, undefined, undefined, 1, pageSize)
return res?.list || []
}
case 'post':
return fetchPost(String(params.id ?? ''))
case 'works':
return fetchWorks()
case 'categories':
return fetchCategories()
case 'snippets':
return fetchSnippets()
case 'columns':
return fetchColumns()
case 'tags':
return fetchTags()
case 'about':
return fetchAboutProfile()
default:
return null
}
}
/** 轻量 HTML 清洗:移除 script/事件属性,防止注入脚本 */
export function sanitizeStaticHtml(html: string): string {
if (!html) return ''
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
.replace(/javascript:/gi, '')
}

View File

@@ -1,6 +1,7 @@
import { createApp } from "vue"
import App from "./App.vue"
import "./style.css"
import "./terminal/terminal.css"
import "highlight.js/styles/atom-one-dark.css"
import router from "./router"

View File

@@ -51,7 +51,7 @@
</div>
<div v-if="filteredCategories.length === 0" class="p-16 text-center">
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl">📂</div>
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl"><Icon name="folder-open" :size="28" /></div>
<h3 class="text-white font-medium mb-2">暂无分类</h3>
<p class="text-art-muted text-sm mb-6">创建分类来整理您的附件</p>
<router-link to="/admin/attachment-categories/create" class="admin-btn-secondary inline-flex items-center gap-2">
@@ -63,6 +63,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getAttachmentCategories, deleteAttachmentCategory, AttachmentCategory } from '../../services/api'

View File

@@ -53,7 +53,7 @@
</div>
<div v-if="filteredCategories.length === 0" class="p-16 text-center">
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl">📂</div>
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl"><Icon name="folder-open" :size="28" /></div>
<h3 class="text-white font-medium mb-2">暂无分类</h3>
<p class="text-art-muted text-sm mb-6">创建分类来整理您的文章</p>
<router-link to="/admin/categories/create" class="admin-btn-secondary inline-flex items-center gap-2">
@@ -65,6 +65,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getAdminCategories, deleteCategory, Category } from '../../services/api'

View File

@@ -4,7 +4,7 @@
<div class="action-bar">
<button class="btn-primary" @click="router.push('/admin/code-types/create')">
<span class="btn-icon"></span>
<span class="btn-icon"><Icon name="plus" :size="14" /></span>
新建类型
</button>
</div>
@@ -41,8 +41,8 @@
<span class="category-badge">{{ categoryLabel(item.category) }}</span>
</td>
<td class="actions">
<button class="btn-edit" @click="router.push(`/admin/code-types/${item.id}/edit`)"></button>
<button class="btn-delete" @click="handleDelete(item.id)">🗑</button>
<button class="btn-edit" @click="router.push(`/admin/code-types/${item.id}/edit`)"><Icon name="pencil" :size="14" /></button>
<button class="btn-delete" @click="handleDelete(item.id)"><Icon name="trash-2" :size="14" /></button>
</td>
</tr>
</tbody>
@@ -53,6 +53,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getAdminCodeTypes, deleteCodeType, CODE_TYPE_CATEGORY_LABELS, type CodeType } from '../../services/api'

View File

@@ -69,7 +69,7 @@
</div>
<div v-if="filteredColumns.length === 0" class="p-16 text-center">
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl">📚</div>
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl"><Icon name="library" :size="28" /></div>
<h3 class="text-white font-medium mb-2">暂无专栏</h3>
<p class="text-art-muted text-sm mb-6">创建专栏来组织系列文章</p>
<router-link to="/admin/columns/create" class="admin-btn-secondary inline-flex items-center gap-2">
@@ -81,6 +81,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getAdminColumns, deleteColumn, Column } from '../../services/api'

View File

@@ -61,7 +61,7 @@
</td>
<td class="table-cell actions">
<button class="btn-delete" @click="handleDelete(s.id)" title="删除">
🗑
<Icon name="trash-2" :size="14" />
</button>
</td>
</tr>
@@ -77,6 +77,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { ref, computed, onMounted } from 'vue'
import { API_BASE, authFetchJson, getAuthHeaders, type EmailSuffix } from '../../services/api'
import { useToast } from '../../composables/useToast'

View File

@@ -5,7 +5,7 @@
<!-- Action Buttons -->
<div class="action-bar">
<button class="btn-primary" @click="router.push('/admin/partners/create')">
<span class="btn-icon"></span>
<span class="btn-icon"><Icon name="plus" :size="14" /></span>
新建合作伙伴
</button>
</div>
@@ -49,10 +49,10 @@
<td class="table-cell">{{ new Date(p.createdAt).toLocaleDateString() }}</td>
<td class="table-cell actions">
<button class="btn-edit" @click="router.push(`/admin/partners/${p.id}/edit`)" title="编辑">
<Icon name="pencil" :size="14" />
</button>
<button class="btn-delete" @click="handleDelete(p.id)" title="删除">
🗑
<Icon name="trash-2" :size="14" />
</button>
</td>
</tr>
@@ -68,6 +68,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { fetchPartners, deletePartner, type Partner } from '../../services/api'

View File

@@ -24,7 +24,7 @@
@click="openHistoryModal"
title="查看历史版本"
>
📜 历史版本
<Icon name="history" :size="14" class="inline-block align-middle mr-1" /> 历史版本
</button>
<button
v-if="isEditing"
@@ -33,7 +33,7 @@
@click="openAccessLogModal"
title="查看访问记录"
>
📊 访问记录
<Icon name="bar-chart-3" :size="14" class="inline-block align-middle mr-1" /> 访问记录
</button>
<button type="button" class="admin-btn-secondary" @click="handleCancel">
取消
@@ -72,7 +72,7 @@
<div class="w-80 space-y-6 overflow-y-auto pr-1 h-full custom-scrollbar pb-6">
<div class="admin-card p-6 space-y-6">
<h3 class="text-sm font-medium text-white border-b border-white/5 pb-4 mb-4 flex items-center gap-2">
<span class="text-art-accent">📝</span> 文章属性
<span class="text-art-accent"><Icon name="file-text" :size="14" /></span> 文章属性
</h3>
<!-- Title Field -->
@@ -280,6 +280,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
// 引入 md-editor-v3

View File

@@ -157,7 +157,7 @@
</div>
<div v-if="posts.length === 0 && !loading" class="p-16 text-center">
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl">📝</div>
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl"><Icon name="file-text" :size="28" /></div>
<h3 class="text-white font-medium mb-2">暂无文章</h3>
<p class="text-art-muted text-sm mb-6">{{ searchQuery ? '没有找到匹配的文章' : '开始您的创作之旅吧' }}</p>
<router-link v-if="!searchQuery" to="/admin/posts/create" class="admin-btn-secondary inline-flex items-center gap-2">
@@ -229,6 +229,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { ref, onMounted, computed } from 'vue'
import { getAdminPosts, deletePost as deletePostApi, Post, togglePostStatus, getAdminCategories, getAdminColumns } from '../../services/api'
import { useToast } from '../../composables/useToast'

View File

@@ -4,7 +4,7 @@
<div class="action-bar">
<button class="btn-primary" @click="router.push('/admin/ppt-templates/create')">
<span class="btn-icon"></span>
<span class="btn-icon"><Icon name="plus" :size="14" /></span>
新建模板
</button>
</div>
@@ -41,7 +41,7 @@
</td>
<td class="table-cell actions">
<button class="btn-edit" title="编辑" @click="router.push(`/admin/ppt-templates/${t.id}/edit`)">
<Icon name="pencil" :size="14" />
</button>
<button
v-if="!t.isSystem"
@@ -49,7 +49,7 @@
title="删除"
@click="handleDelete(t.id)"
>
🗑
<Icon name="trash-2" :size="14" />
</button>
</td>
</tr>
@@ -64,6 +64,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from '../../composables/useToast'

View File

@@ -16,7 +16,7 @@
v-model="searchQuery"
@input="handleSearch"
/>
<button class="search-btn">🔍</button>
<button class="search-btn"><Icon name="search" :size="14" /></button>
</div>
<!-- Roles Table -->
@@ -39,10 +39,10 @@
<td>{{ formatDate(role.createdAt) }}</td>
<td class="actions">
<button class="action-btn edit" @click="router.push(`/admin/roles/${role.id}/edit`)" title="编辑">
<Icon name="pencil" :size="14" />
</button>
<button class="action-btn delete" @click="handleDelete(role.id)" title="删除">
🗑
<Icon name="trash-2" :size="14" />
</button>
</td>
</tr>
@@ -51,7 +51,7 @@
<!-- Empty State -->
<div class="empty-state" v-if="filteredRoles.length === 0">
<div class="empty-icon">🔒</div>
<div class="empty-icon"><Icon name="lock" :size="28" /></div>
<h3>暂无角色数据</h3>
<p>点击右上角按钮创建新角色</p>
</div>
@@ -73,6 +73,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from '../../composables/useToast'

View File

@@ -83,6 +83,31 @@
:placeholder="schema.default || 'example@domain.com'"
/>
<div v-else-if="schema.type === 'template-select'" class="template-picker">
<button
v-for="tpl in templateOptions"
:key="tpl.slug"
type="button"
class="template-opt"
:class="{ active: formValues[schema.key] === tpl.slug }"
@click="formValues[schema.key] = tpl.slug"
>
<div class="tpl-preview">
<TemplatePreviewCanvas :slug="tpl.slug" :width="180" :height="108" />
</div>
<div class="tpl-row">
<span class="tpl-name">{{ tpl.name }}</span>
<span class="tpl-badge" :class="tpl.type === 'builtin' ? 'builtin' : 'custom'">
{{ tpl.type === 'builtin' ? '内置' : '自定义' }}
</span>
</div>
<div class="tpl-slug font-mono">{{ tpl.slug }}</div>
<div v-if="formValues[schema.key] === tpl.slug" class="tpl-check">
<Icon name="check" :size="14" />
</div>
</button>
</div>
<input
v-else
v-model="formValues[schema.key]"
@@ -136,16 +161,20 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ref, reactive, onMounted, computed, watch } from 'vue'
import {
getSettings,
createSetting,
batchUpdateSettings,
deleteSetting as deleteSettingApi,
fetchTemplates,
type Setting,
type TemplateMeta,
} from '../../services/api'
import { useToast } from '../../composables/useToast'
import { usePublicSettings } from '../../composables/usePublicSettings'
import Icon from '../../components/Icon.vue'
import TemplatePreviewCanvas from '../../components/admin/TemplatePreviewCanvas.vue'
import EmailAutocomplete from '../../components/ui/EmailAutocomplete.vue'
import HomepageConfigForm from '../../components/admin/HomepageConfigForm.vue'
import {
@@ -162,15 +191,36 @@ const { invalidate: invalidatePublicSettings } = usePublicSettings()
const loading = ref(true)
const savingAll = ref(false)
const savingGroup = ref<string | null>(null)
const activeTab = ref<SettingSchemaItem['group'] | 'custom'>('site')
// Tab 记忆localStorage 保存最后选择,刷新恢复
const TAB_STORAGE_KEY = 'admin-settings-active-tab'
const validTabIds = [...SETTING_GROUPS.map(g => g.id), 'custom'] as Array<SettingSchemaItem['group'] | 'custom'>
function loadInitialTab(): SettingSchemaItem['group'] | 'custom' {
try {
const saved = localStorage.getItem(TAB_STORAGE_KEY) as SettingSchemaItem['group'] | 'custom' | null
return saved && (validTabIds as string[]).includes(saved) ? saved : 'site'
} catch {
return 'site'
}
}
const activeTab = ref<SettingSchemaItem['group'] | 'custom'>(loadInitialTab())
watch(activeTab, (tab) => {
try { localStorage.setItem(TAB_STORAGE_KEY, tab) } catch { /* ignore */ }
})
const settings = ref<Setting[]>([])
const selectedMenus = ref<string[]>([])
const formValues = reactive<Record<string, string>>({})
const newForm = ref({ keyName: '', value: '', description: '' })
const templateOptions = ref<TemplateMeta[]>([])
const schemaKeys = getSchemaKeys()
const loadTemplateOptions = async () => {
templateOptions.value = await fetchTemplates()
}
const tabs = computed(() => [
...SETTING_GROUPS.map(g => ({ id: g.id as SettingSchemaItem['group'] | 'custom', label: g.label })),
{ id: 'custom' as const, label: '自定义' },
@@ -211,7 +261,6 @@ const fetchSettings = async () => {
loading.value = true
try {
settings.value = await getSettings()
for (const schema of SETTINGS_SCHEMA) {
if (!settings.value.find(s => s.keyName === schema.key)) {
await createSetting({
@@ -298,5 +347,97 @@ const deleteSetting = async (keyName: string) => {
}
}
onMounted(fetchSettings)
onMounted(() => {
void fetchSettings()
void loadTemplateOptions()
})
</script>
<style scoped>
.template-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 0.75rem;
margin-top: 0.5rem;
}
.tpl-preview {
margin-bottom: 0.6rem;
border-radius: 7px;
overflow: hidden;
background: #000;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.template-opt {
position: relative;
text-align: left;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 0.9rem 1rem;
cursor: pointer;
transition: all 0.18s;
}
.template-opt:hover {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.05);
}
.template-opt.active {
border-color: rgb(var(--art-accent));
background: rgba(212, 179, 131, 0.08);
box-shadow: 0 0 0 1px rgb(var(--art-accent));
}
.tpl-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.3rem;
}
.tpl-name {
color: #fff;
font-weight: 600;
font-size: 0.92rem;
}
.tpl-badge {
font-size: 0.68rem;
padding: 1px 7px;
border-radius: 9999px;
}
.tpl-badge.builtin {
background: rgba(212, 179, 131, 0.15);
color: rgb(var(--art-accent));
border: 1px solid rgba(212, 179, 131, 0.35);
}
.tpl-badge.custom {
background: rgba(96, 165, 250, 0.12);
color: #60a5fa;
border: 1px solid rgba(96, 165, 250, 0.3);
}
.tpl-slug {
color: rgba(255, 255, 255, 0.4);
font-size: 0.75rem;
}
.tpl-check {
position: absolute;
top: 0.7rem;
right: 0.7rem;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgb(var(--art-accent));
color: #000;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -68,7 +68,7 @@
<div class="w-full lg:w-80 shrink-0 overflow-y-auto custom-scrollbar min-h-0 lg:max-h-full">
<div class="admin-card p-5 md:p-6 space-y-5">
<h3 class="text-sm font-medium text-white border-b border-white/5 pb-3 flex items-center gap-2">
<span class="text-art-accent"></span> 片段配置
<span class="text-art-accent"><Icon name="settings" :size="14" /></span> 片段配置
</h3>
<div class="space-y-2">
@@ -127,6 +127,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { MdEditor } from 'md-editor-v3'

View File

@@ -5,7 +5,7 @@
<!-- Action Buttons -->
<div class="action-bar">
<button class="btn-primary" @click="router.push('/admin/tags/create')">
<span class="btn-icon"></span>
<span class="btn-icon"><Icon name="plus" :size="14" /></span>
新建标签
</button>
</div>
@@ -39,10 +39,10 @@
<td class="table-cell">{{ tag.updatedAt }}</td>
<td class="table-cell actions">
<button class="btn-edit" @click="router.push(`/admin/tags/${tag.id}/edit`)" title="编辑">
<Icon name="pencil" :size="14" />
</button>
<button class="btn-delete" @click="handleDeleteTag(tag.id)" title="删除">
🗑
<Icon name="trash-2" :size="14" />
</button>
</td>
</tr>
@@ -58,6 +58,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { adminGetTags, deleteTag, type Tag } from '../../services/api'

View File

@@ -0,0 +1,450 @@
<template>
<div class="template-manage-container">
<h1 class="page-title">模板管理</h1>
<!-- 上传区 -->
<div
class="upload-zone"
:class="{ dragging }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<input ref="fileInput" type="file" accept=".zip" class="hidden-input" @change="onFileChange" />
<div class="upload-icon"><Icon name="package" :size="34" /></div>
<p class="upload-title">拖拽 zip 布局包到此处 <span class="upload-link" @click="fileInput?.click()">点击选择文件</span></p>
<p class="upload-hint">zip 需包含 manifest.json以及 layout.json skin.css仅支持 json/css/字体/图片 js/html/svg不超过 10MB</p>
<div v-if="uploading" class="uploading-text">上传中</div>
<div v-if="uploadError" class="upload-error">{{ uploadError }}</div>
</div>
<!-- 模板列表 -->
<div v-if="loading" class="loading-text">加载中</div>
<div v-else class="template-grid">
<div
v-for="t in templates"
:key="t.id"
class="template-card"
:class="{ 'is-current': t.isCurrent, 'is-disabled': !t.isActive }"
>
<div class="card-preview">
<TemplatePreviewCanvas :slug="t.slug" :width="200" :height="120" />
</div>
<div class="card-head">
<span class="card-name">{{ t.name }}</span>
<span class="card-type" :class="t.type === 'builtin' ? 'type-builtin' : 'type-custom'">
{{ t.type === 'builtin' ? '内置' : '自定义' }}
</span>
<span v-if="t.isCurrent" class="card-current">当前</span>
</div>
<div class="card-slug font-mono">{{ t.slug }}</div>
<p class="card-desc">{{ t.description || '暂无描述' }}</p>
<div class="card-actions">
<button class="act-btn act-primary" :disabled="t.isCurrent" @click="handleActivate(t)">
{{ t.isCurrent ? '使用中' : '设为当前' }}
</button>
<button class="act-btn" :disabled="t.isSystem" @click="handleToggleActive(t)">
{{ t.isActive ? '停用' : '启用' }}
</button>
<button class="act-btn" @click="handlePreview(t)">
<Icon name="eye" :size="14" class="act-icon" />预览
</button>
<button class="act-btn" @click="handleDetail(t)">详情</button>
<button
class="act-btn act-danger"
:disabled="t.isSystem || t.isCurrent"
@click="handleDelete(t)"
>
删除
</button>
</div>
<div class="card-status">
<span class="status-dot" :class="t.isActive ? 'status-on' : 'status-off'"></span>
{{ t.isActive ? '已启用' : '已停用' }}
</div>
</div>
</div>
<div v-if="!loading && !templates.length" class="empty-text">暂无模板</div>
<!-- iframe 预览模态框 -->
<TemplatePreviewModal
:open="previewOpen"
:slug="previewSlug"
:name="previewName"
@close="previewOpen = false"
/>
<!-- 模板详情抽屉 -->
<TemplateDetailDrawer
:open="detailOpen"
:template="detailTemplate"
@close="detailOpen = false"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useToast } from '../../composables/useToast'
import Icon from '../../components/Icon.vue'
import TemplatePreviewModal from '../../components/admin/TemplatePreviewModal.vue'
import TemplateDetailDrawer from '../../components/admin/TemplateDetailDrawer.vue'
import TemplatePreviewCanvas from '../../components/admin/TemplatePreviewCanvas.vue'
import {
getAdminTemplates, uploadTemplate, updateTemplate,
activateTemplate, deleteTemplate, type TemplateMeta,
} from '../../services/api'
const toast = useToast()
const templates = ref<TemplateMeta[]>([])
const loading = ref(false)
const uploading = ref(false)
const uploadError = ref('')
const dragging = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const loadTemplates = async () => {
loading.value = true
try {
templates.value = await getAdminTemplates()
} catch (err) {
console.error(err)
toast.error('加载模板失败')
} finally {
loading.value = false
}
}
const notifySettingsChanged = () => {
// 触发前台重新拉取公开设置usePublicSettings 已有事件失效机制)
window.dispatchEvent(new Event('public-settings-changed'))
}
const handleActivate = async (t: TemplateMeta) => {
if (t.isCurrent) return
try {
await activateTemplate(t.id)
toast.success(`已切换到「${t.name}`)
notifySettingsChanged()
await loadTemplates()
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : '切换失败')
await loadTemplates()
}
}
const handleToggleActive = async (t: TemplateMeta) => {
try {
await updateTemplate(t.id, { isActive: !t.isActive })
toast.success(t.isActive ? '已停用' : '已启用')
await loadTemplates()
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : '操作失败')
await loadTemplates()
}
}
const handlePreview = (t: TemplateMeta) => {
previewSlug.value = t.slug
previewName.value = t.name
previewOpen.value = true
}
const handleDetail = (t: TemplateMeta) => {
detailTemplate.value = t
detailOpen.value = true
}
const previewOpen = ref(false)
const previewSlug = ref('')
const previewName = ref('')
const detailOpen = ref(false)
const detailTemplate = ref<TemplateMeta | null>(null)
const handleDelete = async (t: TemplateMeta) => {
if (!confirm(`确定删除模板「${t.name}」?其文件将被一并删除。`)) return
try {
await deleteTemplate(t.id)
toast.success('已删除')
await loadTemplates()
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : '删除失败')
await loadTemplates()
}
}
const doUpload = async (file: File) => {
uploadError.value = ''
uploading.value = true
try {
const meta = await uploadTemplate(file)
toast.success(`模板「${meta.name}」上传成功,请启用后使用`)
await loadTemplates()
} catch (err: unknown) {
uploadError.value = err instanceof Error ? err.message : '上传失败'
} finally {
uploading.value = false
if (fileInput.value) fileInput.value.value = ''
}
}
const onFileChange = (e: Event) => {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) void doUpload(file)
}
const onDrop = (e: DragEvent) => {
dragging.value = false
const file = e.dataTransfer?.files?.[0]
if (file) void doUpload(file)
}
onMounted(() => {
void loadTemplates()
})
</script>
<style scoped>
.template-manage-container {
width: 100%;
padding-bottom: 2rem;
}
.page-title {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #fff;
}
.upload-zone {
border: 2px dashed rgba(255, 255, 255, 0.15);
border-radius: 12px;
padding: 2rem 1.5rem;
text-align: center;
margin-bottom: 2rem;
transition: all 0.2s;
background: rgba(255, 255, 255, 0.02);
}
.upload-zone.dragging {
border-color: rgb(var(--art-accent));
background: rgba(212, 179, 131, 0.06);
}
.hidden-input {
display: none;
}
.upload-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.upload-title {
color: #fff;
font-size: 0.95rem;
margin-bottom: 0.4rem;
}
.upload-link {
color: rgb(var(--art-accent));
cursor: pointer;
text-decoration: underline;
}
.upload-hint {
color: rgba(255, 255, 255, 0.45);
font-size: 0.8rem;
line-height: 1.6;
max-width: 42rem;
margin: 0 auto;
}
.uploading-text {
color: rgb(var(--art-accent));
margin-top: 0.6rem;
font-size: 0.9rem;
}
.upload-error {
color: #ff6b6b;
margin-top: 0.6rem;
font-size: 0.85rem;
word-break: break-all;
}
.loading-text,
.empty-text {
color: rgba(255, 255, 255, 0.5);
text-align: center;
padding: 2rem 0;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.template-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 1.2rem;
transition: all 0.2s;
position: relative;
}
.card-preview {
margin-bottom: 0.8rem;
border-radius: 8px;
overflow: hidden;
background: #000;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.template-card:hover {
border-color: rgba(255, 255, 255, 0.18);
transform: translateY(-2px);
}
.template-card.is-current {
border-color: rgb(var(--art-accent));
box-shadow: 0 0 0 1px rgb(var(--art-accent)), 0 12px 30px -12px rgba(212, 179, 131, 0.3);
}
.template-card.is-disabled {
opacity: 0.55;
}
.card-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.card-name {
color: #fff;
font-weight: 600;
font-size: 1.05rem;
}
.card-type {
font-size: 0.7rem;
padding: 1px 8px;
border-radius: 9999px;
}
.type-builtin {
background: rgba(212, 179, 131, 0.15);
color: rgb(var(--art-accent));
border: 1px solid rgba(212, 179, 131, 0.35);
}
.type-custom {
background: rgba(96, 165, 250, 0.12);
color: #60a5fa;
border: 1px solid rgba(96, 165, 250, 0.3);
}
.card-current {
background: rgb(var(--art-accent));
color: #000;
font-size: 0.7rem;
font-weight: 600;
padding: 1px 8px;
border-radius: 9999px;
}
.card-slug {
color: rgba(255, 255, 255, 0.4);
font-size: 0.78rem;
margin-bottom: 0.6rem;
}
.card-desc {
color: rgba(255, 255, 255, 0.6);
font-size: 0.85rem;
line-height: 1.6;
min-height: 2.6em;
margin-bottom: 0.8rem;
}
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.act-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.85);
padding: 0.35rem 0.8rem;
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
}
.act-icon {
flex-shrink: 0;
}
.act-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.12);
}
.act-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.act-primary {
background: rgb(var(--art-accent));
border-color: rgb(var(--art-accent));
color: #000;
font-weight: 600;
}
.act-primary:hover:not(:disabled) {
opacity: 0.9;
}
.act-danger:hover:not(:disabled) {
background: rgba(255, 107, 107, 0.15);
border-color: #ff6b6b;
color: #ff6b6b;
}
.card-status {
position: absolute;
top: 1.2rem;
right: 1.2rem;
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.72rem;
color: rgba(255, 255, 255, 0.45);
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.status-on {
background: #4ade80;
}
.status-off {
background: #6b7280;
}
</style>

View File

@@ -5,7 +5,7 @@
<!-- Action Buttons -->
<div class="action-bar">
<button class="btn-primary" @click="router.push('/admin/testimonials/create')">
<span class="btn-icon"></span>
<span class="btn-icon"><Icon name="plus" :size="14" /></span>
新建评价
</button>
</div>
@@ -60,10 +60,10 @@
<td class="table-cell">{{ new Date(t.createdAt).toLocaleDateString() }}</td>
<td class="table-cell actions">
<button class="btn-edit" @click="router.push(`/admin/testimonials/${t.id}/edit`)" title="编辑">
<Icon name="pencil" :size="14" />
</button>
<button class="btn-delete" @click="handleDelete(t.id)" title="删除">
🗑
<Icon name="trash-2" :size="14" />
</button>
</td>
</tr>
@@ -79,6 +79,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { fetchTestimonials, deleteTestimonial, type Testimonial } from '../../services/api'

View File

@@ -73,10 +73,10 @@
<td>{{ formatDate(user.createdAt) }}</td>
<td class="actions">
<button class="action-btn edit" @click="router.push(`/admin/users/${user.id}/edit`)" title="编辑">
<Icon name="pencil" :size="14" />
</button>
<button class="action-btn delete" @click="handleDelete(user.id)" title="删除">
🗑
<Icon name="trash-2" :size="14" />
</button>
</td>
</tr>
@@ -85,7 +85,7 @@
<!-- Empty State -->
<div class="empty-state" v-if="!isLoading && users.length === 0">
<div class="empty-icon">👥</div>
<div class="empty-icon"><Icon name="users" :size="28" /></div>
<h3>暂无用户数据</h3>
<p>点击右上角按钮创建新用户</p>
</div>
@@ -107,6 +107,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from '../../composables/useToast'

View File

@@ -60,7 +60,7 @@
</div>
<div v-if="filteredWorks.length === 0" class="p-16 text-center">
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl">🎨</div>
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl"><Icon name="palette" :size="28" /></div>
<h3 class="text-white font-medium mb-2">暂无作品</h3>
<p class="text-art-muted text-sm mb-6">展示您的精彩项目</p>
<router-link to="/admin/works/create" class="admin-btn-secondary inline-flex items-center gap-2">
@@ -72,6 +72,7 @@
</template>
<script setup lang="ts">
import Icon from '../../components/Icon.vue'
import { ref, computed, onMounted } from 'vue'
import { getAdminWorks, deleteWork as deleteWorkApi, Work } from '../../services/api'
import { useToast } from '../../composables/useToast'

View File

@@ -20,7 +20,7 @@
<span class="text-white/40">-</span>
<input type="datetime-local" v-model="endDateTime" class="bg-white/5 border border-white/10 rounded px-2 py-1 text-white text-sm outline-none focus:border-[#d4b383]" @change="validateDates">
<button @click="clearDates" class="px-2 py-1 text-white/40 hover:text-white text-sm" title="清空"></button>
<button @click="clearDates" class="px-2 py-1 text-white/40 hover:text-white text-sm" title="清空"><Icon name="x" :size="14" /></button>
<button @click="handleSearch" class="px-3 py-1 bg-[#d4b383]/20 text-[#d4b383] border border-[#d4b383]/50 rounded text-sm hover:bg-[#d4b383]/30 transition-colors" :disabled="loading">
{{ loading ? '加载中...' : '查询' }}
</button>
@@ -117,6 +117,7 @@
</template>
<script setup lang="ts">
import Icon from '../../../components/Icon.vue'
import { ref, onMounted, computed } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'

View File

@@ -3,28 +3,28 @@
<!-- Top Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="admin-card p-6 flex items-center gap-4 group">
<div class="w-12 h-12 rounded-lg bg-blue-500/10 text-blue-400 flex items-center justify-center text-xl group-hover:scale-110 transition-transform duration-300">📝</div>
<div class="w-12 h-12 rounded-lg bg-blue-500/10 text-blue-400 flex items-center justify-center text-xl group-hover:scale-110 transition-transform duration-300"><Icon name="file-text" :size="28" /></div>
<div>
<h3 class="text-sm font-medium text-art-muted">总文章数</h3>
<p class="text-2xl font-semibold text-white mt-1">{{ stats.posts || 0 }}</p>
</div>
</div>
<div class="admin-card p-6 flex items-center gap-4 group">
<div class="w-12 h-12 rounded-lg bg-green-500/10 text-green-400 flex items-center justify-center text-xl group-hover:scale-110 transition-transform duration-300">👥</div>
<div class="w-12 h-12 rounded-lg bg-green-500/10 text-green-400 flex items-center justify-center text-xl group-hover:scale-110 transition-transform duration-300"><Icon name="users" :size="28" /></div>
<div>
<h3 class="text-sm font-medium text-art-muted">今日访客 (UV)</h3>
<p class="text-2xl font-semibold text-white mt-1">{{ todayUV }}</p>
</div>
</div>
<div class="admin-card p-6 flex items-center gap-4 group">
<div class="w-12 h-12 rounded-lg bg-purple-500/10 text-purple-400 flex items-center justify-center text-xl group-hover:scale-110 transition-transform duration-300">📩</div>
<div class="w-12 h-12 rounded-lg bg-purple-500/10 text-purple-400 flex items-center justify-center text-xl group-hover:scale-110 transition-transform duration-300"><Icon name="inbox" :size="28" /></div>
<div>
<h3 class="text-sm font-medium text-art-muted">合作咨询</h3>
<p class="text-2xl font-semibold text-white mt-1">{{ stats.inquiryCount || 0 }}</p>
</div>
</div>
<div class="admin-card p-6 flex items-center gap-4 group">
<div class="w-12 h-12 rounded-lg bg-yellow-500/10 text-yellow-400 flex items-center justify-center text-xl group-hover:scale-110 transition-transform duration-300">🎨</div>
<div class="w-12 h-12 rounded-lg bg-yellow-500/10 text-yellow-400 flex items-center justify-center text-xl group-hover:scale-110 transition-transform duration-300"><Icon name="palette" :size="28" /></div>
<div>
<h3 class="text-sm font-medium text-art-muted">作品展示</h3>
<p class="text-2xl font-semibold text-white mt-1">{{ stats.works || '-' }}</p>
@@ -65,7 +65,7 @@
<div class="space-y-3">
<div v-for="post in recentPosts" :key="post.id" class="flex items-start gap-4 p-3 rounded-lg hover:bg-white/5 transition-colors border border-transparent hover:border-white/5">
<div class="w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-400 text-sm shrink-0">
📝
<Icon name="file-text" :size="14" />
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-start mb-1">
@@ -89,6 +89,7 @@
</template>
<script setup lang="ts">
import Icon from '../../../components/Icon.vue'
import { ref, onMounted, computed } from 'vue'
import { getDashboardStats, getOperationLogs, getAdminPosts } from '../../../services/api'

View File

@@ -172,6 +172,9 @@ const routes = [
// 系统配置
{ path: 'settings', name: 'admin-settings', component: () => import('./pages/admin/Settings.vue') },
// 模板管理
{ path: 'templates', name: 'admin-templates', component: () => import('./pages/admin/TemplateManage.vue') },
{ path: 'ppt-templates', name: 'admin-ppt-templates', component: () => import('./pages/admin/PptTemplates.vue') },
{ path: 'ppt-templates/create', name: 'admin-ppt-templates-create', component: () => import('./pages/admin/PptTemplateForm.vue') },
{ path: 'ppt-templates/:id/edit', name: 'admin-ppt-templates-edit', component: () => import('./pages/admin/PptTemplateForm.vue') },

View File

@@ -1423,6 +1423,7 @@ export interface PublicSettings {
snippets_per_page?: string
footer_icp?: string
contact_email?: string
site_template?: string
}
export const batchUpdateSettings = async (values: Record<string, string>): Promise<void> => {
@@ -1445,6 +1446,119 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
}
}
// ---------- 前端页面模板 API ----------
export interface TemplateMeta {
id: number
slug: string
name: string
type: 'builtin' | 'custom'
description?: string
skinCssPath?: string
isSystem: boolean
isActive: boolean
isCurrent?: boolean
sortOrder?: number
createdAt?: number
updatedAt?: number
}
export interface TemplateDescriptor {
slug: string
name: string
type: string
description?: string
manifest?: Record<string, any>
layout?: Record<string, any>
skinCssPath?: string
assetsBase: string
}
export interface TemplateDetail extends TemplateMeta {
isCurrent?: boolean
manifest?: Record<string, any> | null
layout?: Record<string, any> | null
assetsBase?: string
}
// 公开:启用模板元数据列表
export const fetchTemplates = async (): Promise<TemplateMeta[]> => {
try {
const response = await fetch(`${API_BASE}/templates`)
const data = await parseApiResponse<TemplateMeta[]>(response)
return Array.isArray(data) ? data : []
} catch (error) {
console.error('Fetch templates error:', error)
return []
}
}
// 公开:模板布局描述(自定义渲染引擎拉取)
export const fetchTemplateDescriptor = async (slug: string): Promise<TemplateDescriptor> => {
const response = await fetch(`${API_BASE}/templates/${slug}`)
return await parseApiResponse<TemplateDescriptor>(response)
}
// 后台:全部模板
export const getAdminTemplates = async (): Promise<TemplateMeta[]> => {
const response = await authFetch(`${API_BASE}/admin/templates`, {
headers: getAuthHeaders()
})
const data = await parseApiResponse<TemplateMeta[]>(response)
return Array.isArray(data) ? data : []
}
// 后台:模板详情(含 manifest/layout 全文)
export const getTemplateDetail = async (id: number): Promise<TemplateDetail> => {
const response = await authFetch(`${API_BASE}/admin/templates/${id}`, {
headers: getAuthHeaders()
})
return await parseApiResponse<TemplateDetail>(response)
}
// 后台上传自定义布局包zip
export const uploadTemplate = async (file: File): Promise<TemplateMeta> => {
const form = new FormData()
form.append('file', file)
const response = await authFetch(`${API_BASE}/admin/templates/upload`, {
method: 'POST',
headers: getAuthHeaders(),
body: form
})
return await parseApiResponse<TemplateMeta>(response)
}
// 后台:更新模板
export const updateTemplate = async (
id: number,
data: { name?: string; description?: string; isActive?: boolean }
): Promise<void> => {
const response = await authFetch(`${API_BASE}/admin/templates/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data)
})
await parseApiResponse(response)
}
// 后台:设为当前模板
export const activateTemplate = async (id: number): Promise<void> => {
const response = await authFetch(`${API_BASE}/admin/templates/${id}/activate`, {
method: 'POST',
headers: getAuthHeaders()
})
await parseApiResponse(response)
}
// 后台:删除模板
export const deleteTemplate = async (id: number): Promise<void> => {
const response = await authFetch(`${API_BASE}/admin/templates/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
await parseApiResponse(response)
}
// 操作日志筛选参数
export interface OperationLogFilters {
action?: string

View File

@@ -0,0 +1,35 @@
<template>
<div class="bt-root" data-template="bento">
<nav class="bt-nav">
<router-link to="/" class="bt-logo">年糕 · Bento</router-link>
<div class="bt-links">
<router-link to="/blog" class="bt-link" active-class="active">思考</router-link>
<router-link to="/columns" class="bt-link" active-class="active">专栏</router-link>
<router-link to="/works" class="bt-link" active-class="active">作品</router-link>
<router-link to="/videos" class="bt-link" active-class="active">视频</router-link>
<router-link to="/snippets" class="bt-link" active-class="active">代码</router-link>
<router-link to="/services" class="bt-link" active-class="active">合作</router-link>
<router-link to="/about" class="bt-link" active-class="active">关于</router-link>
</div>
</nav>
<main class="bt-main" :class="{ full: isDetail }">
<RouterView v-if="!pageComponent" />
<component :is="pageComponent" v-else />
</main>
<footer class="bt-footer">© {{ year }} 年糕崽崽.Dev · Bento Layout</footer>
</div>
</template>
<script setup lang="ts">
import './bento.css'
import { computed } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { pageComponents } from './pages'
const route = useRoute()
const year = new Date().getFullYear()
const pageComponent = computed(() => pageComponents[route.name as string] ?? null)
const isDetail = computed(() => route.name === 'blog-detail')
</script>

View File

@@ -0,0 +1,260 @@
/* ============================================================
BENTO template — 宫格风
深底 + 多彩圆角宫格 + 大圆角卡片
============================================================ */
.bt-root {
/* art-* 变量覆盖:未重写组件(回退经典页)自动适配本模板配色 */
--art-bg: 15 17 21;
--art-surface: 23 26 33;
--art-text: 242 244 248;
--art-muted: 154 163 178;
--art-accent: 108 92 231;
--art-border: rgba(255,255,255,0.08);
--b-bg: #0f1115;
--b-surface: #171a21;
--b-text: #f2f4f8;
--b-muted: #9aa3b2;
--b-line: rgba(255, 255, 255, 0.08);
--b-1: #6c5ce7;
--b-2: #ff6b6b;
--b-3: #4ecdc4;
--b-4: #ffd93d;
font-family: 'Inter', 'Noto Sans SC', sans-serif;
background: var(--b-bg);
color: var(--b-text);
min-height: 100vh;
}
.bt-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid var(--b-line);
position: sticky;
top: 0;
z-index: 40;
backdrop-filter: blur(12px);
}
.bt-logo {
font-weight: 800;
font-size: 18px;
color: var(--b-text);
text-decoration: none;
letter-spacing: 0.02em;
}
.bt-links {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.bt-link {
font-size: 14px;
color: var(--b-muted);
text-decoration: none;
padding: 8px 14px;
border-radius: 9999px;
transition: all 0.15s;
}
.bt-link:hover,
.bt-link.active {
background: var(--b-1);
color: #fff;
}
.bt-main {
/*max-width: 1120px;*/
margin: 0 auto;
padding: 26px 18px 60px;
}
.bt-footer {
border-top: 1px solid var(--b-line);
text-align: center;
padding: 20px;
color: var(--b-muted);
font-size: 13px;
}
/* 宫格卡片:渐变 + 内高光 + 悬浮 */
.bt-card {
position: relative;
border-radius: 22px;
padding: 24px;
background: var(--b-surface);
border: 1px solid var(--b-line);
transition: transform 0.25s, border-color 0.25s, box-shadow 0.25s;
overflow: hidden;
}
/* 顶部内高光(精致感) */
.bt-card::before {
content: '';
position: absolute;
top: 0;
left: 12%;
right: 12%;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.35), transparent);
}
.bt-card:hover {
transform: translateY(-4px);
border-color: rgba(255, 255, 255, 0.24);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.05);
}
/* 彩色卡:渐变底 */
.bt-c1 {
background: linear-gradient(135deg, rgba(108, 92, 231, 0.32), rgba(108, 92, 231, 0.08));
border-color: rgba(108, 92, 231, 0.35);
}
.bt-c2 {
background: linear-gradient(135deg, rgba(255, 107, 107, 0.3), rgba(255, 107, 107, 0.06));
border-color: rgba(255, 107, 107, 0.32);
}
.bt-c3 {
background: linear-gradient(135deg, rgba(78, 205, 196, 0.28), rgba(78, 205, 196, 0.06));
border-color: rgba(78, 205, 196, 0.32);
}
.bt-c4 {
background: linear-gradient(135deg, rgba(255, 217, 61, 0.24), rgba(255, 217, 61, 0.05));
border-color: rgba(255, 217, 61, 0.3);
}
/* 卡片图标 */
.bt-card-icon {
width: 42px;
height: 42px;
border-radius: 13px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 14px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.bt-card-link {
text-decoration: none;
color: inherit;
display: block;
}
.bt-card-cat {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
margin-bottom: 10px;
}
.bt-card-title {
font-size: 17px;
font-weight: 700;
line-height: 1.5;
margin-bottom: 8px;
}
.bt-card-desc {
font-size: 13px;
color: var(--b-muted);
line-height: 1.8;
}
.bt-card-meta {
font-size: 12px;
color: var(--b-muted);
margin-top: 10px;
}
.bt-cc1 { color: #a49cf2; }
.bt-cc2 { color: #ff9d9d; }
.bt-cc3 { color: #7fe3dc; }
.bt-cc4 { color: #ffe07a; }
/* 按钮 */
.bt-btn {
display: inline-block;
font-family: inherit;
font-size: 14px;
font-weight: 600;
color: #fff;
background: var(--b-1);
border: none;
border-radius: 12px;
padding: 11px 22px;
text-decoration: none;
cursor: pointer;
transition: opacity 0.15s;
}
.bt-btn:hover {
opacity: 0.88;
}
.bt-btn-2 { background: var(--b-2); }
.bt-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* 表单 */
.bt-input {
font-family: inherit;
font-size: 14px;
background: var(--b-surface);
border: 1px solid var(--b-line);
border-radius: 12px;
color: var(--b-text);
padding: 11px 16px;
outline: none;
}
.bt-input:focus {
border-color: var(--b-1);
}
/* markdown */
.bt-md {
font-size: 15px;
line-height: 2;
color: #d6dbe4;
}
.bt-md h1, .bt-md h2, .bt-md h3 {
color: var(--b-text);
font-weight: 700;
}
.bt-md h1 { font-size: 26px; margin: 1.4em 0 0.6em; }
.bt-md h2 { font-size: 20px; margin: 1.3em 0 0.5em; }
.bt-md h3 { font-size: 16px; color: var(--b-3); }
.bt-md a { color: var(--b-3); text-decoration: underline; }
.bt-md pre {
background: #0d0f14;
border: 1px solid var(--b-line);
border-radius: 14px;
padding: 16px 18px;
overflow-x: auto;
font-size: 13px;
line-height: 1.8;
}
.bt-md blockquote {
border-left: 3px solid var(--b-2);
padding-left: 16px;
color: var(--b-muted);
}
.bt-md img { max-width: 100%; border-radius: 14px; margin: 1em 0; }
.bt-md table th, .bt-md table td { border: 1px solid var(--b-line); padding: 8px 12px; }
/* 博客详情页不限制最大宽度 */
.bt-main.full { max-width: none; }

View File

@@ -0,0 +1,85 @@
<template>
<div>
<div class="bt-toolbar">
<input v-model="keyword" class="bt-input" placeholder="搜索…" @keyup.enter="load(1)" />
<button class="bt-btn" @click="load(1)">搜索</button>
<select v-model="selectedCat" class="bt-input" @change="load(1)">
<option :value="0">全部分类</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div v-if="loading" style="padding:40px 0;color:var(--b-muted);">加载中</div>
<template v-else>
<div class="bt-grid">
<router-link
v-for="(p, i) in posts"
:key="p.id"
:to="`/blog/${p.id}`"
class="bt-card bt-card-link"
:class="colorOf(i)"
>
<div class="bt-card-cat" :class="colorTextOf(i)">{{ p.categoryName || '未分类' }}</div>
<div class="bt-card-title">{{ p.title }}</div>
<div class="bt-card-desc">{{ p.excerpt || '(暂无摘要)' }}</div>
<div class="bt-card-meta">{{ p.date }}</div>
</router-link>
</div>
<div v-if="!posts.length" style="padding:40px 0;color:var(--b-muted);">暂无文章</div>
<div v-if="totalPages > 1" style="display:flex;gap:14px;justify-content:center;margin-top:28px;">
<button class="bt-btn" :disabled="page <= 1" @click="load(page - 1)"> 上一页</button>
<span style="align-self:center;color:var(--b-muted);font-size:13px;">{{ page }} / {{ totalPages }}</span>
<button class="bt-btn" :disabled="page >= totalPages" @click="load(page + 1)">下一页 </button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchPosts, fetchCategories, type Post } from '../../../services/api'
const posts = ref<Post[]>([])
const categories = ref<Awaited<ReturnType<typeof fetchCategories>>>([])
const keyword = ref('')
const selectedCat = ref(0)
const page = ref(1)
const totalPages = ref(1)
const loading = ref(true)
const PAGE_SIZE = 8
const colorOf = (i: number) => [`bt-c1`, `bt-c2`, `bt-c3`, `bt-c4`][i % 4]
const colorTextOf = (i: number) => [`bt-cc1`, `bt-cc2`, `bt-cc3`, `bt-cc4`][i % 4]
const load = async (p: number) => {
loading.value = true
const res = await fetchPosts(keyword.value || undefined, selectedCat.value > 0 ? selectedCat.value : undefined, undefined, undefined, p, PAGE_SIZE)
const list = 'list' in res ? res.list : (Array.isArray(res) ? res : [])
const total = 'total' in res ? res.total : list.length
posts.value = list
totalPages.value = Math.max(1, Math.ceil(total / PAGE_SIZE))
page.value = p
loading.value = false
}
onMounted(async () => {
categories.value = await fetchCategories()
await load(1)
})
</script>
<style scoped>
.bt-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.bt-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
</style>

View File

@@ -0,0 +1,74 @@
<template>
<div>
<div v-if="!post.id" style="padding:40px 0;color:var(--b-muted);">加载中</div>
<article v-else>
<!-- 宫格头部卡 -->
<div class="bt-card bt-c1 bt-head">
<div class="bt-card-cat bt-cc1">文章详情</div>
<h1 class="bt-head-title">{{ post.title }}</h1>
<div class="bt-head-meta">
<span class="bt-tag bt-c2">{{ post.categoryName || '未分类' }}</span>
<span class="bt-tag bt-c3">{{ post.date }}</span>
<span class="bt-tag bt-c4">阅读 {{ post.readCount ?? 0 }}</span>
</div>
</div>
<!-- 内容卡 -->
<div class="bt-card" style="margin-top:16px;padding:34px;">
<div class="bt-md" v-html="html"></div>
</div>
<div style="margin-top:18px;">
<router-link to="/blog" class="bt-btn"> 返回列表</router-link>
</div>
</article>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchPost, type Post } from '../../../services/api'
import { renderMarkdown } from '../../../utils/markdownRenderer'
const route = useRoute()
const post = ref<Partial<Post>>({})
const html = ref('')
onMounted(async () => {
try {
const p = await fetchPost(String(route.params.id))
post.value = p
html.value = await renderMarkdown(p.content || p.excerpt || '(暂无内容)')
} catch {
post.value = {}
}
})
</script>
<style scoped>
.bt-head {
padding: 34px;
}
.bt-head-title {
font-size: clamp(24px, 4vw, 36px);
font-weight: 800;
line-height: 1.4;
margin: 8px 0 16px;
}
.bt-head-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.bt-tag {
font-size: 12px;
font-weight: 600;
padding: 6px 14px;
border-radius: 9999px;
color: var(--b-text);
}
</style>

View File

@@ -0,0 +1,139 @@
<template>
<div class="bt-bento">
<!-- 大宫格hero -->
<div class="bt-card bt-c1 bt-hero">
<div class="bt-card-icon bt-cc1"><Icon name="sparkles" :size="20" /></div>
<div class="bt-card-cat bt-cc1">首页宫格</div>
<h1 class="bt-hero-title">{{ title }}</h1>
<p class="bt-card-desc">{{ subtitle || '以宫格收纳设计与技术的思考。' }}</p>
<div style="display:flex;gap:12px;margin-top:18px;">
<router-link to="/blog" class="bt-btn">进入思考</router-link>
<router-link to="/works" class="bt-btn bt-btn-2">查看作品</router-link>
</div>
</div>
<div class="bt-grid">
<!-- 精选文章 -->
<router-link v-if="featuredPost.id" :to="`/blog/${featuredPost.id}`" class="bt-card bt-card-link bt-c2">
<div class="bt-card-icon bt-cc2"><Icon name="file-text" :size="20" /></div>
<div class="bt-card-cat bt-cc2">精选文章</div>
<div class="bt-card-title">{{ featuredPost.title }}</div>
<div class="bt-card-meta">{{ featuredPost.categoryName || '未分类' }} · {{ featuredPost.date }}</div>
</router-link>
<!-- 最新作品 -->
<router-link v-if="latestWork.id" :to="`/works/${latestWork.id}`" class="bt-card bt-card-link bt-c3">
<div class="bt-card-icon bt-cc3"><Icon name="palette" :size="20" /></div>
<div class="bt-card-cat bt-cc3">最新作品</div>
<div class="bt-card-title">{{ latestWork.title }}</div>
<div class="bt-card-meta">{{ latestWork.category }} · {{ latestWork.year }}</div>
</router-link>
<!-- 快捷入口 -->
<div class="bt-card bt-c4">
<div class="bt-card-icon bt-cc4"><Icon name="layout-grid" :size="20" /></div>
<div class="bt-card-cat bt-cc4">导航</div>
<div class="bt-quicks">
<router-link v-for="q in quicks" :key="q.path" :to="q.path" class="bt-quick">{{ q.name }}</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchWorks, fetchPosts, type Work, type Post } from '../../../services/api'
import { loadPublicSettings } from '../../../composables/usePublicSettings'
import Icon from '../../../components/Icon.vue'
const title = ref('年糕崽崽.Dev')
const subtitle = ref('')
const latestWork = ref<Partial<Work>>({})
const featuredPost = ref<Partial<Post>>({})
const quicks = [
{ name: '专栏', path: '/columns' },
{ name: '视频', path: '/videos' },
{ name: '代码', path: '/snippets' },
{ name: '合作', path: '/services' },
{ name: '关于', path: '/about' },
]
onMounted(async () => {
try {
const settings = await loadPublicSettings()
title.value = settings.site_title || title.value
subtitle.value = settings.site_description || ''
const [works, posts] = await Promise.all([fetchWorks(), fetchPosts(undefined, undefined, undefined, undefined, 1, 1)])
if (works?.length) latestWork.value = works[0]
const list = 'list' in posts ? posts.list : []
if (list?.length) featuredPost.value = list[0]
} catch { /* fallback */ }
})
</script>
<style scoped>
.bt-bento {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
}
.bt-hero {
grid-row: span 2;
display: flex;
flex-direction: column;
justify-content: center;
padding: 34px;
}
.bt-hero-title {
font-size: clamp(26px, 4vw, 40px);
font-weight: 800;
margin-bottom: 12px;
}
.bt-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.bt-grid .bt-card:last-child {
grid-column: 1 / -1;
}
.bt-quicks {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bt-quick {
font-size: 13px;
color: var(--b-text);
text-decoration: none;
border: 1px solid rgba(255, 217, 61, 0.35);
border-radius: 9999px;
padding: 6px 14px;
transition: all 0.15s;
}
.bt-quick:hover {
background: var(--b-4);
color: #0f1115;
}
@media (max-width: 760px) {
.bt-bento {
grid-template-columns: 1fr;
}
.bt-hero {
grid-row: auto;
}
.bt-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,8 @@
import type { Component } from 'vue'
import { defineAsyncComponent } from 'vue'
export const pageComponents: Record<string, Component> = {
home: defineAsyncComponent(() => import('./Home.vue')),
blog: defineAsyncComponent(() => import('./Blog.vue')),
blogDetail: defineAsyncComponent(() => import('./BlogDetail.vue')),
}

View File

@@ -0,0 +1,35 @@
<template>
<div class="gl-root" data-template="glass">
<nav class="gl-nav">
<router-link to="/" class="gl-logo">年糕 · Glass</router-link>
<div class="gl-links">
<router-link to="/blog" class="gl-link" active-class="active">思考</router-link>
<router-link to="/columns" class="gl-link" active-class="active">专栏</router-link>
<router-link to="/works" class="gl-link" active-class="active">作品</router-link>
<router-link to="/videos" class="gl-link" active-class="active">视频</router-link>
<router-link to="/snippets" class="gl-link" active-class="active">代码</router-link>
<router-link to="/services" class="gl-link" active-class="active">合作</router-link>
<router-link to="/about" class="gl-link" active-class="active">关于</router-link>
</div>
</nav>
<main class="gl-main" :class="{ full: isDetail }">
<RouterView v-if="!pageComponent" />
<component :is="pageComponent" v-else />
</main>
<footer class="gl-footer">© {{ year }} 年糕崽崽.Dev · Glassmorphism</footer>
</div>
</template>
<script setup lang="ts">
import './glass.css'
import { computed } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { pageComponents } from './pages'
const route = useRoute()
const year = new Date().getFullYear()
const pageComponent = computed(() => pageComponents[route.name as string] ?? null)
const isDetail = computed(() => route.name === 'blog-detail')
</script>

View File

@@ -0,0 +1,270 @@
/* ============================================================
GLASS template — 玻璃拟态
渐变背景 + 毛玻璃卡片 + 光感
============================================================ */
.gl-root {
/* art-* 变量覆盖:未重写组件(回退经典页)自动适配本模板配色 */
--art-bg: 24 22 48;
--art-surface: 40 44 76;
--art-text: 244 247 255;
--art-muted: 186 196 226;
--art-accent: 124 220 255;
--art-border: rgba(255,255,255,0.14);
--g-text: #f4f7ff;
--g-muted: rgba(244, 247, 255, 0.65);
font-family: 'Inter', 'Noto Sans SC', sans-serif;
min-height: 100vh;
position: relative;
background:
radial-gradient(60% 45% at 15% 10%, rgba(124, 92, 240, 0.55), transparent 65%),
radial-gradient(55% 40% at 85% 20%, rgba(45, 140, 220, 0.55), transparent 65%),
radial-gradient(70% 55% at 60% 90%, rgba(56, 189, 180, 0.45), transparent 70%),
radial-gradient(35% 25% at 30% 70%, rgba(255, 255, 255, 0.08), transparent 70%),
linear-gradient(160deg, #2a1a5e 0%, #1b2a5e 45%, #123b52 100%);
}
/* 光斑装饰 */
.gl-root::before,
.gl-root::after {
content: '';
position: fixed;
border-radius: 50%;
filter: blur(40px);
opacity: 0.5;
pointer-events: none;
z-index: 1;
}
.gl-root::before {
width: 320px;
height: 320px;
background: rgba(255, 255, 255, 0.12);
top: -80px;
right: 10%;
}
.gl-root::after {
width: 260px;
height: 260px;
background: rgba(124, 240, 220, 0.16);
bottom: -60px;
left: 5%;
}
.gl-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 26px;
position: sticky;
top: 12px;
z-index: 40;
margin: 12px auto 0;
max-width: 1120px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.18);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
}
.gl-logo {
font-weight: 700;
font-size: 17px;
color: var(--g-text);
text-decoration: none;
letter-spacing: 0.04em;
}
.gl-links {
display: flex;
gap: 2px;
flex-wrap: wrap;
}
.gl-link {
font-size: 14px;
color: var(--g-muted);
text-decoration: none;
padding: 8px 13px;
border-radius: 12px;
transition: all 0.15s;
}
.gl-link:hover,
.gl-link.active {
color: #fff;
background: rgba(255, 255, 255, 0.14);
}
.gl-main {
max-width: 1120px;
margin: 0 auto;
padding: 28px 18px 60px;
position: relative;
z-index: 10;
}
.gl-footer {
position: relative;
z-index: 10;
text-align: center;
padding: 22px;
color: var(--g-muted);
font-size: 13px;
}
/* 毛玻璃卡片:内高光 + 多层阴影 */
.gl-card {
position: relative;
border-radius: 24px;
padding: 26px;
background: linear-gradient(150deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.06));
border: 1px solid rgba(255, 255, 255, 0.22);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
transition: transform 0.25s, box-shadow 0.25s, border-color 0.25s;
overflow: hidden;
}
/* 顶部弧光 */
.gl-card::before {
content: '';
position: absolute;
top: -60%;
left: -20%;
right: -20%;
height: 120%;
background: radial-gradient(60% 50% at 50% 0%, rgba(255, 255, 255, 0.18), transparent 70%);
pointer-events: none;
}
.gl-card:hover {
transform: translateY(-4px);
border-color: rgba(255, 255, 255, 0.35);
box-shadow:
0 32px 80px rgba(0, 0, 0, 0.35),
0 0 40px rgba(124, 220, 255, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.gl-card-link {
display: block;
text-decoration: none;
color: inherit;
}
.gl-card-cat {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.55);
margin-bottom: 10px;
}
.gl-card-title {
font-size: 18px;
font-weight: 700;
color: var(--g-text);
line-height: 1.5;
margin-bottom: 8px;
}
.gl-card-desc {
font-size: 13px;
color: var(--g-muted);
line-height: 1.8;
}
.gl-card-meta {
font-size: 12px;
color: var(--g-muted);
margin-top: 10px;
}
/* 按钮 */
.gl-btn {
display: inline-block;
font-family: inherit;
font-size: 14px;
font-weight: 600;
color: #1b2a5e;
background: rgba(255, 255, 255, 0.92);
border: none;
border-radius: 14px;
padding: 11px 22px;
text-decoration: none;
cursor: pointer;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
transition: all 0.15s;
}
.gl-btn:hover {
transform: translateY(-1px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.28);
}
.gl-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* 表单 */
.gl-input {
font-family: inherit;
font-size: 14px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 14px;
color: var(--g-text);
padding: 11px 16px;
outline: none;
backdrop-filter: blur(10px);
}
.gl-input::placeholder { color: rgba(255, 255, 255, 0.4); }
.gl-input:focus { border-color: rgba(255, 255, 255, 0.5); }
/* markdown */
.gl-md {
font-size: 15px;
line-height: 2;
color: rgba(255, 255, 255, 0.9);
}
.gl-md h1, .gl-md h2, .gl-md h3 {
color: var(--g-text);
font-weight: 700;
}
.gl-md h1 { font-size: 26px; margin: 1.4em 0 0.6em; }
.gl-md h2 { font-size: 20px; margin: 1.3em 0 0.5em; }
.gl-md h3 { font-size: 16px; color: rgba(255, 255, 255, 0.75); }
.gl-md a { color: #9de3ff; text-decoration: underline; }
.gl-md pre {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 16px;
padding: 16px 18px;
overflow-x: auto;
font-size: 13px;
line-height: 1.8;
}
.gl-md blockquote {
border-left: 3px solid rgba(255, 255, 255, 0.5);
padding-left: 16px;
color: var(--g-muted);
}
.gl-md img { max-width: 100%; border-radius: 16px; margin: 1em 0; }
.gl-md table th, .gl-md table td { border: 1px solid rgba(255, 255, 255, 0.18); padding: 8px 12px; }
/* 博客详情页不限制最大宽度 */
.gl-main.full { max-width: none; }

View File

@@ -0,0 +1,76 @@
<template>
<div>
<div class="gl-toolbar">
<input v-model="keyword" class="gl-input" placeholder="搜索…" @keyup.enter="load(1)" />
<button class="gl-btn" @click="load(1)">搜索</button>
<select v-model="selectedCat" class="gl-input" @change="load(1)">
<option :value="0">全部分类</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div v-if="loading" style="padding:40px 0;color:var(--g-muted);">加载中</div>
<template v-else>
<div class="gl-grid">
<router-link v-for="p in posts" :key="p.id" :to="`/blog/${p.id}`" class="gl-card gl-card-link">
<div class="gl-card-cat">{{ p.categoryName || '未分类' }}</div>
<div class="gl-card-title">{{ p.title }}</div>
<div class="gl-card-desc">{{ p.excerpt || '(暂无摘要)' }}</div>
<div class="gl-card-meta">{{ p.date }}</div>
</router-link>
</div>
<div v-if="!posts.length" style="padding:40px 0;color:var(--g-muted);">暂无文章</div>
<div v-if="totalPages > 1" style="display:flex;gap:14px;justify-content:center;margin-top:28px;">
<button class="gl-btn" :disabled="page <= 1" @click="load(page - 1)"> 上一页</button>
<span style="align-self:center;color:var(--g-muted);font-size:13px;">{{ page }} / {{ totalPages }}</span>
<button class="gl-btn" :disabled="page >= totalPages" @click="load(page + 1)">下一页 </button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchPosts, fetchCategories, type Post } from '../../../services/api'
const posts = ref<Post[]>([])
const categories = ref<Awaited<ReturnType<typeof fetchCategories>>>([])
const keyword = ref('')
const selectedCat = ref(0)
const page = ref(1)
const totalPages = ref(1)
const loading = ref(true)
const PAGE_SIZE = 8
const load = async (p: number) => {
loading.value = true
const res = await fetchPosts(keyword.value || undefined, selectedCat.value > 0 ? selectedCat.value : undefined, undefined, undefined, p, PAGE_SIZE)
const list = 'list' in res ? res.list : (Array.isArray(res) ? res : [])
const total = 'total' in res ? res.total : list.length
posts.value = list
totalPages.value = Math.max(1, Math.ceil(total / PAGE_SIZE))
page.value = p
loading.value = false
}
onMounted(async () => {
categories.value = await fetchCategories()
await load(1)
})
</script>
<style scoped>
.gl-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.gl-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div>
<div v-if="!post.id" style="padding:40px 0;color:var(--g-muted);">加载中</div>
<article v-else>
<!-- 毛玻璃头部卡 -->
<div class="gl-card gl-head">
<div class="gl-card-cat">{{ post.categoryName || '未分类' }}</div>
<h1 class="gl-head-title">{{ post.title }}</h1>
<div class="gl-head-meta">
<span>{{ post.date }}</span>
<span>·</span>
<span>阅读 {{ post.readCount ?? 0 }}</span>
</div>
</div>
<!-- 内容卡 -->
<div class="gl-card" style="margin-top:18px;padding:34px;">
<div class="gl-md" v-html="html"></div>
</div>
<div style="margin-top:18px;">
<router-link to="/blog" class="gl-btn"> 返回列表</router-link>
</div>
</article>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchPost, type Post } from '../../../services/api'
import { renderMarkdown } from '../../../utils/markdownRenderer'
const route = useRoute()
const post = ref<Partial<Post>>({})
const html = ref('')
onMounted(async () => {
try {
const p = await fetchPost(String(route.params.id))
post.value = p
html.value = await renderMarkdown(p.content || p.excerpt || '(暂无内容)')
} catch {
post.value = {}
}
})
</script>
<style scoped>
.gl-head {
padding: 36px 40px;
text-align: center;
}
.gl-head-title {
font-size: clamp(26px, 4.5vw, 40px);
font-weight: 700;
line-height: 1.4;
margin: 10px 0 14px;
}
.gl-head-meta {
display: flex;
justify-content: center;
gap: 10px;
color: var(--g-muted);
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<div class="gl-grid">
<div class="gl-card gl-hero">
<div class="gl-card-cat">首页玻璃</div>
<h1 class="gl-hero-title">{{ title }}</h1>
<p class="gl-card-desc">{{ subtitle || '在光与影之间,记录设计与技术的思考。' }}</p>
<div style="display:flex;gap:12px;margin-top:20px;">
<router-link to="/blog" class="gl-btn">进入思考</router-link>
<router-link to="/works" class="gl-btn" style="background:rgba(255,255,255,0.25);color:#fff;">查看作品</router-link>
</div>
</div>
<router-link v-if="featuredPost.id" :to="`/blog/${featuredPost.id}`" class="gl-card gl-card-link">
<div class="gl-card-cat">精选文章</div>
<div class="gl-card-title">{{ featuredPost.title }}</div>
<div class="gl-card-desc">{{ featuredPost.excerpt || '(暂无摘要)' }}</div>
<div class="gl-card-meta">{{ featuredPost.categoryName || '未分类' }} · {{ featuredPost.date }}</div>
</router-link>
<router-link v-if="latestWork.id" :to="`/works/${latestWork.id}`" class="gl-card gl-card-link">
<div class="gl-card-cat">最新作品</div>
<div class="gl-card-title">{{ latestWork.title }}</div>
<div class="gl-card-meta">{{ latestWork.category }} · {{ latestWork.year }}</div>
</router-link>
<div class="gl-card">
<div class="gl-card-cat">导航</div>
<div class="gl-quicks">
<router-link v-for="q in quicks" :key="q.path" :to="q.path" class="gl-quick">{{ q.name }}</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchWorks, fetchPosts, type Work, type Post } from '../../../services/api'
import { loadPublicSettings } from '../../../composables/usePublicSettings'
const title = ref('年糕崽崽.Dev')
const subtitle = ref('')
const latestWork = ref<Partial<Work>>({})
const featuredPost = ref<Partial<Post>>({})
const quicks = [
{ name: '专栏', path: '/columns' },
{ name: '视频', path: '/videos' },
{ name: '代码', path: '/snippets' },
{ name: '合作', path: '/services' },
{ name: '关于', path: '/about' },
]
onMounted(async () => {
try {
const settings = await loadPublicSettings()
title.value = settings.site_title || title.value
subtitle.value = settings.site_description || ''
const [works, posts] = await Promise.all([fetchWorks(), fetchPosts(undefined, undefined, undefined, undefined, 1, 1)])
if (works?.length) latestWork.value = works[0]
const list = 'list' in posts ? posts.list : []
if (list?.length) featuredPost.value = list[0]
} catch { /* fallback */ }
})
</script>
<style scoped>
.gl-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.gl-hero {
grid-column: 1 / -1;
padding: 40px;
text-align: center;
}
.gl-hero-title {
font-size: clamp(26px, 4vw, 40px);
font-weight: 700;
margin-bottom: 12px;
}
.gl-quicks {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.gl-quick {
font-size: 13px;
color: var(--g-text);
text-decoration: none;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 9999px;
padding: 6px 14px;
transition: all 0.15s;
background: rgba(255, 255, 255, 0.06);
}
.gl-quick:hover {
background: rgba(255, 255, 255, 0.18);
}
@media (max-width: 720px) {
.gl-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,8 @@
import type { Component } from 'vue'
import { defineAsyncComponent } from 'vue'
export const pageComponents: Record<string, Component> = {
home: defineAsyncComponent(() => import('./Home.vue')),
blog: defineAsyncComponent(() => import('./Blog.vue')),
blogDetail: defineAsyncComponent(() => import('./BlogDetail.vue')),
}

View File

@@ -0,0 +1,34 @@
<template>
<div class="gf-root" data-template="guofeng">
<nav class="gf-nav">
<router-link to="/" class="gf-logo">年糕集<span class="gf-seal">年糕</span></router-link>
<div class="gf-links">
<router-link to="/blog" class="gf-link" active-class="active">文章</router-link>
<router-link to="/columns" class="gf-link" active-class="active">专栏</router-link>
<router-link to="/works" class="gf-link" active-class="active">作品</router-link>
<router-link to="/videos" class="gf-link" active-class="active">视频</router-link>
<router-link to="/snippets" class="gf-link" active-class="active">代码</router-link>
<router-link to="/services" class="gf-link" active-class="active">合作</router-link>
<router-link to="/about" class="gf-link" active-class="active">关于</router-link>
</div>
</nav>
<main class="gf-main" :class="{ full: isDetail }">
<RouterView v-if="!pageComponent" />
<component :is="pageComponent" v-else />
</main>
<footer class="gf-footer">© 甲辰年 年糕崽崽 · 墨韵存真</footer>
</div>
</template>
<script setup lang="ts">
import './guofeng.css'
import { computed } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { pageComponents } from './pages'
const route = useRoute()
const pageComponent = computed(() => pageComponents[route.name as string] ?? null)
const isDetail = computed(() => route.name === 'blog-detail')
</script>

View File

@@ -0,0 +1,289 @@
/* ============================================================
GUOFENG template — 水墨意境风
近白宣纸 + 浓淡墨色 + 朱红印章;超大书法标题;大留白;山水晕染
(与 newspaper 黄纸密集、retro 深牛皮重装饰 区分:更素雅、更留白)
============================================================ */
.gf-root {
--g-paper: #f7f2e7;
--g-ink: #2a2e33;
--g-muted: #8d8879;
--g-red: #a8372c;
--g-line: #ddd5c2;
font-family: 'Noto Serif SC', 'Songti SC', 'STSong', 'SimSun', serif;
background: var(--g-paper);
color: var(--g-ink);
min-height: 100vh;
position: relative;
}
/* 淡墨远山 + 晕染 */
.gf-root::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 3;
opacity: 0.4;
background:
linear-gradient(160deg, transparent 0%, transparent 52%, rgba(42, 46, 51, 0.05) 53%, transparent 56%),
linear-gradient(200deg, transparent 0%, transparent 68%, rgba(42, 46, 51, 0.07) 69%, transparent 72%),
radial-gradient(40% 30% at 85% 90%, rgba(42, 46, 51, 0.04), transparent 70%);
}
/* 顶部:极简墨线 */
.gf-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 30px;
border-bottom: 1px solid var(--g-line);
background: var(--g-paper);
position: sticky;
top: 0;
z-index: 40;
}
.gf-logo {
font-size: 24px;
font-weight: 700;
letter-spacing: 0.4em;
color: var(--g-ink);
text-decoration: none;
}
.gf-seal {
display: inline-block;
background: var(--g-red);
color: var(--g-paper);
font-size: 13px;
padding: 5px 8px;
margin-left: 12px;
vertical-align: middle;
border-radius: 2px;
letter-spacing: 0.08em;
}
.gf-links {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.gf-link {
font-size: 15px;
letter-spacing: 0.25em;
color: var(--g-ink);
text-decoration: none;
padding: 6px 8px;
border-bottom: 1px solid transparent;
transition: all 0.15s;
}
.gf-link:hover,
.gf-link.active {
color: var(--g-red);
border-bottom-color: var(--g-red);
}
.gf-main {
max-width: 960px;
margin: 0 auto;
padding: 56px 24px 80px;
position: relative;
z-index: 10;
}
.gf-footer {
border-top: 1px solid var(--g-line);
text-align: center;
padding: 24px;
font-size: 13px;
color: var(--g-muted);
letter-spacing: 0.3em;
}
/* 卡片:素净宣纸卡 */
.gf-card {
background: rgba(255, 255, 255, 0.45);
border: 1px solid var(--g-line);
padding: 26px 28px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.gf-card:hover {
border-color: var(--g-ink);
box-shadow: 0 8px 30px rgba(42, 46, 51, 0.06);
}
.gf-card-link {
display: block;
text-decoration: none;
color: inherit;
}
.gf-card-cat {
font-size: 12px;
letter-spacing: 0.35em;
color: var(--g-red);
margin-bottom: 12px;
}
.gf-card-title {
font-size: 20px;
font-weight: 700;
line-height: 1.8;
letter-spacing: 0.1em;
margin-bottom: 10px;
}
.gf-card-desc {
font-size: 13px;
color: var(--g-muted);
line-height: 2.1;
}
.gf-card-meta {
font-size: 12px;
color: var(--g-muted);
margin-top: 12px;
letter-spacing: 0.18em;
}
/* 竖排装饰 */
.gf-vertical {
writing-mode: vertical-rl;
letter-spacing: 0.5em;
}
/* 超大书法标题 */
.gf-huge {
font-size: clamp(40px, 9vw, 84px);
font-weight: 900;
letter-spacing: 0.28em;
color: var(--g-ink);
line-height: 1.3;
}
/* 按钮:墨块 */
.gf-btn {
display: inline-block;
font-family: inherit;
font-size: 14px;
letter-spacing: 0.3em;
color: var(--g-paper);
background: var(--g-ink);
border: 1px solid var(--g-ink);
padding: 12px 28px;
text-decoration: none;
cursor: pointer;
transition: all 0.15s;
}
.gf-btn:hover {
background: var(--g-red);
border-color: var(--g-red);
}
.gf-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* 表单 */
.gf-input {
font-family: inherit;
font-size: 14px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid var(--g-line);
color: var(--g-ink);
padding: 11px 16px;
outline: none;
letter-spacing: 0.1em;
}
.gf-input:focus {
border-color: var(--g-ink);
}
/* markdown水墨长卷 */
.gf-md {
font-size: 16px;
line-height: 2.3;
color: #363b41;
}
.gf-md h1,
.gf-md h2,
.gf-md h3 {
font-weight: 700;
color: var(--g-ink);
letter-spacing: 0.12em;
}
.gf-md h1 {
font-size: 30px;
margin: 1.6em 0 0.8em;
padding-left: 0.7em;
border-left: 4px solid var(--g-red);
}
.gf-md h2 {
font-size: 21px;
margin: 1.5em 0 0.6em;
}
.gf-md h3 {
font-size: 17px;
color: var(--g-red);
}
.gf-md a {
color: var(--g-red);
text-decoration: underline;
}
.gf-md pre {
background: rgba(42, 46, 51, 0.05);
border: 1px solid var(--g-line);
padding: 18px;
overflow-x: auto;
font-size: 13px;
line-height: 2;
}
.gf-md code {
font-family: 'JetBrains Mono', monospace;
color: var(--g-red);
}
.gf-md blockquote {
border-left: 3px solid var(--g-red);
padding-left: 18px;
color: var(--g-muted);
margin: 1.4em 0;
}
.gf-md img {
max-width: 100%;
margin: 1.2em 0;
border: 1px solid var(--g-line);
}
.gf-md table th,
.gf-md table td {
border: 1px solid var(--g-line);
padding: 9px 14px;
}
.gf-md table th {
background: rgba(42, 46, 51, 0.05);
}
/* art-* 变量覆盖 */
.gf-root {
--art-bg: 247 242 231;
--art-surface: 255 255 255;
--art-text: 42 46 51;
--art-muted: 141 136 121;
--art-accent: 168 55 44;
--art-border: rgba(42,46,51,0.12);
}
.gf-main.full { max-width: none; }

View File

@@ -0,0 +1,73 @@
<template>
<div>
<div class="gf-toolbar">
<input v-model="keyword" class="gf-input" placeholder="搜文章…" @keyup.enter="load(1)" />
<button class="gf-btn" @click="load(1)">检索</button>
<select v-model="selectedCat" class="gf-input" @change="load(1)">
<option :value="0">全部</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div v-if="loading" style="padding:40px 0;color:var(--g-muted);">研墨中</div>
<template v-else>
<div class="gf-list">
<router-link v-for="p in posts" :key="p.id" :to="`/blog/${p.id}`" class="gf-card gf-card-link gf-row">
<div class="gf-card-cat">{{ p.categoryName || '未分类' }} · {{ p.date }}</div>
<div class="gf-card-title" style="font-size:20px;">{{ p.title }}</div>
<div class="gf-card-desc">{{ p.excerpt || '(暂无摘要)' }}</div>
</router-link>
</div>
<div v-if="!posts.length" style="padding:40px 0;color:var(--g-muted);">暂无文章</div>
<div v-if="totalPages > 1" style="display:flex;gap:14px;justify-content:center;margin-top:28px;">
<button class="gf-btn" :disabled="page <= 1" @click="load(page - 1)"> 上一页</button>
<span style="align-self:center;color:var(--g-muted);font-size:13px;letter-spacing:0.2em;"> {{ page }} / {{ totalPages }} </span>
<button class="gf-btn" :disabled="page >= totalPages" @click="load(page + 1)">下一页 </button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchPosts, fetchCategories, type Post } from '../../../services/api'
const posts = ref<Post[]>([])
const categories = ref<Awaited<ReturnType<typeof fetchCategories>>>([])
const keyword = ref('')
const selectedCat = ref(0)
const page = ref(1)
const totalPages = ref(1)
const loading = ref(true)
const PAGE_SIZE = 8
const load = async (p: number) => {
loading.value = true
const res = await fetchPosts(keyword.value || undefined, selectedCat.value > 0 ? selectedCat.value : undefined, undefined, undefined, p, PAGE_SIZE)
const list = 'list' in res ? res.list : (Array.isArray(res) ? res : [])
const total = 'total' in res ? res.total : list.length
posts.value = list
totalPages.value = Math.max(1, Math.ceil(total / PAGE_SIZE))
page.value = p
loading.value = false
}
onMounted(async () => {
categories.value = await fetchCategories()
await load(1)
})
</script>
<style scoped>
.gf-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.gf-row {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div>
<div v-if="!post.id" style="padding:40px 0;color:var(--g-muted);">研墨中</div>
<article v-else class="gf-article-page">
<!-- 题头竖排日期 + 印章 -->
<div class="gf-article-head">
<div class="gf-date-col">
<span class="gf-date-num">{{ day }}</span>
<span class="gf-date-sub">{{ yearMonth }}</span>
</div>
<div class="gf-article-main">
<div class="gf-card-cat">{{ post.categoryName || '未分类' }}</div>
<h1 class="gf-page-title">{{ post.title }}</h1>
<span class="gf-seal">年糕</span>
</div>
</div>
<div class="gf-divider"></div>
<!-- 正文 -->
<div class="gf-md" v-html="html"></div>
<!-- 落款 -->
<div class="gf-sign">
<span>谨记于 {{ post.date }}</span>
<span>· {{ author }} </span>
</div>
<router-link to="/blog" class="gf-btn"> 返回文章</router-link>
</article>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchPost, type Post } from '../../../services/api'
import { loadPublicSettings } from '../../../composables/usePublicSettings'
import { renderMarkdown } from '../../../utils/markdownRenderer'
const route = useRoute()
const post = ref<Partial<Post>>({})
const html = ref('')
const author = ref('年糕')
const day = computed(() => String((post.value.date || '').slice(8, 10)) || '—')
const yearMonth = computed(() => (post.value.date || '').slice(0, 7) || '')
onMounted(async () => {
try {
const settings = await loadPublicSettings()
author.value = settings.site_author || '年糕'
const p = await fetchPost(String(route.params.id))
post.value = p
html.value = await renderMarkdown(p.content || p.excerpt || '(暂无内容)')
} catch {
post.value = {}
}
})
</script>
<style scoped>
.gf-article-head {
display: flex;
gap: 26px;
align-items: flex-start;
}
.gf-date-col {
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid var(--g-line);
padding: 12px 10px;
flex-shrink: 0;
min-width: 60px;
}
.gf-date-num {
font-size: 30px;
font-weight: 700;
line-height: 1;
color: var(--g-ink);
}
.gf-date-sub {
font-size: 11px;
color: var(--g-muted);
margin-top: 6px;
letter-spacing: 0.15em;
}
.gf-article-main {
flex: 1;
min-width: 0;
}
.gf-page-title {
font-size: clamp(24px, 4vw, 36px);
font-weight: 700;
letter-spacing: 0.12em;
line-height: 1.6;
margin: 8px 0 10px;
}
.gf-seal {
display: inline-block;
background: var(--g-red);
color: var(--g-paper);
font-size: 13px;
padding: 5px 9px;
border-radius: 2px;
letter-spacing: 0.1em;
}
.gf-divider {
border-top: 2px solid var(--g-ink);
margin: 24px 0;
}
.gf-sign {
text-align: right;
font-size: 13px;
color: var(--g-muted);
letter-spacing: 0.2em;
margin: 40px 0 18px;
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div>
<!-- hero留白 + 竖排标题 -->
<section style="display:flex;gap:36px;align-items:stretch;margin-bottom:36px;">
<div style="flex:1;padding:30px 0;">
<div class="gf-card-cat">墨韵 · 首页</div>
<h1 style="font-size:clamp(32px,6vw,52px);font-weight:700;letter-spacing:0.2em;margin:12px 0 16px;">{{ title }}</h1>
<p style="color:var(--g-muted);line-height:2.2;font-size:15px;">{{ subtitle || '以水墨之心,记录设计与技术的思考。' }}</p>
<div style="display:flex;gap:14px;margin-top:24px;">
<router-link to="/blog" class="gf-btn">品读文章</router-link>
<router-link to="/works" class="gf-btn" style="background:var(--g-red);border-color:var(--g-red);">观览作品</router-link>
</div>
</div>
<div class="gf-vertical" style="border-left:1px solid var(--g-line);padding-left:20px;color:var(--g-muted);font-size:14px;display:flex;align-items:center;">
<span>年糕集 · 甲辰年编</span>
</div>
</section>
<div class="gf-grid">
<router-link v-if="featuredPost.id" :to="`/blog/${featuredPost.id}`" class="gf-card gf-card-link">
<div class="gf-card-cat">精选文章</div>
<div class="gf-card-title">{{ featuredPost.title }}</div>
<div class="gf-card-desc">{{ featuredPost.excerpt || '(暂无摘要)' }}</div>
<div class="gf-card-meta">{{ featuredPost.categoryName || '未分类' }} · {{ featuredPost.date }}</div>
</router-link>
<router-link v-if="latestWork.id" :to="`/works/${latestWork.id}`" class="gf-card gf-card-link">
<div class="gf-card-cat">最新作品</div>
<div class="gf-card-title">{{ latestWork.title }}</div>
<div class="gf-card-meta">{{ latestWork.category }} · {{ latestWork.year }}</div>
</router-link>
<div class="gf-card">
<div class="gf-card-cat">栏目</div>
<div class="gf-quicks">
<router-link v-for="q in quicks" :key="q.path" :to="q.path" class="gf-quick">{{ q.name }}</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchWorks, fetchPosts, type Work, type Post } from '../../../services/api'
import { loadPublicSettings } from '../../../composables/usePublicSettings'
const title = ref('年糕崽崽.Dev')
const subtitle = ref('')
const latestWork = ref<Partial<Work>>({})
const featuredPost = ref<Partial<Post>>({})
const quicks = [
{ name: '专栏', path: '/columns' },
{ name: '视频', path: '/videos' },
{ name: '代码', path: '/snippets' },
{ name: '合作', path: '/services' },
{ name: '关于', path: '/about' },
]
onMounted(async () => {
try {
const settings = await loadPublicSettings()
title.value = settings.site_title || title.value
subtitle.value = settings.site_description || ''
const [works, posts] = await Promise.all([fetchWorks(), fetchPosts(undefined, undefined, undefined, undefined, 1, 1)])
if (works?.length) latestWork.value = works[0]
const list = 'list' in posts ? posts.list : []
if (list?.length) featuredPost.value = list[0]
} catch { /* fallback */ }
})
</script>
<style scoped>
.gf-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 22px;
}
.gf-quicks {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.gf-quick {
font-size: 14px;
letter-spacing: 0.2em;
color: var(--g-ink);
text-decoration: none;
border: 1px solid var(--g-line);
padding: 8px 18px;
transition: all 0.15s;
}
.gf-quick:hover {
border-color: var(--g-red);
color: var(--g-red);
}
@media (max-width: 760px) {
.gf-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,8 @@
import type { Component } from 'vue'
import { defineAsyncComponent } from 'vue'
export const pageComponents: Record<string, Component> = {
home: defineAsyncComponent(() => import('./Home.vue')),
blog: defineAsyncComponent(() => import('./Blog.vue')),
blogDetail: defineAsyncComponent(() => import('./BlogDetail.vue')),
}

View File

@@ -0,0 +1,35 @@
<template>
<div class="mag-root" data-template="magazine">
<header class="mag-masthead">
<router-link to="/" class="mag-masthead-title"> </router-link>
<nav class="mag-nav">
<router-link to="/blog" class="mag-nav-link" active-class="active">思考</router-link>
<router-link to="/columns" class="mag-nav-link" active-class="active">专栏</router-link>
<router-link to="/works" class="mag-nav-link" active-class="active">作品</router-link>
<router-link to="/videos" class="mag-nav-link" active-class="active">视频</router-link>
<router-link to="/snippets" class="mag-nav-link" active-class="active">代码</router-link>
<router-link to="/services" class="mag-nav-link" active-class="active">合作</router-link>
<router-link to="/about" class="mag-nav-link" active-class="active">关于</router-link>
</nav>
</header>
<main class="mag-main" :class="{ full: isDetail }">
<RouterView v-if="!pageComponent" />
<component :is="pageComponent" v-else />
</main>
<footer class="mag-footer">© {{ year }} 年糕崽崽.Dev 设计与技术的深度思考</footer>
</div>
</template>
<script setup lang="ts">
import './magazine.css'
import { computed } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { pageComponents } from './pages'
const route = useRoute()
const year = new Date().getFullYear()
const pageComponent = computed(() => pageComponents[route.name as string] ?? null)
const isDetail = computed(() => route.name === 'blog-detail')
</script>

View File

@@ -0,0 +1,364 @@
/* ============================================================
MAGAZINE template — 现代编辑杂志风
纯白底 + 墨黑 + 朱红强调;无衬线为主;编号栏目 + 几何色块
(与 newspaper 旧报纸、retro 活字、guofeng 水墨 本质区分)
============================================================ */
.mag-root {
--m-paper: #f8f8f6;
--m-ink: #101010;
--m-red: #e63946;
--m-muted: #8a8a85;
--m-line: #e5e5e0;
--m-black: #000;
/* art-* 变量覆盖:未重写组件(回退经典页)自动适配本模板配色 */
--art-bg: 248 248 246;
--art-surface: 255 255 255;
--art-text: 16 16 16;
--art-muted: 138 138 133;
--art-accent: 230 57 70;
--art-border: rgba(0, 0, 0, 0.1);
font-family: 'Inter', 'Noto Sans SC', sans-serif;
background: var(--m-paper);
color: var(--m-ink);
min-height: 100vh;
position: relative;
}
/* 报头:杂志刊头(顶部粗红线 + 大字刊名) */
.mag-masthead {
border-bottom: 1px solid var(--m-line);
padding: 0 0 14px;
text-align: center;
background: var(--m-paper);
position: sticky;
top: 0;
z-index: 40;
}
.mag-masthead::after {
content: '';
display: block;
width: 100%;
height: 6px;
background: var(--m-red);
margin-top: 14px;
}
.mag-masthead-title {
font-family: 'Playfair Display', 'Noto Sans SC', serif;
font-size: clamp(30px, 5vw, 52px);
font-weight: 900;
letter-spacing: 0.06em;
color: var(--m-ink);
text-decoration: none;
display: block;
padding-top: 18px;
}
.mag-nav {
display: flex;
justify-content: center;
gap: 4px;
margin-top: 12px;
flex-wrap: wrap;
}
.mag-nav-link {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--m-ink);
text-decoration: none;
text-transform: uppercase;
padding: 6px 14px;
transition: all 0.15s;
}
.mag-nav-link:hover,
.mag-nav-link.active {
color: #fff;
background: var(--m-red);
}
.mag-main {
max-width: 1080px;
margin: 0 auto;
padding: 40px 20px 70px;
}
.mag-footer {
border-top: 1px solid var(--m-line);
text-align: center;
padding: 24px;
font-size: 12px;
letter-spacing: 0.12em;
color: var(--m-muted);
background: var(--m-paper);
}
/* 文章卡:杂志编辑网格(编号 + 无衬线标题 + 色块标签) */
.mag-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 40px;
}
.mag-article {
border-bottom: 1px solid var(--m-line);
padding: 26px 0;
text-decoration: none;
display: block;
position: relative;
}
.mag-article:hover .mag-article-title {
color: var(--m-red);
}
/* 编号(杂志专属元素) */
.mag-article::before {
content: attr(data-index);
position: absolute;
top: 22px;
right: 0;
font-family: 'Playfair Display', serif;
font-size: 40px;
font-weight: 900;
color: var(--m-line);
line-height: 1;
z-index: 0;
}
.mag-article-cat {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--m-red);
margin-bottom: 10px;
position: relative;
z-index: 1;
}
.mag-article-title {
font-family: 'Playfair Display', 'Noto Sans SC', serif;
font-size: 22px;
font-weight: 700;
line-height: 1.4;
color: var(--m-ink);
margin-bottom: 8px;
position: relative;
z-index: 1;
transition: color 0.15s;
}
.mag-article-excerpt {
font-size: 13px;
color: var(--m-muted);
line-height: 1.9;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
position: relative;
z-index: 1;
}
.mag-article-meta {
font-size: 11px;
color: var(--m-muted);
margin-top: 10px;
letter-spacing: 0.08em;
position: relative;
z-index: 1;
}
/* 头条区:大标题 + 侧栏说明 */
.mag-feature {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 44px;
padding-bottom: 40px;
margin-bottom: 10px;
}
.mag-feature-title {
font-family: 'Playfair Display', 'Noto Sans SC', serif;
font-size: clamp(30px, 4.5vw, 46px);
font-weight: 900;
line-height: 1.25;
color: var(--m-ink);
text-decoration: none;
display: block;
margin-bottom: 14px;
}
.mag-feature-title:hover {
color: var(--m-red);
}
/* 按钮:实心黑 + 红色 hover */
.mag-btn {
display: inline-block;
font-family: inherit;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.1em;
color: #fff;
background: var(--m-ink);
border: 1px solid var(--m-ink);
padding: 11px 24px;
text-decoration: none;
cursor: pointer;
transition: all 0.15s;
}
.mag-btn:hover {
background: var(--m-red);
border-color: var(--m-red);
}
.mag-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* 表单 */
.mag-input {
font-family: inherit;
font-size: 14px;
background: #fff;
border: 1px solid var(--m-line);
color: var(--m-ink);
padding: 10px 14px;
outline: none;
border-radius: 2px;
}
.mag-input:focus {
border-color: var(--m-red);
}
/* 编辑器风格色块标签 */
.mag-tag {
display: inline-block;
font-size: 11px;
font-weight: 700;
background: var(--m-red);
color: #fff;
padding: 3px 10px;
letter-spacing: 0.1em;
}
/* markdown现代编辑排版 */
.mag-md {
font-size: 15px;
line-height: 2;
color: #2b2b28;
}
.mag-md h1,
.mag-md h2,
.mag-md h3 {
font-family: 'Playfair Display', 'Noto Sans SC', serif;
font-weight: 800;
color: var(--m-ink);
margin: 1.5em 0 0.6em;
line-height: 1.4;
}
.mag-md h1 {
font-size: 32px;
border-bottom: 3px solid var(--m-red);
padding-bottom: 0.3em;
}
.mag-md h2 {
font-size: 22px;
position: relative;
padding-left: 14px;
}
.mag-md h2::before {
content: '';
position: absolute;
left: 0;
top: 0.2em;
bottom: 0.2em;
width: 4px;
background: var(--m-red);
}
.mag-md h3 {
font-size: 16px;
color: var(--m-red);
}
.mag-md p {
margin: 0.9em 0;
}
.mag-md p:first-of-type::first-letter {
font-size: 3.4em;
font-weight: 800;
float: left;
line-height: 1;
margin: 0.05em 0.14em 0 0;
color: var(--m-red);
font-family: 'Playfair Display', serif;
}
.mag-md a {
color: var(--m-red);
font-weight: 500;
text-decoration: underline;
}
.mag-md pre {
background: #101010;
color: #f0f0ec;
border-radius: 4px;
padding: 18px;
overflow-x: auto;
font-size: 13px;
line-height: 1.8;
}
.mag-md pre code {
background: transparent;
color: inherit;
}
.mag-md code {
font-family: 'JetBrains Mono', monospace;
background: #f0efea;
color: var(--m-red);
padding: 0.1em 0.4em;
border-radius: 3px;
font-size: 0.9em;
}
.mag-md blockquote {
border-left: 4px solid var(--m-red);
padding-left: 18px;
color: var(--m-muted);
font-style: italic;
margin: 1.2em 0;
}
.mag-md img {
max-width: 100%;
margin: 1.2em 0;
border-radius: 4px;
}
.mag-md table th,
.mag-md table td {
border: 1px solid var(--m-line);
padding: 8px 14px;
font-size: 0.95em;
}
.mag-md table th {
background: var(--m-red);
color: #fff;
}
.mag-main.full { max-width: none; }

View File

@@ -0,0 +1,85 @@
<template>
<div>
<div class="mag-toolbar">
<input v-model="keyword" class="mag-input" placeholder="搜索文章…" @keyup.enter="load(1)" />
<button class="mag-btn" @click="load(1)">搜索</button>
<select v-model="selectedCat" class="mag-input" @change="load(1)">
<option :value="0">全部分类</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div v-if="loading" style="padding:40px 0;color:var(--m-muted);">排版中</div>
<template v-else>
<div class="mag-grid">
<router-link v-for="(p, i) in posts" :key="p.id" :to="`/blog/${p.id}`" class="mag-article" :data-index="String(i + 1).padStart(2, '0')">
<div class="mag-article-cat">{{ p.categoryName || '未分类' }}</div>
<h2 class="mag-article-title">{{ p.title }}</h2>
<p class="mag-article-excerpt">{{ p.excerpt || '(暂无摘要)' }}</p>
<div class="mag-article-meta">{{ p.date }}</div>
</router-link>
</div>
<div v-if="!posts.length" style="padding:40px 0;color:var(--m-muted);">暂无文章</div>
<div v-if="totalPages > 1" class="mag-pager">
<button class="mag-btn" :disabled="page <= 1" @click="load(page - 1)"> 上一页</button>
<span style="font-size:13px;color:var(--m-muted);align-self:center;">{{ page }} / {{ totalPages }}</span>
<button class="mag-btn" :disabled="page >= totalPages" @click="load(page + 1)">下一页 </button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchPosts, fetchCategories, type Post } from '../../../services/api'
const posts = ref<Post[]>([])
const categories = ref<Awaited<ReturnType<typeof fetchCategories>>>([])
const keyword = ref('')
const selectedCat = ref(0)
const page = ref(1)
const totalPages = ref(1)
const loading = ref(true)
const PAGE_SIZE = 10
const load = async (p: number) => {
loading.value = true
const res = await fetchPosts(keyword.value || undefined, selectedCat.value > 0 ? selectedCat.value : undefined, undefined, undefined, p, PAGE_SIZE)
const list = 'list' in res ? res.list : (Array.isArray(res) ? res : [])
const total = 'total' in res ? res.total : list.length
posts.value = list
totalPages.value = Math.max(1, Math.ceil(total / PAGE_SIZE))
page.value = p
loading.value = false
}
onMounted(async () => {
categories.value = await fetchCategories()
await load(1)
})
</script>
<style scoped>
.mag-toolbar {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 30px;
border-bottom: 2px solid var(--m-ink);
padding-bottom: 18px;
}
.mag-pager {
display: flex;
gap: 16px;
justify-content: center;
margin-top: 34px;
}
.mag-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<div>
<div v-if="!post.id" style="padding:40px 0;color:var(--m-muted);">排版中</div>
<article v-else class="mag-article-page">
<!-- 刊头信息行 -->
<div class="mag-issue">
<span>年糕杂志 · 思考专栏</span>
<span>VOL. {{ volume }}</span>
<span>{{ post.date }}</span>
</div>
<!-- 特大标题 -->
<div class="mag-article-cat" style="margin-top:8px;">{{ post.categoryName || '未分类' }}</div>
<h1 class="mag-page-title">{{ post.title }}</h1>
<!-- 作者行 -->
<div class="mag-byline">
<span> / {{ author }}</span>
<span>阅读 {{ post.readCount ?? 0 }}</span>
</div>
<!-- 正文首字下沉由 .mag-md 提供 -->
<div class="mag-md" v-html="html"></div>
<!-- 尾注 -->
<div class="mag-colophon">
<span> </span>
</div>
<router-link to="/blog" class="mag-btn"> 返回文章列表</router-link>
</article>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchPost, type Post } from '../../../services/api'
import { loadPublicSettings } from '../../../composables/usePublicSettings'
import { renderMarkdown } from '../../../utils/markdownRenderer'
const route = useRoute()
const post = ref<Partial<Post>>({})
const html = ref('')
const author = ref('年糕')
const volume = String(Math.abs(Number(route.params.id) || 1) % 24 + 1).padStart(2, '0')
onMounted(async () => {
try {
const settings = await loadPublicSettings()
author.value = settings.site_author || '年糕'
const p = await fetchPost(String(route.params.id))
post.value = p
html.value = await renderMarkdown(p.content || p.excerpt || '(暂无内容)')
} catch {
post.value = {}
}
})
</script>
<style scoped>
.mag-issue {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
letter-spacing: 0.15em;
color: var(--m-muted);
border-bottom: 1px solid var(--m-line);
padding-bottom: 10px;
}
.mag-page-title {
font-size: clamp(30px, 5vw, 46px);
font-weight: 700;
line-height: 1.35;
margin: 10px 0 8px;
}
.mag-byline {
display: flex;
gap: 18px;
font-size: 13px;
color: var(--m-muted);
border-bottom: 2px solid var(--m-ink);
padding-bottom: 16px;
margin-bottom: 26px;
}
.mag-colophon {
text-align: center;
color: var(--m-red);
font-size: 13px;
letter-spacing: 0.4em;
margin: 40px 0 20px;
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div>
<!-- 头条区 -->
<section class="mag-feature">
<div>
<div class="mag-article-cat">本期头条</div>
<router-link v-if="featuredPost.id" :to="`/blog/${featuredPost.id}`" class="mag-feature-title">
{{ featuredPost.title }}
</router-link>
<p class="mag-article-excerpt">{{ featuredPost.excerpt || '(暂无摘要)' }}</p>
<div class="mag-article-meta">{{ featuredPost.categoryName || '未分类' }} · {{ featuredPost.date }}</div>
</div>
<div style="border-left:1px solid var(--m-line);padding-left:28px;">
<div class="mag-article-cat">编者按</div>
<p style="font-size:13px;line-height:2;color:var(--m-muted);">{{ subtitle || '欢迎来到年糕崽崽的杂志——记录设计与技术的思考。' }}</p>
<router-link to="/blog" class="mag-btn" style="margin-top:12px;">进入文章</router-link>
</div>
</section>
<!-- 最新作品 -->
<section v-if="latestWork.id" style="margin-bottom:30px;">
<div class="mag-article-cat">最新作品</div>
<router-link :to="`/works/${latestWork.id}`" class="mag-feature-title" style="font-size:24px;display:inline-block;">
{{ latestWork.title }}
</router-link>
<div class="mag-article-meta">{{ latestWork.category }} · {{ latestWork.year }}</div>
</section>
<!-- 快捷入口 -->
<section class="mag-grid" style="grid-template-columns:repeat(4,1fr);gap:14px;">
<router-link v-for="q in quicks" :key="q.path" :to="q.path" class="mag-quick">
<div class="mag-article-cat">{{ q.en }}</div>
<div style="font-size:17px;font-weight:700;">{{ q.name }}</div>
</router-link>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchWorks, fetchPosts, type Work, type Post } from '../../../services/api'
import { loadPublicSettings } from '../../../composables/usePublicSettings'
const subtitle = ref('')
const latestWork = ref<Partial<Work>>({})
const featuredPost = ref<Partial<Post>>({})
const quicks = [
{ name: '专栏', en: '栏目', path: '/columns' },
{ name: '视频', en: '视频', path: '/videos' },
{ name: '代码', en: '代码', path: '/snippets' },
{ name: '关于', en: '关于', path: '/about' },
]
onMounted(async () => {
try {
const settings = await loadPublicSettings()
subtitle.value = settings.site_description || ''
const [works, posts] = await Promise.all([fetchWorks(), fetchPosts(undefined, undefined, undefined, undefined, 1, 1)])
if (works?.length) latestWork.value = works[0]
const list = 'list' in posts ? posts.list : []
if (list?.length) featuredPost.value = list[0]
} catch { /* fallback */ }
})
</script>
<style scoped>
.mag-quick {
text-decoration: none;
border: 1px solid var(--m-line);
padding: 18px;
color: var(--m-ink);
transition: all 0.15s;
}
.mag-quick:hover {
border-color: var(--m-red);
color: var(--m-red);
}
</style>

View File

@@ -0,0 +1,9 @@
import type { Component } from 'vue'
import { defineAsyncComponent } from 'vue'
// 模板专属页面route.name → 页面组件(未注册回退经典页)
export const pageComponents: Record<string, Component> = {
home: defineAsyncComponent(() => import('./Home.vue')),
blog: defineAsyncComponent(() => import('./Blog.vue')),
blogDetail: defineAsyncComponent(() => import('./BlogDetail.vue')),
}

View File

@@ -0,0 +1,41 @@
<template>
<div class="np-root" data-template="newspaper">
<header class="np-masthead">
<div class="np-masthead-top">
<router-link to="/" class="np-masthead-title">年糕日报</router-link>
<span class="np-masthead-date">{{ today }}</span>
</div>
<nav class="np-nav">
<router-link to="/blog" class="np-nav-link" active-class="active">思考</router-link>
<router-link to="/columns" class="np-nav-link" active-class="active">专栏</router-link>
<router-link to="/works" class="np-nav-link" active-class="active">作品</router-link>
<router-link to="/videos" class="np-nav-link" active-class="active">视频</router-link>
<router-link to="/snippets" class="np-nav-link" active-class="active">代码</router-link>
<router-link to="/services" class="np-nav-link" active-class="active">合作</router-link>
<router-link to="/about" class="np-nav-link" active-class="active">关于</router-link>
</nav>
</header>
<main class="np-main" :class="{ full: isDetail }">
<RouterView v-if="!pageComponent" />
<component :is="pageComponent" v-else />
</main>
<footer class="np-footer">© {{ year }} 年糕崽崽.Dev · {{ dateNum }} · 每日一报</footer>
</div>
</template>
<script setup lang="ts">
import './newspaper.css'
import { computed } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { pageComponents } from './pages'
const route = useRoute()
const now = new Date()
const year = now.getFullYear()
const dateNum = now.toISOString().slice(0, 10).replace(/-/g, '')
const today = now.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
const pageComponent = computed(() => pageComponents[route.name as string] ?? null)
const isDetail = computed(() => route.name === 'blog-detail')
</script>

View File

@@ -0,0 +1,304 @@
/* ============================================================
NEWSPAPER template — 旧报纸风
泛黄纸底 + 浓黑标题 + 朱红报头;宋体正文;报头/中缝/多栏
信息密集,与 magazine现代编辑留白本质区分
============================================================ */
.np-root {
--n-paper: #ece2c8;
--n-ink: #1d1a14;
--n-red: #9e2b25;
--n-muted: #6e654f;
--n-line: #b8ab8a;
--n-head: 'Noto Serif SC', 'Songti SC', 'SimSun', serif;
font-family: 'Noto Serif SC', 'Songti SC', 'SimSun', serif;
background: var(--n-paper);
color: var(--n-ink);
min-height: 100vh;
position: relative;
}
/* 纸张纹理 */
.np-root::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 4;
opacity: 0.55;
background-image: radial-gradient(rgba(120, 90, 30, 0.16) 0.5px, transparent 0.5px);
background-size: 4px 4px;
}
/* 报头:双线框 + 日期栏 */
.np-masthead {
border-bottom: 4px double var(--n-ink);
padding: 14px 20px 10px;
background: var(--n-paper);
position: sticky;
top: 0;
z-index: 40;
}
.np-masthead-top {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
border-top: 2px solid var(--n-ink);
border-bottom: 2px solid var(--n-ink);
padding: 6px 0;
}
.np-masthead-title {
font-size: clamp(28px, 6vw, 46px);
font-weight: 900;
letter-spacing: 0.24em;
color: var(--n-ink);
text-decoration: none;
font-family: var(--n-head);
}
.np-masthead-date {
font-size: 11px;
color: var(--n-muted);
letter-spacing: 0.14em;
}
.np-nav {
display: flex;
gap: 2px;
margin-top: 8px;
flex-wrap: wrap;
}
.np-nav-link {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.18em;
color: var(--n-ink);
text-decoration: none;
padding: 4px 12px;
border-right: 1px solid var(--n-line);
}
.np-nav-link:last-child { border-right: none; }
.np-nav-link:hover,
.np-nav-link.active {
color: var(--n-red);
}
.np-main {
max-width: 1000px;
margin: 0 auto;
padding: 26px 18px 60px;
position: relative;
z-index: 10;
}
.np-footer {
border-top: 3px double var(--n-ink);
text-align: center;
padding: 18px;
font-size: 11px;
color: var(--n-muted);
letter-spacing: 0.14em;
}
/* 新闻条目:报版条目(分类小标 + 黑体标题 + 宋体摘要) */
.np-article {
display: block;
border-bottom: 1px solid var(--n-line);
padding: 16px 0;
text-decoration: none;
}
.np-article:hover .np-article-title {
color: var(--n-red);
}
.np-article-cat {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--n-red);
margin-bottom: 5px;
}
.np-article-title {
font-size: 21px;
font-weight: 900;
font-family: var(--n-head);
line-height: 1.5;
color: var(--n-ink);
transition: color 0.15s;
}
.np-article-excerpt {
font-size: 14px;
color: var(--n-muted);
line-height: 1.9;
margin-top: 5px;
}
.np-article-meta {
font-size: 11px;
color: var(--n-muted);
margin-top: 7px;
letter-spacing: 0.06em;
}
/* 双栏正文 */
.np-cols {
column-count: 2;
column-gap: 34px;
column-rule: 1px solid var(--n-line);
}
/* 按钮:旧报排印(描边) */
.np-btn {
display: inline-block;
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: var(--n-ink);
background: transparent;
border: 2px solid var(--n-ink);
padding: 9px 22px;
text-decoration: none;
cursor: pointer;
letter-spacing: 0.08em;
transition: all 0.15s;
}
.np-btn:hover {
background: var(--n-ink);
color: var(--n-paper);
}
.np-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* 表单 */
.np-input {
font-family: inherit;
font-size: 14px;
background: #f7f0dd;
border: 1px solid var(--n-line);
color: var(--n-ink);
padding: 9px 14px;
outline: none;
}
.np-input:focus {
border-color: var(--n-ink);
}
/* markdown报纸排印 */
.np-md {
font-size: 16px;
line-height: 2.1;
color: #2e2a20;
}
.np-md h1,
.np-md h2,
.np-md h3 {
font-weight: 900;
font-family: var(--n-head);
color: var(--n-ink);
}
.np-md h1 {
font-size: 32px;
border-bottom: 3px double var(--n-ink);
padding-bottom: 0.25em;
margin: 1.4em 0 0.6em;
line-height: 1.3;
}
.np-md h2 {
font-size: 22px;
margin: 1.3em 0 0.5em;
border-left: 4px solid var(--n-red);
padding-left: 10px;
}
.np-md h3 {
font-size: 17px;
color: var(--n-red);
}
.np-md p {
margin: 0.8em 0;
text-align: justify;
}
.np-md p:first-of-type::first-letter {
font-size: 3.2em;
font-weight: 900;
float: left;
line-height: 1;
margin: 0.05em 0.12em 0 0;
}
.np-md a {
color: var(--n-red);
text-decoration: underline;
}
.np-md pre {
background: #dcd2b8;
border: 1px solid var(--n-line);
padding: 16px;
overflow-x: auto;
font-size: 13px;
line-height: 1.8;
}
.np-md code {
font-family: 'JetBrains Mono', monospace;
background: rgba(0, 0, 0, 0.06);
color: var(--n-red);
padding: 0.1em 0.35em;
font-size: 0.9em;
}
.np-md blockquote {
border-left: 4px solid var(--n-ink);
padding-left: 16px;
color: var(--n-muted);
font-style: italic;
margin: 1.2em 0;
}
.np-md img {
max-width: 100%;
margin: 1em 0;
border: 1px solid var(--n-line);
}
.np-md table th,
.np-md table td {
border: 1px solid var(--n-line);
padding: 8px 12px;
font-size: 0.95em;
}
.np-md table th {
background: var(--n-ink);
color: var(--n-paper);
}
/* art-* 变量覆盖 */
.np-root {
--art-bg: 236 226 200;
--art-surface: 255 255 255;
--art-text: 29 26 20;
--art-muted: 110 101 79;
--art-accent: 158 43 37;
--art-border: rgba(29,26,20,0.12);
}
.np-main.full { max-width: none; }

View File

@@ -0,0 +1,77 @@
<template>
<div>
<div class="np-toolbar">
<input v-model="keyword" class="np-input" placeholder="搜索报道…" @keyup.enter="load(1)" />
<button class="np-btn" @click="load(1)">检索</button>
<select v-model="selectedCat" class="np-input" @change="load(1)">
<option :value="0">全部版面</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div v-if="loading" style="padding:40px 0;color:var(--n-muted);">排版中</div>
<template v-else>
<div class="np-cols">
<router-link v-for="p in posts" :key="p.id" :to="`/blog/${p.id}`" class="np-article" style="break-inside:avoid;">
<div class="np-article-cat">{{ p.categoryName || '未分类' }}</div>
<div class="np-article-title" style="font-size:18px;">{{ p.title }}</div>
<p class="np-article-excerpt">{{ p.excerpt || '(暂无摘要)' }}</p>
<div class="np-article-meta">{{ p.date }}</div>
</router-link>
</div>
<div v-if="!posts.length" style="padding:40px 0;color:var(--n-muted);">暂无报道</div>
<div v-if="totalPages > 1" style="display:flex;gap:14px;justify-content:center;margin-top:30px;">
<button class="np-btn" :disabled="page <= 1" @click="load(page - 1)"> 上一版</button>
<span style="align-self:center;font-size:12px;color:var(--n-muted);"> {{ page }} / {{ totalPages }} </span>
<button class="np-btn" :disabled="page >= totalPages" @click="load(page + 1)">下一版 </button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchPosts, fetchCategories, type Post } from '../../../services/api'
const posts = ref<Post[]>([])
const categories = ref<Awaited<ReturnType<typeof fetchCategories>>>([])
const keyword = ref('')
const selectedCat = ref(0)
const page = ref(1)
const totalPages = ref(1)
const loading = ref(true)
const PAGE_SIZE = 8
const load = async (p: number) => {
loading.value = true
const res = await fetchPosts(keyword.value || undefined, selectedCat.value > 0 ? selectedCat.value : undefined, undefined, undefined, p, PAGE_SIZE)
const list = 'list' in res ? res.list : (Array.isArray(res) ? res : [])
const total = 'total' in res ? res.total : list.length
posts.value = list
totalPages.value = Math.max(1, Math.ceil(total / PAGE_SIZE))
page.value = p
loading.value = false
}
onMounted(async () => {
categories.value = await fetchCategories()
await load(1)
})
</script>
<style scoped>
.np-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 20px;
border-bottom: 2px solid var(--n-ink);
padding-bottom: 14px;
}
.np-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<div>
<div v-if="!post.id" style="padding:40px 0;color:var(--n-muted);">排版中</div>
<article v-else class="np-article-page">
<!-- 报纸版头 -->
<div class="np-issue">
<span>年糕日报</span>
<span>{{ post.date }} · {{ issueNum }} </span>
<span>分类{{ post.categoryName || '未分类' }}</span>
</div>
<!-- 头条大标题 -->
<h1 class="np-page-title">{{ post.title }}</h1>
<!-- 引题行 -->
<div class="np-deck">
阅读量 {{ post.readCount ?? 0 }} · 作者 {{ author }}
</div>
<!-- 双栏正文 -->
<div class="np-md np-cols" v-html="html"></div>
<div class="np-colophon">
<span> </span>
</div>
<router-link to="/blog" class="np-btn"> 返回列表</router-link>
</article>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchPost, type Post } from '../../../services/api'
import { loadPublicSettings } from '../../../composables/usePublicSettings'
import { renderMarkdown } from '../../../utils/markdownRenderer'
const route = useRoute()
const post = ref<Partial<Post>>({})
const html = ref('')
const author = ref('年糕')
const issueNum = String(Math.abs(Number(route.params.id) || 1) % 300 + 1).padStart(3, '0')
onMounted(async () => {
try {
const settings = await loadPublicSettings()
author.value = settings.site_author || '年糕'
const p = await fetchPost(String(route.params.id))
post.value = p
html.value = await renderMarkdown(p.content || p.excerpt || '(暂无内容)')
} catch {
post.value = {}
}
})
</script>
<style scoped>
.np-issue {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--n-muted);
border-top: 3px double var(--n-ink);
border-bottom: 3px double var(--n-ink);
padding: 8px 0;
margin-bottom: 22px;
}
.np-page-title {
font-size: clamp(30px, 5vw, 46px);
font-weight: 900;
line-height: 1.3;
margin: 0 0 8px;
}
.np-deck {
font-size: 14px;
color: var(--n-muted);
border-bottom: 2px solid var(--n-ink);
padding-bottom: 14px;
margin-bottom: 22px;
}
.np-colophon {
text-align: center;
font-size: 13px;
letter-spacing: 0.4em;
color: var(--n-ink);
margin: 36px 0 18px;
clear: both;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div>
<!-- 头版 -->
<section class="np-headline">
<div class="np-article-cat">头版头条</div>
<router-link v-if="featuredPost.id" :to="`/blog/${featuredPost.id}`" class="np-headline-title">
{{ featuredPost.title }}
</router-link>
<p class="np-article-excerpt np-cols" style="margin-top:10px;">{{ featuredPost.excerpt || '(暂无摘要)' }}</p>
<div class="np-article-meta">{{ featuredPost.categoryName || '未分类' }} · {{ featuredPost.date }}</div>
</section>
<!-- 要闻列表 -->
<section class="np-more">
<div class="np-section-label">今日要闻</div>
<router-link :to="`/blog/${featuredPost.id}`" class="np-article" style="padding-top:0;">
<div class="np-article-cat">精选</div>
<div class="np-article-title" style="font-size:18px;">{{ featuredPost.title }}</div>
</router-link>
<router-link v-if="latestWork.id" :to="`/works/${latestWork.id}`" class="np-article">
<div class="np-article-cat">作品版</div>
<div class="np-article-title" style="font-size:18px;">{{ latestWork.title }} {{ latestWork.category }} · {{ latestWork.year }}</div>
</router-link>
<div class="np-article">
<div class="np-article-cat">栏目导航</div>
<div class="np-nav" style="border:none;padding:0;gap:6px;">
<router-link v-for="q in quicks" :key="q.path" :to="q.path" class="np-nav-link" style="font-size:12px;padding:6px 10px;border:1px solid var(--n-line);">{{ q.name }}</router-link>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchWorks, fetchPosts, type Work, type Post } from '../../../services/api'
import { loadPublicSettings } from '../../../composables/usePublicSettings'
const subtitle = ref('')
const latestWork = ref<Partial<Work>>({})
const featuredPost = ref<Partial<Post>>({})
const quicks = [
{ name: '思考', path: '/blog' },
{ name: '专栏', path: '/columns' },
{ name: '作品', path: '/works' },
{ name: '视频', path: '/videos' },
{ name: '代码', path: '/snippets' },
{ name: '关于', path: '/about' },
]
onMounted(async () => {
try {
const settings = await loadPublicSettings()
subtitle.value = settings.site_description || ''
const [works, posts] = await Promise.all([fetchWorks(), fetchPosts(undefined, undefined, undefined, undefined, 1, 1)])
if (works?.length) latestWork.value = works[0]
const list = 'list' in posts ? posts.list : []
if (list?.length) featuredPost.value = list[0]
} catch { /* fallback */ }
})
</script>
<style scoped>
.np-headline {
border-bottom: 3px double var(--n-ink);
padding-bottom: 24px;
margin-bottom: 24px;
}
.np-headline-title {
font-size: clamp(30px, 6vw, 46px);
font-weight: 900;
line-height: 1.3;
color: var(--n-ink);
text-decoration: none;
display: block;
}
.np-headline-title:hover {
color: var(--n-red);
}
.np-section-label {
font-size: 14px;
font-weight: 900;
letter-spacing: 0.2em;
border-bottom: 2px solid var(--n-ink);
padding-bottom: 8px;
margin-bottom: 4px;
}
</style>

View File

@@ -0,0 +1,8 @@
import type { Component } from 'vue'
import { defineAsyncComponent } from 'vue'
export const pageComponents: Record<string, Component> = {
home: defineAsyncComponent(() => import('./Home.vue')),
blog: defineAsyncComponent(() => import('./Blog.vue')),
blogDetail: defineAsyncComponent(() => import('./BlogDetail.vue')),
}

View File

@@ -0,0 +1,40 @@
<template>
<div class="pixel-root" data-template="pixel">
<nav class="pixel-nav">
<router-link to="/" class="pixel-nav-logo">&gt; 年糕的博客 _</router-link>
<div class="pixel-nav-links">
<router-link to="/blog" class="pixel-nav-link" active-class="active">博客</router-link>
<router-link to="/works" class="pixel-nav-link" active-class="active">作品</router-link>
<router-link to="/columns" class="pixel-nav-link" active-class="active">专栏</router-link>
<router-link to="/videos" class="pixel-nav-link" active-class="active">视频</router-link>
<router-link to="/snippets" class="pixel-nav-link" active-class="active">代码</router-link>
<router-link to="/services" class="pixel-nav-link" active-class="active">合作</router-link>
<router-link to="/about" class="pixel-nav-link" active-class="active">关于</router-link>
</div>
</nav>
<main class="pixel-main" :class="{ full: isDetail }">
<RouterView v-if="!pageComponent" />
<component :is="pageComponent" v-else />
</main>
<footer class="pixel-footer">
<div>&gt; 年糕崽崽 · 投币继续 _</div>
<div style="margin-top:6px;">© {{ year }} · powered by art-code</div>
</footer>
</div>
</template>
<script setup lang="ts">
import './pixel.css'
import { computed } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { pageComponents } from './pages'
const route = useRoute()
const year = new Date().getFullYear()
// 模板专属页面优先,未实现回退经典页(不白屏)
const pageComponent = computed(() => pageComponents[route.name as string] ?? null)
const isDetail = computed(() => route.name === 'blog-detail')
</script>

View File

@@ -0,0 +1,77 @@
<template>
<div>
<div v-if="!profile.name" class="pixel-info" style="padding:30px 0;">&gt; 加载中...</div>
<div v-else class="pixel-box" style="padding:28px;">
<div class="pixel-hero-label">个人档案</div>
<div class="pixel-about-head">
<img v-if="profile.avatar" :src="profile.avatar" alt="" class="pixel-about-avatar" />
<div>
<h1 class="pixel-title" style="font-size:16px;">{{ profile.name }}</h1>
<div v-if="profile.location" class="pixel-info">&gt; 所在地 {{ profile.location }}</div>
</div>
</div>
<div v-if="profile.bio" class="pixel-info" style="margin:16px 0;line-height:2;">{{ profile.bio }}</div>
<div v-if="techStack.length" style="margin-top:18px;">
<div class="pixel-hero-label">技能树</div>
<div class="pixel-skills">
<span v-for="t in techStack" :key="t" class="pixel-skill">{{ t }}</span>
</div>
</div>
<div v-if="profile.experiences?.length" style="margin-top:18px;">
<div class="pixel-hero-label">经历</div>
<div v-for="e in profile.experiences" :key="e.year" class="pixel-info" style="margin:8px 0;">
&gt; {{ e.year }} · {{ e.company }} {{ e.role }}
</div>
</div>
<div v-if="profile.email" class="pixel-info" style="margin-top:16px;opacity:.8;">联系 {{ profile.email }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { fetchAboutProfile, type AboutProfile } from '../../../services/api'
const profile = ref<Partial<AboutProfile>>({})
const techStack = computed(() => Array.isArray(profile.value.techStack) ? profile.value.techStack : [])
onMounted(async () => {
try {
profile.value = await fetchAboutProfile()
} catch { /* fallback */ }
})
</script>
<style scoped>
.pixel-hero-label {
font-family: 'Press Start 2P', 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--p-orange);
margin-bottom: 10px;
}
.pixel-about-head {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 10px;
}
.pixel-about-avatar {
width: 72px;
height: 72px;
border: 3px solid var(--p-green);
image-rendering: pixelated;
object-fit: cover;
}
.pixel-skills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pixel-skill {
font-family: 'Press Start 2P', 'JetBrains Mono', monospace;
font-size: 8px;
color: var(--p-green);
border: 2px solid var(--p-green);
padding: 8px 10px;
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="pixel-blog">
<div class="pixel-blog-toolbar">
<input v-model="keyword" class="pixel-input" placeholder="搜索文章…" @keyup.enter="doSearch" />
<button class="pixel-btn pixel-toolbar-btn" @click="doSearch">搜索</button>
<button class="pixel-btn pixel-btn-secondary pixel-toolbar-btn" @click="resetFilter">全部</button>
<select v-model="selectedCat" class="pixel-input" @change="load(1)">
<option :value="0">全部分类</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div v-if="loading" class="pixel-info" style="padding:30px 0;">加载中</div>
<template v-else>
<router-link
v-for="(p, i) in posts"
:key="p.id"
:to="`/blog/${p.id}`"
class="pixel-stage-row"
>
<span class="pixel-stage-no">{{ String(i + 1).padStart(2, '0') }}.</span>
<span class="pixel-stage-title">{{ p.title }}</span>
<span class="pixel-stage-meta">{{ p.categoryName || '未分类' }} · {{ (p.date || '').slice(0, 10) }}</span>
</router-link>
<div v-if="!posts.length" class="pixel-info" style="padding:30px 0;">暂无文章</div>
<div v-if="totalPages > 1" class="pixel-pager">
<button class="pixel-btn pixel-toolbar-btn" :disabled="page <= 1" @click="load(page - 1)"> 上一页</button>
<span class="pixel-info" style="align-self:center;">{{ page }} / {{ totalPages }}</span>
<button class="pixel-btn pixel-toolbar-btn" :disabled="page >= totalPages" @click="load(page + 1)">下一页 </button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchPosts, fetchCategories, type Post } from '../../../services/api'
const posts = ref<Post[]>([])
const categories = ref<Awaited<ReturnType<typeof fetchCategories>>>([])
const keyword = ref('')
const selectedCat = ref(0)
const page = ref(1)
const totalPages = ref(1)
const loading = ref(true)
const PAGE_SIZE = 8
const load = async (p: number) => {
loading.value = true
const res = await fetchPosts(keyword.value || undefined, selectedCat.value > 0 ? selectedCat.value : undefined, undefined, undefined, p, PAGE_SIZE)
const list = 'list' in res ? res.list : (Array.isArray(res) ? res : [])
const total = 'total' in res ? res.total : list.length
posts.value = list
totalPages.value = Math.max(1, Math.ceil(total / PAGE_SIZE))
page.value = p
loading.value = false
}
const doSearch = () => load(1)
const resetFilter = () => {
keyword.value = ''
selectedCat.value = 0
load(1)
}
onMounted(async () => {
categories.value = await fetchCategories()
await load(1)
})
</script>
<style scoped>
.pixel-blog-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 24px;
align-items: stretch;
}
.pixel-toolbar-btn {
font-size: 11px;
padding: 8px 14px;
}
.pixel-pager {
display: flex;
gap: 14px;
justify-content: center;
margin-top: 28px;
}
.pixel-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div>
<div v-if="!post.id" class="pixel-info" style="padding:40px 0;">加载中</div>
<article v-else class="pixel-post">
<!-- 顶部状态栏 -->
<div class="pixel-post-bar">
<span class="pixel-post-stat">分类{{ post.categoryName || '未分类' }}</span>
<span class="pixel-post-stat">日期{{ post.date }}</span>
<span class="pixel-post-stat">阅读{{ post.readCount ?? 0 }}</span>
</div>
<!-- 像素标题区 -->
<h1 class="pixel-post-title">{{ post.title }}</h1>
<div class="pixel-post-rule" aria-hidden="true">
<span v-for="n in 24" :key="n"></span>
</div>
<!-- 正文 -->
<div class="pixel-md" v-html="html"></div>
<!-- 底部操作 -->
<div class="pixel-post-foot">
<router-link to="/blog" class="pixel-btn" style="font-size:11px;"> 返回列表</router-link>
<span class="pixel-info">END · 阅读完毕</span>
</div>
</article>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchPost, type Post } from '../../../services/api'
import { renderMarkdown } from '../../../utils/markdownRenderer'
const route = useRoute()
const post = ref<Partial<Post>>({})
const html = ref('')
onMounted(async () => {
try {
const p = await fetchPost(String(route.params.id))
post.value = p
html.value = await renderMarkdown(p.content || p.excerpt || '(暂无内容)')
} catch {
post.value = {}
}
})
</script>
<style scoped>
.pixel-post-bar {
display: flex;
gap: 18px;
flex-wrap: wrap;
border: 2px solid var(--p-green);
background: var(--p-surface);
padding: 12px 16px;
margin-bottom: 24px;
box-shadow: 4px 4px 0 rgba(57, 255, 20, 0.25);
}
.pixel-post-stat {
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-size: 12px;
color: var(--p-text);
letter-spacing: 0.08em;
}
.pixel-post-title {
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-size: clamp(20px, 3.5vw, 30px);
font-weight: 700;
color: var(--p-green);
line-height: 1.7;
margin: 0 0 14px;
text-shadow: 2px 2px 0 rgba(57, 255, 20, 0.25);
}
/* 像素分隔条 */
.pixel-post-rule {
display: flex;
gap: 6px;
margin-bottom: 26px;
}
.pixel-post-rule span {
width: 14px;
height: 8px;
background: var(--p-green);
box-shadow: 2px 2px 0 rgba(57, 255, 20, 0.3);
}
.pixel-post-rule span:nth-child(even) {
background: var(--p-orange);
box-shadow: 2px 2px 0 rgba(255, 159, 0, 0.3);
}
.pixel-post-foot {
display: flex;
align-items: center;
gap: 18px;
margin-top: 36px;
border-top: 2px dashed var(--p-border);
padding-top: 20px;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div>
<div v-if="!column.id" class="pixel-info" style="padding:30px 0;">&gt; 加载中...</div>
<template v-else>
<div class="pixel-detail-bar">
<router-link to="/columns" class="pixel-btn" style="font-size:9px;"> 返回</router-link>
<h1 class="pixel-title" style="font-size:14px;margin:0;">{{ column.name }}</h1>
</div>
<p class="pixel-info" style="margin:12px 0 20px;">{{ column.description }}</p>
<router-link v-for="(p, i) in posts" :key="p.id" :to="`/blog/${p.id}`" class="pixel-stage-row">
<span class="pixel-stage-no">{{ String(i + 1).padStart(2, '0') }}</span>
<span class="pixel-stage-title">{{ p.title }}</span>
<span class="pixel-stage-meta">{{ p.date }}</span>
</router-link>
<div v-if="!posts.length" class="pixel-info" style="padding:30px 0;">&gt; NO POSTS</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchColumn, fetchColumnPosts, type Column, type Post } from '../../../services/api'
const route = useRoute()
const column = ref<Partial<Column>>({})
const posts = ref<Post[]>([])
onMounted(async () => {
const id = Number(route.params.id)
try {
column.value = await fetchColumn(id)
posts.value = await fetchColumnPosts(id)
} catch { /* fallback */ }
})
</script>
<style scoped>
.pixel-detail-bar {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div>
<h1 class="pixel-title" style="margin-bottom:22px;">&gt; COLUMNS</h1>
<router-link v-for="(c, i) in columns" :key="c.id" :to="`/columns/${c.id}`" class="pixel-stage-row">
<span class="pixel-stage-no">{{ String(i + 1).padStart(2, '0') }}</span>
<span class="pixel-stage-title">{{ c.name }}</span>
<span class="pixel-stage-meta">专栏</span>
</router-link>
<div v-if="!columns.length" class="pixel-info" style="padding:30px 0;">&gt; 暂无数据</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchColumns, type Column } from '../../../services/api'
const columns = ref<Column[]>([])
onMounted(async () => {
columns.value = await fetchColumns()
})
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div class="pixel-home">
<!-- HERO游戏机开场 -->
<section class="pixel-box pixel-hero">
<div class="pixel-hero-label"> 欢迎来到我的世界 </div>
<h1 class="pixel-title pixel-hero-title">{{ title }}</h1>
<p class="pixel-info pixel-hero-desc">{{ subtitle || '一个用代码记录生活与思考的地方。' }}</p>
<div class="pixel-hero-actions">
<router-link to="/blog" class="pixel-btn"> 进入博客</router-link>
<router-link to="/works" class="pixel-btn pixel-btn-secondary">浏览作品</router-link>
</div>
</section>
<!-- 关卡选择 -->
<section class="pixel-home-grid">
<div v-if="featuredPost.id" class="pixel-box pixel-home-card">
<div class="pixel-hero-label"> 精选文章</div>
<h2 class="pixel-home-card-title">{{ featuredPost.title }}</h2>
<p class="pixel-info pixel-home-excerpt">{{ featuredPost.excerpt || '(暂无摘要)' }}</p>
<button class="pixel-btn pixel-home-btn" @click="$router.push(`/blog/${featuredPost.id}`)">进入阅读 </button>
</div>
<div v-if="latestWork.id" class="pixel-box pixel-box-orange pixel-home-card">
<div class="pixel-hero-label" style="color:var(--p-orange);"> 最新作品</div>
<h2 class="pixel-home-card-title">{{ latestWork.title }}</h2>
<p class="pixel-info pixel-home-excerpt">{{ latestWork.category }} · {{ latestWork.year }}</p>
<button class="pixel-btn pixel-btn-secondary pixel-home-btn" @click="$router.push(`/works/${latestWork.id}`)">进入查看 </button>
</div>
<div class="pixel-box pixel-home-card pixel-home-card-wide">
<div class="pixel-hero-label"> 快速选择</div>
<div class="pixel-quick-grid">
<router-link to="/snippets" class="pixel-quick-btn">代码片段</router-link>
<router-link to="/columns" class="pixel-quick-btn">专栏</router-link>
<router-link to="/videos" class="pixel-quick-btn">视频</router-link>
<router-link to="/about" class="pixel-quick-btn">关于我</router-link>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchWorks, fetchPosts, type Work, type Post } from '../../../services/api'
import { loadPublicSettings } from '../../../composables/usePublicSettings'
const title = ref('年糕崽崽.Dev')
const subtitle = ref('')
const latestWork = ref<Partial<Work>>({})
const featuredPost = ref<Partial<Post>>({})
onMounted(async () => {
try {
const settings = await loadPublicSettings()
title.value = settings.site_title || title.value
subtitle.value = settings.site_description || ''
const [works, posts] = await Promise.all([fetchWorks(), fetchPosts(undefined, undefined, undefined, undefined, 1, 1)])
if (works?.length) latestWork.value = works[0]
const list = 'list' in posts ? posts.list : []
if (list?.length) featuredPost.value = list[0]
} catch { /* fallback */ }
})
</script>
<style scoped>
.pixel-hero {
padding: 44px 32px;
margin-bottom: 28px;
position: relative;
}
.pixel-hero-label {
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-size: 12px;
color: var(--p-orange);
letter-spacing: 0.2em;
margin-bottom: 20px;
}
.pixel-hero-title {
font-size: clamp(24px, 4.5vw, 40px);
margin: 0 0 16px;
line-height: 1.5;
}
.pixel-hero-desc {
max-width: 540px;
margin-bottom: 28px;
}
.pixel-hero-actions {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.pixel-home-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.pixel-home-card {
padding: 26px;
}
.pixel-home-card-wide {
grid-column: 1 / -1;
}
.pixel-home-card-title {
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-size: 14px;
color: var(--p-text);
line-height: 1.8;
margin-bottom: 6px;
}
.pixel-home-excerpt {
margin: 10px 0 16px;
line-height: 1.9;
}
.pixel-home-btn {
font-size: 11px;
}
.pixel-quick-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-top: 16px;
}
.pixel-quick-btn {
display: block;
text-align: center;
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-size: 12px;
color: var(--p-green);
text-decoration: none;
border: 2px solid var(--p-green);
padding: 16px 8px;
transition: all 0.12s;
box-shadow: 3px 3px 0 rgba(57, 255, 20, 0.2);
}
.pixel-quick-btn:hover {
background: var(--p-green);
color: #0a0a0a;
transform: translate(-1px, -1px);
}
@media (max-width: 720px) {
.pixel-home-grid {
grid-template-columns: 1fr;
}
.pixel-quick-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div>
<h1 class="pixel-title" style="margin-bottom:22px;">&gt; COOPERATION</h1>
<section class="pixel-box" style="padding:24px;margin-bottom:24px;">
<div class="pixel-hero-label">客户评价</div>
<div v-for="t in testimonials" :key="t.id" style="margin:14px 0;">
<div class="pixel-info"> {{ '★'.repeat(Math.max(1, t.rating - 1)) }} {{ t.content }}</div>
<div class="pixel-info" style="opacity:.7;">&gt; {{ t.name }}{{ t.role ? `${t.role}` : '' }}</div>
</div>
<div v-if="!testimonials.length" class="pixel-info">&gt; 暂无评价</div>
</section>
<section class="pixel-box pixel-box-orange" style="padding:24px;">
<div class="pixel-hero-label" style="color:var(--p-orange);">合作伙伴</div>
<div v-for="p in partners" :key="p.id" class="pixel-info" style="margin:10px 0;">
&gt; {{ p.name }}{{ p.description ? ` ${p.description}` : '' }}
</div>
<div v-if="!partners.length" class="pixel-info">&gt; NO 合作伙伴</div>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchTestimonials, fetchPartners, type Testimonial, type Partner } from '../../../services/api'
const testimonials = ref<Testimonial[]>([])
const partners = ref<Partner[]>([])
onMounted(async () => {
const [t, p] = await Promise.all([fetchTestimonials(), fetchPartners()])
testimonials.value = t || []
partners.value = p || []
})
</script>
<style scoped>
.pixel-hero-label {
font-family: 'Press Start 2P', 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--p-orange);
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<div>
<h1 class="pixel-title" style="margin-bottom:22px;">&gt; 代码库</h1>
<div v-for="(s, i) in snippets" :key="s.id" class="pixel-snippet">
<button class="pixel-stage-row" style="width:100%;border:none;background:var(--p-card);text-align:left;" @click="toggle(s.id)">
<span class="pixel-stage-no">{{ String(i + 1).padStart(2, '0') }}</span>
<span class="pixel-stage-title">{{ s.title }}</span>
<span class="pixel-stage-meta">{{ s.type || 'code' }}{{ expanded === s.id ? ' ▲' : ' ▼' }}</span>
</button>
<pre v-if="expanded === s.id" class="pixel-snippet-code">{{ s.code }}</pre>
</div>
<div v-if="!snippets.length" class="pixel-info" style="padding:30px 0;">&gt; 暂无数据</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchSnippets, type Snippet } from '../../../services/api'
const snippets = ref<Snippet[]>([])
const expanded = ref<number | null>(null)
const toggle = (id: number) => {
expanded.value = expanded.value === id ? null : id
}
onMounted(async () => {
snippets.value = (await fetchSnippets()) || []
})
</script>
<style scoped>
.pixel-snippet {
margin-bottom: 12px;
}
.pixel-snippet-code {
margin: 0;
padding: 14px 16px;
background: var(--p-surface);
border: 2px dashed var(--p-orange);
border-top: none;
overflow-x: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
line-height: 1.8;
color: var(--p-green);
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div>
<div v-if="!album.id" class="pixel-info" style="padding:30px 0;">&gt; 加载中...</div>
<template v-else>
<div class="pixel-detail-bar">
<router-link to="/videos" class="pixel-btn" style="font-size:9px;"> 返回</router-link>
<h1 class="pixel-title" style="font-size:14px;margin:0;">{{ album.name }}</h1>
</div>
<p v-if="album.description" class="pixel-info" style="margin:12px 0 20px;">{{ album.description }}</p>
<router-link v-for="(v, i) in videos" :key="v.id" :to="`/videos/${v.id}`" class="pixel-stage-row">
<span class="pixel-stage-no">{{ String(i + 1).padStart(2, '0') }}</span>
<span class="pixel-stage-title">{{ v.title }}</span>
<span class="pixel-stage-meta">{{ duration(v) }}</span>
</router-link>
<div v-if="!videos.length" class="pixel-info" style="padding:30px 0;">&gt; 暂无数据</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchVideoAlbum, fetchAlbumVideos, type VideoAlbum, type VideoItem } from '../../../services/api'
const route = useRoute()
const album = ref<Partial<VideoAlbum>>({})
const videos = ref<VideoItem[]>([])
const duration = (v: VideoItem) => {
if (!v.duration) return '?'
return `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}`
}
onMounted(async () => {
const id = Number(route.params.id)
try {
album.value = await fetchVideoAlbum(id)
videos.value = (await fetchAlbumVideos(id)) || []
} catch { /* fallback */ }
})
</script>
<style scoped>
.pixel-detail-bar {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<div>
<div v-if="!video.title" class="pixel-info" style="padding:30px 0;">&gt; 加载中...</div>
<div v-else>
<div class="pixel-detail-bar">
<router-link to="/videos" class="pixel-btn" style="font-size:9px;"> 返回</router-link>
<span class="pixel-stage-meta">{{ video.categoryName || '视频' }} · {{ duration }}</span>
</div>
<h1 class="pixel-title" style="font-size:15px;margin:16px 0 12px;">{{ video.title }}</h1>
<div class="pixel-player">
<video
v-if="video.videoUrl"
:src="video.videoUrl"
:poster="video.poster || video.cover"
controls
playsinline
class="pixel-video-el"
></video>
<div v-else class="pixel-info" style="padding:40px;text-align:center;">&gt; 暂无播放地址</div>
</div>
<p v-if="video.description" class="pixel-info" style="margin-top:14px;">{{ video.description }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchVideo, type VideoItem } from '../../../services/api'
const route = useRoute()
const video = ref<Partial<VideoItem>>({})
const duration = computed(() => {
const d = video.value.duration || 0
if (!d) return '?'
return `${Math.floor(d / 60)}:${String(d % 60).padStart(2, '0')}`
})
onMounted(async () => {
try {
video.value = await fetchVideo(String(route.params.id))
} catch { /* fallback */ }
})
</script>
<style scoped>
.pixel-detail-bar {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.pixel-player {
border: 2px solid var(--p-green);
background: #000;
box-shadow: 4px 4px 0 rgba(57, 255, 20, 0.25);
}
.pixel-video-el {
width: 100%;
display: block;
max-height: 420px;
background: #000;
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<div>
<h1 class="pixel-title" style="margin-bottom:22px;">&gt; VIDEOS</h1>
<div class="pixel-cat-bar">
<button class="pixel-btn" style="font-size:9px;" :class="{ active: !selectedCat }" @click="selectCat(0)">ALL</button>
<button
v-for="c in categories"
:key="c.id"
class="pixel-btn pixel-btn-secondary"
style="font-size:9px;"
:class="{ active: selectedCat === c.id }"
@click="selectCat(c.id)"
>{{ c.name }}</button>
</div>
<router-link v-for="(v, i) in videos" :key="v.id" :to="`/videos/${v.id}`" class="pixel-stage-row">
<span class="pixel-stage-no">{{ String(i + 1).padStart(2, '0') }}</span>
<span class="pixel-stage-title">{{ v.title }}</span>
<span class="pixel-stage-meta">{{ duration(v) }} · {{ v.categoryName || '视频' }}</span>
</router-link>
<div v-if="!videos.length" class="pixel-info" style="padding:30px 0;">&gt; 暂无数据</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchVideoCategories, fetchVideos, fetchVideoAlbums, type VideoCategory, type VideoItem } from '../../../services/api'
const categories = ref<VideoCategory[]>([])
const videos = ref<VideoItem[]>([])
const selectedCat = ref(0)
const load = async (catId: number) => {
const vids = await fetchVideos(catId || undefined)
videos.value = vids || []
}
const selectCat = (id: number) => {
selectedCat.value = id
load(id)
}
const duration = (v: VideoItem) => {
if (!v.duration) return '?'
return `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}`
}
onMounted(async () => {
const cats = await fetchVideoCategories()
categories.value = cats || []
const albums = await fetchVideoAlbums()
// 无分类视频时兜底用全部视频
const vids = await fetchVideos()
videos.value = vids || []
void albums
})
</script>
<style scoped>
.pixel-cat-bar {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 22px;
}
.pixel-btn.active {
outline: 3px solid var(--p-green);
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<div>
<div v-if="!work.title" class="pixel-info" style="padding:30px 0;">&gt; 加载中...</div>
<div v-else class="pixel-md">
<div class="pixel-detail-bar">
<router-link to="/works" class="pixel-btn" style="font-size:9px;"> 返回</router-link>
<span class="pixel-stage-meta">{{ work.category }} · {{ work.year }}</span>
</div>
<h1 class="pixel-title" style="margin:16px 0 10px;">{{ work.title }}</h1>
<p v-if="work.desc" style="line-height:2;">{{ work.desc }}</p>
<div v-if="work.techStack?.length" style="margin-top:18px;">
<div class="pixel-hero-label">技术栈</div>
<div v-for="t in work.techStack" :key="t.category" class="pixel-info">
&gt; {{ t.category }}: {{ t.items.join(' / ') }}
</div>
</div>
<div v-if="work.links?.live || work.links?.github || work.links?.demo" class="pixel-detail-bar" style="margin-top:20px;">
<a v-if="work.links.live" :href="work.links.live" target="_blank" rel="noopener" class="pixel-btn" style="font-size:9px;">LIVE </a>
<a v-if="work.links.demo" :href="work.links.demo" target="_blank" rel="noopener" class="pixel-btn" style="font-size:9px;">DEMO </a>
<a v-if="work.links.github" :href="work.links.github" target="_blank" rel="noopener" class="pixel-btn pixel-btn-secondary" style="font-size:9px;">GITHUB </a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchWork, type Work } from '../../../services/api'
const route = useRoute()
const work = ref<Partial<Work>>({})
onMounted(async () => {
try {
work.value = await fetchWork(String(route.params.id))
} catch { /* fallback */ }
})
</script>
<style scoped>
.pixel-detail-bar {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.pixel-hero-label {
font-family: 'Press Start 2P', 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--p-orange);
margin: 10px 0;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div>
<h1 class="pixel-title" style="margin-bottom:22px;">&gt; WORKS</h1>
<router-link v-for="(w, i) in works" :key="w.id" :to="`/works/${w.id}`" class="pixel-stage-row">
<span class="pixel-stage-no">项目{{ String(i + 1).padStart(2, '0') }}</span>
<span class="pixel-stage-title">{{ w.title }}</span>
<span class="pixel-stage-meta">{{ w.category }} · {{ w.year }}</span>
</router-link>
<div v-if="!works.length" class="pixel-info" style="padding:30px 0;">&gt; 暂无数据</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchWorks, type Work } from '../../../services/api'
const works = ref<Work[]>([])
onMounted(async () => {
works.value = await fetchWorks()
})
</script>

View File

@@ -0,0 +1,19 @@
import type { Component } from 'vue'
import { defineAsyncComponent } from 'vue'
// 模板专属页面route.name → 页面组件(未注册回退经典页)
export const pageComponents: Record<string, Component> = {
home: defineAsyncComponent(() => import('./Home.vue')),
blog: defineAsyncComponent(() => import('./Blog.vue')),
blogDetail: defineAsyncComponent(() => import('./BlogDetail.vue')),
columns: defineAsyncComponent(() => import('./Columns.vue')),
columnDetail: defineAsyncComponent(() => import('./ColumnDetail.vue')),
works: defineAsyncComponent(() => import('./Works.vue')),
workDetail: defineAsyncComponent(() => import('./WorkDetail.vue')),
videos: defineAsyncComponent(() => import('./Videos.vue')),
videoAlbumDetail: defineAsyncComponent(() => import('./VideoAlbumDetail.vue')),
videoDetail: defineAsyncComponent(() => import('./VideoDetail.vue')),
snippets: defineAsyncComponent(() => import('./Snippets.vue')),
services: defineAsyncComponent(() => import('./Services.vue')),
about: defineAsyncComponent(() => import('./About.vue')),
}

View File

@@ -0,0 +1,310 @@
/* ============================================================
PIXEL template — 8-bit 游戏机风格
黑底 + 霓虹绿 #39ff14 + 橙 #ff9f00像素字体8-bit 边框;扫描线
============================================================ */
.pixel-root {
/* art-* 变量覆盖:未重写组件(回退经典页)自动适配本模板配色 */
--art-bg: 10 10 10;
--art-surface: 18 18 18;
--art-text: 232 255 232;
--art-muted: 143 170 143;
--art-accent: 57 255 20;
--art-border: rgba(255,255,255,0.08);
--p-bg: #0a0a0a;
--p-surface: #121212;
--p-card: #161616;
--p-text: #e8ffe8;
--p-muted: #8faa8f;
--p-green: #39ff14;
--p-orange: #ff9f00;
--p-red: #e95420;
--p-blue: #4fc3f7;
--p-border: #2a2a2a;
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
background: var(--p-bg);
color: var(--p-text);
min-height: 100vh;
position: relative;
}
/* 扫描线背景 */
.pixel-root::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 5;
background: repeating-linear-gradient(
to bottom,
transparent 0 2px,
rgba(0, 0, 0, 0.18) 2px 4px
);
opacity: 0.5;
}
/* 像素字体标题 */
.pixel-title {
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
color: var(--p-green);
text-shadow: 2px 2px 0 rgba(57, 255, 20, 0.3);
}
/* 8-bit 边框(阶梯阴影) */
.pixel-box {
background: var(--p-card);
border: 2px solid var(--p-green);
box-shadow: 4px 4px 0 rgba(57, 255, 20, 0.25);
image-rendering: pixelated;
}
.pixel-box-orange {
border-color: var(--p-orange);
box-shadow: 4px 4px 0 rgba(255, 159, 0, 0.25);
}
/* 像素按钮 */
.pixel-btn {
display: inline-block;
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-size: 11px;
color: #0a0a0a;
background: var(--p-green);
border: 2px solid #0a0a0a;
box-shadow: 0 4px 0 #0a0a0a, 4px 4px 0 rgba(57, 255, 20, 0.35);
padding: 10px 16px;
cursor: pointer;
text-decoration: none;
text-transform: uppercase;
transition: transform 0.06s, box-shadow 0.06s;
letter-spacing: 0.04em;
}
.pixel-btn:hover {
transform: translate(1px, 1px);
box-shadow: 0 3px 0 #0a0a0a, 3px 3px 0 rgba(57, 255, 20, 0.35);
}
.pixel-btn:active {
transform: translate(2px, 2px);
box-shadow: 0 1px 0 #0a0a0a;
}
.pixel-btn-secondary {
background: var(--p-orange);
box-shadow: 0 4px 0 #0a0a0a, 4px 4px 0 rgba(255, 159, 0, 0.3);
}
/* 导航 */
.pixel-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 3px solid var(--p-green);
background: var(--p-surface);
position: sticky;
top: 0;
z-index: 40;
}
.pixel-nav-logo {
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-size: 14px;
color: var(--p-green);
text-decoration: none;
text-shadow: 2px 2px 0 rgba(57, 255, 20, 0.4);
}
.pixel-nav-links {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.pixel-nav-link {
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-size: 9px;
color: var(--p-text);
text-decoration: none;
padding: 8px 10px;
border: 2px solid transparent;
transition: all 0.15s;
}
.pixel-nav-link:hover,
.pixel-nav-link.active {
color: #0a0a0a;
background: var(--p-green);
border-color: var(--p-green);
}
/* 页脚 */
.pixel-footer {
border-top: 3px solid var(--p-green);
background: var(--p-surface);
padding: 20px;
text-align: center;
font-size: 9px;
color: var(--p-muted);
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
line-height: 1.9;
}
/* 内容容器 */
.pixel-main {
max-width: 1100px;
margin: 0 auto;
padding: 24px 16px 60px;
position: relative;
z-index: 10;
}
/* 列表行STAGE 关卡式) */
.pixel-stage-row {
display: flex;
align-items: center;
gap: 14px;
background: var(--p-card);
border: 2px solid var(--p-border);
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.6);
padding: 14px 16px;
margin-bottom: 12px;
cursor: pointer;
text-decoration: none;
transition: all 0.12s;
}
.pixel-stage-row:hover {
border-color: var(--p-green);
box-shadow: 4px 4px 0 rgba(57, 255, 20, 0.3);
transform: translate(-1px, -1px);
}
.pixel-stage-no {
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-size: 11px;
color: var(--p-orange);
min-width: 58px;
}
.pixel-stage-title {
font-size: 12px;
color: var(--p-text);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.03em;
}
.pixel-stage-meta {
font-size: 8px;
color: var(--p-muted);
flex-shrink: 0;
}
/* 状态文本 */
.pixel-info {
color: var(--p-muted);
font-size: 9px;
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
line-height: 2;
}
.pixel-error {
color: var(--p-red);
}
/* 表单输入(像素化) */
.pixel-input {
font-family: inherit;
font-size: 11px;
background: var(--p-surface);
border: 2px solid var(--p-green);
color: var(--p-text);
padding: 10px 12px;
outline: none;
}
.pixel-input:focus {
box-shadow: 0 0 0 2px rgba(57, 255, 20, 0.3);
}
/* 详情页 markdown 适配 */
.pixel-md h1,
.pixel-md h2,
.pixel-md h3 {
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
color: var(--p-green);
margin: 1.4em 0 0.6em;
line-height: 1.6;
}
.pixel-md h1 {
font-size: 18px;
border-bottom: 3px solid var(--p-green);
padding-bottom: 0.4em;
}
.pixel-md h2 {
font-size: 14px;
}
.pixel-md h3 {
font-size: 11px;
}
.pixel-md p {
color: var(--p-text);
line-height: 2;
font-size: 12px;
}
.pixel-md a {
color: var(--p-orange);
text-decoration: underline;
}
.pixel-md pre {
background: var(--p-surface);
border: 2px solid var(--p-green);
padding: 14px;
overflow-x: auto;
font-size: 11px;
line-height: 1.8;
}
.pixel-md code {
font-family: 'JetBrains Mono', monospace;
color: var(--p-green);
}
.pixel-md img {
max-width: 100%;
image-rendering: pixelated;
border: 2px solid var(--p-border);
}
.pixel-md blockquote {
border-left: 4px solid var(--p-orange);
padding-left: 14px;
color: var(--p-muted);
margin: 1em 0;
}
.pixel-md table th,
.pixel-md table td {
border: 1px solid var(--p-border);
padding: 8px;
font-size: 11px;
}
.pixel-md table th {
color: var(--p-green);
}
/* 博客详情页不限制最大宽度 */
.pixel-main.full { max-width: none; }

View File

@@ -0,0 +1,17 @@
import type { Component } from 'vue'
// 模板布局注册表slug → 懒加载 Layout
export const TEMPLATE_LAYOUTS: Record<string, () => Promise<Component>> = {
classic: () => import('../layouts/ClassicLayout.vue'),
'ubuntu-terminal': () => import('../layouts/TerminalLayout.vue'),
pixel: () => import('./pixel/PixelLayout.vue'),
magazine: () => import('./magazine/MagazineLayout.vue'),
newspaper: () => import('./newspaper/NewspaperLayout.vue'),
bento: () => import('./bento/BentoLayout.vue'),
glass: () => import('./glass/GlassLayout.vue'),
retro: () => import('./retro/RetroLayout.vue'),
guofeng: () => import('./guofeng/GuofengLayout.vue'),
}
// 内置模板 slug与后端保留字一致
export const BUILTIN_TEMPLATE_SLUGS = Object.keys(TEMPLATE_LAYOUTS)

View File

@@ -0,0 +1,36 @@
<template>
<div class="rt-root" data-template="retro">
<nav class="rt-nav">
<router-link to="/" class="rt-logo">年糕 · 印刷志</router-link>
<div class="rt-links">
<router-link to="/blog" class="rt-link" active-class="active">思考</router-link>
<router-link to="/columns" class="rt-link" active-class="active">专栏</router-link>
<router-link to="/works" class="rt-link" active-class="active">作品</router-link>
<router-link to="/videos" class="rt-link" active-class="active">视频</router-link>
<router-link to="/snippets" class="rt-link" active-class="active">代码</router-link>
<router-link to="/services" class="rt-link" active-class="active">合作</router-link>
<router-link to="/about" class="rt-link" active-class="active">关于</router-link>
</div>
</nav>
<main class="rt-main" :class="{ full: isDetail }">
<RouterView v-if="!pageComponent" />
<component :is="pageComponent" v-else />
</main>
<footer class="rt-footer">© {{ year }} 年糕崽崽.Dev · EST. {{ estYear }} · 复古印刷</footer>
</div>
</template>
<script setup lang="ts">
import './retro.css'
import { computed } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { pageComponents } from './pages'
const route = useRoute()
const year = new Date().getFullYear()
const estYear = year - 2
const pageComponent = computed(() => pageComponents[route.name as string] ?? null)
const isDetail = computed(() => route.name === 'blog-detail')
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div>
<div class="rt-toolbar">
<input v-model="keyword" class="rt-input" placeholder="检索文章…" @keyup.enter="load(1)" />
<button class="rt-btn" @click="load(1)">检索</button>
<select v-model="selectedCat" class="rt-input" @change="load(1)">
<option :value="0">全部分类</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div v-if="loading" style="padding:40px 0;color:var(--r-muted);">排版中</div>
<template v-else>
<div class="rt-list">
<router-link v-for="p in posts" :key="p.id" :to="`/blog/${p.id}`" class="rt-card rt-card-link rt-row">
<div class="rt-card-cat">{{ p.categoryName || '未分类' }} · {{ p.date }}</div>
<div class="rt-card-title" style="font-size:22px;">{{ p.title }}</div>
<div class="rt-card-desc">{{ p.excerpt || '(暂无摘要)' }}</div>
</router-link>
</div>
<div v-if="!posts.length" style="padding:40px 0;color:var(--r-muted);">暂无文章</div>
<div v-if="totalPages > 1" style="display:flex;gap:14px;justify-content:center;margin-top:28px;">
<button class="rt-btn" :disabled="page <= 1" @click="load(page - 1)"> 上一页</button>
<span style="align-self:center;color:var(--r-muted);font-size:13px;"> {{ page }} / {{ totalPages }} </span>
<button class="rt-btn" :disabled="page >= totalPages" @click="load(page + 1)">下一页 </button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchPosts, fetchCategories, type Post } from '../../../services/api'
const posts = ref<Post[]>([])
const categories = ref<Awaited<ReturnType<typeof fetchCategories>>>([])
const keyword = ref('')
const selectedCat = ref(0)
const page = ref(1)
const totalPages = ref(1)
const loading = ref(true)
const PAGE_SIZE = 8
const load = async (p: number) => {
loading.value = true
const res = await fetchPosts(keyword.value || undefined, selectedCat.value > 0 ? selectedCat.value : undefined, undefined, undefined, p, PAGE_SIZE)
const list = 'list' in res ? res.list : (Array.isArray(res) ? res : [])
const total = 'total' in res ? res.total : list.length
posts.value = list
totalPages.value = Math.max(1, Math.ceil(total / PAGE_SIZE))
page.value = p
loading.value = false
}
onMounted(async () => {
categories.value = await fetchCategories()
await load(1)
})
</script>
<style scoped>
.rt-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.rt-row {
margin-bottom: 18px;
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div>
<div v-if="!post.id" style="padding:40px 0;color:var(--r-muted);">排版中</div>
<article v-else class="rt-article-page">
<!-- 印刷刊头 -->
<div class="rt-issue">
<span>印刷志 · 文章</span>
<span>编号 {{ serialNo }}</span>
</div>
<!-- 烫金大标题 -->
<h1 class="rt-page-title rt-gold-text">{{ post.title }}</h1>
<!-- 日期印章框 -->
<div class="rt-meta">
<span>分类{{ post.categoryName || '未分类' }}</span>
<span>刊期{{ post.date }}</span>
<span>阅读{{ post.readCount ?? 0 }}</span>
</div>
<!-- 正文 -->
<div class="rt-md" v-html="html"></div>
<div class="rt-colophon"> 刊毕 </div>
<router-link to="/blog" class="rt-btn"> 返回目录</router-link>
</article>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchPost, type Post } from '../../../services/api'
import { renderMarkdown } from '../../../utils/markdownRenderer'
const route = useRoute()
const post = ref<Partial<Post>>({})
const html = ref('')
const serialNo = String(Math.abs(Number(route.params.id) || 1)).padStart(4, '0')
onMounted(async () => {
try {
const p = await fetchPost(String(route.params.id))
post.value = p
html.value = await renderMarkdown(p.content || p.excerpt || '(暂无内容)')
} catch {
post.value = {}
}
})
</script>
<style scoped>
.rt-issue {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.2em;
color: var(--r-muted);
border-bottom: 3px double var(--r-ink);
padding-bottom: 10px;
margin-bottom: 20px;
}
.rt-page-title {
font-size: clamp(28px, 5vw, 42px);
font-weight: 900;
line-height: 1.4;
margin: 0 0 14px;
}
.rt-meta {
display: flex;
gap: 20px;
flex-wrap: wrap;
font-size: 13px;
color: var(--r-muted);
border: 2px solid var(--r-ink);
padding: 12px 16px;
margin-bottom: 26px;
background: #f0e7d2;
}
.rt-colophon {
text-align: center;
letter-spacing: 0.5em;
color: var(--r-gold);
font-weight: 700;
margin: 40px 0 18px;
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div>
<section class="rt-card" style="padding:36px;margin-bottom:26px;text-align:center;">
<div class="rt-card-cat">印刷志 · 创刊号</div>
<h1 class="rt-gold-text" style="font-size:clamp(30px,6vw,46px);font-weight:900;margin:10px 0;">{{ title }}</h1>
<p class="rt-card-desc" style="max-width:520px;margin:0 auto;">{{ subtitle || '一本记录设计与技术的老派印刷志。' }}</p>
<div style="display:flex;gap:14px;justify-content:center;margin-top:20px;">
<router-link to="/blog" class="rt-btn">翻阅文章</router-link>
<router-link to="/works" class="rt-btn" style="background:var(--r-gold);border-color:var(--r-gold);">浏览作品</router-link>
</div>
</section>
<div class="rt-grid">
<router-link v-if="featuredPost.id" :to="`/blog/${featuredPost.id}`" class="rt-card rt-card-link">
<div class="rt-card-cat">精选 · 头版文章</div>
<div class="rt-card-title">{{ featuredPost.title }}</div>
<div class="rt-card-desc">{{ featuredPost.excerpt || '(暂无摘要)' }}</div>
<div class="rt-card-meta">{{ featuredPost.categoryName || '未分类' }} · {{ featuredPost.date }}</div>
</router-link>
<router-link v-if="latestWork.id" :to="`/works/${latestWork.id}`" class="rt-card rt-card-link">
<div class="rt-card-cat">作品 · 最新</div>
<div class="rt-card-title">{{ latestWork.title }}</div>
<div class="rt-card-meta">{{ latestWork.category }} · {{ latestWork.year }}</div>
</router-link>
<div class="rt-card">
<div class="rt-card-cat">目录</div>
<div class="rt-quicks">
<router-link v-for="q in quicks" :key="q.path" :to="q.path" class="rt-quick">{{ q.name }}</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchWorks, fetchPosts, type Work, type Post } from '../../../services/api'
import { loadPublicSettings } from '../../../composables/usePublicSettings'
const title = ref('年糕崽崽.Dev')
const subtitle = ref('')
const latestWork = ref<Partial<Work>>({})
const featuredPost = ref<Partial<Post>>({})
const quicks = [
{ name: '专栏', path: '/columns' },
{ name: '视频', path: '/videos' },
{ name: '代码', path: '/snippets' },
{ name: '合作', path: '/services' },
{ name: '关于', path: '/about' },
]
onMounted(async () => {
try {
const settings = await loadPublicSettings()
title.value = settings.site_title || title.value
subtitle.value = settings.site_description || ''
const [works, posts] = await Promise.all([fetchWorks(), fetchPosts(undefined, undefined, undefined, undefined, 1, 1)])
if (works?.length) latestWork.value = works[0]
const list = 'list' in posts ? posts.list : []
if (list?.length) featuredPost.value = list[0]
} catch { /* fallback */ }
})
</script>
<style scoped>
.rt-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 22px;
}
.rt-quicks {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.rt-quick {
font-size: 13px;
font-weight: 700;
color: var(--r-ink);
text-decoration: none;
border: 2px solid var(--r-ink);
padding: 7px 16px;
transition: all 0.15s;
}
.rt-quick:hover {
background: var(--r-ink);
color: var(--r-paper);
}
@media (max-width: 720px) {
.rt-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,8 @@
import type { Component } from 'vue'
import { defineAsyncComponent } from 'vue'
export const pageComponents: Record<string, Component> = {
home: defineAsyncComponent(() => import('./Home.vue')),
blog: defineAsyncComponent(() => import('./Blog.vue')),
blogDetail: defineAsyncComponent(() => import('./BlogDetail.vue')),
}

View File

@@ -0,0 +1,321 @@
/* ============================================================
RETRO template — 活字印刷风
深牛皮纸 + 深棕墨 + 烫金;活字贴纸/邮票框/art-deco 装饰线
(与 newspaper 旧报纸黄纸、magazine 白底 区分:更深沉、装饰更重)
============================================================ */
.rt-root {
--r-paper: #d9c8a3;
--r-ink: #3d2f1f;
--r-muted: #7c6a4c;
--r-gold: #b8860b;
--r-gold2: #d9ab2b;
--r-line: #b7a37c;
font-family: 'Noto Serif SC', 'Songti SC', 'SimSun', Georgia, serif;
background: var(--r-paper);
color: var(--r-ink);
min-height: 100vh;
position: relative;
}
/* 纸张颗粒(更重,模拟旧纸) */
.rt-root::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 4;
opacity: 0.55;
background-image:
radial-gradient(rgba(90, 60, 20, 0.18) 0.7px, transparent 0.7px),
radial-gradient(rgba(255, 250, 230, 0.12) 1px, transparent 1px);
background-size: 3px 3px, 5px 5px;
}
/* 烫金标题 */
.rt-gold-text {
background: linear-gradient(180deg, #e8c65a 0%, var(--r-gold) 40%, #8a6508 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* 顶部:活字横幅 */
.rt-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px;
border-bottom: 4px solid var(--r-ink);
background: var(--r-paper);
position: sticky;
top: 0;
z-index: 40;
}
.rt-logo {
font-family: 'Playfair Display', 'Noto Serif SC', serif;
font-size: 20px;
font-weight: 800;
color: var(--r-ink);
text-decoration: none;
letter-spacing: 0.14em;
}
.rt-logo span {
color: var(--r-gold);
}
.rt-links {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.rt-link {
font-size: 14px;
font-weight: 700;
color: var(--r-ink);
text-decoration: none;
padding: 6px 12px;
border: 2px solid var(--r-ink);
transition: all 0.15s;
}
.rt-link:hover,
.rt-link.active {
background: var(--r-ink);
color: var(--r-paper);
}
.rt-main {
max-width: 1000px;
margin: 0 auto;
padding: 32px 18px 60px;
position: relative;
z-index: 10;
}
.rt-footer {
border-top: 4px solid var(--r-ink);
text-align: center;
padding: 20px;
font-size: 12px;
color: var(--r-muted);
letter-spacing: 0.2em;
}
/* 卡片:旧书页(粗描边 + 角标) */
.rt-card {
background: #e6d6b0;
border: 3px solid var(--r-ink);
padding: 26px;
box-shadow: 6px 6px 0 rgba(61, 47, 31, 0.22);
position: relative;
transition: transform 0.15s, box-shadow 0.15s;
}
.rt-card:hover {
transform: translate(-2px, -2px);
box-shadow: 8px 8px 0 rgba(61, 47, 31, 0.25);
}
/* 贴纸角标retro 专属) */
.rt-stamp {
position: absolute;
top: -12px;
right: 14px;
background: var(--r-ink);
color: var(--r-paper);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
padding: 4px 12px;
transform: rotate(3deg);
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.3);
}
.rt-card-link {
display: block;
text-decoration: none;
color: inherit;
}
.rt-card-cat {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--r-gold);
margin-bottom: 10px;
}
.rt-card-title {
font-family: 'Playfair Display', 'Noto Serif SC', serif;
font-size: 20px;
font-weight: 800;
line-height: 1.5;
margin-bottom: 8px;
}
.rt-card-desc {
font-size: 13px;
color: var(--r-muted);
line-height: 1.9;
}
.rt-card-meta {
font-size: 12px;
color: var(--r-muted);
margin-top: 10px;
letter-spacing: 0.1em;
}
/* art-deco 装饰线 */
.rt-deco {
display: flex;
align-items: center;
gap: 8px;
margin: 18px 0;
}
.rt-deco::before,
.rt-deco::after {
content: '';
flex: 1;
border-top: 2px solid var(--r-gold);
}
.rt-deco i {
width: 8px;
height: 8px;
background: var(--r-gold);
transform: rotate(45deg);
}
/* 按钮 */
.rt-btn {
display: inline-block;
font-family: inherit;
font-size: 14px;
font-weight: 800;
color: var(--r-paper);
background: var(--r-ink);
border: 2px solid var(--r-ink);
padding: 10px 22px;
text-decoration: none;
cursor: pointer;
letter-spacing: 0.1em;
transition: all 0.15s;
}
.rt-btn:hover {
background: var(--r-gold);
border-color: var(--r-gold);
}
.rt-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* 表单 */
.rt-input {
font-family: inherit;
font-size: 14px;
background: #efe1c0;
border: 2px solid var(--r-line);
color: var(--r-ink);
padding: 10px 14px;
outline: none;
}
.rt-input:focus {
border-color: var(--r-ink);
}
/* markdown活字排印 */
.rt-md {
font-size: 16px;
line-height: 2.1;
color: #3a2d1e;
}
.rt-md h1,
.rt-md h2,
.rt-md h3 {
font-weight: 900;
font-family: 'Noto Serif SC', 'Songti SC', serif;
color: var(--r-ink);
}
.rt-md h1 {
font-size: 30px;
margin: 1.5em 0 0.6em;
padding-bottom: 0.25em;
border-bottom: 3px double var(--r-gold);
}
.rt-md h2 {
font-size: 22px;
margin: 1.3em 0 0.5em;
}
.rt-md h3 {
font-size: 17px;
color: var(--r-gold);
}
.rt-md a {
color: var(--r-gold);
font-weight: 700;
text-decoration: underline;
}
.rt-md pre {
background: #d6c49c;
border: 2px solid var(--r-line);
padding: 16px;
overflow-x: auto;
font-size: 13px;
line-height: 1.8;
}
.rt-md code {
font-family: 'JetBrains Mono', monospace;
background: rgba(61, 47, 31, 0.1);
color: var(--r-gold);
padding: 0.1em 0.35em;
}
.rt-md blockquote {
border-left: 4px solid var(--r-gold);
padding-left: 16px;
color: var(--r-muted);
font-style: italic;
}
.rt-md img {
max-width: 100%;
border: 3px solid var(--r-ink);
margin: 1em 0;
}
.rt-md table th,
.rt-md table td {
border: 2px solid var(--r-line);
padding: 8px 12px;
}
.rt-md table th {
background: var(--r-ink);
color: var(--r-paper);
}
/* art-* 变量覆盖 */
.rt-root {
--art-bg: 217 200 163;
--art-surface: 230 214 176;
--art-text: 61 47 31;
--art-muted: 124 106 76;
--art-accent: 184 134 11;
--art-border: rgba(61,47,31,0.16);
}
.rt-main.full { max-width: none; }

View File

@@ -0,0 +1,72 @@
<template>
<div v-if="payload" class="term-preview">
<div class="term-preview-body">
<PostDetailOutput
v-if="payload.kind === 'post'"
:payload="payload.data as PostPayload"
hide-back
@back="$emit('close')"
/>
<WorkDetailOutput
v-else-if="payload.kind === 'work'"
:payload="payload.data as WorkPayload"
hide-back
@back="$emit('close')"
/>
<SnippetDetailOutput
v-else-if="payload.kind === 'snippet'"
:payload="payload.data as SnippetPayload"
hide-back
@back="$emit('close')"
/>
<VideoOutput
v-else-if="payload.kind === 'video'"
:payload="payload.data as VideoPayload"
hide-back
@back="$emit('close')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import PostDetailOutput from './output/PostDetailOutput.vue'
import WorkDetailOutput from './output/WorkDetailOutput.vue'
import SnippetDetailOutput from './output/SnippetDetailOutput.vue'
import VideoOutput from './output/VideoOutput.vue'
import type { PreviewPayload, PostPayload, WorkPayload, SnippetPayload, VideoPayload } from './types'
defineProps<{ payload: PreviewPayload }>()
defineEmits<{ (e: 'close'): void }>()
</script>
<style scoped>
.term-preview {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
width: 100%;
}
.term-preview-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 16px 16px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
}
.term-preview-body::-webkit-scrollbar {
width: 8px;
}
.term-preview-body::-webkit-scrollbar-track {
background: transparent;
}
.term-preview-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.18);
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<div ref="scrollRef" class="term-body" @click="focusInput">
<div class="term-lines">
<template v-for="ln in lines" :key="ln.id">
<!-- 命令回显 -->
<div v-if="ln.kind === 'cmd'" class="term-line">
<span class="term-prompt">{{ promptText }}</span>
<span class="term-cmd-text">{{ ln.text }}</span>
</div>
<!-- 纯文本 -->
<div v-else-if="ln.kind === 'text' || ln.kind === 'info' || ln.kind === 'error'" class="term-line" :class="{
'term-info': ln.kind === 'info',
'term-error': ln.kind === 'error',
}">{{ ln.text }}</div>
<!-- ASCII banner -->
<pre v-else-if="ln.kind === 'banner'" class="term-banner">{{ bannerText }}</pre>
<!-- 结构化输出 -->
<div v-else class="term-line">
<PostListOutput
v-if="ln.kind === 'posts'"
:payload="ln.data as PostsPayload"
@open="onOpenPost"
@open-cat="onOpenCat"
/>
<PostDetailOutput v-else-if="ln.kind === 'post'" :payload="ln.data as PostPayload" @back="emit('back')" />
<WorkDetailOutput v-else-if="ln.kind === 'work'" :payload="ln.data as WorkPayload" @back="emit('back')" />
<SnippetDetailOutput v-else-if="ln.kind === 'snippet'" :payload="ln.data as SnippetPayload" @back="emit('back')" />
<VideoOutput v-else-if="ln.kind === 'video'" :payload="ln.data as VideoPayload" @back="emit('back')" />
<ListOutput
v-else-if="ln.kind === 'works' || ln.kind === 'columns' || ln.kind === 'snippets' || ln.kind === 'videos' || ln.kind === 'tags' || ln.kind === 'categories'"
:payload="ln.data as ListPayload<Record<string, any>>"
:variant="ln.kind"
@open="(id: string) => onOpenList(id, ln.kind)"
/>
<AboutOutput v-else-if="ln.kind === 'about'" :payload="ln.data as AboutPayload" />
</div>
</template>
<!-- 输入行 -->
<div class="term-line term-input-row">
<span class="term-prompt">{{ promptText }}</span>
<input
ref="inputRef"
v-model="input"
class="term-input"
type="text"
:placeholder="placeholder"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
@keydown="onKeydown"
@input="onInput"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import PostListOutput from './output/PostListOutput.vue'
import PostDetailOutput from './output/PostDetailOutput.vue'
import WorkDetailOutput from './output/WorkDetailOutput.vue'
import SnippetDetailOutput from './output/SnippetDetailOutput.vue'
import VideoOutput from './output/VideoOutput.vue'
import ListOutput from './output/ListOutput.vue'
import AboutOutput from './output/AboutOutput.vue'
import { getCompletionCandidates, BANNER } from './commands'
import type { TermLine, PostsPayload, PostPayload, WorkPayload, SnippetPayload, VideoPayload, ListPayload, AboutPayload } from './types'
const props = defineProps<{
lines: TermLine[]
cwd: string
user: string
host: string
siteTitle: string
}>()
const emit = defineEmits<{
(e: 'command', raw: string): void
(e: 'navigate', path: string): void
(e: 'hint', text: string): void
(e: 'back'): void
}>()
const scrollRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const input = ref('')
const history: string[] = []
let historyIndex = -1
const bannerText = BANNER
const promptText = computed(() => {
const dir = props.cwd ? `/${props.cwd}` : '~'
return `${props.user}@${props.host}:${dir}$`
})
const placeholder = computed(() => `输入 help 查看命令 · ${props.siteTitle}`)
// 自动滚动到底部
watch(() => props.lines.length, async () => {
await nextTick()
scrollToBottom()
})
watch(() => props.cwd, async () => {
await nextTick()
scrollToBottom()
})
function scrollToBottom() {
const el = scrollRef.value
if (el) el.scrollTop = el.scrollHeight
}
function focusInput() {
inputRef.value?.focus()
}
function onInput() {
// 输入变化时保持焦点(移动端虚拟键盘场景)
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
submit()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (history.length === 0) return
historyIndex = historyIndex < 0 ? history.length - 1 : Math.max(0, historyIndex - 1)
input.value = history[historyIndex]
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (historyIndex < 0) return
historyIndex += 1
if (historyIndex >= history.length) {
historyIndex = -1
input.value = ''
} else {
input.value = history[historyIndex]
}
} else if (e.key === 'Tab') {
e.preventDefault()
complete()
}
}
function complete() {
const candidates = getCompletionCandidates(input.value)
if (candidates.length === 1) {
const parts = input.value.trim().split(/\s+/)
if (parts.length <= 1) {
input.value = candidates[0]
} else {
parts[parts.length - 1] = candidates[0]
input.value = parts.join(' ') + ' '
}
} else if (candidates.length > 1 && input.value.trim().length > 0) {
// 输出候选列表
emit('hint', candidates.join(' '))
}
}
function submit() {
const raw = input.value.trim()
if (!raw) return
history.push(raw)
historyIndex = -1
input.value = ''
emit('command', raw)
}
function onOpenPost(id: string) {
emit('navigate', `/blog/${id}`)
}
function onOpenCat(slug: string) {
// 终端语义:直接展开分类下的文章(不改 URL
emit('command', `ls blog/${slug}`)
}
function onOpenList(id: string, variant: string) {
if (variant === 'columns') {
emit('navigate', `/columns/${id}`)
} else if (variant === 'videos') {
emit('navigate', `/videos/${id}`)
} else {
emit('navigate', `/works/${id}`)
}
}
defineExpose({ focusInput })
</script>

View File

@@ -0,0 +1,91 @@
<template>
<div class="term-theme-panel" @click.stop>
<h4>终端配色</h4>
<div class="term-theme-presets">
<button
v-for="(p, key) in THEME_PRESETS"
:key="key"
type="button"
class="term-theme-preset"
:class="{ active: theme.preset === key }"
@click="selectPreset(key)"
>
<span class="tp-name">{{ p.name }}</span>
<span class="tp-desc">{{ p.desc }}</span>
</button>
</div>
<h4>自定义颜色</h4>
<div class="term-theme-colors">
<div v-for="item in colorItems" :key="item.key" class="term-theme-color-row">
<label>{{ item.label }}</label>
<input
type="color"
:value="colors[item.key]"
@input="onColorChange(item.key, ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
<div class="term-theme-actions">
<button type="button" class="term-theme-btn reset" @click="resetTheme">恢复 Ubuntu 默认</button>
<button type="button" class="term-theme-btn" @click="emit('close')">关闭</button>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import {
THEME_PRESETS, mergedColors,
type TerminalTheme, type ThemeColors,
} from './theme'
const props = defineProps<{ theme: TerminalTheme }>()
const emit = defineEmits<{
(e: 'update', theme: TerminalTheme): void
(e: 'close'): void
}>()
const colorItems: Array<{ key: keyof ThemeColors; label: string }> = [
{ key: 'bg', label: '页面背景' },
{ key: 'windowBg', label: '窗口背景' },
{ key: 'titlebarBg', label: '标题栏' },
{ key: 'titlebarText', label: '标题栏文字' },
{ key: 'text', label: '主文字' },
{ key: 'muted', label: '次要文字' },
{ key: 'prompt', label: '提示符' },
{ key: 'accent', label: '强调色' },
{ key: 'error', label: '错误色' },
{ key: 'banner', label: 'Banner' },
]
// 当前合并后的完整色板(用于 color input 显示)
const colors = reactive<ThemeColors>(mergedColors(props.theme))
watch(() => props.theme, (t) => {
const merged = mergedColors(t)
for (const k of Object.keys(merged) as Array<keyof ThemeColors>) {
colors[k] = merged[k]
}
}, { deep: true })
function commit(colorsPatch: Partial<ThemeColors>) {
emit('update', { preset: props.theme.preset, colors: { ...props.theme.colors, ...colorsPatch } })
}
function selectPreset(key: string) {
emit('update', { preset: key, colors: {} })
}
function onColorChange(key: keyof ThemeColors, value: string) {
colors[key] = value
commit({ [key]: value })
}
function resetTheme() {
emit('update', { preset: 'ubuntu', colors: {} })
}
</script>

Some files were not shown because too many files have changed in this diff Show More