Переглянути джерело

Merge pull request #2448 from visuddhinanda/development

Development
visuddhinanda 2 днів тому
батько
коміт
9f98b0f986
100 змінених файлів з 3598 додано та 440 видалено
  1. 95 0
      .agents/skills/frontend-design/SKILL.md
  2. 74 0
      .agents/skills/frontend-design/references/ant-design-v6.md
  3. 81 0
      .agents/skills/frontend-design/references/design-tokens.md
  4. 98 0
      .agents/skills/frontend-design/references/laravel-blade.md
  5. 131 0
      .agents/skills/frontend-design/references/tailwind-v4.md
  6. 2 0
      api-v13/.env.example
  7. 15 16
      api-v13/app/Console/Commands/ExportPaliSynonyms.php
  8. 8 104
      api-v13/app/Console/Commands/IndexTerm.php
  9. 56 3
      api-v13/app/Http/Api/TemplateRender.php
  10. 22 10
      api-v13/app/Http/Controllers/CorpusController.php
  11. 28 0
      api-v13/app/Http/Controllers/DhammaTermController.php
  12. 193 0
      api-v13/app/Http/Controllers/Library/CourseController.php
  13. 44 77
      api-v13/app/Http/Controllers/PageIndexController.php
  14. 6 1
      api-v13/app/Http/Controllers/RecentController.php
  15. 14 3
      api-v13/app/Http/Resources/SentSimResource.php
  16. 20 19
      api-v13/app/Services/OpenSearchService.php
  17. 1 1
      api-v13/app/Services/PaliTextService.php
  18. 118 0
      api-v13/app/Services/TermIndexService.php
  19. 4 1
      api-v13/config/mint.php
  20. 70 0
      api-v13/documents/homepage-image-prompts.md
  21. BIN
      api-v13/public/img/home/course-1280.webp
  22. BIN
      api-v13/public/img/home/development-1280.webp
  23. BIN
      api-v13/public/img/home/library-1280.webp
  24. BIN
      api-v13/public/img/home/workspace-1280.webp
  25. 264 0
      api-v13/resources/css/home.css
  26. 467 0
      api-v13/resources/css/modules/library-course.css
  27. 23 0
      api-v13/resources/lang/en/library.php
  28. 1 0
      api-v13/resources/lang/en/site.php
  29. 23 0
      api-v13/resources/lang/zh-Hans/library.php
  30. 1 0
      api-v13/resources/lang/zh-Hans/site.php
  31. 23 0
      api-v13/resources/lang/zh-Hant/library.php
  32. 1 0
      api-v13/resources/lang/zh-Hant/site.php
  33. 115 0
      api-v13/resources/views/components/home/card.blade.php
  34. 52 0
      api-v13/resources/views/components/library/course-card.blade.php
  35. 68 0
      api-v13/resources/views/components/library/course-row.blade.php
  36. 2 0
      api-v13/resources/views/components/library/header.blade.php
  37. 12 0
      api-v13/resources/views/components/library/navbar.blade.php
  38. 59 0
      api-v13/resources/views/library/course/history.blade.php
  39. 128 0
      api-v13/resources/views/library/course/index.blade.php
  40. 42 0
      api-v13/resources/views/pages/home.blade.php
  41. 3 1
      api-v13/routes/api.php
  42. 12 14
      api-v13/routes/web.php
  43. 2 0
      api-v13/vite.config.ts
  44. 44 0
      dashboard-v6/src/api/article.ts
  45. 39 0
      dashboard-v6/src/api/pali-text.ts
  46. 18 0
      dashboard-v6/src/api/task.ts
  47. 2 2
      dashboard-v6/src/components/anthology/EditableTocTree.tsx
  48. 2 1
      dashboard-v6/src/components/article/ArticleEditTools.tsx
  49. 2 2
      dashboard-v6/src/components/article/ArticleList.tsx
  50. 2 2
      dashboard-v6/src/components/article/TypePage.tsx
  51. 41 31
      dashboard-v6/src/components/article/TypePali.tsx
  52. 15 4
      dashboard-v6/src/components/article/TypeTerm.tsx
  53. 9 5
      dashboard-v6/src/components/article/components/ArticleLayout.tsx
  54. 2 2
      dashboard-v6/src/components/article/components/ArticleReaderToolbar.tsx
  55. 2 1
      dashboard-v6/src/components/discussion/DiscussionAnchor.tsx
  56. 21 10
      dashboard-v6/src/components/general/DisplayWrapper.tsx
  57. 14 23
      dashboard-v6/src/components/navigation/MainMenu.tsx
  58. 132 0
      dashboard-v6/src/components/notification/NotificationIcon.tsx
  59. 260 0
      dashboard-v6/src/components/notification/NotificationList.tsx
  60. 8 1
      dashboard-v6/src/components/recent/RecentModal.tsx
  61. 1 1
      dashboard-v6/src/components/related-para/RelatedPara.tsx
  62. 9 4
      dashboard-v6/src/components/sentence/SentCart.tsx
  63. 2 2
      dashboard-v6/src/components/sentence/SentEditMenu.tsx
  64. 2 2
      dashboard-v6/src/components/sentence/SentTab.tsx
  65. 2 2
      dashboard-v6/src/components/template/Article.tsx
  66. 3 0
      dashboard-v6/src/components/template/MdTpl.tsx
  67. 7 1
      dashboard-v6/src/components/template/Para.tsx
  68. 2 2
      dashboard-v6/src/components/template/ParaHandle.tsx
  69. 39 0
      dashboard-v6/src/components/template/Quality.tsx
  70. 2 1
      dashboard-v6/src/components/term/TermItem.tsx
  71. 115 0
      dashboard-v6/src/components/term/TermShare.tsx
  72. 2 2
      dashboard-v6/src/components/tipitaka/TocPath.tsx
  73. 46 50
      dashboard-v6/src/components/wbw/WbwSentCtl.tsx
  74. 186 0
      dashboard-v6/src/features/editor/CsPara.tsx
  75. 7 0
      dashboard-v6/src/features/editor/Term.tsx
  76. 5 1
      dashboard-v6/src/layouts/workspace/index.tsx
  77. 2 0
      dashboard-v6/src/locales/en-US.ts
  78. 4 0
      dashboard-v6/src/locales/en-US/label.ts
  79. 2 0
      dashboard-v6/src/locales/en-US/message.ts
  80. 18 0
      dashboard-v6/src/locales/en-US/pages.ts
  81. 2 0
      dashboard-v6/src/locales/zh-Hans.ts
  82. 3 0
      dashboard-v6/src/locales/zh-Hans/label.ts
  83. 2 0
      dashboard-v6/src/locales/zh-Hans/message.ts
  84. 18 0
      dashboard-v6/src/locales/zh-Hans/pages.ts
  85. 9 2
      dashboard-v6/src/pages/dashboard/index.tsx
  86. 8 1
      dashboard-v6/src/pages/dashboard/settings/ai-model/edit.tsx
  87. 8 1
      dashboard-v6/src/pages/dashboard/settings/ai-model/index.tsx
  88. 8 1
      dashboard-v6/src/pages/dashboard/settings/ai-model/log.tsx
  89. 9 2
      dashboard-v6/src/pages/home.tsx
  90. 5 0
      dashboard-v6/src/pages/users/account-info.tsx
  91. 11 1
      dashboard-v6/src/pages/users/change-password.tsx
  92. 14 9
      dashboard-v6/src/pages/users/forgot-password.tsx
  93. 3 0
      dashboard-v6/src/pages/users/logs.tsx
  94. 9 2
      dashboard-v6/src/pages/users/personal/index.tsx
  95. 12 9
      dashboard-v6/src/pages/users/reset-password.tsx
  96. 1 0
      dashboard-v6/src/pages/users/sign-in.tsx
  97. 3 0
      dashboard-v6/src/pages/users/sign-in2.tsx
  98. 11 8
      dashboard-v6/src/pages/users/sign-up.tsx
  99. 10 1
      dashboard-v6/src/pages/workspace/anthology/edit.tsx
  100. 5 1
      dashboard-v6/src/pages/workspace/anthology/index.tsx

+ 95 - 0
.agents/skills/frontend-design/SKILL.md

@@ -0,0 +1,95 @@
+---
+name: frontend-design
+description: 前端视觉设计与美学规范:视觉层级、8pt 间距、60-30-10 配色、字号阶梯、组件质量与响应式。覆盖 Tailwind CSS v4、Ant Design v6、React/Inertia、Laravel Blade 与原生 HTML/CSS。目标是让生成/修改的界面「美观、专业、克制」而不只是能用。
+whenToUse: 生成或修改任何页面、组件、样式时;用户要求「好看 / 美观 / 美化 / 重设计 / 更现代 / 精致」时;编写 HTML、CSS、React、Blade、Tailwind、Ant Design 代码时。
+---
+
+# Frontend Design 美学与实现规范
+
+> 目标:让界面在「能用」之上达到「美观、专业、克制」。美观不是堆特效,而是 **一致性 + 层次 + 留白 + 克制的色彩**。
+
+## 0. 动手前:先对齐现有系统(最高优先级)
+
+1. 先读项目现有代码,找到已有的设计系统 / 主题 / 组件,**优先复用,绝不另起炉灶**。
+2. 本仓库技术栈分工:
+   - `api-v13/`:Laravel + Inertia(React 19)+ **Tailwind CSS v4**(主样式方案),图标用 `@tabler/icons` + FontAwesome。
+   - `dashboard-v6/`:React 19 + **Ant Design v6**(`antd` + ProComponents + `@ant-design/charts`)。
+   - 存量页面可能残留 Bootstrap 5 / Bulma / 原生 CSS——只在维护它们时才用对应框架;**新建页面一律用该子项目的主方案**。
+3. 一个子项目只允许一套主样式体系:不混用 Tailwind 与 Bootstrap/Bulma 于同一页面。
+
+## 1. 核心美学原则
+
+### 1.1 视觉层级:一屏只有一个主角
+- 每个页面有且仅有一个**主焦点**(标题、关键数据或主操作)。
+- 用「字号 / 字重 / 颜色 / 间距」四件套制造层级,而不是「更大 + 更亮 + 更粗」一起堆。
+- 次要信息主动降噪:降低对比度、缩小字号、移到次要位置。
+
+### 1.2 间距:8pt 网格
+- 间距一律取 4 或 8 的倍数:`4 8 12 16 24 32 48 64 96`。
+- 卡片内边距 `16 / 20 / 24`;卡片间距 `16 / 24`;区块间距 `32 / 48 / 64`。
+- 禁止出现 `7px`、`13px`、`19px` 这类零碎值。
+
+### 1.3 色彩:60-30-10 + 语义色 + 对比度
+- 60% 中性底色(背景 / 卡片)、30% 主色(品牌 / 交互)、10% 强调色(关键状态 / CTA)。
+- 主色只用于交互与强调,**不要大面积铺满**。
+- 状态色语义固定:success=绿、warning=黄/橙、danger=红、info=蓝;不要用彩色随意表达「好看」。
+- 文字与背景对比度满足 WCAG AA(正文 ≥ 4.5:1,大字 ≥ 3:1)。灰色文字不要浅到看不清。
+
+### 1.4 排版
+- 每个页面用**同一套字号阶梯**(见 `references/design-tokens.md`),不即兴造字号。
+- 标题与正文要有明确字重 / 字号对比;正文行高 1.5–1.6,标题行高 1.2–1.3。
+- 数字 / 金额 / 表格用 tabular-nums 并右对齐,便于扫描。
+
+### 1.5 留白与密度
+- 默认**宁可多留白**:拥挤是「丑」的头号来源。
+- 相关元素靠得近(成组),不相关元素拉开距离(分隔)。
+
+### 1.6 一致性(重中之重)
+- 圆角、阴影、边框、图标风格、按钮尺寸在一页内**统一**。
+- 圆角:小元素 `6–8px`、卡片 `10–12px`、全圆(头像 / 徽章)。同一界面最多 2 档圆角。
+- 阴影只用 1–2 层、且非常克制(hover / 浮层才用),不要到处投影。
+
+### 1.7 克制
+- 动画只用于反馈(hover / 进入 / 加载),时长 150–250ms,缓动一致。
+- 少用高饱和渐变、玻璃拟态、发光、大图背景;需要时只做点缀。
+- 图标统一一套(`@tabler/icons` 或 antd icons),不混搭风格。
+
+## 2. 布局模式(可直接套用)
+
+- **卡片**:标题 + 正文 + 操作区(右下角)。用边框或浅色底代替重阴影。
+- **表单**:标签在输入框上方、左对齐;必填用 `*`;错误信息贴近字段且为红色;主提交按钮位置固定。
+- **表格**:表头加粗、行高 48–56px、斑马纹可选但弱;数字右对齐;操作列固定右侧。
+- **导航**:当前项高亮(主色或字重),hover 有反馈;层级清晰。
+- **空状态**:图标 + 一句说明 + 一个行动按钮,不要留白一片。
+- **加载态**:骨架屏或 spinner + 文案;不要在空白里闪一下。
+- **错误 / 异常态**:清楚说明「发生了什么 + 用户可以做什么」,不要只抛红字。
+
+## 3. 组件质量红线(每个组件交付前自查)
+
+- [ ] 交互元素有 hover / focus / disabled 三态。
+- [ ] 键盘可达,表单 / 弹窗 / 菜单有正确的 `aria-*`。
+- [ ] 长文本不截断不溢出(ellipsis 或换行策略)。
+- [ ] 响应式:至少 3 档断点不塌;移动端可点区域 ≥ 44×44px。
+- [ ] 间距、圆角、阴影、颜色来自 token,不写魔法数字。
+- [ ] 空 / 加载 / 错误三态齐全。
+
+## 4. 技术栈指引(按子项目阅读对应参考)
+
+- Tailwind CSS v4(api-v13 主方案)→ 读 `references/tailwind-v4.md`
+- Ant Design v6(dashboard-v6)→ 读 `references/ant-design-v6.md`
+- Laravel Blade + Inertia → 读 `references/laravel-blade.md`
+- 具体 token 数值 → 读 `references/design-tokens.md`
+
+## 5. 反模式(出现即视为不合格)
+
+- ❌ 原生浏览器默认样式直接上线(默认按钮、无样式表格、蓝色下划线链接墙)。
+- ❌ 满屏高饱和色 / 彩虹配色 / 大面积纯黑纯白强对比。
+- ❌ 间距凌乱(`7px / 13px`)或元素挤成一团。
+- ❌ 同一页面混用多套圆角、多套阴影、多套图标风格。
+- ❌ 纯功能无状态:没有空态、加载态、错误态、hover 反馈。
+- ❌ 无响应式:只适配一种宽度。
+- ❌ 文案未对齐、数字不对齐、按钮大小不一。
+
+## 6. 完成标准(Definition of Done)
+
+提交前逐条确认:层级清晰、8pt 间距、色彩克制且对比达标、字号阶梯统一、圆角 / 阴影 / 图标统一、三态齐全、响应式通过、无魔法数字、与项目现有设计系统一致。

+ 74 - 0
.agents/skills/frontend-design/references/ant-design-v6.md

@@ -0,0 +1,74 @@
+# Ant Design v6(dashboard-v6)
+
+> dashboard-v6 用 **antd v6**(`antd` + `@ant-design/pro-components` + `@ant-design/charts`/`plots`)。核心:**用 ConfigProvider 的 theme 统一风格,不要在每个组件上写内联样式**。
+
+## 统一主题(最重要)
+
+在应用根节点用 `ConfigProvider` 设 token,全站一致:
+
+```tsx
+import { ConfigProvider, theme } from 'antd'
+
+<ConfigProvider
+  theme={{
+    token: {
+      colorPrimary: '#2563eb',
+      borderRadius: 8,
+      fontSize: 14,
+      colorText: '#1f2937',
+      colorTextSecondary: '#6b7280',
+      colorBorder: '#e5e7eb',
+    },
+    components: {
+      Table: { headerBg: '#f8fafc' },
+      Card: { borderRadiusLG: 12 },
+    },
+  }}
+>
+  <App />
+</ConfigProvider>
+```
+
+- 颜色、圆角、字号都在 token 层定义;单个组件的特殊样式用 `components.*` 覆盖,而非散落 `style={{...}}`。
+- 主色、语义色(`colorSuccess` / `colorWarning` / `colorError` / `colorInfo`)与 `design-tokens.md` 对齐。
+
+## ProComponents 用法
+
+- `ProTable`:表格优先用它,自带查询表单、分页、工具栏;列定义里数字列用 `align: 'right'`。
+- `ProForm`:表单优先用它,`ProFormText` / `ProFormSelect` 等;校验错误、必填标记自动处理。
+- 不要为了「看起来高级」而手写原生 table/form 绕开 Pro 组件——那会破坏一致性。
+
+## 图表(@ant-design/charts)
+
+- 图表配色与主色一致,最多 5–6 种区分色,不要默认彩虹色。
+- 数字轴右对齐、单位标注清楚;图例可读;空数据给占位提示。
+
+```tsx
+import { Column } from '@ant-design/plots'
+
+<Column
+  data={rows}
+  xField="month"
+  yField="value"
+  color="#2563eb"
+  columnStyle={{ radius: [6, 6, 0, 0] }}
+/>
+```
+
+## 布局与间距
+
+- 用 antd 的 `Space`(`size` 取 8 / 16 / 24)和 `Row/Col`(`gutter` 取 16 / 24)控制间距,不写零碎 `margin`。
+- 卡片间距 `16`,区块间距 `24`,与 8pt 网格一致。
+- 表格行高、弹窗宽度、按钮尺寸都用默认档位,不要逐处改。
+
+## 状态与反馈
+
+- 空状态:`Empty` + 一句说明 + 一个行动按钮,不要空白。
+- 加载:`Skeleton` 或 `Spin`,表格用 `Table loading` / `ProTable` 自带。
+- 错误:`Alert` 或 `Result status="error"` 说明「发生什么 + 怎么处理」。
+
+## 一致性红线
+
+- 同一页面圆角 / 阴影 / 图标风格统一(图标用 `@ant-design/icons`)。
+- 别混用 antd 组件与手写 div 模拟相同语义(如用 div 造按钮)。
+- 弹窗、抽屉、消息(`message` / `notification`)走 antd 全局实例,不自己写。

+ 81 - 0
.agents/skills/frontend-design/references/design-tokens.md

@@ -0,0 +1,81 @@
+# Design Tokens(具体数值)
+
+> 统一的设计变量。Tailwind 项目落到 `@theme`,AntD 项目落到 `ConfigProvider` 的 `theme.token`。同一界面不要出现 token 之外的魔法数值。
+
+## 中性色(背景 / 文字 / 边框)
+
+| Token | 值 | 用途 |
+|---|---|---|
+| `bg-page` | `#f7f8fa`(或 `slate-50`) | 页面底色 |
+| `bg-surface` | `#ffffff` | 卡片 / 面板 |
+| `text-primary` | `#1f2937`(`slate-800`) | 主文字 |
+| `text-secondary` | `#6b7280`(`slate-500`) | 次要文字 |
+| `text-muted` | `#9ca3af`(`slate-400`) | 占位 / 禁用 |
+| `border` | `#e5e7eb`(`slate-200`) | 分割线 / 卡片描边 |
+
+主文字与次要文字、次要与占位之间要拉开对比,避免「一片灰」。
+
+## 主色与强调色
+
+| Token | 建议值 | 用途 |
+|---|---|---|
+| `brand` | `#2563eb`(`blue-600`) | 主操作 / 链接 / 选中态 |
+| `brand-hover` | `#1d4ed8`(`blue-700`) | 主色 hover |
+| `brand-soft` | `#eff6ff`(`blue-50`) | 选中底色 / 浅底标签 |
+
+> 主色可换成项目已有的品牌色,但**选定后全局统一**,不要一个页面两个主色。
+
+## 语义色
+
+| 语义 | 前景 | 浅底 | 描边 |
+|---|---|---|---|
+| success | `#16a34a` | `#f0fdf4` | `#bbf7d0` |
+| warning | `#d97706` | `#fffbeb` | `#fde68a` |
+| danger | `#dc2626` | `#fef2f2` | `#fecaca` |
+| info | `#2563eb` | `#eff6ff` | `#bfdbfe` |
+
+状态提示统一用「浅底 + 前景字 + 描边」三件套,不要用纯色块糊脸。
+
+## 间距阶梯(8pt)
+
+`0 4 8 12 16 24 32 48 64 96`
+
+- 组件内部元素间:`4 / 8 / 12`
+- 卡片内边距:`16 / 20 / 24`
+- 卡片间距:`16 / 24`
+- 区块间距:`32 / 48 / 64`
+
+## 字号阶梯
+
+| 层级 | 字号 | 行高 | 字重 |
+|---|---|---|---|
+| display(页面大标题) | 30 / 36 | 1.2 | 700 |
+| h1 | 24 | 1.25 | 700 |
+| h2 | 20 | 1.3 | 600 |
+| h3 | 16 | 1.4 | 600 |
+| body | 14 | 1.6 | 400 |
+| small / caption | 12 | 1.5 | 400 |
+
+> 具体字号可随项目微调,但**比例关系要稳定**:每一级差约 1.2–1.25 倍,不要相邻两级只差 1px。
+
+## 圆角
+
+| Token | 值 | 用途 |
+|---|---|---|
+| `radius-sm` | 6px | 按钮 / 输入框 / 徽章 |
+| `radius-md` | 10px | 卡片 / 弹窗 |
+| `radius-full` | 9999px | 头像 / 胶囊 / 全圆徽章 |
+
+同一界面最多 2 档圆角。
+
+## 阴影(克制使用)
+
+| Token | 值 | 用途 |
+|---|---|---|
+| `shadow-sm` | `0 1px 2px rgba(0,0,0,.05)` | 卡片默认 |
+| `shadow-md` | `0 4px 12px rgba(0,0,0,.08)` | 悬浮 / 弹窗 |
+| `shadow-lg` | `0 12px 32px rgba(0,0,0,.12)` | 下拉 / 模态框 |
+
+## z-index 约定
+
+`0 内容` → `10 吸顶导航` → `20 下拉/浮层` → `30 模态框` → `40 toast/通知`

+ 98 - 0
.agents/skills/frontend-design/references/laravel-blade.md

@@ -0,0 +1,98 @@
+# Laravel Blade + Inertia
+
+> api-v13 是 Laravel + **Inertia(React)** 项目:页面主体是 Inertia 的 React 页面,Blade 负责应用外壳与纯服务端页面。两者都用 Tailwind v4 样式。
+
+## Blade 目录与职责
+
+- `resources/views/layouts/`:布局骨架(`@yield` / `@stack`)。
+- `resources/views/components/`:匿名组件(`<x-*>`)或类组件。
+- `resources/views/partials/`:可复用片段(`@include`)。
+- `resources/js/Pages/`:Inertia 页面(React 组件)。
+
+## 布局继承
+
+```blade
+{{-- layouts/app.blade.php --}}
+<!DOCTYPE html>
+<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  @vite(['resources/css/app.css', 'resources/js/app.jsx'])
+  @stack('head')
+</head>
+<body class="bg-slate-50 text-slate-800">
+  <main>@yield('content')</main>
+  @stack('scripts')
+</body>
+</html>
+```
+
+```blade
+{{-- 页面 --}}
+@extends('layouts.app')
+
+@section('content')
+  <x-card class="max-w-2xl">
+    <h1 class="text-2xl font-bold">标题</h1>
+    {{-- ... --}}
+  </x-card>
+@endsection
+```
+
+## 匿名组件(推荐)
+
+```blade
+{{-- resources/views/components/card.blade.php --}}
+@props(['title' => null])
+
+<div {{ $attributes->merge(['class' => 'rounded-md border border-slate-200 bg-white p-6']) }}>
+  @if ($title)
+    <h3 class="text-base font-semibold text-slate-800">{{ $title }}</h3>
+  @endif
+  {{ $slot }}
+</div>
+```
+
+- 用 `$attributes->merge(['class' => ...])` 让外部能追加 class,别把样式写死在外层 div 上。
+- 需要透传数据的用类组件(`php artisan make:component`),否则优先匿名组件。
+
+## 表单与安全
+
+```blade
+<form method="POST" action="{{ route('term.update', $term) }}" class="space-y-4">
+  @csrf
+  @method('PATCH')
+  {{-- 字段 --}}
+  <button type="submit" class="...">保存</button>
+</form>
+```
+
+- 写操作必带 `@csrf`,PUT/PATCH/DELETE 带 `@method`。
+- 校验错误用 `@error('field') <p class="text-xs text-red-600">{{ $message }}</p> @enderror`。
+
+## Inertia 集成
+
+```tsx
+import { Head, Link, useForm } from '@inertiajs/react'
+
+export default function TermEdit({ term, errors }) {
+  const { data, setData, post, processing } = useForm({ name: term.name })
+  return (
+    <>
+      <Head title="编辑术语" />
+      {/* 页面主体,Tailwind v4 样式 */}
+      <Link href="/terms">返回</Link>
+    </>
+  )
+}
+```
+
+- 页面内导航用 Inertia 的 `<Link>`(保留 SPA 状态),不要用整页 `<a>` 跳转。
+- 每个页面加 `<Head>` 设标题。
+- Inertia 页面样式走 Tailwind v4(见 `tailwind-v4.md`),不引入 antd。
+
+## i18n 与命名
+
+- 文案用 `__('messages.xxx')` 或 Blade `@lang('messages.xxx')`,不硬编码中文散落各处。
+- 路由命名、Blade 组件命名遵循现有约定(小写 kebab-case)。

+ 131 - 0
.agents/skills/frontend-design/references/tailwind-v4.md

@@ -0,0 +1,131 @@
+# Tailwind CSS v4 + React(api-v13 主方案)
+
+> api-v13 用 Tailwind **v4**(不是 v3)。v4 是 CSS-first 配置,语法与 v3 有差异,务必按 v4 写法,别搬 v3 的 `tailwind.config.js` 老套路。
+
+## v4 与 v3 的关键差异
+
+- **无 `tailwind.config.js`**:v4 默认零配置,直接在 CSS 里配置。
+- **入口**:`@import "tailwindcss";`
+- **自定义 token**:用 `@theme` 定义设计变量,之后自动生成对应 utility。
+
+```css
+/* resources/css/app.css */
+@import "tailwindcss";
+
+@theme {
+  --color-brand: #2563eb;
+  --color-brand-hover: #1d4ed8;
+  --color-brand-soft: #eff6ff;
+  --radius-card: 10px;
+}
+```
+
+- **自定义工具类**:用 `@utility` 而非 `@layer utilities`。
+- **ring 语法**:v4 用 `ring` / `ring-2`,不再有 `ring-opacity-*`(用 `/50` 透明度写法)。
+- **`outline-none` → `outline-hidden`**:隐藏焦点环改用 `outline-hidden`(`outline-none` 语义变了)。
+- 动态 class 拼接(`bg-${color}`)在 v4 的 JIT 下不会生成——颜色必须写全称或通过 token。
+
+## 项目里的组合工具
+
+`class-variance-authority`(cva)+ `clsx` + `tailwind-merge` 是依赖里已装的,用它做**变体组件**:
+
+```tsx
+import { cva } from 'class-variance-authority'
+import { cn } from '@/lib/cn' // clsx + tailwind-merge 封装
+
+const button = cva('inline-flex items-center gap-2 rounded-md font-medium transition-colors', {
+  variants: {
+    variant: {
+      primary: 'bg-brand text-white hover:bg-brand-hover',
+      ghost: 'text-secondary hover:bg-slate-100',
+      danger: 'bg-red-600 text-white hover:bg-red-700',
+    },
+    size: {
+      sm: 'h-8 px-3 text-xs',
+      md: 'h-10 px-4 text-sm',
+    },
+  },
+  defaultVariants: { variant: 'primary', size: 'md' },
+})
+
+export function Button({ className, variant, size, ...props }) {
+  return <button className={cn(button({ variant, size }), className)} {...props} />
+}
+```
+
+## 常用漂亮组件配方
+
+**卡片**
+
+```tsx
+<div className="rounded-md border border-slate-200 bg-white p-6">
+  <h3 className="text-base font-semibold text-slate-800">标题</h3>
+  <p className="mt-2 text-sm leading-6 text-slate-500">正文……</p>
+  <div className="mt-4 flex justify-end gap-2">{/* 操作区右下角 */}</div>
+</div>
+```
+
+**表单输入(含错误态)**
+
+```tsx
+<div>
+  <label className="mb-1.5 block text-sm font-medium text-slate-700">
+    字段名 <span className="text-red-500">*</span>
+  </label>
+  <input
+    className={cn(
+      'h-10 w-full rounded-md border bg-white px-3 text-sm outline-hidden',
+      'focus:ring-2 focus:ring-brand/30',
+      error ? 'border-red-500' : 'border-slate-200',
+    )}
+  />
+  {error && <p className="mt-1.5 text-xs text-red-600">{error}</p>}
+</div>
+```
+
+**徽章 / 状态标签**(浅底 + 前景 + 描边)
+
+```tsx
+<span className="inline-flex items-center rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 ring-1 ring-green-200">
+  已完成
+</span>
+```
+
+**表格**
+
+```tsx
+<table className="w-full text-sm">
+  <thead className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
+    <tr>{/* th py-3 px-4 */}</tr>
+  </thead>
+  <tbody className="divide-y divide-slate-100">
+    <tr className="hover:bg-slate-50">{/* td py-3 px-4 */}</tr>
+  </tbody>
+</table>
+```
+
+**空状态**
+
+```tsx
+<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
+  <Icon className="size-10 text-slate-300" />
+  <p className="text-sm text-slate-500">暂无数据</p>
+  <Button size="sm">新建</Button>
+</div>
+```
+
+**骨架屏**
+
+```tsx
+<div className="animate-pulse space-y-3">
+  <div className="h-4 w-1/3 rounded bg-slate-200" />
+  <div className="h-4 w-full rounded bg-slate-200" />
+  <div className="h-4 w-2/3 rounded bg-slate-200" />
+</div>
+```
+
+## 图标与响应式约定
+
+- 图标统一 `@tabler/icons-react`(`@tabler/icons` 已装),尺寸用 `size-4` / `size-5`,别混 FontAwesome 与 Tabler。
+- 断点用 `sm md lg xl`,移动端先写、桌面端 `md:` 增强。
+- 间距 / 圆角 / 颜色都从 `@theme` 的 token 取,不写魔法数字。

+ 2 - 0
api-v13/.env.example

@@ -75,3 +75,5 @@ JWT_SECRETS_KEY=
 
 
 # 运维接口令牌 /api/ops/*
 # 运维接口令牌 /api/ops/*
 APP_OPS_TOKEN=
 APP_OPS_TOKEN=
+
+APP_OPENSEARCH_SYNONYMS_PATH=""

+ 15 - 16
api-v13/app/Console/Commands/ExportPaliSynonyms.php

@@ -2,11 +2,12 @@
 
 
 namespace App\Console\Commands;
 namespace App\Console\Commands;
 
 
-use App\Http\Api\DictApi;
+use Illuminate\Console\Command;
+use Illuminate\Support\Str;
+
 use App\Models\DhammaTerm;
 use App\Models\DhammaTerm;
 use App\Models\UserDict;
 use App\Models\UserDict;
 use App\Services\OpenSearchService;
 use App\Services\OpenSearchService;
-use Illuminate\Console\Command;
 
 
 class ExportPaliSynonyms extends Command
 class ExportPaliSynonyms extends Command
 {
 {
@@ -54,8 +55,8 @@ class ExportPaliSynonyms extends Command
         $dictId[] = DictApi::getSysDict('system_regular');
         $dictId[] = DictApi::getSysDict('system_regular');
         $dictId[] = DictApi::getSysDict('robot_compound');
         $dictId[] = DictApi::getSysDict('robot_compound');
 */
 */
-        $filename = $this->option('output');
-        $fp = fopen($filename, 'w') or exit('Unable to open file!');
+        $filename =  $this->option('output');
+        $fp = fopen(Str::finish(config('mint.opensearch.config.synonyms_path'), '/') . $filename, 'w') or exit('Unable to open file!');
 
 
         $parents = UserDict::select('parent')
         $parents = UserDict::select('parent')
             ->whereNotNull('parent')
             ->whereNotNull('parent')
@@ -81,7 +82,7 @@ class ExportPaliSynonyms extends Command
             foreach ($teams as $term) {
             foreach ($teams as $term) {
                 $wordsList[$term->meaning] = 1;
                 $wordsList[$term->meaning] = 1;
             }
             }
-            $this->info("[{$parent->parent}] ".count($words).' team='.count($teams));
+            $this->info("[{$parent->parent}] " . count($words) . ' team=' . count($teams));
             // 合并 $parent->parent, $words->word, $team->meaning 为一个字符串数组
             // 合并 $parent->parent, $words->word, $team->meaning 为一个字符串数组
             $combinedArray = [];
             $combinedArray = [];
             $combinedArray[] = $parent->parent;
             $combinedArray[] = $parent->parent;
@@ -113,18 +114,16 @@ class ExportPaliSynonyms extends Command
         // 关闭文件
         // 关闭文件
         fclose($fp);
         fclose($fp);
 
 
-        // 写index.json
-        $info = [
-            'index' => config('mint.opensearch.index'),
-            'pali_synonyms' => app(OpenSearchService::class)->getPaliSynonymsSetting(),
-        ];
-        file_put_contents(
-            $this->changeExtensionToJson($filename),
-            json_encode($info, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
-        );
         $this->info("过滤掉非法 term {$droppedTerms} 个,整行丢弃 {$droppedLines} 行。");
         $this->info("过滤掉非法 term {$droppedTerms} 个,整行丢弃 {$droppedLines} 行。");
+
+        //update opensearch
+        $this->info('update opensearch synonyms');
+        $result = app(OpenSearchService::class)->updatePaliSynonymsPath($filename);
+        $this->info(json_encode($result, JSON_PRETTY_PRINT));
+
         $this->info('done');
         $this->info('done');
 
 
+
         return 0;
         return 0;
     }
     }
 
 
@@ -188,9 +187,9 @@ class ExportPaliSynonyms extends Command
         $filename = $pathInfo['filename'] ?? '';
         $filename = $pathInfo['filename'] ?? '';
 
 
         // 如果目录不是根目录,则添加目录分隔符
         // 如果目录不是根目录,则添加目录分隔符
-        $dirname = rtrim($dirname, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
+        $dirname = rtrim($dirname, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
 
 
         // 构建新路径
         // 构建新路径
-        return $dirname.$filename.'.json';
+        return $dirname . $filename . '.json';
     }
     }
 }
 }

+ 8 - 104
api-v13/app/Console/Commands/IndexTerm.php

@@ -4,7 +4,7 @@ namespace App\Console\Commands;
 
 
 use App\Models\DhammaTerm;
 use App\Models\DhammaTerm;
 use App\Services\OpenSearchService;
 use App\Services\OpenSearchService;
-use App\Services\TermService;
+use App\Services\TermIndexService;
 use Illuminate\Console\Command;
 use Illuminate\Console\Command;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Log;
@@ -25,7 +25,7 @@ class IndexTerm extends Command
 
 
     public function __construct(
     public function __construct(
         protected OpenSearchService $openSearchService,
         protected OpenSearchService $openSearchService,
-        protected TermService $termService,
+        protected TermIndexService $termIndexService,
     ) {
     ) {
         parent::__construct();
         parent::__construct();
     }
     }
@@ -82,7 +82,12 @@ class IndexTerm extends Command
                     Cache::put(self::CACHE_KEY, $term->id, now()->addHours(48));
                     Cache::put(self::CACHE_KEY, $term->id, now()->addHours(48));
                 }
                 }
 
 
-                $this->indexTerm($term->guid);
+                if ($this->isTest) {
+                    $document = $this->termIndexService->buildDocument($term->guid);
+                    $this->info($document['title']['text']['pali']);
+                } else {
+                    $this->termIndexService->index($term->guid);
+                }
             }
             }
 
 
             // 全部完成,清除断点缓存
             // 全部完成,清除断点缓存
@@ -97,105 +102,4 @@ class IndexTerm extends Command
             return 1;
             return 1;
         }
         }
     }
     }
-
-    /**
-     * 构建单条词条文档并写入 OpenSearch
-     *
-     * 文档结构遵循新版 mapping:
-     *   title.text.pali / title.text.zh  → 全文检索
-     *   title.suggest.pali / title.suggest.zh → 自动建议
-     *   content.text.pali / content.text.zh   → 正文内容
-     *
-     * @param  string  $id  DhammaTerm 的 guid
-     */
-    protected function indexTerm(string $id): void
-    {
-        $termData = $this->termService->find($id, 'text');
-        $channelName = $termData['channel']['name'] ?? '';
-        $isCommunity = $this->termService->isCommunity($termData['channel_id']);
-        $content = $termData['html'] ?? $termData['meaning'];
-
-        $categories = $this->extractCategories($termData['note'] ?? '');
-        $quality = $this->extractFirstQuality($termData['note'] ?? '');
-        $tags = [];
-        foreach ($categories as $key => $category) {
-            $tags[] = "category:{$category}";
-        }
-        if (! empty($quality)) {
-            $tags[] = "quality:{$quality}";
-        }
-        $document = [
-            'id' => "term_{$id}",
-            'resource_id' => $id,
-            'resource_type' => 'term',
-            'title' => [
-                'text' => [
-                    'pali' => $termData['word'],
-                    'zh' => $termData['meaning'],
-                ],
-                'suggest' => [
-                    'pali' => [$termData['word']],
-                    'zh' => [$termData['meaning']],
-                ],
-            ],
-            'summary' => [
-                'text' => $termData['summary'] ?? '',
-            ],
-            'content' => [],
-            'bold_single' => [$termData['meaning'], $termData['word']],
-            'related_id' => $termData['word'],
-            'category' => null,
-            'tags' => $tags,
-            'language' => $termData['language'],
-            'updated_at' => now()->toIso8601String(),
-            'path' => $termData['studio']['realName']."/{$channelName}",
-            'metadata' => ['channel' => $termData['channel_id']],
-        ];
-
-        // TODO: 补充语言判断,将内容放入对应的 text.pali 或 text.zh 字段
-        $plainText = strip_tags($content);
-        if (str_contains($termData['language'], 'zh')) {
-            $document['content']['text']['zh'] = $plainText;
-        } else {
-            $document['content']['text']['zh'] = $plainText;
-        }
-        $document['content']['display'] = $content;             // 展示
-
-        if ($this->isTest) {
-            $this->info($document['title']['text']['pali']);
-            // $this->info($document['summary']['text']);
-        } else {
-            $this->openSearchService->create($document['id'], $document);
-        }
-    }
-
-    /**
-     * 提取 Markdown 中的 {{category|...}} 分类标签
-     */
-    private function extractCategories(string $content): array
-    {
-        if (empty($content)) {
-            return [];
-        }
-        preg_match_all('/\{\{category\|([^}]+)\}\}/u', $content, $matches);
-
-        return array_values(array_filter(array_map(
-            fn ($item) => trim($item),
-            $matches[1] ?? []
-        )));
-    }
-
-    /**
-     * 提取 Markdown 中第一个 {{quality|...}} 标签内的内容
-     */
-    private function extractFirstQuality(string $content): string
-    {
-        if (empty($content)) {
-            return '';
-        }
-
-        preg_match('/\{\{quality\|([^}]+)\}\}/u', $content, $matches);
-
-        return isset($matches[1]) ? trim($matches[1]) : '';
-    }
 }
 }

+ 56 - 3
api-v13/app/Http/Api/TemplateRender.php

@@ -177,6 +177,10 @@ class TemplateRender
             case 'category':
             case 'category':
                 $result = $this->render_category();
                 $result = $this->render_category();
                 break;
                 break;
+            case 'quality':
+                // 质量标签模版:{{quality|pending}}
+                $result = $this->render_quality();
+                break;
             default:
             default:
                 if (mb_substr($tpl_name, 0, 4, 'UTF-8') === 'Tpl:') {
                 if (mb_substr($tpl_name, 0, 4, 'UTF-8') === 'Tpl:') {
                     $result = $this->render_tpl($tpl_name);
                     $result = $this->render_tpl($tpl_name);
@@ -232,6 +236,9 @@ class TemplateRender
         $props['id'] = $this->get_param($this->param, 'id', 1);
         $props['id'] = $this->get_param($this->param, 'id', 1);
         $props['title'] = $this->get_param($this->param, 'title', 2);
         $props['title'] = $this->get_param($this->param, 'title', 2);
         $props['style'] = $this->get_param($this->param, 'style', 3);
         $props['style'] = $this->get_param($this->param, 'style', 3);
+        if (! empty($this->channel_id)) {
+            $props['channel'] = implode('_', $this->channel_id);
+        }
 
 
         $output = [];
         $output = [];
         switch ($this->format) {
         switch ($this->format) {
@@ -274,6 +281,49 @@ class TemplateRender
         return $output;
         return $output;
     }
     }
 
 
+    /**
+     * 渲染质量标签模版:{{quality|pending}}
+     *
+     * 合法的取值只有四个:featured | standard | draft | pending
+     * 非法取值统一回退为 pending,避免渲染出未知样式。
+     */
+    public function render_quality()
+    {
+        // 默认值为 pending,同时支持命名参数 {{quality|value=featured}}
+        $value = $this->get_param($this->param, 'value', 1, 'pending');
+
+        // 校验取值是否合法,不合法则回退到默认值
+        $allowed = ['featured', 'standard', 'draft', 'pending'];
+        if (! in_array($value, $allowed, true)) {
+            $value = 'pending';
+        }
+
+        $props = ['value' => $value];
+
+        $output = [];
+        switch ($this->format) {
+            case 'react':
+                // 前端通过 props 解析,使用 antd Tag 按取值渲染不同颜色
+                $output = [
+                    'props' => base64_encode(\json_encode($props)),
+                    'html' => $value,
+                    'tag' => 'span',
+                    'tpl' => 'quality',
+                ];
+                break;
+            case 'html':
+                // 静态 html 输出,class 包含固定前缀 tag-quality 和具体取值
+                $output = "<span class=\"tag-quality {$value}\">{$value}</span>";
+                break;
+            default:
+                // text / tex / simple 等纯文本格式直接输出取值
+                $output = $value;
+                break;
+        }
+
+        return $output;
+    }
+
     public function getTermProps($word, $tag = null, $channel = null)
     public function getTermProps($word, $tag = null, $channel = null)
     {
     {
         if ($channel && ! empty($channel)) {
         if ($channel && ! empty($channel)) {
@@ -1020,6 +1070,7 @@ class TemplateRender
         $sid = $this->get_param($this->param, 'id', 1);
         $sid = $this->get_param($this->param, 'id', 1);
         $channel = $this->get_param($this->param, 'channel', 2);
         $channel = $this->get_param($this->param, 'channel', 2);
         $show = $this->get_param($this->param, 'text', 2, 'both');
         $show = $this->get_param($this->param, 'text', 2, 'both');
+        $mode = $this->get_param($this->param, 'mode', 3, null);
 
 
         if (! empty($channel)) {
         if (! empty($channel)) {
             $channels = explode(',', $channel);
             $channels = explode(',', $channel);
@@ -1032,10 +1083,11 @@ class TemplateRender
             $channels = [$sentInfo[1]];
             $channels = [$sentInfo[1]];
         }
         }
         $Sent = new CorpusController;
         $Sent = new CorpusController;
+        $currMode = $mode ?? $this->mode;
         $props = $Sent->getSentTpl(
         $props = $Sent->getSentTpl(
             $sentId,
             $sentId,
             $channels,
             $channels,
-            $this->mode,
+            $currMode,
             true,
             true,
             $this->format
             $this->format
         );
         );
@@ -1043,13 +1095,14 @@ class TemplateRender
             $props['error'] = '句子模版渲染错误。句子参数个数不符。应该是四个。';
             $props['error'] = '句子模版渲染错误。句子参数个数不符。应该是四个。';
             Log::error('句子模版渲染错误。句子参数个数不符。应该是四个。');
             Log::error('句子模版渲染错误。句子参数个数不符。应该是四个。');
         }
         }
-        if ($this->mode === 'read') {
+        if ($currMode === 'read') {
             $tpl = 'sentread';
             $tpl = 'sentread';
         } else {
         } else {
             $tpl = 'sentedit';
             $tpl = 'sentedit';
         }
         }
         if (is_array($props)) {
         if (is_array($props)) {
             $props['show'] = $show;
             $props['show'] = $show;
+            $props['mode'] = $currMode;
         }
         }
 
 
         // 输出引用
         // 输出引用
@@ -1510,7 +1563,7 @@ class TemplateRender
         return $output;
         return $output;
     }
     }
 
 
-    private function get_param(array $param, string $name, int $id, string $default = '')
+    private function get_param(array $param, string $name, int $id, ?string $default = '')
     {
     {
         if (isset($param[$name])) {
         if (isset($param[$name])) {
             return trim($param[$name]);
             return trim($param[$name]);

+ 22 - 10
api-v13/app/Http/Controllers/CorpusController.php

@@ -18,6 +18,7 @@ use App\Models\SentSimIndex;
 use App\Models\Wbw;
 use App\Models\Wbw;
 use App\Models\WbwBlock;
 use App\Models\WbwBlock;
 use App\Services\AuthService;
 use App\Services\AuthService;
+use App\Services\PaliContentService;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
 use Illuminate\Http\Response;
 use Illuminate\Support\Arr;
 use Illuminate\Support\Arr;
@@ -272,21 +273,26 @@ class CorpusController extends Controller
         }
         }
         //
         //
         $channels = [];
         $channels = [];
-        if ($request->get('mode') === 'edit') {
-            // 翻译模式加载json格式原文
-            $channels[] = ChannelApi::getSysChannel('_System_Wbw_VRI_');
-        } else {
-            // 阅读模式加载html格式原文
-            $channels[] = ChannelApi::getSysChannel('_System_Pali_VRI_');
-        }
-
         if ($request->has('channels')) {
         if ($request->has('channels')) {
             if (strpos($request->get('channels'), ',') === false) {
             if (strpos($request->get('channels'), ',') === false) {
                 $getChannel = explode('_', $request->get('channels'));
                 $getChannel = explode('_', $request->get('channels'));
             } else {
             } else {
                 $getChannel = explode(',', $request->get('channels'));
                 $getChannel = explode(',', $request->get('channels'));
             }
             }
-            $channels = array_merge($channels, $getChannel);
+            foreach ($getChannel as $channel) {
+                if (Str::isUuid($channel)) {
+                    $channels[] = $channel;
+                }
+            }
+        }
+
+        // 系统原文channel必须追加在末尾,makeContentObj 依赖此约定剔除它
+        if ($request->get('mode') === 'edit') {
+            // 翻译模式加载json格式原文
+            $channels[] = ChannelApi::getSysChannel('_System_Wbw_VRI_');
+        } else {
+            // 阅读模式加载html格式原文
+            $channels[] = ChannelApi::getSysChannel('_System_Pali_VRI_');
         }
         }
         $para = explode(',', $request->get('par'));
         $para = explode(',', $request->get('par'));
 
 
@@ -356,7 +362,12 @@ class CorpusController extends Controller
         if (count($record) === 0) {
         if (count($record) === 0) {
             $this->result['content'] = '<span>No Data</span>';
             $this->result['content'] = '<span>No Data</span>';
         } else {
         } else {
-            $this->result['content'] = $this->makeContent($record, $request->get('mode', 'read'), $indexChannel, $indexedHeading, false, true);
+            $paliService = app(PaliContentService::class);
+            $this->result['content'] = json_encode(
+                $paliService->makeContentObj($record, $request->get('mode', 'read'), $indexChannel),
+                JSON_UNESCAPED_UNICODE
+            );
+            $this->result['content_type'] = 'json';
         }
         }
 
 
         return $this->ok($this->result);
         return $this->ok($this->result);
@@ -979,6 +990,7 @@ class CorpusController extends Controller
      */
      */
     private function pushSent($result, $sent, $level = 0, $mode = 'read')
     private function pushSent($result, $sent, $level = 0, $mode = 'read')
     {
     {
+        $sent['mode'] = $mode;
 
 
         $sentProps = base64_encode(\json_encode($sent));
         $sentProps = base64_encode(\json_encode($sent));
         if ($mode === 'read') {
         if ($mode === 'read') {

+ 28 - 0
api-v13/app/Http/Controllers/DhammaTermController.php

@@ -11,17 +11,23 @@ use App\Models\AiModel;
 use App\Models\Channel;
 use App\Models\Channel;
 use App\Models\DhammaTerm;
 use App\Models\DhammaTerm;
 use App\Services\AuthService;
 use App\Services\AuthService;
+use App\Services\TermIndexService;
 use App\Tools\Tools;
 use App\Tools\Tools;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
 use Illuminate\Http\Response;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
 use Illuminate\Support\Str;
 
 
 class DhammaTermController extends Controller
 class DhammaTermController extends Controller
 {
 {
     use ChecksChannelEditPower;
     use ChecksChannelEditPower;
 
 
+    public function __construct(
+        private TermIndexService $termIndexService,
+    ) {}
+
     /**
     /**
      * Display a listing of the resource.
      * Display a listing of the resource.
      *
      *
@@ -302,6 +308,8 @@ class DhammaTermController extends Controller
             $term->save();
             $term->save();
             // 删除cache
             // 删除cache
             $this->deleteCache($term);
             $this->deleteCache($term);
+            // 重建 OpenSearch 索引
+            $this->reindex($term);
 
 
             return $this->ok(new TermResource($term));
             return $this->ok(new TermResource($term));
         } else {
         } else {
@@ -343,6 +351,24 @@ class DhammaTermController extends Controller
         }
         }
     }
     }
 
 
+    /**
+     * 术语新建/修改后重建 OpenSearch 索引。
+     *
+     * 索引是检索/维基展示的副作用数据,重建失败不应阻断本次写入,
+     * 因此只记录日志、不向上抛异常。
+     */
+    private function reindex(DhammaTerm $term): void
+    {
+        try {
+            $this->termIndexService->index($term->guid);
+        } catch (\Throwable $e) {
+            Log::error('Failed to index term after write', [
+                'guid' => $term->guid,
+                'error' => $e->getMessage(),
+            ]);
+        }
+    }
+
     /**
     /**
      * Display the specified resource.
      * Display the specified resource.
      *
      *
@@ -417,6 +443,8 @@ class DhammaTermController extends Controller
         $dhammaTerm->save();
         $dhammaTerm->save();
         // 删除cache
         // 删除cache
         $this->deleteCache($dhammaTerm);
         $this->deleteCache($dhammaTerm);
+        // 重建 OpenSearch 索引
+        $this->reindex($dhammaTerm);
 
 
         return $this->ok(new TermResource($dhammaTerm));
         return $this->ok(new TermResource($dhammaTerm));
     }
     }

+ 193 - 0
api-v13/app/Http/Controllers/Library/CourseController.php

@@ -0,0 +1,193 @@
+<?php
+
+namespace App\Http\Controllers\Library;
+
+use App\Http\Api\UserApi;
+use App\Http\Controllers\Controller;
+use App\Models\Attachment;
+use App\Models\Course;
+use App\Models\CourseMember;
+use Carbon\Carbon;
+use Illuminate\Contracts\View\View;
+use Illuminate\Http\Request;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+
+class CourseController extends Controller
+{
+    // 无封面时的渐变占位色池:按课程 id 取余,保证同一课程颜色稳定
+    private array $coverGradients = [
+        'linear-gradient(160deg, #2d2010, rgb(150, 104, 40))',
+        'linear-gradient(160deg, #1a2d10, rgb(88, 122, 44))',
+        'linear-gradient(160deg, #0d1f3c, rgb(45, 92, 138))',
+        'linear-gradient(160deg, #2d1020, rgb(140, 60, 96))',
+        'linear-gradient(160deg, #1a1a2d, rgb(72, 70, 128))',
+        'linear-gradient(160deg, #1a2820, rgb(52, 110, 90))',
+    ];
+
+    // 讲师头像文字底色池
+    private array $authorColors = [
+        '#c8860a',
+        '#2e7d32',
+        '#1565c0',
+        '#6a1b9a',
+        '#c62828',
+        '#00695c',
+        '#4e342e',
+        '#37474f',
+    ];
+
+    /**
+     * 课程栏目首页:Hero 由布局渲染,正文含统计条、最新课程、开放报名、历史课程预览。
+     */
+    public function index(): View
+    {
+        $today = Carbon::today()->toDateString();
+        $base = Course::where('publicity', 30);
+
+        $latest = (clone $base)->orderByDesc('created_at')->take(4)->get();
+        $open = (clone $base)->whereDate('start_at', '>', $today)->orderBy('start_at')->get();
+        $history = (clone $base)->whereDate('start_at', '<=', $today)->orderByDesc('start_at')->take(5)->get();
+
+        $stats = [
+            'total' => (clone $base)->count(),
+            'open' => (clone $base)->whereDate('start_at', '>', $today)->count(),
+            'closed' => (clone $base)->whereDate('start_at', '<=', $today)->count(),
+        ];
+
+        return view('library.course.index', [
+            'latestCourses' => $this->present($latest),
+            'openCourses' => $this->present($open),
+            'historyCourses' => $this->present($history),
+            'stats' => $stats,
+        ]);
+    }
+
+    /**
+     * 历史课程列表页:分页展示已开课/已结束的公开课程。
+     */
+    public function history(Request $request): View
+    {
+        $today = Carbon::today()->toDateString();
+        $perPage = 10;
+
+        $paginator = Course::where('publicity', 30)
+            ->whereDate('start_at', '<=', $today)
+            ->orderByDesc('start_at')
+            ->paginate($perPage);
+
+        $paginator->setCollection($this->present($paginator->getCollection()));
+
+        return view('library.course.history', [
+            'courses' => $paginator,
+            'total' => $paginator->total(),
+        ]);
+    }
+
+    // -------------------------------------------------------------------------
+    // 将 Course 集合加工为视图所需数组:批量解析讲师与报名人数,避免 N+1
+    // -------------------------------------------------------------------------
+    private function present(Collection $courses): Collection
+    {
+        if ($courses->isEmpty()) {
+            return collect();
+        }
+
+        // 报名人数:按 course_id 一次聚合
+        $memberCounts = CourseMember::whereIn('course_id', $courses->pluck('id'))
+            ->where('is_current', true)
+            ->selectRaw('course_id, count(*) as cnt')
+            ->groupBy('course_id')
+            ->pluck('cnt', 'course_id');
+
+        // 讲师:按 teacher uuid 一次批量解析
+        $teacherUids = $courses->pluck('teacher')->filter()->unique()->values();
+        $teachers = $teacherUids->isEmpty()
+            ? collect()
+            : collect(UserApi::getListByUuid($teacherUids->all()))->keyBy('id');
+
+        $baseUrl = rtrim(config('mint.server.workspace_base_path'), '/');
+
+        return $courses->values()->map(function (Course $course, int $index) use ($memberCounts, $teachers, $baseUrl) {
+            $teacher = $teachers->get($course->teacher);
+
+            return [
+                'id' => $course->id,
+                'title' => $course->title,
+                'subtitle' => $course->subtitle,
+                'summary' => $course->summary,
+                'number' => (int) $course->number,
+                'join' => $course->join,
+                'start_at' => $course->start_at,
+                'end_at' => $course->end_at,
+                'sign_up_end_at' => $course->sign_up_end_at,
+                'start_date' => $course->start_at ? Carbon::parse($course->start_at)->format('Y-m-d') : null,
+                'sign_up_end_date' => $course->sign_up_end_at ? Carbon::parse($course->sign_up_end_at)->format('Y-m-d') : null,
+                'cover_url' => $this->coverUrl($course),
+                'cover_gradient' => $this->coverGradients[$this->colorIndex($course->id) % count($this->coverGradients)],
+                'teacher' => $this->formatTeacher($teacher, $index),
+                'member_count' => (int) ($memberCounts[$course->id] ?? 0),
+                'status' => $this->statusOf($course->start_at),
+                'detail_url' => $baseUrl.'/course/'.$course->id,
+            ];
+        });
+    }
+
+    private function statusOf(mixed $startAt): string
+    {
+        if (! $startAt) {
+            return 'closed';
+        }
+
+        return Carbon::parse($startAt)->startOfDay()->isAfter(Carbon::today()) ? 'open' : 'closed';
+    }
+
+    private function formatTeacher(?array $teacher, int $index): array
+    {
+        $name = $teacher['nickName'] ?? null;
+        if (! $name || $name === 'unknown') {
+            return ['name' => null, 'avatar' => null, 'initials' => null, 'color' => null];
+        }
+
+        return [
+            'name' => $name,
+            'avatar' => $teacher['avatar'] ?? null,
+            'initials' => mb_substr($name, 0, 2),
+            'color' => $this->authorColors[$index % count($this->authorColors)],
+        ];
+    }
+
+    // -------------------------------------------------------------------------
+    // 封面 URL:cover 可能是文件名(Storage),也可能是附件 uuid
+    // -------------------------------------------------------------------------
+    private function coverUrl(Course $course): ?string
+    {
+        $cover = $course->cover;
+        if (! $cover) {
+            return null;
+        }
+
+        // 附件 uuid:取 _m 中图
+        if (Str::isUuid($cover)) {
+            $attachment = Attachment::find($cover);
+            if ($attachment) {
+                return Storage::disk('public')->url($attachment->bucket.'/'.$attachment->id.'_m.jpg');
+            }
+
+            return null;
+        }
+
+        $thumb = str_replace('.jpg', '_m.jpg', $cover);
+
+        return App::environment(['local', 'testing'])
+            ? Storage::url($thumb)
+            : Storage::temporaryUrl($thumb, now()->addDays(6));
+    }
+
+    private function colorIndex(string $id): int
+    {
+        return hexdec(substr(str_replace('-', '', $id), 0, 4)) % 255;
+    }
+}

+ 44 - 77
api-v13/app/Http/Controllers/PageIndexController.php

@@ -6,93 +6,60 @@ class PageIndexController extends Controller
 {
 {
     public function index()
     public function index()
     {
     {
-        $nav = [
+        // 首页四个分流栏目:加栏目只需往 $cards 里追加一条,模板无需改动。
+        $cards = [
             [
             [
-                'title' => '最新',
-                'id' => 'new',
-                'link' => config('mint.server.dashboard_base_path').'/community/list',
+                'slug' => 'library',
+                'eyebrow' => 'Library',
+                'title' => '圣典文库',
+                'lead' => '三藏 · 义注 · 复注 · 字典',
+                'image' => 'library',
+                'tint' => '#2E4A63',
+                'align' => 'start',
+                'href' => '/library',
+                'cta' => '进入文库',
+                'available' => true,
             ],
             ],
             [
             [
-                'title' => '圣典',
-                'id' => 'pali',
-                'link' => config('mint.server.dashboard_base_path').'/palicanon/list',
+                'slug' => 'course',
+                'eyebrow' => 'Course',
+                'title' => '次第课程',
+                'lead' => '从零开始,逐部研读',
+                'image' => 'course',
+                'tint' => '#2A5257',
+                'align' => 'end',
+                'href' => '/library/course',
+                'cta' => '开始学习',
+                'available' => true,
             ],
             ],
             [
             [
-                'title' => '课程',
-                'id' => 'course',
-                'link' => config('mint.server.dashboard_base_path').'/course/list',
+                'slug' => 'workspace',
+                'eyebrow' => 'Workspace',
+                'title' => '翻译工作台',
+                'lead' => '术语、协作、校对、发布',
+                'image' => 'workspace',
+                'tint' => '#2C4A3C',
+                'align' => 'start',
+                'href' => '/pcd-v2026/workspace',
+                'cta' => '进入工作台',
+                'available' => true,
             ],
             ],
             [
             [
-                'title' => '字典',
-                'id' => 'dict',
-                'link' => config('mint.server.dashboard_base_path').'/dict/recent',
-            ],
-            [
-                'title' => '文集',
-                'id' => 'anthology',
-                'link' => config('mint.server.dashboard_base_path').'/anthology/list',
-            ],
-            [
-                'title' => '注册/登录',
-                'id' => 'sign_in',
-                'link' => config('mint.server.dashboard_base_path').'/anonymous/users/sign-in',
-            ],
-        ];
-        $wish = [
-            [
-                'title' => '翻译一套三藏',
-                'description' => '我们希望把完整的巴利三藏、义注、复注、nissaya都翻译成为中文。',
-            ],
-            [
-                'title' => '整理一本词典',
-                'description' => '我们希望借助沉淀下来的数据,整理一套完整的巴中字典。这项工作将在整个三藏翻译的过程中逐渐完成。',
-            ],
-            [
-                'title' => '开发一个平台',
-                'description' => '我们会持续开发和维护wikipali平台,并不断发展新的功能,令其越来越方便与巴利翻译和研究。',
-            ],
-        ];
-
-        $Gallery = [
-            [
-                'image' => '/assets/gallery/02.jpg',
-                'title' => '云台翻译中心',
-                'id' => 'desc_01',
-                'description' => '远眺翻译中心',
-            ],
-            [
-                'image' => '/assets/gallery/01.jpg',
-                'title' => '翻译人才培养',
-                'id' => 'desc_02',
-                'description' => '翻译中还不断地培养翻译人才,加入到翻译工作中。',
-            ],
-            [
-                'image' => '/assets/gallery/03.jpg',
-                'title' => '云台翻译中心',
-                'id' => 'desc_03',
-                'description' => '外景',
-            ],
-            [
-                'image' => '/assets/gallery/04.jpg',
-                'title' => '翻译中心接待室',
-                'id' => 'desc_04',
-                'description' => '人工湖畔的接待室,访客活动中心',
-            ],
-            [
-                'image' => '/assets/gallery/05.jpg',
-                'title' => '办公环境',
-                'id' => 'desc_05',
-                'description' => '27寸竖屏保证翻译的效率和质量,站坐交替的升降台,保证翻译者的健康。',
+                'slug' => 'development',
+                'eyebrow' => 'Development',
+                'title' => '二次开发',
+                'lead' => 'API · MCP · GitHub',
+                'image' => 'development',
+                'tint' => '#4A4A48',
+                'align' => 'end',
+                'href' => null,
+                'cta' => '即将上线',
+                'available' => false,
             ],
             ],
         ];
         ];
 
 
-        return view('typhoon', [
-            'nav' => $nav,
-            'title' => '巴 利 圣 典 文 库',
-            'subtitle' => '巴利圣典翻译计划欢迎您的参与',
-            'description' => '巴利语学习与翻译工具',
-            'gallery' => $Gallery,
-            'api' => config('app.url').'/api/v2',
+        return view('pages.home', [
+            'cards' => $cards,
         ]);
         ]);
     }
     }
 }
 }

+ 6 - 1
api-v13/app/Http/Controllers/RecentController.php

@@ -21,7 +21,12 @@ class RecentController extends Controller
         //
         //
         switch ($request->view) {
         switch ($request->view) {
             case 'user':
             case 'user':
-                $table = Recent::where('user_uid', $request->input('id'));
+                // recents.user_uid 是 uuid 列,非法值直接拒绝,避免 Postgres 报 22P02。
+                $userUid = $request->input('id');
+                if (! Str::isUuid($userUid)) {
+                    return $this->error('invalid id', [], 422);
+                }
+                $table = Recent::where('user_uid', $userUid);
                 break;
                 break;
             default:
             default:
                 return $this->error('known view');
                 return $this->error('known view');

+ 14 - 3
api-v13/app/Http/Resources/SentSimResource.php

@@ -7,6 +7,7 @@ use App\Models\PaliSentence;
 use Illuminate\Contracts\Support\Arrayable;
 use Illuminate\Contracts\Support\Arrayable;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
 use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Support\Str;
 
 
 class SentSimResource extends JsonResource
 class SentSimResource extends JsonResource
 {
 {
@@ -20,12 +21,22 @@ class SentSimResource extends JsonResource
     {
     {
         // 获取实际句子信息
         // 获取实际句子信息
         $sent = PaliSentence::find($this->sent2);
         $sent = PaliSentence::find($this->sent2);
-        $channels = explode(',', $request->input('channels'));
+        if (! $sent) {
+            return [];
+        }
+
+        // 过滤掉空值或非 uuid 的 channel,避免空 channels 参数导致 500
+        $channels = [];
+        foreach (explode(',', (string) $request->input('channels', '')) as $channel) {
+            if (Str::isUuid($channel)) {
+                $channels[] = $channel;
+            }
+        }
+
         $mode = $request->input('mode', 'read');
         $mode = $request->input('mode', 'read');
         $sentId = $sent->book.'-'.$sent->paragraph.'-'.$sent->word_begin.'-'.$sent->word_end;
         $sentId = $sent->book.'-'.$sent->paragraph.'-'.$sent->word_begin.'-'.$sent->word_end;
         $Sent = new CorpusController;
         $Sent = new CorpusController;
-        $tpl =
-            $data['sent'] = $Sent->getSentTpl($sentId, $channels, $mode);
+        $data['sent'] = $Sent->getSentTpl($sentId, $channels, $mode);
         $data['sim'] = $this->sim;
         $data['sim'] = $this->sim;
 
 
         return $data;
         return $data;

+ 20 - 19
api-v13/app/Services/OpenSearchService.php

@@ -8,8 +8,11 @@ use Exception;
 use GuzzleHttp\Client;
 use GuzzleHttp\Client;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
+
 use OpenSearch\GuzzleClientFactory;
 use OpenSearch\GuzzleClientFactory;
 
 
+
 class OpenSearchService
 class OpenSearchService
 {
 {
     protected $client;
     protected $client;
@@ -392,12 +395,12 @@ class OpenSearchService
     {
     {
         try {
         try {
             $info = $this->client->info();
             $info = $this->client->info();
-            $message = 'OpenSearch 连接成功: '.json_encode($info['version']['number']);
+            $message = 'OpenSearch 连接成功: ' . json_encode($info['version']['number']);
             Log::info($message);
             Log::info($message);
 
 
             return [true, $message];
             return [true, $message];
         } catch (Exception $e) {
         } catch (Exception $e) {
-            $message = 'OpenSearch 连接失败: '.$e->getMessage();
+            $message = 'OpenSearch 连接失败: ' . $e->getMessage();
             Log::error($message);
             Log::error($message);
 
 
             return [false, $message];
             return [false, $message];
@@ -491,11 +494,9 @@ class OpenSearchService
      * @example
      * @example
      *   $service->updatePaliSynonymsPath('20260731');
      *   $service->updatePaliSynonymsPath('20260731');
      */
      */
-    public function updatePaliSynonymsPath(string $version): array
+    public function updatePaliSynonymsPath(string $filename): array
     {
     {
-        if (! preg_match('/^[A-Za-z0-9._-]+$/', $version)) {
-            throw new Exception("Invalid synonyms version [$version].");
-        }
+
 
 
         $index = config('mint.opensearch.index');
         $index = config('mint.opensearch.index');
 
 
@@ -503,7 +504,7 @@ class OpenSearchService
             throw new Exception("Index [$index] does not exist.");
             throw new Exception("Index [$index] does not exist.");
         }
         }
 
 
-        $path = "/var/lib/opensearch/analysis/pali_synonyms-{$version}.txt";
+        $path = Str::finish(config('mint.opensearch.config.synonyms_path'), '/') . $filename;
 
 
         Log::debug('OpenSearchService::updatePaliSynonymsPath', [
         Log::debug('OpenSearchService::updatePaliSynonymsPath', [
             'index' => $index,
             'index' => $index,
@@ -878,7 +879,7 @@ class OpenSearchService
     {
     {
         $fields = [];
         $fields = [];
         foreach ($this->weights['fuzzy'] as $field => $weight) {
         foreach ($this->weights['fuzzy'] as $field => $weight) {
-            $fields[] = $field.'^'.$weight;
+            $fields[] = $field . '^' . $weight;
         }
         }
 
 
         return [
         return [
@@ -909,7 +910,7 @@ class OpenSearchService
             if (in_array($field, ['fuzzy_ratio', 'semantic_ratio'])) {
             if (in_array($field, ['fuzzy_ratio', 'semantic_ratio'])) {
                 continue;
                 continue;
             }
             }
-            $fuzzyFields[] = $field.'^'.$weight;
+            $fuzzyFields[] = $field . '^' . $weight;
         }
         }
 
 
         $fuzzyPart = [
         $fuzzyPart = [
@@ -982,12 +983,12 @@ class OpenSearchService
             throw new Exception('请在 .env 设置 OPENAI_API_KEY');
             throw new Exception('请在 .env 设置 OPENAI_API_KEY');
         }
         }
 
 
-        $cacheKey = 'embedding:'.md5($text);
+        $cacheKey = 'embedding:' . md5($text);
 
 
         return Cache::remember($cacheKey, now()->addDays(7), function () use ($text) {
         return Cache::remember($cacheKey, now()->addDays(7), function () use ($text) {
             $response = $this->http->post('embeddings', [
             $response = $this->http->post('embeddings', [
                 'headers' => [
                 'headers' => [
-                    'Authorization' => 'Bearer '.$this->openaiApiKey,
+                    'Authorization' => 'Bearer ' . $this->openaiApiKey,
                     'Content-Type' => 'application/json',
                     'Content-Type' => 'application/json',
                 ],
                 ],
                 'json' => [
                 'json' => [
@@ -999,7 +1000,7 @@ class OpenSearchService
             $json = json_decode((string) $response->getBody(), true);
             $json = json_decode((string) $response->getBody(), true);
 
 
             if (empty($json['data'][0]['embedding'])) {
             if (empty($json['data'][0]['embedding'])) {
-                throw new Exception('OpenAI embedding 返回异常: '.json_encode($json));
+                throw new Exception('OpenAI embedding 返回异常: ' . json_encode($json));
             }
             }
 
 
             return $json['data'][0]['embedding'];
             return $json['data'][0]['embedding'];
@@ -1017,7 +1018,7 @@ class OpenSearchService
      */
      */
     public function clearEmbeddingCache(string $text): bool
     public function clearEmbeddingCache(string $text): bool
     {
     {
-        $cacheKey = 'embedding:'.md5($text);
+        $cacheKey = 'embedding:' . md5($text);
 
 
         return Cache::forget($cacheKey);
         return Cache::forget($cacheKey);
     }
     }
@@ -1102,7 +1103,7 @@ class OpenSearchService
         // 过滤无效字段
         // 过滤无效字段
         $searchFields = array_values(array_filter(
         $searchFields = array_values(array_filter(
             $searchFields,
             $searchFields,
-            fn ($field) => isset($fieldMap[$field])
+            fn($field) => isset($fieldMap[$field])
         ));
         ));
 
 
         if (empty($searchFields)) {
         if (empty($searchFields)) {
@@ -1112,7 +1113,7 @@ class OpenSearchService
         // 构建 suggest DSL
         // 构建 suggest DSL
         $suggests = [];
         $suggests = [];
         foreach ($searchFields as $field) {
         foreach ($searchFields as $field) {
-            $suggests[$field.'_suggest'] = [
+            $suggests[$field . '_suggest'] = [
                 'prefix' => $query,
                 'prefix' => $query,
                 'completion' => [
                 'completion' => [
                     'field' => $fieldMap[$field],
                     'field' => $fieldMap[$field],
@@ -1136,7 +1137,7 @@ class OpenSearchService
         // 整理结果,附加来源字段
         // 整理结果,附加来源字段
         $results = [];
         $results = [];
         foreach ($searchFields as $field) {
         foreach ($searchFields as $field) {
-            $options = $response['suggest'][$field.'_suggest'][0]['options'] ?? [];
+            $options = $response['suggest'][$field . '_suggest'][0]['options'] ?? [];
 
 
             foreach ($options as $opt) {
             foreach ($options as $opt) {
                 $results[] = [
                 $results[] = [
@@ -1150,7 +1151,7 @@ class OpenSearchService
         }
         }
 
 
         // 按分数降序排序
         // 按分数降序排序
-        usort($results, fn ($a, $b) => $b['score'] <=> $a['score']);
+        usort($results, fn($a, $b) => $b['score'] <=> $a['score']);
 
 
         return $results;
         return $results;
     }
     }
@@ -1203,10 +1204,10 @@ class OpenSearchService
         ]);
         ]);
 
 
         $tokens = array_map(
         $tokens = array_map(
-            fn ($token) => $token['token'] ?? '',
+            fn($token) => $token['token'] ?? '',
             $response['tokens'] ?? []
             $response['tokens'] ?? []
         );
         );
 
 
-        return array_values(array_unique(array_filter($tokens, fn ($t) => $t !== '')));
+        return array_values(array_unique(array_filter($tokens, fn($t) => $t !== '')));
     }
     }
 }
 }

+ 1 - 1
api-v13/app/Services/PaliTextService.php

@@ -45,7 +45,7 @@ class PaliTextService
         $paragraph = PaliText::where('book', $book)
         $paragraph = PaliText::where('book', $book)
             ->where('paragraph', '<=', $para)
             ->where('paragraph', '<=', $para)
             ->where('level', 1)
             ->where('level', 1)
-            ->orderBy('paragraph', 'asc')->first();
+            ->orderBy('paragraph', 'desc')->first();
         if (! $paragraph) {
         if (! $paragraph) {
             Log::warning('not found book ', ['book' => $book, 'para' => $para]);
             Log::warning('not found book ', ['book' => $book, 'para' => $para]);
 
 

+ 118 - 0
api-v13/app/Services/TermIndexService.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Services;
+
+class TermIndexService
+{
+    public function __construct(
+        protected OpenSearchService $openSearchService,
+        protected TermService $termService,
+    ) {}
+
+    /**
+     * 构建单条词条文档并写入 OpenSearch
+     *
+     * @param  string  $guid  DhammaTerm 的 guid
+     */
+    public function index(string $guid): void
+    {
+        $document = $this->buildDocument($guid);
+
+        $this->openSearchService->create($document['id'], $document);
+    }
+
+    /**
+     * 构建单条词条的 OpenSearch 文档(不写入)
+     *
+     * 文档结构遵循新版 mapping:
+     *   title.text.pali / title.text.zh  → 全文检索
+     *   title.suggest.pali / title.suggest.zh → 自动建议
+     *   content.text.pali / content.text.zh   → 正文内容
+     *
+     * @param  string  $guid  DhammaTerm 的 guid
+     * @return array<string, mixed>
+     */
+    public function buildDocument(string $guid): array
+    {
+        $termData = $this->termService->find($guid, 'text');
+        $channelName = $termData['channel']['name'] ?? '';
+        $content = $termData['html'] ?? $termData['meaning'];
+
+        $categories = $this->extractCategories($termData['note'] ?? '');
+        $quality = $this->extractFirstQuality($termData['note'] ?? '');
+        $tags = [];
+        foreach ($categories as $category) {
+            $tags[] = "category:{$category}";
+        }
+        if (! empty($quality)) {
+            $tags[] = "quality:{$quality}";
+        }
+
+        $document = [
+            'id' => "term_{$guid}",
+            'resource_id' => $guid,
+            'resource_type' => 'term',
+            'title' => [
+                'text' => [
+                    'pali' => $termData['word'],
+                    'zh' => $termData['meaning'],
+                ],
+                'suggest' => [
+                    'pali' => [$termData['word']],
+                    'zh' => [$termData['meaning']],
+                ],
+            ],
+            'summary' => [
+                'text' => $termData['summary'] ?? '',
+            ],
+            'content' => [],
+            'bold_single' => [$termData['meaning'], $termData['word']],
+            'related_id' => $termData['word'],
+            'category' => null,
+            'tags' => $tags,
+            'language' => $termData['language'],
+            'updated_at' => now()->toIso8601String(),
+            'path' => $termData['studio']['realName']."/{$channelName}",
+            'metadata' => ['channel' => $termData['channel_id']],
+        ];
+
+        // TODO: 补充语言判断,将内容放入对应的 text.pali 或 text.zh 字段
+        $plainText = strip_tags($content);
+        $document['content']['text']['zh'] = $plainText;
+        $document['content']['display'] = $content;             // 展示
+
+        return $document;
+    }
+
+    /**
+     * 提取 Markdown 中的 {{category|...}} 分类标签
+     *
+     * @return array<int, string>
+     */
+    private function extractCategories(string $content): array
+    {
+        if (empty($content)) {
+            return [];
+        }
+        preg_match_all('/\{\{category\|([^}]+)\}\}/u', $content, $matches);
+
+        return array_values(array_filter(array_map(
+            fn ($item) => trim($item),
+            $matches[1] ?? []
+        )));
+    }
+
+    /**
+     * 提取 Markdown 中第一个 {{quality|...}} 标签内的内容
+     */
+    private function extractFirstQuality(string $content): string
+    {
+        if (empty($content)) {
+            return '';
+        }
+
+        preg_match('/\{\{quality\|([^}]+)\}\}/u', $content, $matches);
+
+        return isset($matches[1]) ? trim($matches[1]) : '';
+    }
+}

+ 4 - 1
api-v13/config/mint.php

@@ -61,7 +61,8 @@ return [
         ],
         ],
         'assets' => env('ASSETS_SERVER', 'localhost:9999'),
         'assets' => env('ASSETS_SERVER', 'localhost:9999'),
 
 
-        'dashboard_base_path' => env('DASHBOARD_BASE_PATH', 'http://127.0.0.1:3000/my'),
+        'dashboard_base_path' => env('DASHBOARD_BASE_PATH', 'http://127.0.0.1:4000/pcd-v2026'),
+        'workspace_base_path' => env('DASHBOARD_BASE_PATH', 'http://127.0.0.1:4000/pcd-v2026').'/workspace',
 
 
         'cdn_urls' => explode(',', env('CDN_URLS', 'https://www.wikipali.cc/downloads')),
         'cdn_urls' => explode(',', env('CDN_URLS', 'https://www.wikipali.cc/downloads')),
 
 
@@ -176,6 +177,8 @@ return [
             'username' => env('OPENSEARCH_USERNAME', ''),
             'username' => env('OPENSEARCH_USERNAME', ''),
             'password' => env('OPENSEARCH_PASSWORD', ''),
             'password' => env('OPENSEARCH_PASSWORD', ''),
             'ssl_verification' => env('OPENSEARCH_SSL_VERIFICATION', false),
             'ssl_verification' => env('OPENSEARCH_SSL_VERIFICATION', false),
+            'synonyms_path' => env('APP_OPENSEARCH_SYNONYMS_PATH', '/opt/opensearch/config/analysis'),
+
         ],
         ],
 
 
     ],
     ],

+ 70 - 0
api-v13/documents/homepage-image-prompts.md

@@ -0,0 +1,70 @@
+# WikiPali 首页底图提示词
+
+四张通栏卡片底图的中文生图提示词。统一要求:**明亮色调**、**无具象佛教元素**(不出现佛像、寺庙、僧侣、佛塔、念珠、香炉、莲花等宗教符号)、主题相关但保持抽象克制。
+
+构图规则:**主体一律在文字块的反侧**(文字在左 → 主体在右;文字在右 → 主体在左),另一侧留出干净负空间供文字压图。
+
+生成时请使用**宽幅 16:9 或更宽**的画面(卡片通栏满铺,`object-fit: cover` 会自动裁切)。
+
+---
+
+## 01 Library · 圣典文库
+
+- 文字位置:**左** → 主体靠**右**
+- 色调:靛青蓝(tint `#2E4A63`)
+- 主题方向:古籍 / 纸页 / 安静的文库
+
+**提示词:**
+
+> 明亮通透的静物摄影,安静的文库一角:一叠泛黄的古籍纸页与空白羊皮纸,摊在浅色木质阅读桌上,柔和漫射的日光从侧面洒入,靛蓝与暖纸色交织,浅景深。构图抽象,主体纸页堆集中在画面右侧,左侧三分之一留出干净的空负空间用于叠加文字。整体明亮、哑光、极简。画面中不要出现人物、雕像、宗教符号、可辨认的文字、标志。
+
+---
+
+## 02 Course · 次第课程
+
+- 文字位置:**右** → 主体靠**左**
+- 色调:青蓝(tint `#2A5257`)
+- 主题方向:由浅入深的色面递进 / 晨雾中的柔和光晕
+
+**提示词:**
+
+> 抽象极简的色彩构成:层层叠叠的半透明青蓝与青绿色面,如薄雾般由近及远缓缓晕开,暖色高光若隐若现地浮在左上角,形成由浅入深、层层递进的视觉节奏。视觉重心集中在画面左侧,右侧三分之一留出干净的空负空间用于叠加文字。整体明亮、通透、哑光、宁静内敛。画面中不要出现任何具象物体、人物、文字、标志。
+
+---
+
+## 03 Workspace · 翻译工作台
+
+- 文字位置:**左** → 主体靠**右**
+- 色调:墨绿(tint `#2C4A3C`)
+- 主题方向:协作工作台 / 纸稿 / 文具
+
+**提示词:**
+
+> 明亮极简的工作台静物:一张干净的浅木色书桌,叠放着一摞纸质文稿、一本翻开的笔记本和一支笔,柔和的自然窗光照亮桌面,灰绿与森林绿为主,带纸面暖色。桌面陈设集中在画面右侧,左侧三分之一留出干净的墙面负空间用于叠加文字。整体明亮、哑光、编辑感、克制。画面中不要出现人物、宗教物品、可辨认的文字、标志。
+
+---
+
+## 04 Development · 二次开发
+
+- 文字位置:**右** → 主体靠**左**
+- 色调:石墨(tint `#4A4A48`)
+- 主题方向:API / 代码 / 极简几何光流
+
+**提示词:**
+
+> 抽象极简的科技画面:柔和的明亮光线与几何电路光迹,在浅石墨灰的哑光背景上流动延展,冷白与银灰高光点缀,干净通透。发光主体集中在画面左侧,右侧三分之一留出干净的空负空间用于叠加文字。整体明亮、通透、哑光、简洁。画面中不要出现屏幕、代码文字、标志、人物。
+
+---
+
+## 交付规格
+
+图片放到 `public/img/home/`,命名 `{slug}-{尺寸}.{格式}`,四组共 36 个文件,单张 ≤ 200KB:
+
+```
+library-{640|1280|2560}.{avif|webp|jpg}
+course-{640|1280|2560}.{avif|webp|jpg}
+workspace-{640|1280|2560}.{avif|webp|jpg}
+development-{640|1280|2560}.{avif|webp|jpg}
+```
+
+建议流程:先生成一张 **2560px 宽**的主图,再降采样出 1280 / 640 三档,分别压成 AVIF、WebP、JPEG。

BIN
api-v13/public/img/home/course-1280.webp


BIN
api-v13/public/img/home/development-1280.webp


BIN
api-v13/public/img/home/library-1280.webp


BIN
api-v13/public/img/home/workspace-1280.webp


+ 264 - 0
api-v13/resources/css/home.css

@@ -0,0 +1,264 @@
+/* resources/css/home.css
+   WikiPāli 首页(next.wikipali.org)样式入口。
+   只服务于首页:品牌 hero + 四张通栏分流卡片。
+   平面、哑光、克制,无渐变 / 发光 / 阴影 / 圆角卡片。
+*/
+
+/* 1. Tabler(Bootstrap 5 底座)+ Tabler Icons */
+@import '@tabler/core/dist/css/tabler.min.css';
+@import '@tabler/icons-webfont/dist/tabler-icons.min.css';
+
+/* 2. 设计令牌 */
+:root {
+    --home-bg: #F6F1E7;
+    --home-text: #2B2318;
+    --home-text-secondary: #63594A;
+    --home-text-muted: #6F6555;
+    --home-border-muted: #B6AB98;
+    --home-cta-hover-text: #F6F1E7;
+    --home-hero-tint: #8A6A3C;
+}
+
+/* 3. 页面底色 */
+.home-page {
+    margin: 0;
+    background: var(--home-bg);
+    color: var(--home-text);
+}
+
+.home-page .page {
+    background: var(--home-bg);
+}
+
+/* 4. 品牌 Hero */
+.home-hero {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    text-align: center;
+    height: 44vh;
+    min-height: 280px;
+    padding: 2rem 1.5rem;
+    background: color-mix(in srgb, var(--home-hero-tint) 16%, var(--home-bg));
+}
+
+.home-hero__title {
+    margin: 0 0 .75rem;
+    color: var(--home-text);
+    font-size: clamp(2rem, 5vw, 3.5rem);
+    font-weight: 500;
+    line-height: 1.2;
+    letter-spacing: .02em;
+}
+
+.home-hero__lead {
+    margin: 0;
+    color: var(--home-text-secondary);
+    font-size: clamp(.95rem, 1.6vw, 1.125rem);
+    line-height: 1.5;
+}
+
+/* 5. 通栏卡片 */
+.home-card {
+    position: relative;
+    display: flex;
+    align-items: center;
+    height: 40vh;
+    min-height: 320px;
+    max-height: 480px;
+    overflow: hidden;
+    color: var(--home-text);
+}
+
+/* 末卡(二次开发)高度略矮 */
+#card-development {
+    height: 36vh;
+    min-height: 280px;
+    max-height: none;
+}
+
+/* 底图:通栏满铺 */
+.home-card__media-wrap {
+    position: absolute;
+    inset: 0;
+    display: block;
+    margin: 0;
+    padding: 0;
+}
+
+.home-card__media {
+    display: block;
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+}
+
+/* 文字在左 → 图的视觉重心在右;文字在右 → 视觉重心在左 */
+.home-card--start .home-card__media {
+    object-position: 70% center;
+}
+
+.home-card--end .home-card__media {
+    object-position: 30% center;
+}
+
+/* 蒙版:保证文字对比度 ≥ 4.5:1 */
+.home-card__veil {
+    position: absolute;
+    inset: 0;
+    background: color-mix(in srgb, var(--card-tint) 16%, rgba(246, 241, 231, .8));
+}
+
+/* 文字块:垂直居中,横向贴边 */
+.home-card__body {
+    position: relative;
+    z-index: 1;
+    display: flex;
+    align-items: center;
+    width: 100%;
+    padding: 2rem clamp(1.5rem, 6vw, 5rem);
+}
+
+.home-card--start .home-card__body {
+    justify-content: flex-start;
+    padding-left: calc(clamp(1.5rem, 6vw, 5rem) + 300px);
+}
+
+.home-card--end .home-card__body {
+    justify-content: flex-end;
+    padding-right: calc(clamp(1.5rem, 6vw, 5rem) + 300px);
+}
+
+.home-card__text {
+    width: 100%;
+    max-width: 26rem;
+}
+
+.home-card--start .home-card__text {
+    text-align: left;
+}
+
+.home-card--end .home-card__text {
+    text-align: right;
+}
+
+.home-card__eyebrow {
+    margin: 0 0 .5rem;
+    color: var(--home-text-muted);
+    font-size: 12px;
+    letter-spacing: .22em;
+    text-transform: uppercase;
+}
+
+.home-card__title {
+    margin: 0 0 .75rem;
+    color: var(--home-text);
+    font-size: clamp(1.5rem, 3.2vw, 2.5rem);
+    font-weight: 500;
+    line-height: 1.3;
+}
+
+.home-card__lead {
+    margin: 0 0 1.5rem;
+    color: var(--home-text-secondary);
+    font-size: clamp(.9rem, 1.4vw, 1.0625rem);
+    line-height: 1.5;
+}
+
+/* CTA / 状态角标:胶囊形 */
+.home-card__cta,
+.home-card__badge {
+    display: inline-flex;
+    align-items: center;
+    gap: .5rem;
+    border-radius: 999px;
+    padding: .55rem 1.25rem;
+    font-size: 14px;
+    line-height: 1;
+}
+
+.home-card__cta {
+    border: 1px solid var(--home-text);
+    color: var(--home-text);
+    text-decoration: none;
+    transition: background-color .2s ease, color .2s ease;
+}
+
+.home-card__cta:hover,
+.home-card__cta:focus {
+    background: var(--home-text);
+    color: var(--home-cta-hover-text);
+    text-decoration: none;
+}
+
+.home-card__cta:focus-visible {
+    outline: 2px solid var(--home-text);
+    outline-offset: 3px;
+}
+
+.home-card__badge {
+    border: 1px solid var(--home-border-muted);
+    color: var(--home-text-muted);
+}
+
+/* 6. 响应式:768–991px(高度不变,文字块收窄) */
+@media (max-width: 991.98px) {
+    .home-card__text {
+        max-width: 20rem;
+    }
+}
+
+/* 7. 响应式:<768px(全部左对齐,换移动端高度,蒙版加深) */
+@media (max-width: 767.98px) {
+    .home-hero {
+        height: 40vh;
+        min-height: 240px;
+    }
+
+    .home-card {
+        height: 52vh;
+        min-height: 300px;
+        max-height: none;
+    }
+
+    #card-development {
+        height: 46vh;
+        min-height: 260px;
+    }
+
+    .home-card__body {
+        justify-content: flex-start;
+        padding: 2rem 1.5rem;
+    }
+
+    .home-card--start .home-card__body,
+    .home-card--end .home-card__body {
+        padding-left: 1.5rem;
+        padding-right: 1.5rem;
+    }
+
+    .home-card__text {
+        max-width: 100%;
+        text-align: left;
+    }
+
+    .home-card__media {
+        object-position: 75% center;
+    }
+
+    .home-card__veil {
+        background: color-mix(in srgb, var(--card-tint) 20%, rgba(246, 241, 231, .88));
+    }
+
+    .home-card__eyebrow {
+        font-size: 11px;
+    }
+}
+
+/* 8. 尊重减少动态偏好:关闭过渡 */
+@media (prefers-reduced-motion: reduce) {
+    .home-card__cta {
+        transition: none;
+    }
+}

+ 467 - 0
api-v13/resources/css/modules/library-course.css

@@ -0,0 +1,467 @@
+/* resources/css/modules/library-course.css
+   课程栏目专属样式。
+   视觉语言沿用 --wp-* 暖金 token,与 anthology / library-index 一致。
+*/
+
+/* ══════════════════════════════════════════
+   〇、区块通用结构(课程页独立声明,不依赖 library-index.css)
+   ══════════════════════════════════════════ */
+
+.lib-section {
+    margin-top: 3rem;
+    margin-bottom: 0.5rem;
+}
+
+.lib-section__header {
+    display: flex;
+    align-items: center;
+    gap: 0.75rem;
+    margin-bottom: 1rem;
+    padding-bottom: 0.75rem;
+    border-bottom: 1px solid var(--wp-border);
+}
+
+.lib-section__title {
+    font-size: 1rem;
+    font-weight: 600;
+    color: var(--wp-ink);
+    margin: 0;
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    flex: 1;
+}
+
+.lib-section__title .ti {
+    font-size: 1.125rem;
+    color: var(--wp-brand);
+}
+
+.lib-section__more {
+    font-size: 0.8125rem;
+    color: var(--wp-brand);
+    text-decoration: none;
+    white-space: nowrap;
+    display: flex;
+    align-items: center;
+    gap: 0.25rem;
+    flex-shrink: 0;
+}
+
+.lib-section__more:hover {
+    text-decoration: underline;
+}
+
+/* ══════════════════════════════════════════
+   一、统计条
+   ══════════════════════════════════════════ */
+
+.course-stats {
+    display: grid;
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+    gap: 1rem;
+    margin-top: 1.5rem;
+}
+
+.course-stat {
+    background: var(--wp-card-bg);
+    border: 1px solid var(--wp-border);
+    border-radius: 10px;
+    padding: 1.25rem 1rem;
+    text-align: center;
+}
+
+.course-stat__value {
+    font-size: 1.75rem;
+    font-weight: 700;
+    color: var(--wp-brand);
+    line-height: 1.2;
+    font-variant-numeric: tabular-nums;
+}
+
+.course-stat__label {
+    font-size: 0.75rem;
+    color: var(--wp-ink-muted);
+    margin-top: 0.375rem;
+}
+
+/* ══════════════════════════════════════════
+   二、最新课程卡片网格
+   ══════════════════════════════════════════ */
+
+.course-grid {
+    display: grid;
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+    gap: 1rem;
+}
+
+.course-card {
+    display: flex;
+    flex-direction: column;
+    background: var(--wp-card-bg);
+    border: 1px solid var(--wp-border);
+    border-radius: 10px;
+    overflow: hidden;
+    text-decoration: none;
+    color: inherit;
+    transition:
+        box-shadow 0.2s,
+        transform 0.2s;
+    height: 100%;
+}
+
+.course-card:hover {
+    box-shadow:
+        0 8px 24px rgba(200, 134, 10, 0.12),
+        0 2px 8px rgba(0, 0, 0, 0.06);
+    transform: translateY(-2px);
+    color: inherit;
+    text-decoration: none;
+}
+
+.course-card__cover {
+    position: relative;
+    aspect-ratio: 16 / 10;
+    background-size: cover;
+    background-position: center;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    overflow: hidden;
+}
+
+.course-card__cover-img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    display: block;
+}
+
+.course-card__cover-fallback {
+    color: rgba(255, 255, 255, 0.92);
+    font-family: 'Noto Serif SC', 'Noto Serif', Georgia, serif;
+    font-size: 1.05rem;
+    font-weight: 600;
+    padding: 0 1rem;
+    text-align: center;
+    line-height: 1.4;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+}
+
+.course-card__body {
+    padding: 1rem 1.1rem 1.1rem;
+    display: flex;
+    flex-direction: column;
+    gap: 0.5rem;
+    flex: 1;
+}
+
+.course-card__title {
+    font-family: 'Noto Serif SC', 'Noto Serif', Georgia, serif;
+    font-size: 1rem;
+    font-weight: 600;
+    color: var(--wp-ink);
+    line-height: 1.4;
+    margin: 0;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+    min-height: 2.8em;
+}
+
+.course-card:hover .course-card__title {
+    color: var(--wp-brand);
+}
+
+.course-card__teacher {
+    min-height: 1.5rem;
+}
+
+.course-card__foot {
+    display: flex;
+    align-items: center;
+    gap: 0.75rem;
+    font-size: 0.75rem;
+    color: var(--wp-ink-muted);
+    margin-top: auto;
+    font-variant-numeric: tabular-nums;
+}
+
+/* ══════════════════════════════════════════
+   三、状态徽章
+   ══════════════════════════════════════════ */
+
+.course-badge {
+    display: inline-flex;
+    align-items: center;
+    font-size: 0.6875rem;
+    font-weight: 600;
+    line-height: 1;
+    padding: 4px 9px;
+    border-radius: 20px;
+    white-space: nowrap;
+}
+
+.course-badge--open {
+    background: #eaf3de;
+    color: #3b6d11;
+    border: 1px solid #c0dd97;
+}
+
+.course-badge--closed {
+    background: #f1efe8;
+    color: #6b6a64;
+    border: 1px solid #d8d6cc;
+}
+
+.course-card__cover .course-badge {
+    position: absolute;
+    top: 0.75rem;
+    left: 0.75rem;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+}
+
+/* ══════════════════════════════════════════
+   四、横向列表行(开放报名 / 历史课程)
+   ══════════════════════════════════════════ */
+
+.course-list {
+    display: grid;
+    grid-template-columns: 1fr;
+    gap: 1rem;
+}
+
+/* 桌面端改为两列,避免横向卡片过宽、中间留白 */
+@media (min-width: 992px) {
+    .course-list {
+        grid-template-columns: repeat(2, minmax(0, 1fr));
+    }
+}
+
+.course-row {
+    display: flex;
+    align-items: center;
+    gap: 1rem;
+    background: var(--wp-card-bg);
+    border: 1px solid var(--wp-border);
+    border-radius: 10px;
+    padding: 0.85rem 1.1rem;
+    text-decoration: none;
+    color: inherit;
+    transition:
+        box-shadow 0.2s,
+        transform 0.2s,
+        border-color 0.2s;
+}
+
+.course-row:hover {
+    box-shadow: 0 6px 20px rgba(200, 134, 10, 0.1);
+    transform: translateY(-1px);
+    border-color: var(--wp-brand-light);
+    color: inherit;
+    text-decoration: none;
+}
+
+.course-row__thumb {
+    width: 112px;
+    height: 72px;
+    flex-shrink: 0;
+    border-radius: 6px;
+    overflow: hidden;
+    background-size: cover;
+    background-position: center;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+.course-row__thumb img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    display: block;
+}
+
+.course-row__thumb span {
+    color: rgba(255, 255, 255, 0.9);
+    font-size: 0.72rem;
+    font-weight: 600;
+    padding: 0 0.5rem;
+    text-align: center;
+    line-height: 1.3;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+}
+
+.course-row__main {
+    flex: 1;
+    min-width: 0;
+}
+
+.course-row__title {
+    font-size: 0.95rem;
+    font-weight: 600;
+    color: var(--wp-ink);
+    line-height: 1.4;
+    margin: 0 0 0.2rem;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.course-row:hover .course-row__title {
+    color: var(--wp-brand);
+}
+
+.course-row__summary {
+    font-size: 0.78rem;
+    color: var(--wp-ink-muted);
+    line-height: 1.5;
+    margin-bottom: 0.45rem;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+}
+
+.course-row__meta {
+    display: flex;
+    align-items: center;
+    flex-wrap: wrap;
+    gap: 0.25rem 0.9rem;
+    font-size: 0.75rem;
+    color: var(--wp-ink-muted);
+    font-variant-numeric: tabular-nums;
+}
+
+.course-row__period,
+.course-row__members,
+.course-row__date {
+    white-space: nowrap;
+}
+
+.course-row__side {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-end;
+    gap: 0.5rem;
+    flex-shrink: 0;
+}
+
+.course-row__cta {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.15rem;
+    font-size: 0.78rem;
+    font-weight: 600;
+    color: var(--wp-brand);
+}
+
+/* ══════════════════════════════════════════
+   五、历史课程列表页头部
+   ══════════════════════════════════════════ */
+
+.course-page-header {
+    background: linear-gradient(135deg, var(--wp-ink) 0%, #2d2010 100%);
+    padding: 2.25rem 0 2rem;
+    position: relative;
+    overflow: hidden;
+}
+
+.course-page-header::before {
+    content: '课';
+    font-family: 'Noto Serif SC', serif;
+    font-size: 16rem;
+    font-weight: 700;
+    color: rgba(255, 255, 255, 0.03);
+    position: absolute;
+    right: -1rem;
+    top: -2.5rem;
+    line-height: 1;
+    pointer-events: none;
+}
+
+.course-page-header h1 {
+    font-family: 'Noto Serif SC', 'Noto Serif', Georgia, serif;
+    font-size: 1.75rem;
+    font-weight: 600;
+    color: #fff;
+    margin: 0 0 0.3rem;
+    letter-spacing: 0.08em;
+}
+
+.course-page-header p {
+    color: rgba(255, 255, 255, 0.45);
+    font-size: 0.85rem;
+    margin: 0;
+}
+
+.course-count-badge {
+    background: var(--wp-brand);
+    color: var(--wp-ink);
+    font-size: 0.75rem;
+    font-weight: 700;
+    padding: 2px 9px;
+    border-radius: 20px;
+    margin-left: 0.6rem;
+    vertical-align: middle;
+    font-variant-numeric: tabular-nums;
+}
+
+/* ══════════════════════════════════════════
+   六、响应式
+   ══════════════════════════════════════════ */
+
+@media (max-width: 1199px) {
+    .course-grid {
+        grid-template-columns: repeat(3, minmax(0, 1fr));
+    }
+}
+
+@media (max-width: 991px) {
+    .course-grid {
+        grid-template-columns: repeat(2, minmax(0, 1fr));
+    }
+}
+
+@media (max-width: 575px) {
+    .course-grid {
+        grid-template-columns: 1fr;
+    }
+
+    .course-stats {
+        gap: 0.5rem;
+    }
+
+    .course-stat {
+        padding: 0.9rem 0.5rem;
+    }
+
+    .course-stat__value {
+        font-size: 1.4rem;
+    }
+
+    .course-row {
+        gap: 0.75rem;
+        padding: 0.75rem;
+    }
+
+    .course-row__thumb {
+        width: 84px;
+        height: 56px;
+    }
+
+    .course-row__summary {
+        display: none;
+    }
+
+    .course-row__side {
+        gap: 0.4rem;
+    }
+}

+ 23 - 0
api-v13/resources/lang/en/library.php

@@ -134,4 +134,27 @@ return [
     'cancel' => 'Cancel',
     'cancel' => 'Cancel',
     'confirm' => 'OK',
     'confirm' => 'OK',
     'no_toc' => 'This book has no table of contents',
     'no_toc' => 'This book has no table of contents',
+
+    // course (course/index.blade.php + course/history.blade.php)
+    'course' => 'Courses',
+    'course_hero_title' => 'Courses',
+    'course_hero_subtitle' => 'Study the Canon with Pāli experts from around the world',
+    'course_search_placeholder' => 'Search courses, teachers…',
+    'course_stat_total' => 'Public Courses',
+    'course_stat_open' => 'Open Enrollment',
+    'course_stat_closed' => 'Completed',
+    'course_section_latest' => 'Latest Courses',
+    'course_section_open' => 'Open Enrollment',
+    'course_section_history' => 'Past Courses',
+    'course_view_all' => 'View All',
+    'course_status_open' => 'Enrolling',
+    'course_status_closed' => 'Ended',
+    'course_period' => 'Cohort :n',
+    'course_members' => ':n learners',
+    'course_signup' => 'Enroll',
+    'course_no_latest' => 'No courses yet',
+    'course_no_open' => 'No courses open for enrollment',
+    'course_no_history' => 'No past courses',
+    'course_history_title' => 'Past Courses',
+    'course_history_subtitle' => 'Completed public courses — revisit past study groups',
 ];
 ];

+ 1 - 0
api-v13/resources/lang/en/site.php

@@ -23,6 +23,7 @@ return [
         'tipitaka' => 'Tipitaka',
         'tipitaka' => 'Tipitaka',
         'wiki' => 'Wiki',
         'wiki' => 'Wiki',
         'anthology' => 'Anthology',
         'anthology' => 'Anthology',
+        'course' => 'Courses',
         'download' => 'Download',
         'download' => 'Download',
 
 
         'menu' => 'Menu',
         'menu' => 'Menu',

+ 23 - 0
api-v13/resources/lang/zh-Hans/library.php

@@ -137,4 +137,27 @@ return [
     'pāḷi' => '巴利原典',
     'pāḷi' => '巴利原典',
     'aṭṭhakathā' => '义注',
     'aṭṭhakathā' => '义注',
     'ṭīkā' => '复注',
     'ṭīkā' => '复注',
+
+    // course (course/index.blade.php + course/history.blade.php)
+    'course' => '课程',
+    'course_hero_title' => '课程',
+    'course_hero_subtitle' => '跟随世界各地的巴利专家研读圣典',
+    'course_search_placeholder' => '搜索课程、讲师…',
+    'course_stat_total' => '公开课程',
+    'course_stat_open' => '开放报名',
+    'course_stat_closed' => '已结课',
+    'course_section_latest' => '最新课程',
+    'course_section_open' => '开放报名',
+    'course_section_history' => '历史课程',
+    'course_view_all' => '查看全部',
+    'course_status_open' => '报名中',
+    'course_status_closed' => '已结束',
+    'course_period' => '第 :n 期',
+    'course_members' => ':n 人',
+    'course_signup' => '报名',
+    'course_no_latest' => '暂无课程',
+    'course_no_open' => '暂无开放报名的课程',
+    'course_no_history' => '暂无历史课程',
+    'course_history_title' => '历史课程',
+    'course_history_subtitle' => '已结束的公开课程,回顾往期研读',
 ];
 ];

+ 1 - 0
api-v13/resources/lang/zh-Hans/site.php

@@ -23,6 +23,7 @@ return [
         'tipitaka' => '三藏',
         'tipitaka' => '三藏',
         'wiki' => '百科',
         'wiki' => '百科',
         'anthology' => '文集',
         'anthology' => '文集',
+        'course' => '课程',
         'download' => '下载',
         'download' => '下载',
 
 
         'menu' => '导航',
         'menu' => '导航',

+ 23 - 0
api-v13/resources/lang/zh-Hant/library.php

@@ -134,4 +134,27 @@ return [
     'cancel' => '取消',
     'cancel' => '取消',
     'confirm' => '確定',
     'confirm' => '確定',
     'no_toc' => '本書無目錄',
     'no_toc' => '本書無目錄',
+
+    // course (course/index.blade.php + course/history.blade.php)
+    'course' => '課程',
+    'course_hero_title' => '課程',
+    'course_hero_subtitle' => '跟隨世界各地的巴利專家研讀聖典',
+    'course_search_placeholder' => '搜尋課程、講師…',
+    'course_stat_total' => '公開課程',
+    'course_stat_open' => '開放報名',
+    'course_stat_closed' => '已結課',
+    'course_section_latest' => '最新課程',
+    'course_section_open' => '開放報名',
+    'course_section_history' => '歷史課程',
+    'course_view_all' => '查看全部',
+    'course_status_open' => '報名中',
+    'course_status_closed' => '已結束',
+    'course_period' => '第 :n 期',
+    'course_members' => ':n 人',
+    'course_signup' => '報名',
+    'course_no_latest' => '暫無課程',
+    'course_no_open' => '暫無開放報名的課程',
+    'course_no_history' => '暫無歷史課程',
+    'course_history_title' => '歷史課程',
+    'course_history_subtitle' => '已結束的公開課程,回顧往期研讀',
 ];
 ];

+ 1 - 0
api-v13/resources/lang/zh-Hant/site.php

@@ -23,6 +23,7 @@ return [
         'tipitaka' => '三藏',
         'tipitaka' => '三藏',
         'wiki' => '百科',
         'wiki' => '百科',
         'anthology' => '文集',
         'anthology' => '文集',
+        'course' => '課程',
         'download' => '下載',
         'download' => '下載',
         'menu' => '導航',
         'menu' => '導航',
         'open_menu' => '打開導航',
         'open_menu' => '打開導航',

+ 115 - 0
api-v13/resources/views/components/home/card.blade.php

@@ -0,0 +1,115 @@
+{{-- resources/views/components/home/card.blade.php
+     WikiPāli 首页通栏卡片。底图满铺,文字压在图上。
+     available && href 非空 → 渲染链接按钮;否则渲染不可点击的状态角标。
+
+     底图按「存在的文件」动态输出:优先 AVIF → WebP → JPEG 兜底。
+     有底图时直接显示底图(不加颜色混合蒙版);素材未就位时才用蒙版纯色块占位,不引用任何不存在的文件,
+     避免浏览器选中 AVIF 源却 404 导致整张图不显示。
+--}}
+@props([
+    'slug',
+    'eyebrow',
+    'title',
+    'lead',
+    'image',
+    'tint',
+    'align',
+    'href' => null,
+    'cta',
+    'available' => false,
+    'eager' => false,
+])
+
+@php
+    $sizes = [640, 1280, 2560];
+    $formats = ['avif' => 'image/avif', 'webp' => 'image/webp', 'jpg' => 'image/jpeg'];
+
+    // 收集磁盘上实际存在的底图文件
+    $byFormat = []; // ext => [ ['url'=>..., 'width'=>...], ... ]
+    foreach ($formats as $ext => $mime) {
+        $found = [];
+        foreach ($sizes as $width) {
+            $url = "/img/home/{$image}-{$width}.{$ext}";
+            if (is_file(public_path("img/home/{$image}-{$width}.{$ext}"))) {
+                $found[] = ['url' => $url, 'width' => $width];
+            }
+        }
+        if ($found !== []) {
+            $byFormat[$ext] = $found;
+        }
+    }
+
+    // 每种存在格式的 srcset 串
+    $sets = [];
+    foreach ($byFormat as $ext => $entries) {
+        $parts = [];
+        foreach ($entries as $e) {
+            $parts[] = $e['url'].' '.$e['width'].'w';
+        }
+        $sets[$ext] = implode(', ', $parts);
+    }
+
+    // <img> 兜底:优先 jpg,其次 webp,再次 avif;尺寸优先 1280
+    $fallbackFormat = null;
+    foreach (['jpg', 'webp', 'avif'] as $ext) {
+        if (isset($byFormat[$ext])) {
+            $fallbackFormat = $ext;
+            break;
+        }
+    }
+
+    $fallbackSrc = null;
+    if ($fallbackFormat !== null) {
+        foreach ($byFormat[$fallbackFormat] as $e) {
+            if ($e['width'] === 1280) {
+                $fallbackSrc = $e['url'];
+                break;
+            }
+        }
+        $fallbackSrc ??= $byFormat[$fallbackFormat][0]['url'];
+    }
+
+    $titleId = "{$slug}-title";
+@endphp
+
+<section id="card-{{ $slug }}"
+    class="home-card home-card--{{ $align }}"
+    style="--card-tint: {{ $tint }}"
+    aria-labelledby="{{ $titleId }}">
+
+    @if ($fallbackSrc !== null)
+    <picture class="home-card__media-wrap">
+        @if (isset($sets['avif']))
+        <source type="image/avif" srcset="{{ $sets['avif'] }}" sizes="100vw">
+        @endif
+        @if (isset($sets['webp']))
+        <source type="image/webp" srcset="{{ $sets['webp'] }}" sizes="100vw">
+        @endif
+        <img class="home-card__media"
+            src="{{ $fallbackSrc }}"
+            @if (isset($sets[$fallbackFormat])) srcset="{{ $sets[$fallbackFormat] }}" sizes="100vw" @endif
+            alt=""
+            decoding="async"
+            @if($eager) loading="eager" fetchpriority="high" @else loading="lazy" @endif>
+    </picture>
+    @else
+    <div class="home-card__veil" aria-hidden="true"></div>
+    @endif
+
+    <div class="home-card__body">
+        <div class="home-card__text">
+            <p class="home-card__eyebrow">{{ $eyebrow }}</p>
+            <h2 class="home-card__title" id="{{ $titleId }}">{{ $title }}</h2>
+            <p class="home-card__lead">{{ $lead }}</p>
+
+            @if ($available && filled($href))
+                <a class="home-card__cta" href="{{ $href }}">{{ $cta }}</a>
+            @else
+                <span class="home-card__badge">
+                    <i class="ti ti-clock" aria-hidden="true"></i>
+                    {{ $cta }}
+                </span>
+            @endif
+        </div>
+    </div>
+</section>

+ 52 - 0
api-v13/resources/views/components/library/course-card.blade.php

@@ -0,0 +1,52 @@
+{{-- resources/views/components/library/course-card.blade.php
+     课程封面卡片(用于「最新课程」推荐位网格)。
+     Props: $course — 由 Library\CourseController 加工后的课程数组。
+--}}
+@props(['course'])
+
+@php
+    $teacher = $course['teacher'] ?? [];
+    $status = $course['status'] ?? 'closed';
+    $statusKey = $status === 'open' ? 'course_status_open' : 'course_status_closed';
+    $cover = $course['cover_url'] ?? null;
+@endphp
+
+<a href="{{ $course['detail_url'] }}" class="course-card" target="_blank" rel="noopener">
+
+    {{-- 封面 --}}
+    <div class="course-card__cover" style="{{ empty($cover) ? 'background:'.$course['cover_gradient'] : '' }}">
+        @if($cover)
+        <img src="{{ $cover }}" alt="{{ $course['title'] }}" loading="lazy" class="course-card__cover-img">
+        @else
+        <span class="course-card__cover-fallback">{{ $course['title'] }}</span>
+        @endif
+
+        <span class="course-badge course-badge--{{ $status }}">
+            {{ __("library.{$statusKey}") }}
+        </span>
+    </div>
+
+    {{-- 正文 --}}
+    <div class="course-card__body">
+        <h3 class="course-card__title">{{ $course['title'] }}</h3>
+
+        <div class="course-card__teacher">
+            @if(!empty($teacher['name']))
+            <x-ui.author-avatar
+                :avatar="$teacher['avatar'] ?? null"
+                :color="$teacher['color']"
+                :initials="$teacher['initials']"
+                :name="$teacher['name']"
+                size="sm" />
+            @endif
+        </div>
+
+        <div class="course-card__foot">
+            @if($course['number'] > 0)
+            <span class="course-card__period">{{ __('library.course_period', ['n' => $course['number']]) }}</span>
+            @endif
+            <span class="course-card__members">{{ __('library.course_members', ['n' => $course['member_count']]) }}</span>
+        </div>
+    </div>
+
+</a>

+ 68 - 0
api-v13/resources/views/components/library/course-row.blade.php

@@ -0,0 +1,68 @@
+{{-- resources/views/components/library/course-row.blade.php
+     课程横向列表行(用于「开放报名」与「历史课程」列表)。
+     Props: $course — 由 Library\CourseController 加工后的课程数组。
+--}}
+@props(['course'])
+
+@php
+    $teacher = $course['teacher'] ?? [];
+    $status = $course['status'] ?? 'closed';
+    $statusKey = $status === 'open' ? 'course_status_open' : 'course_status_closed';
+    $cover = $course['cover_url'] ?? null;
+@endphp
+
+<a href="{{ $course['detail_url'] }}" class="course-row" target="_blank" rel="noopener">
+
+    {{-- 缩略图 --}}
+    <div class="course-row__thumb" style="{{ empty($cover) ? 'background:'.$course['cover_gradient'] : '' }}">
+        @if($cover)
+        <img src="{{ $cover }}" alt="{{ $course['title'] }}" loading="lazy">
+        @else
+        <span>{{ $course['title'] }}</span>
+        @endif
+    </div>
+
+    {{-- 主体 --}}
+    <div class="course-row__main">
+        <h3 class="course-row__title">{{ $course['title'] }}</h3>
+
+        @if(!empty($course['summary']))
+        <div class="course-row__summary">{{ $course['summary'] }}</div>
+        @endif
+
+        <div class="course-row__meta">
+            @if(!empty($teacher['name']))
+            <span class="course-row__teacher">
+                <x-ui.author-avatar
+                    :avatar="$teacher['avatar'] ?? null"
+                    :color="$teacher['color']"
+                    :initials="$teacher['initials']"
+                    :name="$teacher['name']"
+                    size="sm" />
+            </span>
+            @endif
+
+            @if($course['number'] > 0)
+            <span class="course-row__period">{{ __('library.course_period', ['n' => $course['number']]) }}</span>
+            @endif
+
+            <span class="course-row__members">{{ __('library.course_members', ['n' => $course['member_count']]) }}</span>
+
+            @if($course['start_date'])
+            <span class="course-row__date">{{ $course['start_date'] }}</span>
+            @endif
+        </div>
+    </div>
+
+    {{-- 右侧:状态 + 报名引导 --}}
+    <div class="course-row__side">
+        <span class="course-badge course-badge--{{ $status }}">
+            {{ __("library.{$statusKey}") }}
+        </span>
+
+        @if($status === 'open')
+        <span class="course-row__cta">{{ __('library.course_signup') }} <i class="ti ti-chevron-right"></i></span>
+        @endif
+    </div>
+
+</a>

+ 2 - 0
api-v13/resources/views/components/library/header.blade.php

@@ -12,6 +12,7 @@
                 <li><a href="{{ route('library.tipitaka.index') }}">三藏</a></li>
                 <li><a href="{{ route('library.tipitaka.index') }}">三藏</a></li>
                 <li><a href="{{ route('library.wiki.home') }}">百科</a></li>
                 <li><a href="{{ route('library.wiki.home') }}">百科</a></li>
                 <li><a href="{{ route('library.anthology.index') }}">文集</a></li>
                 <li><a href="{{ route('library.anthology.index') }}">文集</a></li>
+                <li><a href="{{ route('library.course') }}">课程</a></li>
                 <li><a href="{{ route('library.download') }}">下载</a></li>
                 <li><a href="{{ route('library.download') }}">下载</a></li>
                 <li>
                 <li>
                     <x-language-switcher />
                     <x-language-switcher />
@@ -43,6 +44,7 @@
         <li><a href="{{ route('library.tipitaka.index') }}">三藏</a></li>
         <li><a href="{{ route('library.tipitaka.index') }}">三藏</a></li>
         <li><a href="{{ route('library.wiki.home') }}">百科</a></li>
         <li><a href="{{ route('library.wiki.home') }}">百科</a></li>
         <li><a href="{{ route('library.anthology.index') }}">文集</a></li>
         <li><a href="{{ route('library.anthology.index') }}">文集</a></li>
+        <li><a href="{{ route('library.course') }}">课程</a></li>
         <li><a href="{{ route('library.download') }}">下载</a></li>
         <li><a href="{{ route('library.download') }}">下载</a></li>
         <li style="padding:1rem 0.25rem;">
         <li style="padding:1rem 0.25rem;">
             <x-language-switcher />
             <x-language-switcher />

+ 12 - 0
api-v13/resources/views/components/library/navbar.blade.php

@@ -35,6 +35,13 @@
                     </a>
                     </a>
                 </li>
                 </li>
 
 
+                <li>
+                    <a href="{{ route('library.course') }}"
+                        class="{{ request()->routeIs('library.course', 'library.course.history') ? 'active' : '' }}">
+                        {{ __('site.nav.course') }}
+                    </a>
+                </li>
+
                 <li>
                 <li>
                     <a href="{{ route('library.download') }}"
                     <a href="{{ route('library.download') }}"
                         class="{{ request()->routeIs('library.download') ? 'active' : '' }}">
                         class="{{ request()->routeIs('library.download') ? 'active' : '' }}">
@@ -89,6 +96,11 @@
                 {{ __('site.nav.anthology') }}
                 {{ __('site.nav.anthology') }}
             </a></li>
             </a></li>
 
 
+        <li><a href="{{ route('library.course') }}"
+                class="{{ request()->routeIs('library.course', 'library.course.history') ? 'active' : '' }}">
+                {{ __('site.nav.course') }}
+            </a></li>
+
         <li><a href="{{ route('library.download') }}"
         <li><a href="{{ route('library.download') }}"
                 class="{{ request()->routeIs('library.download') ? 'active' : '' }}">
                 class="{{ request()->routeIs('library.download') ? 'active' : '' }}">
                 {{ __('site.nav.download') }}
                 {{ __('site.nav.download') }}

+ 59 - 0
api-v13/resources/views/library/course/history.blade.php

@@ -0,0 +1,59 @@
+{{-- resources/views/library/course/history.blade.php
+     历史课程列表页(分页)。
+--}}
+@extends('layouts.library')
+
+@section('title', __('library.course_history_title') . ' · ' . __('library.site_name'))
+
+@push('styles')
+@vite('resources/css/modules/library-course.css')
+@endpush
+
+@section('breadcrumb')
+<li class="breadcrumb-item">
+    <a href="{{ route('library.home') }}">{{ __('library.home') }}</a>
+</li>
+<li class="breadcrumb-item">
+    <a href="{{ route('library.course') }}">{{ __('library.course') }}</a>
+</li>
+<li class="breadcrumb-item active">{{ __('library.course_history_title') }}</li>
+@endsection
+
+@section('hero')
+<div class="course-page-header">
+    <div class="container-xl">
+        <h1>
+            {{ __('library.course_history_title') }}
+            <span class="course-count-badge">{{ $total }}</span>
+        </h1>
+        <p>{{ __('library.course_history_subtitle') }}</p>
+    </div>
+</div>
+@endsection
+
+@section('content')
+<div class="page-body">
+    <div class="container-xl">
+
+        <div class="lib-section">
+            @if($courses->isNotEmpty())
+            <div class="course-list">
+                @foreach($courses as $course)
+                <x-library.course-row :course="$course" />
+                @endforeach
+            </div>
+
+            {{-- 分页 --}}
+            <div class="d-flex justify-content-center mt-3">
+                {{ $courses->links('library.anthology.pagination') }}
+            </div>
+            @else
+            <div class="wiki-card">
+                <x-ui.empty-state :title="__('library.course_no_history')" />
+            </div>
+            @endif
+        </div>
+
+    </div>
+</div>
+@endsection

+ 128 - 0
api-v13/resources/views/library/course/index.blade.php

@@ -0,0 +1,128 @@
+{{-- resources/views/library/course/index.blade.php
+     课程栏目首页。
+     区块:Hero → 统计条 → 最新课程 → 开放报名 → 历史课程预览
+--}}
+@extends('layouts.library')
+
+@section('title', __('library.course') . ' · ' . __('library.site_name'))
+
+@push('styles')
+@vite('resources/css/modules/library-course.css')
+@endpush
+
+@section('breadcrumb')
+<li class="breadcrumb-item">
+    <a href="{{ route('library.home') }}">{{ __('library.home') }}</a>
+</li>
+<li class="breadcrumb-item active">{{ __('library.course') }}</li>
+@endsection
+
+{{-- Hero --}}
+@section('hero')
+<section class="hero-section"
+    style="background-image: url('{{ URL::asset('assets/images/hero-1.jpg') }}')">
+    <div class="hero-overlay"></div>
+    <div class="hero-content">
+        <h1 class="hero-title">{{ __('library.course_hero_title') }}</h1>
+        <p class="hero-subtitle">{{ __('library.course_hero_subtitle') }}</p>
+        <div class="search-box">
+            <x-ui.search-input
+                :placeholder="__('library.course_search_placeholder')"
+                size="lg" />
+        </div>
+    </div>
+</section>
+@endsection
+
+@section('content')
+<div class="page-body">
+    <div class="container-xl">
+
+        {{-- ── 统计条 ── --}}
+        <div class="course-stats">
+            <div class="course-stat">
+                <div class="course-stat__value">{{ $stats['total'] }}</div>
+                <div class="course-stat__label">{{ __('library.course_stat_total') }}</div>
+            </div>
+            <div class="course-stat">
+                <div class="course-stat__value">{{ $stats['open'] }}</div>
+                <div class="course-stat__label">{{ __('library.course_stat_open') }}</div>
+            </div>
+            <div class="course-stat">
+                <div class="course-stat__value">{{ $stats['closed'] }}</div>
+                <div class="course-stat__label">{{ __('library.course_stat_closed') }}</div>
+            </div>
+        </div>
+
+        {{-- ── 一、最新课程 ── --}}
+        <div class="lib-section">
+            <div class="lib-section__header">
+                <h2 class="lib-section__title">
+                    <i class="ti ti-trending-up"></i>
+                    {{ __('library.course_section_latest') }}
+                </h2>
+            </div>
+
+            @if($latestCourses->isNotEmpty())
+            <div class="course-grid">
+                @foreach($latestCourses as $course)
+                <x-library.course-card :course="$course" />
+                @endforeach
+            </div>
+            @else
+            <div class="wiki-card">
+                <x-ui.empty-state :title="__('library.course_no_latest')" />
+            </div>
+            @endif
+        </div>
+
+        {{-- ── 二、开放报名 ── --}}
+        <div class="lib-section">
+            <div class="lib-section__header">
+                <h2 class="lib-section__title">
+                    <i class="ti ti-calendar-event"></i>
+                    {{ __('library.course_section_open') }}
+                </h2>
+            </div>
+
+            @if($openCourses->isNotEmpty())
+            <div class="course-list">
+                @foreach($openCourses as $course)
+                <x-library.course-row :course="$course" />
+                @endforeach
+            </div>
+            @else
+            <div class="wiki-card">
+                <x-ui.empty-state :title="__('library.course_no_open')" />
+            </div>
+            @endif
+        </div>
+
+        {{-- ── 三、历史课程 ── --}}
+        <div class="lib-section">
+            <div class="lib-section__header">
+                <h2 class="lib-section__title">
+                    <i class="ti ti-history"></i>
+                    {{ __('library.course_section_history') }}
+                </h2>
+                <a href="{{ route('library.course.history') }}" class="lib-section__more">
+                    {{ __('library.course_view_all') }} <i class="ti ti-arrow-right"></i>
+                </a>
+            </div>
+
+            @if($historyCourses->isNotEmpty())
+            <div class="course-list">
+                @foreach($historyCourses as $course)
+                <x-library.course-row :course="$course" />
+                @endforeach
+            </div>
+            @else
+            <div class="wiki-card">
+                <x-ui.empty-state :title="__('library.course_no_history')" />
+            </div>
+            @endif
+        </div>
+
+    </div>
+</div>
+@endsection

+ 42 - 0
api-v13/resources/views/pages/home.blade.php

@@ -0,0 +1,42 @@
+{{-- resources/views/pages/home.blade.php
+     WikiPāli 首页:品牌 hero + 四张通栏分流卡片。
+     卡片数据全部来自 PageIndexController 传入的 $cards,模板不硬编码栏目文案。
+--}}
+@extends('layouts.base')
+
+@section('title', 'WikiPāli · 巴利佛典百科')
+
+@push('styles')
+@vite(['resources/css/home.css'])
+@endpush
+
+@section('body-class', 'home-page')
+
+@section('page')
+<main class="home">
+
+    {{-- 品牌 Hero:站名 + 一行说明,居中 --}}
+    <header class="home-hero">
+        <h1 class="home-hero__title">WikiPāli</h1>
+        <p class="home-hero__lead">巴利佛典百科 —— 由浅入深,进入巴利圣典的世界</p>
+    </header>
+
+    {{-- 四张通栏卡片,纵向排列 --}}
+    @foreach ($cards as $card)
+        <x-home.card
+            :slug="$card['slug']"
+            :eyebrow="$card['eyebrow']"
+            :title="$card['title']"
+            :lead="$card['lead']"
+            :image="$card['image']"
+            :tint="$card['tint']"
+            :align="$card['align']"
+            :href="$card['href']"
+            :cta="$card['cta']"
+            :available="$card['available']"
+            :eager="$loop->first"
+        />
+    @endforeach
+
+</main>
+@endsection

+ 3 - 1
api-v13/routes/api.php

@@ -206,9 +206,12 @@ Route::group([
 
 
     Route::post('export_wbw', [ExportWbwController::class, 'index']);
     Route::post('export_wbw', [ExportWbwController::class, 'index']);
     Route::apiResource('attachments', UploadController::class);
     Route::apiResource('attachments', UploadController::class);
+
     Route::apiResource('discussion', DiscussionController::class);
     Route::apiResource('discussion', DiscussionController::class);
     Route::post('sent-discussion-tree', [DiscussionController::class, 'discussion_tree']);
     Route::post('sent-discussion-tree', [DiscussionController::class, 'discussion_tree']);
     Route::get('discussion-anchor/{id}', [DiscussionController::class, 'anchor']);
     Route::get('discussion-anchor/{id}', [DiscussionController::class, 'anchor']);
+    Route::apiResource('discussion-count', DiscussionCountController::class);
+
     Route::apiResource('user', UserController::class);
     Route::apiResource('user', UserController::class);
     Route::apiResource('group-member', GroupMemberController::class);
     Route::apiResource('group-member', GroupMemberController::class);
     Route::apiResource('share', ShareController::class);
     Route::apiResource('share', ShareController::class);
@@ -283,7 +286,6 @@ Route::group([
     Route::apiResource('chapter-index', ChapterIndexController::class);
     Route::apiResource('chapter-index', ChapterIndexController::class);
     Route::apiResource('wbw-sentence', WbwSentenceController::class);
     Route::apiResource('wbw-sentence', WbwSentenceController::class);
     Route::apiResource('snowflake', SnowFlakeIdController::class);
     Route::apiResource('snowflake', SnowFlakeIdController::class);
-    Route::apiResource('discussion-count', DiscussionCountController::class);
     Route::apiResource('tags-in-chapter', TagsInChapterCountController::class);
     Route::apiResource('tags-in-chapter', TagsInChapterCountController::class);
     Route::apiResource('tag-map', TagMapController::class);
     Route::apiResource('tag-map', TagMapController::class);
     Route::apiResource('editable-sentence', EditableSentenceController::class);
     Route::apiResource('editable-sentence', EditableSentenceController::class);

+ 12 - 14
api-v13/routes/web.php

@@ -1,19 +1,20 @@
 <?php
 <?php
 
 
-use Illuminate\Support\Facades\Route;
-use Illuminate\Support\Facades\File;
-use App\Http\Controllers\WbwAnalysisController;
-use App\Http\Controllers\PageIndexController;
 use App\Http\Controllers\AssetsController;
 use App\Http\Controllers\AssetsController;
 use App\Http\Controllers\BlogController;
 use App\Http\Controllers\BlogController;
 use App\Http\Controllers\DownloadController;
 use App\Http\Controllers\DownloadController;
 use App\Http\Controllers\Library\AnthologyController;
 use App\Http\Controllers\Library\AnthologyController;
 use App\Http\Controllers\Library\AnthologyReadController;
 use App\Http\Controllers\Library\AnthologyReadController;
 use App\Http\Controllers\Library\BookController;
 use App\Http\Controllers\Library\BookController;
-use App\Http\Controllers\Library\WikiController;
-use App\Http\Controllers\Library\SearchController;
+use App\Http\Controllers\Library\CourseController;
 use App\Http\Controllers\Library\HomeController;
 use App\Http\Controllers\Library\HomeController;
+use App\Http\Controllers\Library\SearchController;
 use App\Http\Controllers\Library\TipitakaController;
 use App\Http\Controllers\Library\TipitakaController;
+use App\Http\Controllers\Library\WikiController;
+use App\Http\Controllers\PageIndexController;
+use App\Http\Controllers\WbwAnalysisController;
+use Illuminate\Support\Facades\File;
+use Illuminate\Support\Facades\Route;
 
 
 /*
 /*
 |--------------------------------------------------------------------------
 |--------------------------------------------------------------------------
@@ -38,7 +39,6 @@ Route::get('/export/wbw', function () {
     return view('export_wbw', ['sentences' => []]);
     return view('export_wbw', ['sentences' => []]);
 });
 });
 
 
-
 Route::get('/privacy/{file}', function (string $file) {
 Route::get('/privacy/{file}', function (string $file) {
     $path = base_path("documents/mobile/privacy/{$file}.md");
     $path = base_path("documents/mobile/privacy/{$file}.md");
 
 
@@ -54,13 +54,10 @@ Route::get('/book/{id}', function ($id) {
 });
 });
 Route::redirect('/privacy', '/privacy/index');
 Route::redirect('/privacy', '/privacy/index');
 
 
-
-
-
 Route::post('/theme/toggle', [BookController::class, 'toggleTheme'])->name('theme.toggle');
 Route::post('/theme/toggle', [BookController::class, 'toggleTheme'])->name('theme.toggle');
 Route::post('/logout', function () {
 Route::post('/logout', function () {
     // Handle logout
     // Handle logout
-    //Auth::logout();
+    // Auth::logout();
     return redirect('/login');
     return redirect('/login');
 })->name('logout');
 })->name('logout');
 
 
@@ -76,11 +73,12 @@ Route::prefix('library')->name('library.')->group(function () {
     Route::get('/wiki/{lang}', [WikiController::class, 'index'])->name('wiki.index');
     Route::get('/wiki/{lang}', [WikiController::class, 'index'])->name('wiki.index');
     Route::get('/wiki/{lang}/{word}', [WikiController::class, 'show'])->name('wiki.show');
     Route::get('/wiki/{lang}/{word}', [WikiController::class, 'show'])->name('wiki.show');
 
 
-    Route::get('/course', [DownloadController::class, 'index'])->name('course');
+    Route::get('/course', [CourseController::class, 'index'])->name('course');
+    Route::get('/course/history', [CourseController::class, 'history'])->name('course.history');
     Route::get('/download', [DownloadController::class, 'index'])->name('download');
     Route::get('/download', [DownloadController::class, 'index'])->name('download');
     // 文集
     // 文集
-    Route::get('/anthology',          [AnthologyController::class, 'index'])->name('anthology.index');
-    Route::get('/anthology/{id}',     [AnthologyController::class, 'show'])->name('anthology.show');
+    Route::get('/anthology', [AnthologyController::class, 'index'])->name('anthology.index');
+    Route::get('/anthology/{id}', [AnthologyController::class, 'show'])->name('anthology.show');
     Route::get(
     Route::get(
         '/anthology/{anthology}/read/{article}',
         '/anthology/{anthology}/read/{article}',
         [AnthologyReadController::class, 'read']
         [AnthologyReadController::class, 'read']

+ 2 - 0
api-v13/vite.config.ts

@@ -10,11 +10,13 @@ export default defineConfig({
         laravel({
         laravel({
             input: [
             input: [
                 'resources/css/app.css', // library/* + blog 列表页
                 'resources/css/app.css', // library/* + blog 列表页
+                'resources/css/home.css', // 首页(next.wikipali.org)
                 'resources/css/library.css', // library/* + blog 列表页
                 'resources/css/library.css', // library/* + blog 列表页
                 'resources/css/modules/library-index.css',
                 'resources/css/modules/library-index.css',
                 'resources/css/modules/wiki.css',
                 'resources/css/modules/wiki.css',
                 'resources/css/modules/tipitaka.css',
                 'resources/css/modules/tipitaka.css',
                 'resources/css/modules/anthology.css',
                 'resources/css/modules/anthology.css',
+                'resources/css/modules/library-course.css',
                 'resources/css/reader.css', // 全站阅读页(待建)
                 'resources/css/reader.css', // 全站阅读页(待建)
                 'resources/js/app.js',
                 'resources/js/app.js',
                 'resources/js/reader.js',
                 'resources/js/reader.js',

+ 44 - 0
dashboard-v6/src/api/article.ts

@@ -596,3 +596,47 @@ export const fetchPageNav = (pageId: string): Promise<IPageNavResponse> => {
   }-${pageParam[3]}`;
   }-${pageParam[3]}`;
   return get<IPageNavResponse>(url);
   return get<IPageNavResponse>(url);
 };
 };
+
+/**
+ * cs-para 导航
+ *
+ * csParaId 形如 `book_para_page`,例如 `169_3_64`。
+ * GET /api/v2/nav-cs-para/{csParaId}
+ */
+export const fetchCSParaNav = (
+  csParaId: string
+): Promise<ICSParaNavResponse> => {
+  return get<ICSParaNavResponse>(`/api/v2/nav-cs-para/${csParaId}`);
+};
+
+export async function csParaLoader({ params }: LoaderFunctionArgs) {
+  const id = params.id;
+
+  if (!id) {
+    throw new Response("Missing cs-para id", { status: 400 });
+  }
+
+  const res = await fetchCSParaNav(id);
+
+  if (!res.ok) {
+    throw new Response("cs-para not found", { status: 404 });
+  }
+
+  // title 使用 `book_para_page` 中的 page(第三段),例如 `169_3_64` -> `64`
+  return { title: id.split("_")[2] ?? id };
+}
+
+/**
+ * 页码引用路由 loader。
+ *
+ * 仅用于提供面包屑标题,页面内容由 TypePage 内部的 usePageNav 解析渲染。
+ */
+export async function pageLoader({ params }: LoaderFunctionArgs) {
+  const id = params.id;
+
+  if (!id) {
+    throw new Response("Missing page id", { status: 400 });
+  }
+
+  return { title: id };
+}

+ 39 - 0
dashboard-v6/src/api/pali-text.ts

@@ -1,5 +1,6 @@
 // src/api/pali-text.ts
 // src/api/pali-text.ts
 import type { MenuProps } from "antd";
 import type { MenuProps } from "antd";
+import type { LoaderFunctionArgs } from "react-router";
 import { get } from "../request";
 import { get } from "../request";
 import type { ArticleMode, IArticleResponse } from "./article";
 import type { ArticleMode, IArticleResponse } from "./article";
 import type { IWidgetSentEditInner } from "../components/sentence/SentEdit";
 import type { IWidgetSentEditInner } from "../components/sentence/SentEdit";
@@ -202,3 +203,41 @@ export const fetchChapterToc = (
     `/api/v2/chapter?view=toc&book=${book}&para=${para}`
     `/api/v2/chapter?view=toc&book=${book}&para=${para}`
   );
   );
 };
 };
+
+export async function chapterLoader({ params }: LoaderFunctionArgs) {
+  const id = params.id;
+
+  if (!id) {
+    throw new Response("Missing chapter id", { status: 400 });
+  }
+
+  const res = await fetchChapter(id, "read");
+
+  if (!res.ok) {
+    throw new Response("Chapter not found", { status: 404 });
+  }
+
+  return res.data;
+}
+
+export async function paraLoader({ params }: LoaderFunctionArgs) {
+  const id = params.id;
+
+  if (!id) {
+    throw new Response("Missing paragraph id", { status: 400 });
+  }
+
+  const [book, para] = id.split("-");
+
+  if (!book || !para) {
+    throw new Response("Invalid paragraph id", { status: 400 });
+  }
+
+  const res = await fetchPara(book, para, "read");
+
+  if (!res.ok) {
+    throw new Response("Paragraph not found", { status: 404 });
+  }
+
+  return res.data;
+}

+ 18 - 0
dashboard-v6/src/api/task.ts

@@ -12,6 +12,8 @@
             $table->timestamps();
             $table->timestamps();
  */
  */
 
 
+import type { LoaderFunctionArgs } from "react-router";
+import { get } from "../request";
 import type { TPrivacy } from "./ai";
 import type { TPrivacy } from "./ai";
 import type { IStudio, IUser } from "./Auth";
 import type { IStudio, IUser } from "./Auth";
 
 
@@ -257,3 +259,19 @@ export interface IProjectTreeResponse {
   message: string;
   message: string;
   data: { rows: IProjectTreeData[]; count: number };
   data: { rows: IProjectTreeData[]; count: number };
 }
 }
+
+export async function projectLoader({ params }: LoaderFunctionArgs) {
+  const projectId = params.projectId;
+
+  if (!projectId) {
+    throw new Response("Missing projectId", { status: 400 });
+  }
+
+  const res = await get<IProjectResponse>(`/api/v2/project/${projectId}`);
+
+  if (!res.ok) {
+    throw new Response("Project not found", { status: 404 });
+  }
+
+  return res.data;
+}

+ 2 - 2
dashboard-v6/src/components/anthology/EditableTocTree.tsx

@@ -17,7 +17,7 @@ import type {
 } from "../../api/article";
 } from "../../api/article";
 import ArticleListModal from "../article/ArticleListModal";
 import ArticleListModal from "../article/ArticleListModal";
 
 
-import { fullUrl, randomString } from "../../utils";
+import { articlePath, fullUrl, randomString } from "../../utils";
 import type {
 import type {
   ListNodeData,
   ListNodeData,
   TreeNodeData,
   TreeNodeData,
@@ -172,7 +172,7 @@ const EditableTocTree = ({
           node: TreeNodeData
           node: TreeNodeData
         ) => {
         ) => {
           if (e.ctrlKey || e.metaKey) {
           if (e.ctrlKey || e.metaKey) {
-            window.open(fullUrl(`/article/article/${node.id}`), "_blank");
+            window.open(fullUrl(articlePath("article", node.id)), "_blank");
           } else {
           } else {
             setViewArticle(node);
             setViewArticle(node);
             setOpenViewer(true);
             setOpenViewer(true);

+ 2 - 1
dashboard-v6/src/components/article/ArticleEditTools.tsx

@@ -7,6 +7,7 @@ import ShareModal from "../share/ShareModal";
 import TplBuilder from "../tpl-builder/TplBuilder";
 import TplBuilder from "../tpl-builder/TplBuilder";
 import AddToAnthology from "../anthology/AddToAnthology";
 import AddToAnthology from "../anthology/AddToAnthology";
 import { EResType } from "../share/utils";
 import { EResType } from "../share/utils";
+import { articlePath } from "../../utils";
 
 
 interface IWidget {
 interface IWidget {
   studioName?: string;
   studioName?: string;
@@ -42,7 +43,7 @@ const ArticleEditToolsWidget = ({
           resType={EResType.article}
           resType={EResType.article}
         />
         />
       ) : undefined}
       ) : undefined}
-      <Link to={`/article/article/${articleId}`} target="_blank">
+      <Link to={articlePath("article", articleId ?? "")} target="_blank">
         {intl.formatMessage({ id: "buttons.open.in.tab" })}
         {intl.formatMessage({ id: "buttons.open.in.tab" })}
       </Link>
       </Link>
       <TplBuilder
       <TplBuilder

+ 2 - 2
dashboard-v6/src/components/article/ArticleList.tsx

@@ -32,7 +32,7 @@ import Share from "../share/Share";
 import AnthologySelect from "../anthology/AnthologySelect";
 import AnthologySelect from "../anthology/AnthologySelect";
 import StudioName from "../auth/Studio";
 import StudioName from "../auth/Studio";
 
 
-import { getSorterUrl } from "../../utils";
+import { articlePath, getSorterUrl } from "../../utils";
 import TransferCreate from "../transfer/TransferCreate";
 import TransferCreate from "../transfer/TransferCreate";
 import { TransferOutLinedIcon } from "../../assets/icon";
 import { TransferOutLinedIcon } from "../../assets/icon";
 import type { IStudio, IUser } from "../../api/Auth";
 import type { IStudio, IUser } from "../../api/Auth";
@@ -392,7 +392,7 @@ const ArticleList = ({
                 >
                 >
                   <Link
                   <Link
                     key={index}
                     key={index}
-                    to={`/article/article/${row.id}`}
+                    to={articlePath("article", row.id)}
                     target="_blank"
                     target="_blank"
                   >
                   >
                     {intl.formatMessage({
                     {intl.formatMessage({

+ 2 - 2
dashboard-v6/src/components/article/TypePage.tsx

@@ -4,7 +4,7 @@ import { useIntl } from "react-intl";
 
 
 import type { ArticleMode, ArticleType } from "../../api/article";
 import type { ArticleMode, ArticleType } from "../../api/article";
 import type { TTarget } from "../../types";
 import type { TTarget } from "../../types";
-import { fullUrl } from "../../utils";
+import { articlePath, fullUrl } from "../../utils";
 import TypePali from "./TypePali";
 import TypePali from "./TypePali";
 import NavigateButton from "./components/NavigateButton";
 import NavigateButton from "./components/NavigateButton";
 import ArticleSkeleton from "./components/ArticleSkeleton";
 import ArticleSkeleton from "./components/ArticleSkeleton";
@@ -82,7 +82,7 @@ const TypePageWidget = ({
       onArticleChange("page", id, target);
       onArticleChange("page", id, target);
     } else {
     } else {
       if (target === "_blank") {
       if (target === "_blank") {
-        let url = `/article/page/${id}?mode=${mode}`;
+        let url = `${articlePath("page", id)}?mode=${mode}`;
         if (channelId) {
         if (channelId) {
           url += `&channel=${channelId}`;
           url += `&channel=${channelId}`;
         }
         }

+ 41 - 31
dashboard-v6/src/components/article/TypePali.tsx

@@ -47,11 +47,13 @@ interface IWidget {
   active?: boolean;
   active?: boolean;
   focus?: string | null;
   focus?: string | null;
   hideNav?: boolean;
   hideNav?: boolean;
+  hideTitle?: boolean;
+  hideHead?: boolean;
   onArticleChange?: (
   onArticleChange?: (
     type: ArticleType,
     type: ArticleType,
     id: string,
     id: string,
     target: TTarget,
     target: TTarget,
-    param?: ISearchParams[]
+    param?: ISearchParams[],
   ) => void;
   ) => void;
   onLoad?: (data: IArticleDataResponse) => void;
   onLoad?: (data: IArticleDataResponse) => void;
   onTitle?: (title: string) => void;
   onTitle?: (title: string) => void;
@@ -68,6 +70,8 @@ const TypePali = ({
   active = true,
   active = true,
   focus,
   focus,
   hideNav = false,
   hideNav = false,
+  hideTitle = false,
+  hideHead = false,
   onArticleChange,
   onArticleChange,
 }: IWidget) => {
 }: IWidget) => {
   const intl = useIntl();
   const intl = useIntl();
@@ -129,7 +133,7 @@ const TypePali = ({
 
 
   const handlePathChange = (
   const handlePathChange = (
     node: ITocPathNode,
     node: ITocPathNode,
-    e: React.MouseEvent<HTMLSpanElement | HTMLAnchorElement, MouseEvent>
+    e: React.MouseEvent<HTMLSpanElement | HTMLAnchorElement, MouseEvent>,
   ) => {
   ) => {
     let newType = type;
     let newType = type;
     let newArticle = "";
     let newArticle = "";
@@ -193,7 +197,7 @@ const TypePali = ({
                   window.open(
                   window.open(
                     import.meta.env.VITE_APP_API_SERVER +
                     import.meta.env.VITE_APP_API_SERVER +
                       `/library/tipitaka/${id}/read?channel=${channels[0]}`,
                       `/library/tipitaka/${id}/read?channel=${channels[0]}`,
-                    "_blank"
+                    "_blank",
                   );
                   );
                 }
                 }
               },
               },
@@ -209,7 +213,9 @@ const TypePali = ({
         subTitle={articleData?.subtitle}
         subTitle={articleData?.subtitle}
         summary={articleData?.summary}
         summary={articleData?.summary}
         nodes={nodeData.map((item) => {
         nodes={nodeData.map((item) => {
-          return <ParagraphNode initData={item} />;
+          return (
+            <ParagraphNode key={`${item.book}-${item.para}`} initData={item} />
+          );
         })}
         })}
         html={
         html={
           nodeData.length === 0
           nodeData.length === 0
@@ -221,6 +227,8 @@ const TypePali = ({
         loading={loading}
         loading={loading}
         errorCode={errorCode}
         errorCode={errorCode}
         remains={remains}
         remains={remains}
+        hideTitle={hideTitle}
+        hideHead={hideHead}
         onEnd={() => {
         onEnd={() => {
           if (type === "chapter") loadNextChunk();
           if (type === "chapter") loadNextChunk();
         }}
         }}
@@ -248,7 +256,7 @@ const TypePali = ({
         })}
         })}
         onClick={(
         onClick={(
           id: string,
           id: string,
-          e: React.MouseEvent<HTMLSpanElement, MouseEvent>
+          e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
         ) => {
         ) => {
           if (e.ctrlKey || e.metaKey) {
           if (e.ctrlKey || e.metaKey) {
             onArticleChange?.("chapter", id, "_blank");
             onArticleChange?.("chapter", id, "_blank");
@@ -258,33 +266,35 @@ const TypePali = ({
         }}
         }}
       />
       />
 
 
-      <Divider />
-
       {!hideNav && (
       {!hideNav && (
-        <Navigate
-          type={type}
-          articleId={id}
-          path={fullPath}
-          onPathChange={(key: string) => {
-            const node = articleData?.path?.find((v) => v.title === key);
-            if (node) {
-              const newType = node.level === 0 ? "series" : "chapter";
-              const newArticle = node.key ?? `${node.book}-${node.paragraph}`;
-              onArticleChange?.(newType, newArticle, "_self");
-            }
-          }}
-          onChange={(
-            event: React.MouseEvent<HTMLElement, MouseEvent>,
-            newId: string
-          ) => {
-            const target = event.ctrlKey || event.metaKey ? "_blank" : "_self";
-            let param: ISearchParams[] = [];
-            if (type === "para" && newId?.split("-").length > 1) {
-              param = [{ key: "par", value: newId.split("-")[1] }];
-            }
-            onArticleChange?.(type as ArticleType, newId, target, param);
-          }}
-        />
+        <>
+          <Divider />
+          <Navigate
+            type={type}
+            articleId={id}
+            path={fullPath}
+            onPathChange={(key: string) => {
+              const node = articleData?.path?.find((v) => v.title === key);
+              if (node) {
+                const newType = node.level === 0 ? "series" : "chapter";
+                const newArticle = node.key ?? `${node.book}-${node.paragraph}`;
+                onArticleChange?.(newType, newArticle, "_self");
+              }
+            }}
+            onChange={(
+              event: React.MouseEvent<HTMLElement, MouseEvent>,
+              newId: string,
+            ) => {
+              const target =
+                event.ctrlKey || event.metaKey ? "_blank" : "_self";
+              let param: ISearchParams[] = [];
+              if (type === "para" && newId?.split("-").length > 1) {
+                param = [{ key: "par", value: newId.split("-")[1] }];
+              }
+              onArticleChange?.(type as ArticleType, newId, target, param);
+            }}
+          />
+        </>
       )}
       )}
     </div>
     </div>
   );
   );

+ 15 - 4
dashboard-v6/src/components/article/TypeTerm.tsx

@@ -1,4 +1,6 @@
 import { Breadcrumb, Button, Space } from "antd";
 import { Breadcrumb, Button, Space } from "antd";
+import { useIntl } from "react-intl";
+import { EditOutlined, ShareAltOutlined } from "@ant-design/icons";
 import type { ArticleMode } from "../../api/article";
 import type { ArticleMode } from "../../api/article";
 import { useAppSelector } from "../../hooks";
 import { useAppSelector } from "../../hooks";
 import { currentUser } from "../../reducers/current-user";
 import { currentUser } from "../../reducers/current-user";
@@ -7,6 +9,7 @@ import "./article.css";
 import ArticleHeader from "./components/ArticleHeader";
 import ArticleHeader from "./components/ArticleHeader";
 import ArticleLayout from "./components/ArticleLayout";
 import ArticleLayout from "./components/ArticleLayout";
 import { useTerm } from "./hooks/useTerm";
 import { useTerm } from "./hooks/useTerm";
+import TermShare from "../term/TermShare";
 
 
 interface IWidget {
 interface IWidget {
   id?: string;
   id?: string;
@@ -29,6 +32,7 @@ const TypeTermWidget = ({
     mode,
     mode,
   });
   });
   const currUser = useAppSelector(currentUser);
   const currUser = useAppSelector(currentUser);
+  const intl = useIntl();
 
 
   const path = [
   const path = [
     { title: currUser?.nickName },
     { title: currUser?.nickName },
@@ -37,7 +41,6 @@ const TypeTermWidget = ({
   ];
   ];
   return (
   return (
     <div>
     <div>
-      <title>{articleData?.title}-百科</title>
       <ArticleHeader
       <ArticleHeader
         header={
         header={
           <Space>
           <Space>
@@ -52,9 +55,17 @@ const TypeTermWidget = ({
           </Space>
           </Space>
         }
         }
         action={
         action={
-          <Button type="primary" onClick={onEdit}>
-            Edit
-          </Button>
+          <Space>
+            <Button type="primary" icon={<EditOutlined />} onClick={onEdit}>
+              {intl.formatMessage({ id: "buttons.edit" })}
+            </Button>
+            {id ? (
+              <TermShare
+                id={id}
+                trigger={<Button icon={<ShareAltOutlined />} />}
+              />
+            ) : undefined}
+          </Space>
         }
         }
       />
       />
       <ArticleLayout
       <ArticleLayout

+ 9 - 5
dashboard-v6/src/components/article/components/ArticleLayout.tsx

@@ -34,6 +34,7 @@ export interface IArticleLayout {
   remains?: boolean;
   remains?: boolean;
   anthology?: IFirstAnthology;
   anthology?: IFirstAnthology;
   hideTitle?: boolean;
   hideTitle?: boolean;
+  hideHead?: boolean;
   onEnd?: () => void;
   onEnd?: () => void;
 }
 }
 
 
@@ -49,7 +50,8 @@ const ArticleLayout = ({
   resList,
   resList,
   loading,
   loading,
   errorCode,
   errorCode,
-  hideTitle,
+  hideTitle = false,
+  hideHead = false,
   remains,
   remains,
   onEnd,
   onEnd,
 }: IArticleLayout) => {
 }: IArticleLayout) => {
@@ -63,10 +65,12 @@ const ArticleLayout = ({
         <ErrorResult code={errorCode} />
         <ErrorResult code={errorCode} />
       ) : (
       ) : (
         <div>
         <div>
-          <Flex orientation="vertical" gap="middle">
-            {hideTitle ? (
-              <></>
-            ) : (
+          <Flex
+            style={{ display: hideHead ? "none" : "block" }}
+            orientation="vertical"
+            gap="middle"
+          >
+            {!hideTitle && (
               <Title level={4}>
               <Title level={4}>
                 <div
                 <div
                   dangerouslySetInnerHTML={{
                   dangerouslySetInnerHTML={{

+ 2 - 2
dashboard-v6/src/components/article/components/ArticleReaderToolbar.tsx

@@ -16,7 +16,7 @@ import { useAppSelector } from "../../../hooks";
 import { currentUser } from "../../../reducers/current-user";
 import { currentUser } from "../../../reducers/current-user";
 
 
 import { useState } from "react";
 import { useState } from "react";
-import { fullUrl } from "../../../utils";
+import { articlePath, fullUrl } from "../../../utils";
 
 
 import type { TRole } from "../../../api/Auth";
 import type { TRole } from "../../../api/Auth";
 import { useIntl } from "react-intl";
 import { useIntl } from "react-intl";
@@ -129,7 +129,7 @@ const TypeArticleReaderToolbarWidget = ({
                 switch (key) {
                 switch (key) {
                   case "open_in_tab":
                   case "open_in_tab":
                     window.open(
                     window.open(
-                      fullUrl(`/article/article/${articleId}`),
+                      fullUrl(articlePath("article", articleId ?? "")),
                       "_blank"
                       "_blank"
                     );
                     );
                     break;
                     break;

+ 2 - 1
dashboard-v6/src/components/discussion/DiscussionAnchor.tsx

@@ -10,6 +10,7 @@ import { Link } from "react-router";
 import type { TResType } from "../../api/discussion";
 import type { TResType } from "../../api/discussion";
 import type { ISentenceData, ISentenceResponse } from "../../api/sentence";
 import type { ISentenceData, ISentenceResponse } from "../../api/sentence";
 import MdView from "../general/MdView";
 import MdView from "../general/MdView";
+import { articlePath } from "../../utils";
 
 
 export interface IAnchor {
 export interface IAnchor {
   type: TResType;
   type: TResType;
@@ -81,7 +82,7 @@ const DiscussionAnchorWidget = ({
           .then((json) => {
           .then((json) => {
             if (json.ok) {
             if (json.ok) {
               setTitle(
               setTitle(
-                <Link to={`/article/article/${resId}`}>{json.data.title}</Link>
+                <Link to={articlePath("article", resId ?? "")}>{json.data.title}</Link>
               );
               );
               setContent(json.data.content?.substring(0, 200));
               setContent(json.data.content?.substring(0, 200));
             }
             }

+ 21 - 10
dashboard-v6/src/components/general/DisplayWrapper.tsx

@@ -86,7 +86,7 @@
 
 
 import { useState } from "react";
 import { useState } from "react";
 import { Button, Card, Collapse, Modal, Popover, Tag, Typography } from "antd";
 import { Button, Card, Collapse, Modal, Popover, Tag, Typography } from "antd";
-import { CloseOutlined, FileOutlined } from "@ant-design/icons";
+import { CloseOutlined, EditOutlined, FileOutlined } from "@ant-design/icons";
 
 
 // ---- Types ----
 // ---- Types ----
 
 
@@ -128,7 +128,7 @@ const HoverPopover = ({ triggerNode, title, children }: IHoverPopoverProps) => (
   <Popover
   <Popover
     title={title}
     title={title}
     content={children}
     content={children}
-    styles={{ container: { width: 700 } }}
+    styles={{ container: { width: 700, maxHeight: "90dvh" } }}
     trigger="hover"
     trigger="hover"
     placement="bottom"
     placement="bottom"
   >
   >
@@ -282,14 +282,25 @@ const referenceStyle: React.CSSProperties = {
   color: "inherit",
   color: "inherit",
 };
 };
 
 
-const ReferenceDisplay = ({ title, children }: IDisplayWrapperProps) => (
-  <HoverPopover
-    title={title}
-    triggerNode={<span style={referenceStyle}>{title}</span>}
-  >
-    {children}
-  </HoverPopover>
-);
+const ReferenceDisplay = ({ title, children }: IDisplayWrapperProps) => {
+  const [open, setOpen] = useState(false);
+  return (
+    <>
+      <HoverPopover
+        title={title}
+        triggerNode={<span style={referenceStyle}>{title}</span>}
+      >
+        {children}
+      </HoverPopover>
+      <Button
+        type="link"
+        icon={<EditOutlined />}
+        onClick={() => setOpen((v) => !v)}
+      />
+      {open && children}
+    </>
+  );
+};
 
 
 // ---- DisplayWrapper 主组件 ----
 // ---- DisplayWrapper 主组件 ----
 
 

+ 14 - 23
dashboard-v6/src/components/navigation/MainMenu.tsx

@@ -19,9 +19,6 @@ import {
   TipitakaIcon,
   TipitakaIcon,
 } from "../../assets/icon";
 } from "../../assets/icon";
 import React, { useState } from "react";
 import React, { useState } from "react";
-import { useAppSelector } from "../../hooks";
-import { currentUser } from "../../reducers/current-user";
-import { useRecent } from "../../hooks/useRecent.ts";
 import RecentModal from "../recent/RecentModal.tsx";
 import RecentModal from "../recent/RecentModal.tsx";
 import SettingModal from "../setting/SettingModal.tsx";
 import SettingModal from "../setting/SettingModal.tsx";
 import { useIntl } from "react-intl";
 import { useIntl } from "react-intl";
@@ -81,6 +78,17 @@ function findSelectedKey(
   }
   }
 }
 }
 
 
+/* ================= 最近编辑跳转地址 ================= */
+
+// 巴利三藏文本类型(chapter/para/cs-para/page 等)路由位于 /workspace/tipitaka 下
+const TIPITAKA_TYPES = new Set(["chapter", "para", "cs-para", "page"]);
+
+function recentPath(type: string, id: string): string {
+  return TIPITAKA_TYPES.has(type)
+    ? `/workspace/tipitaka/${type}/${id}`
+    : `/workspace/${type}/${id}`;
+}
+
 /* ================= 找展开父级keys ================= */
 /* ================= 找展开父级keys ================= */
 
 
 function findOpenKeys(
 function findOpenKeys(
@@ -114,21 +122,10 @@ const Widget = ({ onSearch }: Props) => {
 
 
   const navigate = useNavigate();
   const navigate = useNavigate();
   const routeId = useCurrentRouteId();
   const routeId = useCurrentRouteId();
-  const currUser = useAppSelector(currentUser);
 
 
-  const { data } = useRecent(currUser?.id, 5, 0);
   const [recentOpen, setRecentOpen] = useState(false);
   const [recentOpen, setRecentOpen] = useState(false);
   const [openSetting, setOpenSetting] = useState(false);
   const [openSetting, setOpenSetting] = useState(false);
 
 
-  const recentList: MenuItem[] = data
-    ? data?.data.rows.map((item, id) => {
-        return {
-          key: `recent-${id}`,
-          label: item.title,
-        };
-      })
-    : [];
-
   /* ================= 菜单配置 ================= */
   /* ================= 菜单配置 ================= */
 
 
   const items: MenuItem[] = [
   const items: MenuItem[] = [
@@ -184,13 +181,6 @@ const Widget = ({ onSearch }: Props) => {
       label: intl.formatMessage({
       label: intl.formatMessage({
         id: "columns.studio.recent.title",
         id: "columns.studio.recent.title",
       }),
       }),
-      children: [
-        ...recentList,
-        {
-          key: "/workspace/recent/list",
-          label: "更多……",
-        },
-      ],
     },
     },
 
 
     {
     {
@@ -342,7 +332,7 @@ const Widget = ({ onSearch }: Props) => {
     if (key === "search") {
     if (key === "search") {
       onSearch?.();
       onSearch?.();
       return;
       return;
-    } else if (key === "/workspace/recent/list") {
+    } else if (key === "/workspace/recent") {
       setRecentOpen(true);
       setRecentOpen(true);
       return;
       return;
     } else if (key === "/workspace/setting") {
     } else if (key === "/workspace/setting") {
@@ -369,8 +359,9 @@ const Widget = ({ onSearch }: Props) => {
           if (e.ctrlKey || e.metaKey) {
           if (e.ctrlKey || e.metaKey) {
             window.open("");
             window.open("");
           } else {
           } else {
-            navigate(`/workspace/${row.type}/${row.articleId}`);
+            navigate(recentPath(row.type, row.articleId));
           }
           }
+          setRecentOpen(false);
         }}
         }}
       />
       />
       <SettingModal open={openSetting} onClose={() => setOpenSetting(false)} />
       <SettingModal open={openSetting} onClose={() => setOpenSetting(false)} />

+ 132 - 0
dashboard-v6/src/components/notification/NotificationIcon.tsx

@@ -0,0 +1,132 @@
+import { useEffect, useState } from "react";
+import { Badge, Popover } from "antd";
+
+import { get } from "../../request";
+import type { INotificationListResponse } from "../../api/notification";
+import { NotificationIcon } from "../../assets/icon";
+import NotificationList from "./NotificationList";
+import { useAppSelector } from "../../hooks";
+import { currentUser, type IUser } from "../../reducers/current-user";
+
+const NotificationIconWidget = () => {
+  const [count, setCount] = useState<number>();
+  const currUser = useAppSelector(currentUser);
+  const [mute, setMute] = useState(false);
+
+  const queryNotification = (user?: IUser) => {
+    if (!user) {
+      console.debug("未登录 不查询 notification");
+      return;
+    }
+    const isMute = localStorage.getItem("notification/mute");
+    if (isMute && isMute === "true") {
+      setMute(true);
+    } else {
+      setMute(false);
+    }
+    const now = new Date();
+    const notificationUpdatedAt = localStorage.getItem(
+      "notification/updatedAt"
+    );
+    if (notificationUpdatedAt) {
+      if (now.getTime() - parseInt(notificationUpdatedAt) < 59000) {
+        const notificationCount = localStorage.getItem("notification/count");
+        if (notificationCount !== null) {
+          setCount(parseInt(notificationCount));
+          console.debug("has notification count");
+          return;
+        }
+      }
+    }
+
+    const url = `/api/v2/notification?view=to&limit=1`;
+    console.info("notification api request", url);
+    get<INotificationListResponse>(url).then((json) => {
+      if (json.ok) {
+        console.debug("notification fetch ok ", json.data.unread);
+        localStorage.setItem(
+          "notification/updatedAt",
+          now.getTime().toString()
+        );
+        localStorage.setItem("notification/count", json.data.unread.toString());
+        setCount(json.data.unread);
+        if (json.data.count > 0) {
+          const newMessageTime = json.data.rows[0].created_at;
+          const lastTime = localStorage.getItem("notification/new");
+          if (lastTime === null || lastTime !== newMessageTime) {
+            localStorage.setItem("notification/new", newMessageTime);
+
+            const title = json.data.rows[0].res_type;
+            const content = json.data.rows[0].content;
+            localStorage.setItem(
+              "notification/message",
+              JSON.stringify({ title: title, content: content })
+            );
+            // 发送通知
+            if (!isMute || isMute !== "true") {
+              if (window.Notification && Notification.permission !== "denied") {
+                Notification.requestPermission(function () {
+                  const notification = new Notification(title, {
+                    body: content,
+                    icon: import.meta.env.BASE_URL + "logo192.png",
+                    tag: json.data.rows[0].id,
+                  });
+                  notification.onclick = (event) => {
+                    event.preventDefault(); // 阻止浏览器聚焦于 Notification 的标签页
+                    window.open(json.data.rows[0].url, "_blank");
+                  };
+                });
+              }
+            }
+          }
+        }
+      } else {
+        console.error(json.message);
+      }
+    });
+  };
+
+  useEffect(() => {
+    const timer = setInterval(() => queryNotification(currUser), 1000 * 60);
+    return () => {
+      clearInterval(timer);
+    };
+  }, [currUser]);
+
+  return (
+    <>
+      {currUser ? (
+        <Popover
+          placement="bottomLeft"
+          arrow={{ pointAtCenter: true }}
+          destroyOnHidden
+          content={
+            <div style={{ width: 600 }}>
+              <NotificationList
+                onChange={(unread: number) => setCount(unread)}
+              />
+            </div>
+          }
+          trigger="click"
+        >
+          <Badge count={count} size="small" dot={mute}>
+            <span
+              style={{
+                color: "inherit",
+                cursor: "pointer",
+                fontSize: 18,
+                display: "inline-flex",
+              }}
+            >
+              <NotificationIcon />
+            </span>
+          </Badge>
+        </Popover>
+      ) : (
+        <></>
+      )}
+    </>
+  );
+};
+
+export default NotificationIconWidget;

+ 260 - 0
dashboard-v6/src/components/notification/NotificationList.tsx

@@ -0,0 +1,260 @@
+import { useRef, useState, type Key } from "react";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+import { Avatar, Button, Space, Switch, Tag, Typography } from "antd";
+import { ReloadOutlined } from "@ant-design/icons";
+
+import { get, put } from "../../request";
+import type {
+  INotificationListResponse,
+  INotificationPutResponse,
+  INotificationRequest,
+} from "../../api/notification";
+import type { IUser } from "../../api/Auth";
+import type { IChannel } from "../../api/channel";
+import TimeShow from "../general/TimeShow";
+import Marked from "../general/Marked";
+
+const { Text } = Typography;
+
+interface INotification {
+  id: string;
+  from: IUser;
+  to: IUser;
+  channel: IChannel;
+  url?: string;
+  title?: string;
+  book_title?: string;
+  content?: string;
+  content_type: string;
+  res_type: string;
+  res_id: string;
+  status: string;
+  deleted_at?: string;
+  created_at: string;
+  updated_at: string;
+}
+interface IWidget {
+  onChange?: (unread: number) => void;
+}
+
+const NotificationListWidget = ({ onChange }: IWidget) => {
+  const ref = useRef<ActionType | null>(null);
+  const [activeKey, setActiveKey] = useState<Key | undefined>("inbox");
+  const [mute, setMute] = useState<boolean>(() => {
+    const stored = localStorage.getItem("notification/mute");
+    return stored === "true";
+  });
+
+  const putStatus = (id: string, status: string) => {
+    const url = `/api/v2/notification/${id}`;
+    console.info("api request", url);
+    put<INotificationRequest, INotificationPutResponse>(url, {
+      status: status,
+    }).then((json) => {
+      console.info("api response", json);
+      if (json.ok) {
+        ref.current?.reload();
+        if (typeof onChange !== "undefined") {
+          onChange(json.data.unread);
+        }
+      }
+    });
+  };
+
+  return (
+    <ProList<INotification>
+      rowKey="id"
+      actionRef={ref}
+      onRow={(record) => {
+        return {
+          onClick: () => {
+            // 点击行
+            if (record.status === "unread") {
+              putStatus(record.id, "read");
+            }
+          },
+        };
+      }}
+      toolBarRender={() => {
+        return [
+          <>
+            {"免打扰"}
+            <Switch
+              size="small"
+              checked={mute}
+              onChange={(checked: boolean) => {
+                setMute(checked);
+                if (checked) {
+                  localStorage.setItem("notification/mute", "true");
+                } else {
+                  localStorage.setItem("notification/mute", "false");
+                }
+              }}
+            />
+          </>,
+          <Button
+            key="4"
+            type="link"
+            icon={<ReloadOutlined />}
+            onClick={() => {
+              ref.current?.reload();
+            }}
+          />,
+        ];
+      }}
+      search={{
+        filterType: "light",
+      }}
+      request={async (params = {}, sorter, filter) => {
+        console.log(params, sorter, filter);
+        let queryStatus = activeKey;
+        if (activeKey === "inbox") {
+          queryStatus = "read,unread";
+        }
+        let url = `/api/v2/notification?view=to&status=${queryStatus}`;
+        const offset =
+          ((params.current ? params.current : 1) - 1) *
+          (params.pageSize ? params.pageSize : 5);
+        url += `&limit=${params.pageSize}&offset=${offset}`;
+        console.info("api request", url);
+        const res = await get<INotificationListResponse>(url);
+        console.info("api response", res);
+        let items: INotification[] = [];
+        if (res.ok) {
+          items = res.data.rows.map((item) => {
+            return {
+              id: item.id,
+              from: item.from,
+              to: item.to,
+              channel: item.channel,
+              url: item.url,
+              title: item.title,
+              book_title: item.book_title,
+              content: item.content,
+              content_type: item.content_type,
+              res_type: item.res_type,
+              res_id: item.res_id,
+              status: item.status,
+              deleted_at: item.deleted_at,
+              created_at: item.created_at,
+              updated_at: item.updated_at,
+            };
+          });
+          if (typeof onChange !== "undefined") {
+            onChange(res.data.unread);
+          }
+        }
+
+        console.debug(items);
+        return {
+          total: res.data.count,
+          success: true,
+          data: items,
+        };
+      }}
+      pagination={{
+        pageSize: 5,
+      }}
+      metas={{
+        title: {
+          dataIndex: "user",
+          search: false,
+          render: (_, row) => {
+            return (
+              <Text strong={row.status === "unread"}>{row.from.nickName}</Text>
+            );
+          },
+        },
+        avatar: {
+          dataIndex: "avatar",
+          search: false,
+          render: (_, row) => {
+            return (
+              <Avatar size={"small"}>{row.from.nickName.slice(0, 1)}</Avatar>
+            );
+          },
+        },
+        description: {
+          dataIndex: "title",
+          search: false,
+          render: (_, row) => {
+            return (
+              <Text
+                style={{
+                  cursor: "pointer",
+                  opacity: row.status === "unread" ? 1 : 0.7,
+                }}
+                onClick={() => {
+                  window.open(row.url, "_blank");
+                }}
+              >
+                <div>
+                  <Text type="secondary">{row.book_title}</Text>
+                </div>
+                <Text style={{ fontWeight: 700 }}>{row.title}</Text>
+                <Marked style={{}} text={row.content} />
+              </Text>
+            );
+          },
+        },
+        subTitle: {
+          dataIndex: "labels",
+          render: (_, row) => {
+            return (
+              <Space>
+                <TimeShow createdAt={row.created_at} />
+                <Tag color="#87d068">{row.channel?.name}</Tag>
+                <Tag color="blue">{row.res_type}</Tag>
+              </Space>
+            );
+          },
+          search: false,
+        },
+        status: {
+          // 自己扩展的字段,主要用于筛选,不在列表中显示
+          title: "类型筛选",
+          valueType: "select",
+          valueEnum: {
+            all: { text: "全部", status: "Default" },
+            pr: {
+              text: "修改建议",
+              status: "Error",
+            },
+            discussion: {
+              text: "讨论",
+              status: "Success",
+            },
+          },
+        },
+      }}
+      toolbar={{
+        menu: {
+          activeKey,
+          items: [
+            {
+              key: "inbox",
+              label: "Inbox",
+            },
+            {
+              key: "unread",
+              label: "Unread",
+            },
+            {
+              key: "archived",
+              label: "Archived",
+            },
+          ],
+          onChange(key) {
+            setActiveKey(key);
+            ref.current?.reload();
+            if (ref.current?.setPageInfo) {
+              ref.current?.setPageInfo({ current: 1 });
+            }
+          },
+        },
+      }}
+    />
+  );
+};
+
+export default NotificationListWidget;

+ 8 - 1
dashboard-v6/src/components/recent/RecentModal.tsx

@@ -45,13 +45,20 @@ const RecentModal = ({
       <Modal
       <Modal
         width={"80%"}
         width={"80%"}
         title={intl.formatMessage({
         title={intl.formatMessage({
-          id: `labels.recent-scan`,
+          id: `columns.studio.recent.title`,
         })}
         })}
         footer={false}
         footer={false}
         open={isModalOpen}
         open={isModalOpen}
         onOk={handleOk}
         onOk={handleOk}
         onCancel={handleCancel}
         onCancel={handleCancel}
         destroyOnHidden
         destroyOnHidden
+        style={{ top: 10 }}
+        styles={{
+          body: {
+            maxHeight: "calc(100vh - 160px)",
+            overflowY: "auto",
+          },
+        }}
       >
       >
         <RecentList onSelect={onSelect} />
         <RecentList onSelect={onSelect} />
       </Modal>
       </Modal>

+ 1 - 1
dashboard-v6/src/components/related-para/RelatedPara.tsx

@@ -127,7 +127,7 @@ const RelatedParaWidget = ({ book, para, trigger }: IWidget) => {
                     <Card
                     <Card
                       title={
                       title={
                         <Link
                         <Link
-                          to={`/article/para/${item.book}-${firstPara}?book=${item.book}&par=${item.para}`}
+                          to={`/workspace/tipitaka/para/${item.book}-${firstPara}?book=${item.book}&par=${item.para}`}
                           target="_blank"
                           target="_blank"
                         >
                         >
                           {item.book_title_pali}
                           {item.book_title_pali}

+ 9 - 4
dashboard-v6/src/components/sentence/SentCart.tsx

@@ -50,9 +50,7 @@ const SentCartWidget = () => {
         placement="bottomRight"
         placement="bottomRight"
         arrow={{ pointAtCenter: true }}
         arrow={{ pointAtCenter: true }}
         destroyOnHidden
         destroyOnHidden
-        getTooltipContainer={() =>
-          document.getElementsByClassName("toolbar_center")[0] as HTMLElement
-        }
+        getTooltipContainer={() => document.body}
         content={
         content={
           <div>
           <div>
             <div style={{ display: "flex", justifyContent: "space-between" }}>
             <div style={{ display: "flex", justifyContent: "space-between" }}>
@@ -102,7 +100,14 @@ const SentCartWidget = () => {
         trigger="click"
         trigger="click"
       >
       >
         <Badge style={{ cursor: "pointer" }} count={count} size="small">
         <Badge style={{ cursor: "pointer" }} count={count} size="small">
-          <span style={{ color: "white", cursor: "pointer" }}>
+          <span
+            style={{
+              color: "inherit",
+              cursor: "pointer",
+              fontSize: 18,
+              display: "inline-flex",
+            }}
+          >
             <ShoppingCartOutlined />
             <ShoppingCartOutlined />
           </span>
           </span>
         </Badge>
         </Badge>

+ 2 - 2
dashboard-v6/src/components/sentence/SentEditMenu.tsx

@@ -22,7 +22,7 @@ import {
   PasteOutLinedIcon,
   PasteOutLinedIcon,
 } from "../../assets/icon";
 } from "../../assets/icon";
 import { useIntl } from "react-intl";
 import { useIntl } from "react-intl";
-import { fullUrl } from "../../utils";
+import { articlePath, fullUrl } from "../../utils";
 import SentHistoryModal from "../sentence-history/SentHistoryModal";
 import SentHistoryModal from "../sentence-history/SentHistoryModal";
 
 
 interface IWidget {
 interface IWidget {
@@ -67,7 +67,7 @@ const SentEditMenuWidget = ({
         break;
         break;
       case "copy-link":
       case "copy-link":
         if (data) {
         if (data) {
-          let link = `/article/para/${data.book}-${data.para}?mode=edit`;
+          let link = `${articlePath("para", `${data.book}-${data.para}`)}?mode=edit`;
           link += `&book=${data.book}&par=${data.para}`;
           link += `&book=${data.book}&par=${data.para}`;
           link += `&channel=${data.channel.id}`;
           link += `&channel=${data.channel.id}`;
 
 

+ 2 - 2
dashboard-v6/src/components/sentence/SentTab.tsx

@@ -15,7 +15,7 @@ import TocPath from "../../../src/components/tipitaka/TocPath";
 import SentMenu from "./SentMenu";
 import SentMenu from "./SentMenu";
 
 
 import SentTabCopy from "./SentTabCopy";
 import SentTabCopy from "./SentTabCopy";
-import { fullUrl } from "../../utils";
+import { articlePath, fullUrl } from "../../utils";
 import SentWbw from "./SentWbw";
 import SentWbw from "./SentWbw";
 import SentTabButtonWbw from "./SentTabButtonWbw";
 import SentTabButtonWbw from "./SentTabButtonWbw";
 
 
@@ -177,7 +177,7 @@ const SentTabWidget = ({
                   break;
                   break;
                 }
                 }
                 case "copy-link": {
                 case "copy-link": {
-                  let link = `/article/para/${book}-${para}?mode=edit`;
+                  let link = `${articlePath("para", `${book}-${para}`)}?mode=edit`;
                   link += `&book=${book}&par=${para}`;
                   link += `&book=${book}&par=${para}`;
                   if (channelsId) {
                   if (channelsId) {
                     link += `&channel=` + channelsId?.join("_");
                     link += `&channel=` + channelsId?.join("_");

+ 2 - 2
dashboard-v6/src/components/template/Article.tsx

@@ -9,7 +9,7 @@ import TypeArticle from "../article/TypeArticle";
 import TypeAnthology from "../article/TypeAnthology";
 import TypeAnthology from "../article/TypeAnthology";
 import TypePali from "../article/TypePali";
 import TypePali from "../article/TypePali";
 import TypePage from "../article/TypePage";
 import TypePage from "../article/TypePage";
-import { fullUrl } from "../../utils";
+import { articlePath, fullUrl } from "../../utils";
 
 
 const { Text } = Typography;
 const { Text } = Typography;
 
 
@@ -175,7 +175,7 @@ export const ArticleCtl = ({
     />
     />
   );
   );
   let output = <></>;
   let output = <></>;
-  let articleLink = `/article/${type}/${id}?mode=${currMode}`;
+  let articleLink = `${articlePath(type ?? "", id ?? "")}?mode=${currMode}`;
   articleLink += channel ? `&channel=${currChannels.join("_")}` : "";
   articleLink += channel ? `&channel=${currChannels.join("_")}` : "";
 
 
   const OpenLink = (
   const OpenLink = (

+ 3 - 0
dashboard-v6/src/components/template/MdTpl.tsx

@@ -10,6 +10,7 @@ import ParaHandle from "./ParaHandle";
 import ParaShell from "./ParaShell";
 import ParaShell from "./ParaShell";
 import Paragraph from "./Paragraph";
 import Paragraph from "./Paragraph";
 import Qa from "./Qa";
 import Qa from "./Qa";
+import Quality from "./Quality";
 import Quote from "./Quote";
 import Quote from "./Quote";
 import QuoteLink from "./QuoteLink";
 import QuoteLink from "./QuoteLink";
 import Reference from "./Reference";
 import Reference from "./Reference";
@@ -75,6 +76,8 @@ const Widget = ({ tpl, props, children }: IWidgetMdTpl) => {
       return <Tpl props={props ? props : ""} />;
       return <Tpl props={props ? props : ""} />;
     case "para":
     case "para":
       return <Para props={props ? props : ""} />;
       return <Para props={props ? props : ""} />;
+    case "quality":
+      return <Quality props={props ? props : ""} />;
     default:
     default:
       return <>未定义模版({tpl})</>;
       return <>未定义模版({tpl})</>;
   }
   }

+ 7 - 1
dashboard-v6/src/components/template/Para.tsx

@@ -6,11 +6,17 @@ import TypePali from "../article/TypePali";
 
 
 interface IWidget extends IDisplayWrapperProps {
 interface IWidget extends IDisplayWrapperProps {
   id?: string;
   id?: string;
+  channel?: string;
 }
 }
 const ParaCtl = (props: IWidget) => {
 const ParaCtl = (props: IWidget) => {
   return (
   return (
     <DisplayWrapper {...props}>
     <DisplayWrapper {...props}>
-      <TypePali type="para" id={props.id} />
+      <TypePali
+        type="para"
+        id={props.id}
+        channelId={props.channel}
+        hideNav
+      />
     </DisplayWrapper>
     </DisplayWrapper>
   );
   );
 };
 };

+ 2 - 2
dashboard-v6/src/components/template/ParaHandle.tsx

@@ -1,6 +1,6 @@
 import { Button, Dropdown, type MenuProps, message, notification } from "antd";
 import { Button, Dropdown, type MenuProps, message, notification } from "antd";
 import { useNavigate, useSearchParams } from "react-router";
 import { useNavigate, useSearchParams } from "react-router";
-import { fullUrl, scrollToTop } from "../../utils";
+import { articlePath, fullUrl, scrollToTop } from "../../utils";
 import { useIntl } from "react-intl";
 import { useIntl } from "react-intl";
 import store from "../../store";
 import store from "../../store";
 import { modeChange } from "../../reducers/article-mode";
 import { modeChange } from "../../reducers/article-mode";
@@ -124,7 +124,7 @@ export const ParaHandleCtl = ({
      * TODO 临时的解决方案。以后应该从传参获取其他参数,然后reducer 通知更新。
      * TODO 临时的解决方案。以后应该从传参获取其他参数,然后reducer 通知更新。
      * 因为如果是Article组件被嵌入其他页面。不能直接更新浏览器,而是应该更新Article组件内部
      * 因为如果是Article组件被嵌入其他页面。不能直接更新浏览器,而是应该更新Article组件内部
      */
      */
-    let url = `/article/para/${book}-${para}?book=${book}&par=${para}`;
+    let url = `${articlePath("para", `${book}-${para}`)}?book=${book}&par=${para}`;
     const param: string[] = [];
     const param: string[] = [];
     searchParams.forEach((value: unknown, key: unknown) => {
     searchParams.forEach((value: unknown, key: unknown) => {
       if (key !== "book" && key !== "par") {
       if (key !== "book" && key !== "par") {

+ 39 - 0
dashboard-v6/src/components/template/Quality.tsx

@@ -0,0 +1,39 @@
+import { Tag } from "antd";
+
+/**
+ * 质量标签取值 -> antd Tag 颜色映射
+ * featured 精选   -> gold    金色
+ * standard 标准   -> green   绿色
+ * draft    草稿   -> default 默认灰色
+ * pending  待定   -> orange  橙色
+ */
+const QUALITY_COLORS: Record<string, string> = {
+  featured: "gold",
+  standard: "green",
+  draft: "default",
+  pending: "orange",
+};
+
+interface IQualityCtl {
+  value?: string;
+}
+
+/**
+ * 渲染单个质量标签
+ * 非法取值回退为默认的 pending,保证样式始终可预期
+ */
+const QualityCtl = ({ value = "pending" }: IQualityCtl) => {
+  const color = QUALITY_COLORS[value] ?? QUALITY_COLORS.pending;
+  return <Tag color={color}>{value}</Tag>;
+};
+
+interface IWidget {
+  props: string;
+}
+
+const Widget = ({ props }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IQualityCtl;
+  return <QualityCtl {...prop} />;
+};
+
+export default Widget;

+ 2 - 1
dashboard-v6/src/components/term/TermItem.tsx

@@ -20,6 +20,7 @@ import Discussion from "../discussion/DiscussionMock";
 import { useIntl } from "react-intl";
 import { useIntl } from "react-intl";
 import User from "../auth/User";
 import User from "../auth/User";
 import MdView from "../general/MdView";
 import MdView from "../general/MdView";
+import { articlePath } from "../../utils";
 
 
 const { Text } = Typography;
 const { Text } = Typography;
 
 
@@ -95,7 +96,7 @@ const TermItemWidget = ({ data, onTermClick }: IWidget) => {
                     setOpenTermModal(true);
                     setOpenTermModal(true);
                     break;
                     break;
                   case "translate":
                   case "translate":
-                    navigate(`/article/term/${data?.guid}`);
+                    navigate(articlePath("term", data?.guid ?? ""));
                     break;
                     break;
                   default:
                   default:
                     break;
                     break;

+ 115 - 0
dashboard-v6/src/components/term/TermShare.tsx

@@ -0,0 +1,115 @@
+import { useIntl } from "react-intl";
+import { useState } from "react";
+import { Button, Divider, Input, Modal, Typography, message } from "antd";
+import {
+  CopyOutlined,
+  ExportOutlined,
+  GlobalOutlined,
+  LinkOutlined,
+} from "@ant-design/icons";
+
+import { fullUrl } from "../../utils";
+
+const { Text } = Typography;
+
+interface IWidget {
+  id?: string;
+  trigger?: React.ReactNode;
+  open?: boolean;
+  onClose?: () => void;
+}
+
+const TermShareWidget = ({ id, trigger, open, onClose }: IWidget) => {
+  const intl = useIntl();
+  const [innerOpen, setInnerOpen] = useState(false);
+
+  const isOpen = open ?? innerOpen;
+
+  // 藏经阁(对外公开)访问链接
+  const libraryUrl = id
+    ? `${import.meta.env.VITE_APP_API_SERVER}/library/wiki/${intl.locale}/${id}`
+    : "";
+  // 译经楼(工作台)访问链接
+  const studioUrl = id ? fullUrl(`/workspace/term/${id}`) : "";
+
+  const close = () => {
+    if (onClose) {
+      onClose();
+    } else {
+      setInnerOpen(false);
+    }
+  };
+
+  const copy = (url: string) => {
+    navigator.clipboard
+      .writeText(url)
+      .then(() => {
+        message.success(intl.formatMessage({ id: "message.copy.success" }));
+      })
+      .catch(() => {
+        message.error(intl.formatMessage({ id: "message.copy.fail" }));
+      });
+  };
+
+  const linkRow = (
+    label: string,
+    url: string,
+    prefix: React.ReactNode
+  ): React.ReactNode => (
+    <div>
+      <Text type="secondary">{label}</Text>
+      <div style={{ display: "flex", gap: 8, marginTop: 8 }}>
+        <Input
+          readOnly
+          value={url}
+          prefix={prefix}
+          style={{ flex: 1 }}
+          onFocus={(e) => e.target.select()}
+        />
+        <Button icon={<CopyOutlined />} onClick={() => copy(url)}>
+          {intl.formatMessage({ id: "buttons.copy" })}
+        </Button>
+        <Button
+          icon={<ExportOutlined />}
+          onClick={() => window.open(url, "_blank")}
+        >
+          {intl.formatMessage({ id: "buttons.open" })}
+        </Button>
+      </div>
+    </div>
+  );
+
+  return (
+    <>
+      <span onClick={() => setInnerOpen(true)}>{trigger}</span>
+      <Modal
+        destroyOnHidden
+        width={560}
+        title={intl.formatMessage({ id: "buttons.share" })}
+        open={isOpen}
+        onCancel={close}
+        footer={null}
+      >
+        {linkRow(
+          intl.formatMessage({ id: "labels.library.access.link" }),
+          libraryUrl,
+          <GlobalOutlined />
+        )}
+        <Text
+          type="secondary"
+          style={{ fontSize: 12, display: "block", marginTop: 8 }}
+        >
+          {intl.formatMessage({ id: "labels.library.access.tip" })}
+        </Text>
+        <Divider style={{ margin: "16px 0" }} />
+        {linkRow(
+          intl.formatMessage({ id: "labels.studio.access.link" }),
+          studioUrl,
+          <LinkOutlined />
+        )}
+      </Modal>
+    </>
+  );
+};
+
+export default TermShareWidget;

+ 2 - 2
dashboard-v6/src/components/tipitaka/TocPath.tsx

@@ -2,7 +2,7 @@ import { useNavigate, useSearchParams } from "react-router";
 import { Breadcrumb, Popover, Tag, Typography } from "antd";
 import { Breadcrumb, Popover, Tag, Typography } from "antd";
 
 
 import React, { type JSX } from "react";
 import React, { type JSX } from "react";
-import { fullUrl } from "../../utils";
+import { articlePath, fullUrl } from "../../utils";
 import type { ITocPathNode } from "../../api/pali-text";
 import type { ITocPathNode } from "../../api/pali-text";
 import PaliText from "../general/PaliText";
 import PaliText from "../general/PaliText";
 
 
@@ -49,7 +49,7 @@ const TocPathWidget = ({
             : searchParams.get("channel");
             : searchParams.get("channel");
           const mode = searchParams.get("mode");
           const mode = searchParams.get("mode");
           const urlMode = mode ? mode : "read";
           const urlMode = mode ? mode : "read";
-          let url = `/article/${type}/${item.book}-${item.paragraph}?mode=${urlMode}${param}`;
+          let url = `${articlePath(type, `${item.book}-${item.paragraph}`)}?mode=${urlMode}${param}`;
           url += channel ? `&channel=${channel}` : "";
           url += channel ? `&channel=${channel}` : "";
           if (e.ctrlKey || e.metaKey) {
           if (e.ctrlKey || e.metaKey) {
             window.open(fullUrl(url), "_blank");
             window.open(fullUrl(url), "_blank");

+ 46 - 50
dashboard-v6/src/components/wbw/WbwSentCtl.tsx

@@ -752,64 +752,60 @@ const WbwSentCtl = memo(
             studio={studio}
             studio={studio}
             readonly={readonly}
             readonly={readonly}
             onChange={(e: IWbw, isPublish?: boolean, isPublic?: boolean) => {
             onChange={(e: IWbw, isPublish?: boolean, isPublic?: boolean) => {
-              setWordData((origin) => {
-                const newData = [...origin];
-                const snKey = createSnKey(e.sn);
+              const snKey = createSnKey(e.sn);
+              const newData = [...wordData];
 
 
-                // 更新当前单词
-                const index = newData.findIndex(
-                  (v) => createSnKey(v.sn) === snKey
-                );
-                if (index !== -1) {
-                  newData[index] = e;
-                }
+              // 更新当前单词
+              const index = newData.findIndex(
+                (v) => createSnKey(v.sn) === snKey
+              );
+              if (index !== -1) {
+                newData[index] = e;
+              }
 
 
-                // 如果是拆分后的单词,更新父单词的 factorMeaning
-                if (e.sn.length > 1) {
-                  const parentSn = e.sn.slice(0, e.sn.length - 1);
-                  const parentSnKey = createSnKey(parentSn);
-
-                  const factorMeaning = newData
-                    .filter(
-                      (value) =>
-                        value.sn.length === e.sn.length &&
-                        createSnKey(value.sn.slice(0, e.sn.length - 1)) ===
-                          parentSnKey &&
-                        value.real.value &&
-                        value.real.value.length > 0
-                    )
-                    .map((item) => item.meaning?.value)
-                    .join("+");
-
-                  const parentIndex = newData.findIndex(
-                    (v) => createSnKey(v.sn) === parentSnKey
-                  );
-                  if (parentIndex !== -1) {
-                    newData[parentIndex] = {
-                      ...newData[parentIndex],
-                      factorMeaning: {
-                        value: factorMeaning,
-                        status: 5,
-                      },
+              // 如果是拆分后的单词,更新父单词的 factorMeaning
+              if (e.sn.length > 1) {
+                const parentSn = e.sn.slice(0, e.sn.length - 1);
+                const parentSnKey = createSnKey(parentSn);
+
+                const factorMeaning = newData
+                  .filter(
+                    (value) =>
+                      value.sn.length === e.sn.length &&
+                      createSnKey(value.sn.slice(0, e.sn.length - 1)) ===
+                        parentSnKey &&
+                      value.real.value &&
+                      value.real.value.length > 0
+                  )
+                  .map((item) => item.meaning?.value)
+                  .join("+");
+
+                const parentIndex = newData.findIndex(
+                  (v) => createSnKey(v.sn) === parentSnKey
+                );
+                if (parentIndex !== -1) {
+                  newData[parentIndex] = {
+                    ...newData[parentIndex],
+                    factorMeaning: {
+                      value: factorMeaning,
+                      status: 5,
+                    },
+                  };
+
+                  if (newData[parentIndex].meaning?.status !== WbwStatus.manual) {
+                    newData[parentIndex].meaning = {
+                      value: factorMeaning.replaceAll("+", " "),
+                      status: 5,
                     };
                     };
-
-                    if (
-                      newData[parentIndex].meaning?.status !== WbwStatus.manual
-                    ) {
-                      newData[parentIndex].meaning = {
-                        value: factorMeaning.replaceAll("+", " "),
-                        status: 5,
-                      };
-                    }
                   }
                   }
                 }
                 }
+              }
 
 
-                return newData;
-              });
+              setWordData(newData);
 
 
-              // 延迟保存以批量处理
+              // 延迟保存以批量处理(使用更新后的 newData,避免闭包里的旧 wordData 覆盖用户选择)
               setTimeout(() => {
               setTimeout(() => {
-                saveWord(wordData, e.sn[0]);
+                saveWord(newData, e.sn[0]);
               }, 100);
               }, 100);
 
 
               if (isPublish === true) {
               if (isPublish === true) {

+ 186 - 0
dashboard-v6/src/features/editor/CsPara.tsx

@@ -0,0 +1,186 @@
+// ─────────────────────────────────────────────
+// Props
+// ─────────────────────────────────────────────
+
+import { useEffect, useState } from "react";
+import { useLocation } from "react-router";
+import type {
+  ArticleMode,
+  ArticleType,
+  ICSParaNavData,
+} from "../../api/article";
+import { fetchCSParaNav } from "../../api/article";
+import TypePali, {
+  type ISearchParams,
+} from "../../components/article/TypePali";
+import NavigateButton from "../../components/article/components/NavigateButton";
+import ArticleSkeleton from "../../components/article/components/ArticleSkeleton";
+import ErrorResult from "../../components/general/ErrorResult";
+import Editor from "../../components/editor";
+import PaliTextToc from "../../components/tipitaka/PaliTextToc";
+import { useSaveRecent } from "../../hooks/useSaveRecent";
+import type { TTarget } from "../../types";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import { HttpError } from "../../request";
+
+export interface CsParaEditorProps {
+  /** cs-para id,形如 `book_para_page`,例如 `169_3_64` */
+  articleId?: string;
+  mode?: ArticleMode;
+  channelId?: string | null;
+
+  // ── 路由事件回调(由 page 层处理导航)──
+  onArticleChange?: (
+    type: ArticleType,
+    id: string,
+    target: TTarget,
+    param?: ISearchParams[]
+  ) => void;
+}
+
+// ─────────────────────────────────────────────
+// Component
+// ─────────────────────────────────────────────
+
+export default function CsParaEditor({
+  articleId,
+  mode = "read",
+  channelId,
+  onArticleChange,
+}: CsParaEditorProps) {
+  const [nav, setNav] = useState<ICSParaNavData>();
+  const [errorCode, setErrorCode] = useState<number>();
+  const [errorMessage, setErrorMessage] = useState<string>();
+  const currUser = useAppSelector(currentUser);
+  const { save } = useSaveRecent();
+  const { search } = useLocation();
+
+  // 记录最近访问
+  useEffect(() => {
+    if (!currUser?.id || !articleId) return;
+    const paramObj = search
+      ? Object.fromEntries(new URLSearchParams(search))
+      : undefined;
+    save({
+      type: "cs-para",
+      article_id: articleId,
+      param: JSON.stringify(paramObj),
+    });
+  }, [currUser?.id, articleId, search, save]);
+
+  // 拉取 cs-para 导航数据,推导实际要渲染的段落区间
+  useEffect(() => {
+    if (typeof articleId === "undefined") {
+      console.error("articleId 不能为空");
+      return;
+    }
+    const pageParam = articleId.split("_");
+    if (pageParam.length !== 3) {
+      console.error("pageParam 必须为三个");
+      return;
+    }
+    setNav(undefined);
+    setErrorCode(undefined);
+    setErrorMessage(undefined);
+    fetchCSParaNav(articleId)
+      .then((json) => {
+        if (json.ok) {
+          setNav(json.data);
+        } else {
+          setErrorCode(500);
+          setErrorMessage(json.message);
+        }
+      })
+      .catch((e) => {
+        console.error(e);
+        if (e instanceof HttpError) {
+          setErrorCode(e.status);
+          setErrorMessage(e.message);
+        } else {
+          setErrorCode(500);
+        }
+      });
+  }, [articleId]);
+
+  // 由导航数据推导实际要渲染的段落区间 id(book-start-end)
+  const paraId = nav
+    ? `${nav.curr.book}-${nav.curr.start}-${nav.end}`
+    : undefined;
+  const book = nav?.curr.book;
+  const para = nav?.curr.start;
+
+  const goto = (
+    offset: number,
+    event: React.MouseEvent<HTMLElement, MouseEvent>
+  ) => {
+    if (!articleId) return;
+    const pageParam = articleId.split("_");
+    if (pageParam.length !== 3) return;
+    const nextPage = parseInt(pageParam[2]) + offset;
+    if (nextPage < 0) return;
+    const id = `${pageParam[0]}_${pageParam[1]}_${nextPage}`;
+    const target = event.ctrlKey || event.metaKey ? "_blank" : "_self";
+    onArticleChange?.("cs-para", id, target);
+  };
+
+  if (errorCode !== undefined) {
+    return <ErrorResult code={errorCode} message={errorMessage} />;
+  }
+
+  return (
+    <Editor
+      sidebarTitle="recent scan"
+      sidebar={
+        <PaliTextToc
+          book={book}
+          para={para}
+          onSelect={(selected) => {
+            if (selected) {
+              onArticleChange?.("chapter", selected[0], "_self");
+            }
+          }}
+        />
+      }
+      articleId={paraId}
+      articleType="para"
+      channelId={channelId}
+      onChannelSelect={(selected) => {
+        if (articleId) {
+          const channelParams = [
+            {
+              key: "channel",
+              value: selected.map((item) => item.id).join("_"),
+            },
+          ];
+          console.debug("onChannelSelect", channelParams);
+          onArticleChange?.("cs-para", articleId, "_self", channelParams);
+        }
+      }}
+    >
+      {({ expandButton }) =>
+        nav ? (
+          <>
+            <TypePali
+              id={paraId}
+              type="para"
+              mode={mode}
+              channelId={channelId}
+              headerExtra={expandButton}
+              hideNav
+              onArticleChange={onArticleChange}
+            />
+            <NavigateButton
+              prevTitle={nav.prev?.content.slice(0, 10)}
+              nextTitle={nav.next?.content.slice(0, 10)}
+              onPrev={(event) => goto(-1, event)}
+              onNext={(event) => goto(1, event)}
+            />
+          </>
+        ) : (
+          <ArticleSkeleton />
+        )
+      }
+    </Editor>
+  );
+}

+ 7 - 0
dashboard-v6/src/features/editor/Term.tsx

@@ -4,6 +4,7 @@
 
 
 import { useEffect } from "react";
 import { useEffect } from "react";
 import type { ArticleMode } from "../../api/article";
 import type { ArticleMode } from "../../api/article";
+import type { IChannel } from "../../api/channel";
 import TypeTerm from "../../components/article/TypeTerm";
 import TypeTerm from "../../components/article/TypeTerm";
 import Editor from "../../components/editor";
 import Editor from "../../components/editor";
 import { Recent } from "../../components/recent/Recent";
 import { Recent } from "../../components/recent/Recent";
@@ -24,6 +25,9 @@ export interface ArticleEditorProps {
   /** 选择了新的 term 时触发 */
   /** 选择了新的 term 时触发 */
   onTermSelect?: (id: string) => void;
   onTermSelect?: (id: string) => void;
 
 
+  /** 右边栏「版本」面板选中频道时触发 */
+  onChannelSelect?: (selected: IChannel[]) => void;
+
   onEdit?: () => void;
   onEdit?: () => void;
 }
 }
 
 
@@ -36,6 +40,7 @@ export default function TermEditor({
   anthologyId,
   anthologyId,
   mode = "read",
   mode = "read",
   channelId,
   channelId,
+  onChannelSelect,
   onEdit,
   onEdit,
 }: ArticleEditorProps) {
 }: ArticleEditorProps) {
   const currUser = useAppSelector(currentUser);
   const currUser = useAppSelector(currentUser);
@@ -65,7 +70,9 @@ export default function TermEditor({
       }
       }
       articleId={termId}
       articleId={termId}
       anthologyId={anthologyId}
       anthologyId={anthologyId}
+      articleType={"term"}
       channelId={channelId}
       channelId={channelId}
+      onChannelSelect={onChannelSelect}
     >
     >
       {({ expandButton }) => (
       {({ expandButton }) => (
         <TypeTerm
         <TypeTerm

+ 5 - 1
dashboard-v6/src/layouts/workspace/index.tsx

@@ -9,6 +9,8 @@ import ThemeSwitch from "../../components/theme/ThemeSwitch";
 import { NetworkStatus } from "../../components/general/NetworkStatus";
 import { NetworkStatus } from "../../components/general/NetworkStatus";
 import { useAuth } from "../../hooks/useAuth";
 import { useAuth } from "../../hooks/useAuth";
 import UiLangSelect from "../../components/general/UiLangSelect";
 import UiLangSelect from "../../components/general/UiLangSelect";
+import SentCart from "../../components/sentence/SentCart";
+import NotificationIcon from "../../components/notification/NotificationIcon";
 
 
 const { Sider, Content } = Layout;
 const { Sider, Content } = Layout;
 const Widget = () => {
 const Widget = () => {
@@ -54,7 +56,9 @@ const Widget = () => {
           }}
           }}
         >
         >
           <HeaderBreadcrumb />
           <HeaderBreadcrumb />
-          <Space>
+          <Space size="middle">
+            <SentCart />
+            <NotificationIcon />
             <NetworkStatus />
             <NetworkStatus />
             <ThemeSwitch />
             <ThemeSwitch />
             <UiLangSelect />
             <UiLangSelect />

+ 2 - 0
dashboard-v6/src/locales/en-US.ts

@@ -9,6 +9,7 @@ import course from "./en-US/course";
 import message from "./en-US/message";
 import message from "./en-US/message";
 import label from "./en-US/label";
 import label from "./en-US/label";
 import error from "./en-US/error";
 import error from "./en-US/error";
+import pages from "./en-US/pages";
 
 
 const items = {
 const items = {
   "columns.library.title": "Library",
   "columns.library.title": "Library",
@@ -57,6 +58,7 @@ const items = {
   ...message,
   ...message,
   ...label,
   ...label,
   ...error,
   ...error,
+  ...pages,
 };
 };
 
 
 export default items;
 export default items;

+ 4 - 0
dashboard-v6/src/locales/en-US/label.ts

@@ -9,6 +9,10 @@ const items = {
   "labels.collaborators": "collaborators",
   "labels.collaborators": "collaborators",
   "labels.collaboration": "collaboration",
   "labels.collaboration": "collaboration",
   "labels.link": "link",
   "labels.link": "link",
+  "labels.library.access.link": "Library access link",
+  "labels.library.access.tip":
+    "Anyone with this link can read this term in the Library.",
+  "labels.studio.access.link": "Studio access link",
   "labels.upload": "upload",
   "labels.upload": "upload",
   "labels.first-term": "first term",
   "labels.first-term": "first term",
   "labels.sign-in": "sign in",
   "labels.sign-in": "sign in",

+ 2 - 0
dashboard-v6/src/locales/en-US/message.ts

@@ -1,5 +1,7 @@
 const items = {
 const items = {
   "flashes.success": "success",
   "flashes.success": "success",
+  "message.copy.success": "Copied to clipboard",
+  "message.copy.fail": "Copy failed",
   "message.irrevocable": "irrevocable",
   "message.irrevocable": "irrevocable",
   "message.delete.confirm": "confirm delete",
   "message.delete.confirm": "confirm delete",
   "message.auth.guest.alert": "guest read public resource only.",
   "message.auth.guest.alert": "guest read public resource only.",

+ 18 - 0
dashboard-v6/src/locales/en-US/pages.ts

@@ -0,0 +1,18 @@
+const items = {
+  "pages.dashboard.title": "Dashboard",
+  "pages.ai-model.edit.title": "Edit AI Model",
+  "pages.ai-model.log.title": "AI Model Log",
+  "pages.users.account-info.title": "Account Info",
+  "pages.users.change-password.title": "Change Password",
+  "pages.users.personal.title": "Personal",
+  "pages.tipitaka.title": "Tipitaka",
+  "pages.workspace.home.title": "Welcome to WikiPali",
+  "pages.team.title": "Team",
+  "pages.task.project.title": "Project",
+  "pages.tipitaka.chapter.title": "Chapter",
+  "pages.tipitaka.para.title": "Paragraph",
+  "pages.tipitaka.cs-para.title": "CS Paragraph",
+  "pages.tipitaka.page.title": "Page",
+};
+
+export default items;

+ 2 - 0
dashboard-v6/src/locales/zh-Hans.ts

@@ -9,6 +9,7 @@ import course from "./zh-Hans/course";
 import message from "./zh-Hans/message";
 import message from "./zh-Hans/message";
 import label from "./zh-Hans/label";
 import label from "./zh-Hans/label";
 import error from "./zh-Hans/error";
 import error from "./zh-Hans/error";
+import pages from "./zh-Hans/pages";
 
 
 const items = {
 const items = {
   "columns.library.title": "藏经阁",
   "columns.library.title": "藏经阁",
@@ -57,6 +58,7 @@ const items = {
   ...message,
   ...message,
   ...label,
   ...label,
   ...error,
   ...error,
+  ...pages,
 };
 };
 
 
 export default items;
 export default items;

+ 3 - 0
dashboard-v6/src/locales/zh-Hans/label.ts

@@ -9,6 +9,9 @@ const items = {
   "labels.collaborators": "协作者",
   "labels.collaborators": "协作者",
   "labels.collaboration": "协作",
   "labels.collaboration": "协作",
   "labels.link": "链接",
   "labels.link": "链接",
+  "labels.library.access.link": "藏经阁访问链接",
+  "labels.library.access.tip": "任何获得此链接的人,都可以在藏经阁中阅读该术语。",
+  "labels.studio.access.link": "译经楼访问链接",
   "labels.upload": "上传",
   "labels.upload": "上传",
   "labels.first-term": "第一个术语",
   "labels.first-term": "第一个术语",
   "labels.sign-in": "登录",
   "labels.sign-in": "登录",

+ 2 - 0
dashboard-v6/src/locales/zh-Hans/message.ts

@@ -1,5 +1,7 @@
 const items = {
 const items = {
   "flashes.success": "操作成功",
   "flashes.success": "操作成功",
+  "message.copy.success": "已复制到剪贴板",
+  "message.copy.fail": "复制失败",
   "message.irrevocable": "此操作不可撤销",
   "message.irrevocable": "此操作不可撤销",
   "message.delete.confirm": "确定删除吗?",
   "message.delete.confirm": "确定删除吗?",
   "message.auth.guest.alert":
   "message.auth.guest.alert":

+ 18 - 0
dashboard-v6/src/locales/zh-Hans/pages.ts

@@ -0,0 +1,18 @@
+const items = {
+  "pages.dashboard.title": "控制台",
+  "pages.ai-model.edit.title": "编辑 AI 模型",
+  "pages.ai-model.log.title": "AI 模型日志",
+  "pages.users.account-info.title": "账户信息",
+  "pages.users.change-password.title": "修改密码",
+  "pages.users.personal.title": "个人中心",
+  "pages.tipitaka.title": "圣典",
+  "pages.workspace.home.title": "欢迎来到wikipali",
+  "pages.team.title": "团队",
+  "pages.task.project.title": "项目",
+  "pages.tipitaka.chapter.title": "章节",
+  "pages.tipitaka.para.title": "段落",
+  "pages.tipitaka.cs-para.title": "CS段落",
+  "pages.tipitaka.page.title": "页码",
+};
+
+export default items;

+ 9 - 2
dashboard-v6/src/pages/dashboard/index.tsx

@@ -1,6 +1,13 @@
+import { useIntl } from "react-intl";
+
 const Widget = () => {
 const Widget = () => {
-  // TODO
-  return <>dashboard index</>;
+  const intl = useIntl();
+  return (
+    <>
+      <title>{intl.formatMessage({ id: "pages.dashboard.title" })}</title>
+      dashboard index
+    </>
+  );
 };
 };
 
 
 export default Widget;
 export default Widget;

+ 8 - 1
dashboard-v6/src/pages/dashboard/settings/ai-model/edit.tsx

@@ -1,4 +1,5 @@
 import { useParams } from "react-router";
 import { useParams } from "react-router";
+import { useIntl } from "react-intl";
 import AiModelEdit from "../../../../components/ai-model/AiModelEdit";
 import AiModelEdit from "../../../../components/ai-model/AiModelEdit";
 
 
 import { useAppSelector } from "../../../../hooks";
 import { useAppSelector } from "../../../../hooks";
@@ -7,8 +8,14 @@ import { currentUser } from "../../../../reducers/current-user";
 const Widget = () => {
 const Widget = () => {
   const currUser = useAppSelector(currentUser);
   const currUser = useAppSelector(currentUser);
   const { id } = useParams();
   const { id } = useParams();
+  const intl = useIntl();
 
 
-  return <AiModelEdit studioName={currUser?.realName} modelId={id} />;
+  return (
+    <>
+      <title>{intl.formatMessage({ id: "pages.ai-model.edit.title" })}</title>
+      <AiModelEdit studioName={currUser?.realName} modelId={id} />
+    </>
+  );
 };
 };
 
 
 export default Widget;
 export default Widget;

+ 8 - 1
dashboard-v6/src/pages/dashboard/settings/ai-model/index.tsx

@@ -1,11 +1,18 @@
+import { useIntl } from "react-intl";
 import AiModelList from "../../../../components/ai-model/AiModelList";
 import AiModelList from "../../../../components/ai-model/AiModelList";
 import { useAppSelector } from "../../../../hooks";
 import { useAppSelector } from "../../../../hooks";
 import { currentUser } from "../../../../reducers/current-user";
 import { currentUser } from "../../../../reducers/current-user";
 
 
 const Widget = () => {
 const Widget = () => {
   const currUser = useAppSelector(currentUser);
   const currUser = useAppSelector(currentUser);
+  const intl = useIntl();
 
 
-  return <AiModelList studioName={currUser?.realName} />;
+  return (
+    <>
+      <title>{intl.formatMessage({ id: "buttons.ai-models" })}</title>
+      <AiModelList studioName={currUser?.realName} />
+    </>
+  );
 };
 };
 
 
 export default Widget;
 export default Widget;

+ 8 - 1
dashboard-v6/src/pages/dashboard/settings/ai-model/log.tsx

@@ -1,11 +1,18 @@
 import { useParams } from "react-router";
 import { useParams } from "react-router";
+import { useIntl } from "react-intl";
 
 
 import AiModelLogList from "../../../../components/ai-model/AiModelLogList";
 import AiModelLogList from "../../../../components/ai-model/AiModelLogList";
 
 
 const Widget = () => {
 const Widget = () => {
   const { id } = useParams();
   const { id } = useParams();
+  const intl = useIntl();
 
 
-  return <AiModelLogList modelId={id} />;
+  return (
+    <>
+      <title>{intl.formatMessage({ id: "pages.ai-model.log.title" })}</title>
+      <AiModelLogList modelId={id} />
+    </>
+  );
 };
 };
 
 
 export default Widget;
 export default Widget;

+ 9 - 2
dashboard-v6/src/pages/home.tsx

@@ -1,6 +1,13 @@
+import { useIntl } from "react-intl";
+
 const Widget = () => {
 const Widget = () => {
-  // TODO
-  return <>home</>;
+  const intl = useIntl();
+  return (
+    <>
+      <title>{intl.formatMessage({ id: "columns.library.home.title" })}</title>
+      home
+    </>
+  );
 };
 };
 
 
 export default Widget;
 export default Widget;

+ 5 - 0
dashboard-v6/src/pages/users/account-info.tsx

@@ -1,3 +1,4 @@
+import { useIntl } from "react-intl";
 import ChangePassword from "../../components/users/ChangePassword";
 import ChangePassword from "../../components/users/ChangePassword";
 import Profile from "../../components/users/Profile";
 import Profile from "../../components/users/Profile";
 import { useAppSelector } from "../../hooks";
 import { useAppSelector } from "../../hooks";
@@ -5,10 +6,14 @@ import { currentUser } from "../../reducers/current-user";
 
 
 const Widget = () => {
 const Widget = () => {
   const user = useAppSelector(currentUser);
   const user = useAppSelector(currentUser);
+  const intl = useIntl();
   console.log(user?.avatar);
   console.log(user?.avatar);
 
 
   return (
   return (
     <div>
     <div>
+      <title>
+        {intl.formatMessage({ id: "pages.users.account-info.title" })}
+      </title>
       <h3> account info</h3>
       <h3> account info</h3>
       <ChangePassword />
       <ChangePassword />
       <Profile />
       <Profile />

+ 11 - 1
dashboard-v6/src/pages/users/change-password.tsx

@@ -1,5 +1,15 @@
+import { useIntl } from "react-intl";
+
 const Widget = () => {
 const Widget = () => {
-  return <div>change password</div>;
+  const intl = useIntl();
+  return (
+    <div>
+      <title>
+        {intl.formatMessage({ id: "pages.users.change-password.title" })}
+      </title>
+      change password
+    </div>
+  );
 };
 };
 
 
 export default Widget;
 export default Widget;

+ 14 - 9
dashboard-v6/src/pages/users/forgot-password.tsx

@@ -6,15 +6,20 @@ import { useIntl } from "react-intl";
 const Widget = () => {
 const Widget = () => {
   const intl = useIntl();
   const intl = useIntl();
   return (
   return (
-    <Card
-      title={intl.formatMessage({
-        id: "buttons.forgot.password",
-      })}
-    >
-      <ForgotPassword />
-      <Divider />
-      <NonSignInSharedLinks />
-    </Card>
+    <>
+      <title>
+        {intl.formatMessage({ id: "nut.users.forgot-password.title" })}
+      </title>
+      <Card
+        title={intl.formatMessage({
+          id: "buttons.forgot.password",
+        })}
+      >
+        <ForgotPassword />
+        <Divider />
+        <NonSignInSharedLinks />
+      </Card>
+    </>
   );
   );
 };
 };
 
 

+ 3 - 0
dashboard-v6/src/pages/users/logs.tsx

@@ -1,8 +1,11 @@
+import { useIntl } from "react-intl";
 import Logs from "../../components/users/Logs";
 import Logs from "../../components/users/Logs";
 
 
 const Widget = () => {
 const Widget = () => {
+  const intl = useIntl();
   return (
   return (
     <div>
     <div>
+      <title>{intl.formatMessage({ id: "nut.users.logs.title" })}</title>
       logs
       logs
       <br />
       <br />
       <Logs />
       <Logs />

+ 9 - 2
dashboard-v6/src/pages/users/personal/index.tsx

@@ -1,6 +1,13 @@
+import { useIntl } from "react-intl";
+
 const Widget = () => {
 const Widget = () => {
-  // TODO
-  return <>User personal</>;
+  const intl = useIntl();
+  return (
+    <>
+      <title>{intl.formatMessage({ id: "pages.users.personal.title" })}</title>
+      User personal
+    </>
+  );
 };
 };
 
 
 export default Widget;
 export default Widget;

+ 12 - 9
dashboard-v6/src/pages/users/reset-password.tsx

@@ -9,15 +9,18 @@ const Widget = () => {
   const intl = useIntl();
   const intl = useIntl();
 
 
   return (
   return (
-    <Card
-      title={intl.formatMessage({
-        id: "buttons.reset.password",
-      })}
-    >
-      <ResetPassword token={token} />
-      <Divider />
-      <NonSignInSharedLinks />
-    </Card>
+    <>
+      <title>{intl.formatMessage({ id: "buttons.reset.password" })}</title>
+      <Card
+        title={intl.formatMessage({
+          id: "buttons.reset.password",
+        })}
+      >
+        <ResetPassword token={token} />
+        <Divider />
+        <NonSignInSharedLinks />
+      </Card>
+    </>
   );
   );
 };
 };
 
 

+ 1 - 0
dashboard-v6/src/pages/users/sign-in.tsx

@@ -7,6 +7,7 @@ const Widget = () => {
   const intl = useIntl();
   const intl = useIntl();
   return (
   return (
     <div>
     <div>
+      <title>{intl.formatMessage({ id: "nut.users.sign-in.title" })}</title>
       <Card
       <Card
         title={intl.formatMessage({
         title={intl.formatMessage({
           id: "nut.users.sign-in.title",
           id: "nut.users.sign-in.title",

+ 3 - 0
dashboard-v6/src/pages/users/sign-in2.tsx

@@ -3,6 +3,7 @@ import { LoginForm, ProFormText } from "@ant-design/pro-components";
 import { message } from "antd";
 import { message } from "antd";
 import { UserOutlined, LockOutlined } from "@ant-design/icons";
 import { UserOutlined, LockOutlined } from "@ant-design/icons";
 import { useNavigate } from "react-router";
 import { useNavigate } from "react-router";
+import { useIntl } from "react-intl";
 
 
 interface loginRequest {
 interface loginRequest {
   username: string;
   username: string;
@@ -23,6 +24,7 @@ const fakeLogin = async (values: loginRequest) => {
 const Login: React.FC = () => {
 const Login: React.FC = () => {
   const [loading, setLoading] = useState(false);
   const [loading, setLoading] = useState(false);
   const navigate = useNavigate();
   const navigate = useNavigate();
+  const intl = useIntl();
   const handleSubmit = async (values: loginRequest) => {
   const handleSubmit = async (values: loginRequest) => {
     setLoading(true);
     setLoading(true);
     const ok = await fakeLogin(values);
     const ok = await fakeLogin(values);
@@ -47,6 +49,7 @@ const Login: React.FC = () => {
           "linear-gradient(135deg,#f0f5ff 0%, #ffffff 50%, #f0f5ff 100%)",
           "linear-gradient(135deg,#f0f5ff 0%, #ffffff 50%, #f0f5ff 100%)",
       }}
       }}
     >
     >
+      <title>{intl.formatMessage({ id: "nut.users.sign-in.title" })}</title>
       <LoginForm
       <LoginForm
         title="登陆"
         title="登陆"
         subTitle="wikipali 巴利语文献编辑平台"
         subTitle="wikipali 巴利语文献编辑平台"

+ 11 - 8
dashboard-v6/src/pages/users/sign-up.tsx

@@ -9,14 +9,17 @@ const Widget = () => {
   const { token } = useParams(); //url 参数
   const { token } = useParams(); //url 参数
 
 
   return (
   return (
-    <Card
-      title={intl.formatMessage({
-        id: "buttons.sign-up",
-      })}
-    >
-      <SignUp token={token} />
-      <SharedLinks />
-    </Card>
+    <>
+      <title>{intl.formatMessage({ id: "nut.users.sign-up.title" })}</title>
+      <Card
+        title={intl.formatMessage({
+          id: "buttons.sign-up",
+        })}
+      >
+        <SignUp token={token} />
+        <SharedLinks />
+      </Card>
+    </>
   );
   );
 };
 };
 
 

+ 10 - 1
dashboard-v6/src/pages/workspace/anthology/edit.tsx

@@ -1,4 +1,5 @@
-import { useParams } from "react-router";
+import { useMatches, useParams } from "react-router";
+import { useIntl } from "react-intl";
 
 
 import AnthologyTocEdit from "../../../components/anthology/AnthologyTocEdit";
 import AnthologyTocEdit from "../../../components/anthology/AnthologyTocEdit";
 import { useAppSelector } from "../../../hooks";
 import { useAppSelector } from "../../../hooks";
@@ -7,9 +8,17 @@ import { currentUser } from "../../../reducers/current-user";
 const Widget = () => {
 const Widget = () => {
   const { anthologyId } = useParams(); //url 参数
   const { anthologyId } = useParams(); //url 参数
   const user = useAppSelector(currentUser);
   const user = useAppSelector(currentUser);
+  const intl = useIntl();
+  const matches = useMatches() as {
+    data?: { title?: string; name?: string; word?: string };
+  }[];
+  const data = [...matches].reverse().find((m) => m.data)?.data;
+  const name = data?.title ?? data?.name ?? data?.word;
+  const prefix = intl.formatMessage({ id: "columns.studio.anthology.title" });
 
 
   return (
   return (
     <>
     <>
+      <title>{name ? `${prefix}-${name}` : prefix}</title>
       <AnthologyTocEdit id={anthologyId} editorStudioName={user?.realName} />
       <AnthologyTocEdit id={anthologyId} editorStudioName={user?.realName} />
     </>
     </>
   );
   );

+ 5 - 1
dashboard-v6/src/pages/workspace/anthology/index.tsx

@@ -1,3 +1,4 @@
+import { useIntl } from "react-intl";
 import { useAppSelector } from "../../../hooks";
 import { useAppSelector } from "../../../hooks";
 import { currentUser } from "../../../reducers/current-user";
 import { currentUser } from "../../../reducers/current-user";
 import AnthologyList from "../../../components/anthology/AnthologyList";
 import AnthologyList from "../../../components/anthology/AnthologyList";
@@ -7,6 +8,7 @@ const Widget = () => {
   const user = useAppSelector(currentUser);
   const user = useAppSelector(currentUser);
   const studioName = user?.realName;
   const studioName = user?.realName;
   const navigate = useNavigate();
   const navigate = useNavigate();
+  const intl = useIntl();
 
 
   const [searchParams, setSearchParams] = useSearchParams();
   const [searchParams, setSearchParams] = useSearchParams();
 
 
@@ -35,7 +37,9 @@ const Widget = () => {
   console.debug("anthology list", studioName);
   console.debug("anthology list", studioName);
   return (
   return (
     <>
     <>
-      <title>anthology</title>
+      <title>
+        {intl.formatMessage({ id: "columns.studio.anthology.title" })}
+      </title>
       <AnthologyList
       <AnthologyList
         tab={tab}
         tab={tab}
         page={page}
         page={page}

Деякі файли не було показано, через те що забагато файлів було змінено