@starwish-ai/xingyuan-dsh 0.4.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.en-US.md +57 -0
  3. package/README.md +57 -0
  4. package/cordis.patch.yml +22 -0
  5. package/lib/cascade.js +37 -0
  6. package/lib/category-color.js +78 -0
  7. package/lib/client/api.js +70 -0
  8. package/lib/client/cards.js +205 -0
  9. package/lib/client/hooks.js +105 -0
  10. package/lib/client/i18n.js +689 -0
  11. package/lib/client/index.js +90 -0
  12. package/lib/client/pages/calendar.js +139 -0
  13. package/lib/client/pages/categories.js +147 -0
  14. package/lib/client/pages/color-swatch.js +37 -0
  15. package/lib/client/pages/detail.js +170 -0
  16. package/lib/client/pages/format.js +62 -0
  17. package/lib/client/pages/growth.js +107 -0
  18. package/lib/client/pages/index.js +7 -0
  19. package/lib/client/pages/memory.js +227 -0
  20. package/lib/client/pages/quick-create.js +118 -0
  21. package/lib/client/pages/settings.js +183 -0
  22. package/lib/client/pages/task-line.js +36 -0
  23. package/lib/client/pages/tasks.js +70 -0
  24. package/lib/client/pages/today.js +73 -0
  25. package/lib/client/pages/types.js +2 -0
  26. package/lib/client/pages/wishes.js +104 -0
  27. package/lib/client/styles.js +367 -0
  28. package/lib/client/types.js +1 -0
  29. package/lib/client/ui.js +106 -0
  30. package/lib/client.js +3633 -0
  31. package/lib/domain.js +99 -0
  32. package/lib/events.js +1 -0
  33. package/lib/growth.js +149 -0
  34. package/lib/index.js +37 -0
  35. package/lib/micro.js +76 -0
  36. package/lib/opportunity.js +111 -0
  37. package/lib/preset/charts.js +361 -0
  38. package/lib/preset/hitl.js +39 -0
  39. package/lib/preset/prompts.js +487 -0
  40. package/lib/preset/side.js +44 -0
  41. package/lib/preset/tools.js +1234 -0
  42. package/lib/preset-root.js +56 -0
  43. package/lib/routes/api.js +572 -0
  44. package/lib/routes/config.js +2 -0
  45. package/lib/routes/errors.js +25 -0
  46. package/lib/routes/index.js +88 -0
  47. package/lib/routes/pages-html.js +204 -0
  48. package/lib/sqlite.js +158 -0
  49. package/lib/store.js +351 -0
  50. package/lib/types/cascade.d.ts +16 -0
  51. package/lib/types/category-color.d.ts +27 -0
  52. package/lib/types/client/api.d.ts +13 -0
  53. package/lib/types/client/cards.d.ts +26 -0
  54. package/lib/types/client/hooks.d.ts +36 -0
  55. package/lib/types/client/i18n.d.ts +312 -0
  56. package/lib/types/client/index.d.ts +28 -0
  57. package/lib/types/client/pages/calendar.d.ts +3 -0
  58. package/lib/types/client/pages/categories.d.ts +10 -0
  59. package/lib/types/client/pages/color-swatch.d.ts +17 -0
  60. package/lib/types/client/pages/detail.d.ts +24 -0
  61. package/lib/types/client/pages/format.d.ts +12 -0
  62. package/lib/types/client/pages/growth.d.ts +8 -0
  63. package/lib/types/client/pages/index.d.ts +7 -0
  64. package/lib/types/client/pages/memory.d.ts +3 -0
  65. package/lib/types/client/pages/quick-create.d.ts +13 -0
  66. package/lib/types/client/pages/settings.d.ts +25 -0
  67. package/lib/types/client/pages/task-line.d.ts +11 -0
  68. package/lib/types/client/pages/tasks.d.ts +3 -0
  69. package/lib/types/client/pages/today.d.ts +3 -0
  70. package/lib/types/client/pages/types.d.ts +119 -0
  71. package/lib/types/client/pages/wishes.d.ts +3 -0
  72. package/lib/types/client/styles.d.ts +12 -0
  73. package/lib/types/client/types.d.ts +9 -0
  74. package/lib/types/client/ui.d.ts +35 -0
  75. package/lib/types/domain.d.ts +112 -0
  76. package/lib/types/events.d.ts +100 -0
  77. package/lib/types/growth.d.ts +72 -0
  78. package/lib/types/index.d.ts +27 -0
  79. package/lib/types/micro.d.ts +33 -0
  80. package/lib/types/opportunity.d.ts +35 -0
  81. package/lib/types/preset/charts.d.ts +34 -0
  82. package/lib/types/preset/hitl.d.ts +21 -0
  83. package/lib/types/preset/prompts.d.ts +15 -0
  84. package/lib/types/preset/side.d.ts +40 -0
  85. package/lib/types/preset/tools.d.ts +37 -0
  86. package/lib/types/preset-root.d.ts +4 -0
  87. package/lib/types/routes/api.d.ts +19 -0
  88. package/lib/types/routes/config.d.ts +9 -0
  89. package/lib/types/routes/errors.d.ts +17 -0
  90. package/lib/types/routes/index.d.ts +11 -0
  91. package/lib/types/routes/pages-html.d.ts +6 -0
  92. package/lib/types/sqlite.d.ts +15 -0
  93. package/lib/types/store.d.ts +92 -0
  94. package/lib/types/types.d.ts +6 -0
  95. package/lib/types.js +5 -0
  96. package/package.json +138 -0
  97. package/presets/xingyuan/agent.cordis.yml +4 -0
  98. package/presets/xingyuan/preset.yml +3 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 starwish-ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,57 @@
1
+ # XingYuan for DeepSeek Harness
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@starwish-ai/xingyuan-dsh)](https://www.npmjs.com/package/@starwish-ai/xingyuan-dsh)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
5
+ [![CI](https://github.com/starwish-ai/xingyuan-dsh/actions/workflows/ci.yml/badge.svg)](https://github.com/starwish-ai/xingyuan-dsh/actions/workflows/ci.yml)
6
+
7
+ [简体中文](./README.md) | English
8
+
9
+ XingYuan is a wish & habit-building companion, shipped as a DeepSeek Harness plugin bundle + agent preset.
10
+
11
+ ## Features
12
+
13
+ - **Conversation-driven**: create wishes / tasks in natural language with duplicate detection
14
+ - **Opportunity-day check-ins**: check-in, make-up, cancel, and future pre-checks; progress always recomputed against today
15
+ - **Growth system**: levels Lv.1–Lv.10, streak bonuses, stat cards, 30-day bar chart
16
+ - **Session view tabs**: Today / Wishes / Tasks / Calendar / Growth / Memory — buttons call action endpoints directly
17
+ - **Memory**: important memories injected into context (limit configurable), full CRUD
18
+ - **Charts**: 15 chart types rendered as cards
19
+ - **Safe writes**: write operations confirmed by default (configurable); deletion always confirmed
20
+ - **Theming**: light/dark follows the app theme
21
+
22
+ Tools are only mounted on sessions using the XingYuan preset; other sessions are unaffected.
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ dsh plugin --profile web add @starwish-ai/xingyuan-dsh
28
+ ```
29
+
30
+ After starting the Web GUI, installation succeeded when「星愿」appears in the agent picker.
31
+
32
+ ## Data & Backup
33
+
34
+ Business data lives at `~/.dsh/xingyuan/xingyuan.sqlite` and survives uninstall / upgrade; backup is just copying that directory.
35
+
36
+ ## Settings (Web GUI → Settings → XingYuan)
37
+
38
+ | Option | Description |
39
+ | --- | --- |
40
+ | Coach style | Gentle / humorous / strict |
41
+ | User profile | Nickname, occupation, interests |
42
+ | Write confirmation | Whether create / check-in writes need confirmation (deletion always does) |
43
+ | Memory injection limit | Max memories injected per turn |
44
+
45
+ ## Development
46
+
47
+ ```sh
48
+ pnpm install
49
+ pnpm build # tsc + tsdown (integration tests depend on lib/)
50
+ pnpm test # vitest
51
+ ```
52
+
53
+ Releasing: push a `v*` tag to build and publish to npm automatically (prereleases get the `alpha` dist-tag).
54
+
55
+ ## License
56
+
57
+ [MIT](./LICENSE)
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # 星愿 XingYuan for DeepSeek Harness
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@starwish-ai/xingyuan-dsh)](https://www.npmjs.com/package/@starwish-ai/xingyuan-dsh)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
5
+ [![CI](https://github.com/starwish-ai/xingyuan-dsh/actions/workflows/ci.yml/badge.svg)](https://github.com/starwish-ai/xingyuan-dsh/actions/workflows/ci.yml)
6
+
7
+ 简体中文 | [English](./README.en-US.md)
8
+
9
+ 星愿是一个愿望与习惯养成助手,以 DeepSeek Harness 插件组合包 + Agent Preset 形式交付。
10
+
11
+ ## 功能
12
+
13
+ - **对话驱动**:自然语言创建愿望 / 任务,自动查重相似项
14
+ - **机会日打卡**:周期任务打卡、补卡、取消、未来预勾,进度跨天恒为最新
15
+ - **成长体系**:等级 Lv.1–Lv.10、连击加成、统计卡与近 30 天柱状图
16
+ - **会话视图页**:今日 / 愿望 / 任务 / 日历 / 成长 / 记忆 六个标签页,页面按钮直连动作接口
17
+ - **记忆**:重要记忆自动注入上下文(上限可配),支持增删改查
18
+ - **图表**:15 种统计图表自动渲染为卡片
19
+ - **安全确认**:写操作默认二次确认(可关闭),删除始终确认
20
+ - **主题适配**:深浅色跟随应用主题
21
+
22
+ 工具只挂载在选择星愿 preset 的会话上,不影响其他会话。
23
+
24
+ ## 安装
25
+
26
+ ```sh
27
+ dsh plugin --profile web add @starwish-ai/xingyuan-dsh
28
+ ```
29
+
30
+ 启动 Web GUI 后,agent 选择器出现「星愿」即安装成功。
31
+
32
+ ## 数据与备份
33
+
34
+ 业务数据存于 `~/.dsh/xingyuan/xingyuan.sqlite`,卸载 / 升级均存活,备份只需拷贝该目录。
35
+
36
+ ## 设置(Web GUI → 设置 → 星愿)
37
+
38
+ | 配置项 | 说明 |
39
+ | --- | --- |
40
+ | 教练风格 | 温柔 / 幽默 / 严格 |
41
+ | 用户画像 | 昵称、职业、兴趣 |
42
+ | 写操作二次确认 | 创建 / 打卡类写操作是否确认(删除始终确认) |
43
+ | 记忆注入上限 | 每次注入上下文的记忆条数上限 |
44
+
45
+ ## 开发
46
+
47
+ ```sh
48
+ pnpm install
49
+ pnpm build # tsc + tsdown(集成测试依赖 lib/ 产物)
50
+ pnpm test # vitest
51
+ ```
52
+
53
+ 发布:推送 `v*` tag 自动构建并发布到 npm(预发布版本挂 `alpha` tag)。
54
+
55
+ ## License
56
+
57
+ [MIT](./LICENSE)
@@ -0,0 +1,22 @@
1
+ # 星愿 bundle 层:只插常驻行(存储后端 / HTTP 路由 / schedule);preset 由 preset-root.ts 发布到用户根。
2
+ # 语义:`- insert:` 插入新行;裸行按 id 整行替换既有配置(不深合并)。
3
+ # DB 文件固定在 ~/.dsh/xingyuan/:插件卸载 / 升级数据均存活,禁止放入包安装目录。
4
+ - insert:
5
+ # sqlite 后端:只依赖 storage hub,DB 位于 ~/.dsh/xingyuan/
6
+ - id: xingyuan-sqlite
7
+ name: '@starwish-ai/xingyuan-dsh/sqlite'
8
+ config:
9
+ path: !!js "dshHomePath('xingyuan', 'xingyuan.sqlite')"
10
+ # 主行 id 不能与 preset 目录名同名(同为 'xingyuan' 时会话挂载 preset 会死锁),故加 xy- 前缀
11
+ - id: xy-bundle
12
+ name: '@starwish-ai/xingyuan-dsh'
13
+ # 宿主默认组装不含 schedule 行,需补插以满足加载顺序要求
14
+ - id: schedule
15
+ name: '@deepseek-ai/dsh-schedule'
16
+ # 领域路由:星愿领域走 sqlite 后端;其余领域维持 json(整行替换须重述 backend)
17
+ - id: storage-domain
18
+ name: '@deepseek-ai/dsh-storage-domain'
19
+ config:
20
+ backend: json
21
+ routes:
22
+ xingyuan: sqlite
package/lib/cascade.js ADDED
@@ -0,0 +1,37 @@
1
+ import { restartMicroAction } from './micro.js';
2
+ /** 删除任务的全部打卡记录。 */
3
+ export async function removeAllCheckins(store, taskId) {
4
+ const keys = [];
5
+ for (const [key, record] of store.domain.table('checkins').entries()) {
6
+ if (record.taskId === taskId)
7
+ keys.push(key);
8
+ }
9
+ for (const key of keys)
10
+ await store.domain.table('checkins').delete(key);
11
+ }
12
+ /**
13
+ * 彻底删除单个任务:打卡记录、微行动状态随任务级联清理。
14
+ * 返回被删任务快照(供事件补发);任务不存在时返回 undefined。
15
+ */
16
+ export async function removeTaskCompletely(store, taskId) {
17
+ const task = store.domain.table('tasks').get(taskId);
18
+ if (task === undefined)
19
+ return undefined;
20
+ await removeAllCheckins(store, taskId);
21
+ // 微行动状态复用 restart 的清除语义(存在才清,幂等)
22
+ await restartMicroAction(store, taskId);
23
+ await store.domain.table('tasks').delete(taskId);
24
+ return task;
25
+ }
26
+ /** 彻底删除愿望及其下属全部任务(含各自打卡与微行动状态);返回被级联的任务。 */
27
+ export async function removeWishCompletely(store, wishId) {
28
+ const removed = [];
29
+ for (const [, task] of store.domain.table('tasks').entries()) {
30
+ if (task.wishId !== wishId)
31
+ continue;
32
+ await removeTaskCompletely(store, task.taskId);
33
+ removed.push(task);
34
+ }
35
+ await store.domain.table('wishes').delete(wishId);
36
+ return removed;
37
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 愿望分类颜色。
3
+ *
4
+ * - 22 个预设 colorKey(5 中性 + 17 彩色);
5
+ * - 未设 colorKey 时按分类名哈希到色环取稳定色相,老数据/未选色的分类也有稳定可辨的颜色;
6
+ * - 颜色以「色相 + 饱和度」CSS 变量下发,亮度由样式表按主题切换:
7
+ * 壳内跟随 body[data-ds-dark-theme],独立页走 prefers-color-scheme。
8
+ */
9
+ /** 分类颜色白名单(与工具 schema enum、Web 预设表同源;顺序即展示顺序)。 */
10
+ export const CATEGORY_COLOR_KEYS = [
11
+ 'slate', 'gray', 'zinc', 'neutral', 'stone',
12
+ 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan',
13
+ 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose',
14
+ ];
15
+ /** 中性色键(饱和度走低档)。 */
16
+ const NEUTRAL_KEYS = new Set(['slate', 'gray', 'zinc', 'neutral', 'stone']);
17
+ /** 彩色键 → 色相(Web COLOR_HUES 同值)。 */
18
+ const COLOR_HUES = {
19
+ red: 0, orange: 24, amber: 38, yellow: 55, lime: 78, green: 142, emerald: 160,
20
+ teal: 175, cyan: 190, sky: 200, blue: 215, indigo: 235, violet: 260, purple: 275,
21
+ fuchsia: 300, pink: 330, rose: 345,
22
+ };
23
+ const NEUTRAL_HUES = {
24
+ slate: 215, gray: 220, zinc: 240, neutral: 0, stone: 25,
25
+ };
26
+ /** 色环:未知键哈希落位(由彩色预设值派生,与 Web CATEGORY_COLOR_HUE_RING 恒等)。 */
27
+ const HUE_RING = [...new Set(Object.values(COLOR_HUES))].sort((a, b) => a - b);
28
+ /** djb2 文本哈希(Web hashText 同式)。 */
29
+ function hashText(text) {
30
+ let h = 5381;
31
+ for (let i = 0; i < text.length; i += 1)
32
+ h = ((h * 33) ^ text.charCodeAt(i)) >>> 0;
33
+ return h;
34
+ }
35
+ export function normalizeColorKey(colorKey) {
36
+ if (!colorKey)
37
+ return '';
38
+ return String(colorKey).trim().toLowerCase();
39
+ }
40
+ /** 预设键/名查表;未命中按种子哈希入色环(Web resolveHueSpecBySeed 同式)。 */
41
+ function hueSpecOf(seed) {
42
+ if (seed in NEUTRAL_HUES)
43
+ return { hue: NEUTRAL_HUES[seed], neutral: true };
44
+ if (seed in COLOR_HUES)
45
+ return { hue: COLOR_HUES[seed], neutral: false };
46
+ const index = hashText(seed) % HUE_RING.length;
47
+ return { hue: HUE_RING[index] ?? HUE_RING[0], neutral: false };
48
+ }
49
+ /**
50
+ * 解析分类的色相与中性标记:
51
+ * colorKey 在白名单内 → 预设色相;否则回退到「分类名为种子」的同一解析器
52
+ * (与 Web getWishCategoryCombinedStyle 一致:名称恰为英文预设键时两端同色;
53
+ * 空名兜底也用 Web 的 'default' 种子)。
54
+ */
55
+ export function categoryHue(colorKey, categoryName) {
56
+ const key = normalizeColorKey(colorKey);
57
+ if (key !== '')
58
+ return hueSpecOf(key);
59
+ const name = (categoryName ?? '').trim();
60
+ return hueSpecOf(name === '' ? 'default' : name);
61
+ }
62
+ /** 饱和度档(Web CATEGORY_COLOR_THEME / neutral 分支同值;主题无关,只随中性变化)。 */
63
+ const SAT_LIGHT = { bg: 58, fg: 46, border: 46 };
64
+ const SAT_NEUTRAL = { bg: 16, fg: 20, border: 12 };
65
+ /**
66
+ * 徽章内联 CSS 变量(style 属性用):JS 拥有主题无关的色相/饱和度,
67
+ * CSS 拥有各主题的亮度——深浅色翻转零 JS 参与。
68
+ */
69
+ export function categoryVars(colorKey, categoryName) {
70
+ const { hue, neutral } = categoryHue(colorKey, categoryName);
71
+ const sat = neutral ? SAT_NEUTRAL : SAT_LIGHT;
72
+ return {
73
+ '--cat-h': String(hue),
74
+ '--cat-sbg': String(sat.bg),
75
+ '--cat-sfg': String(sat.fg),
76
+ '--cat-sbd': String(sat.border),
77
+ };
78
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 同源 HTTP 访问层:GET 数据 / POST 动作(页面按钮 = 用户本人授权,不经模型不走 HITL)。
3
+ * 服务端错误优先取 payload.code 走 i18n 文案(err.* 键),未知 code 回落服务端 message 原文;
4
+ * 非 JSON 响应兜底本地化文案而非直出英文异常。
5
+ */
6
+ import { t } from './i18n.js';
7
+ /** 携带稳定 code 的业务错误:message 为中文兜底文案,code 供客户端本地化。 */
8
+ export class ActionError extends Error {
9
+ code;
10
+ params;
11
+ constructor(code, message, params) {
12
+ super(message);
13
+ this.name = 'ActionError';
14
+ this.code = code;
15
+ this.params = params;
16
+ }
17
+ }
18
+ /** 服务端 code → i18n 键(编译期校验每个码都有文案)。 */
19
+ const ERROR_KEY = {
20
+ missing_field: 'err.missing_field',
21
+ not_found: 'err.not_found',
22
+ already_checked: 'err.already_checked',
23
+ not_opportunity_day: 'err.not_opportunity_day',
24
+ already_claimed: 'err.already_claimed',
25
+ task_closed: 'err.task_closed',
26
+ due_past: 'err.due_past',
27
+ bad_category_name: 'err.bad_category_name',
28
+ bad_color_key: 'err.bad_color_key',
29
+ bad_date: 'err.bad_date',
30
+ overwrite_required: 'err.overwrite_required',
31
+ };
32
+ /** 把任意抛错转成用户可读文案:ActionError 按键本地化;其余直出消息。 */
33
+ export function describeError(e) {
34
+ if (e instanceof ActionError)
35
+ return t(ERROR_KEY[e.code], e.params);
36
+ const message = e instanceof Error ? e.message : String(e);
37
+ // 兼容旧口径服务端纯中文消息里的「${date} 已打卡」模式(恢复卡片态用)
38
+ return message;
39
+ }
40
+ export async function postAction(path, body) {
41
+ return postJson(`/xingyuan/api/action/${path}`, body);
42
+ }
43
+ export async function postJson(path, body) {
44
+ const response = await fetch(path, {
45
+ method: 'POST',
46
+ headers: { 'content-type': 'application/json' },
47
+ body: JSON.stringify(body),
48
+ });
49
+ return parsePayload(response, () => t('common.actionFailed'));
50
+ }
51
+ export async function getJson(path) {
52
+ const response = await fetch(path, { headers: { accept: 'application/json' } });
53
+ return parsePayload(response, () => t('common.requestFailed'));
54
+ }
55
+ async function parsePayload(response, fallback) {
56
+ let payload;
57
+ try {
58
+ payload = await response.json();
59
+ }
60
+ catch {
61
+ throw new Error(`${fallback()}(${response.status})`);
62
+ }
63
+ if (!response.ok) {
64
+ if (typeof payload.code === 'string' && payload.code !== '') {
65
+ throw new ActionError(payload.code, payload.error ?? fallback(), payload.params);
66
+ }
67
+ throw new Error(payload.error ?? `${fallback()}(${response.status})`);
68
+ }
69
+ return payload;
70
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * 会话卡片渲染组件(愿望/任务/打卡/图表/微行动)。
3
+ * 渲染器只读 node.data(whole-value 事件),不扫描事件窗口;可回放可分页。
4
+ * 任务卡带内联领取/打卡动作(本地态防重复,服务端校验兜底)。
5
+ * 文案经 useXyT():语言切换由 locale revision 订阅驱动重渲(见 i18n.ts §方案)。
6
+ */
7
+ import { createElement, useRef, useState } from 'react';
8
+ import {} from '../events.js';
9
+ import { postAction, ActionError, describeError } from './api.js';
10
+ import { toast, toastError } from './ui.js';
11
+ import { localYmd, softConfirm } from './hooks.js';
12
+ import { useXyT, t, activeLocale } from './i18n.js';
13
+ import { categoryVars } from '../category-color.js';
14
+ import { cycleLabel, durationText, dateSuffix } from './pages/format.js';
15
+ function WishView(props) {
16
+ const t = useXyT();
17
+ const wish = props.node.data.data;
18
+ const deleted = wish.op === 'deleted';
19
+ return createElement('div', { className: `xy-card ${deleted ? 'xy-deleted' : ''}` }, createElement('div', { className: 'xy-card-head' }, createElement('span', {
20
+ className: 'xy-badge xy-badge-cat',
21
+ style: categoryVars(wish.wish.colorKey, wish.wish.categoryName),
22
+ }, t('badge.wish')), createElement('span', { className: 'xy-title' }, deleted ? t('state.deleted', { title: wish.wish.title }) : wish.wish.title)),
23
+ // 已删除卡不再渲染活体进度(删除态 × 进度百分比自相矛盾)
24
+ createElement('div', { className: 'xy-meta' }, deleted
25
+ ? wish.wish.categoryName
26
+ : `${wish.wish.categoryName} · ${t('wish.progress', { percent: wish.wish.progress })}`));
27
+ }
28
+ function TaskView(props) {
29
+ const t = useXyT();
30
+ const event = props.node.data.data;
31
+ const task = event.task;
32
+ // 内联动作(对齐 Web 聊天卡领取/打卡):点击即本人授权,本地态防重复,服务端仍校验兜底。
33
+ // 状态机对齐 Web markCardActioned:领取 → 原地转为可打卡;打卡 → 定格「已完成(日期)」。
34
+ const [phase, setPhase] = useState('idle');
35
+ const [doneDate, setDoneDate] = useState(undefined);
36
+ const [busy, setBusy] = useState(false);
37
+ // 同帧双击守卫:渲染态 busy 在同一事件循环内读不到新值,与页面侧
38
+ // useActionGuard 的 busyRef 对齐(服务端去重兜底,这里保证不发多余请求)
39
+ const busyRef = useRef(false);
40
+ const run = (action) => {
41
+ if (busyRef.current || phase === 'done')
42
+ return;
43
+ const future = task.nextOpportunityDate !== undefined && task.nextOpportunityDate > localYmd(new Date())
44
+ ? task.nextOpportunityDate
45
+ : undefined;
46
+ if (action === 'checkin' && future !== undefined && !softConfirm(t('confirm.futureCheckin', { name: task.name, date: future })))
47
+ return;
48
+ busyRef.current = true;
49
+ setBusy(true);
50
+ postAction(action, { taskId: task.taskId })
51
+ .then((payload) => {
52
+ if (action === 'claim') {
53
+ setPhase('claimed');
54
+ toast(t('toast.claimed', { name: task.name }), 'ok');
55
+ }
56
+ else {
57
+ setPhase('done');
58
+ setDoneDate(typeof payload.date === 'string' ? payload.date : undefined);
59
+ toast(t('toast.checkinOk') + dateSuffix(typeof payload.date === 'string' ? payload.date : undefined), 'ok');
60
+ }
61
+ })
62
+ .catch((e) => {
63
+ // 会话重放后本地态丢失,服务端状态是事实:按稳定错误码恢复卡片到真实阶段
64
+ //(store.ToolError 携带 code 经路由透传;文案按 code 本地化,不依赖中文子串)
65
+ if (e instanceof ActionError) {
66
+ if (action === 'claim' && e.code === 'already_claimed') {
67
+ setPhase('claimed');
68
+ toast(describeError(e), 'info');
69
+ return;
70
+ }
71
+ if (action === 'checkin' && (e.code === 'already_checked' || e.code === 'task_closed')) {
72
+ setPhase('done');
73
+ setDoneDate(typeof e.params?.date === 'string' ? e.params.date : undefined);
74
+ toast(describeError(e), 'info');
75
+ return;
76
+ }
77
+ }
78
+ toastError(e);
79
+ })
80
+ .finally(() => {
81
+ busyRef.current = false;
82
+ setBusy(false);
83
+ });
84
+ };
85
+ const deleted = event.op === 'deleted';
86
+ const showClaim = !deleted && phase === 'idle' && task.status === 'pending';
87
+ const showCheckIn = !deleted && phase !== 'done' && (task.status === 'in_progress' || phase === 'claimed');
88
+ const preview = event.opportunityPreview.length > 0
89
+ ? t('task.upcoming', { dates: event.opportunityPreview.join(activeLocale() === 'en' ? ', ' : '、') })
90
+ : undefined;
91
+ return createElement('div', { className: `xy-card ${deleted ? 'xy-deleted' : ''}` }, createElement('div', { className: 'xy-card-head' }, createElement('span', { className: 'xy-badge xy-badge-task' }, t('badge.task')), createElement('span', { className: 'xy-title' }, deleted ? t('state.deleted', { title: task.name }) : task.name), !deleted && phase === 'done'
92
+ ? createElement('span', { className: 'xy-meta xy-actioned' }, t('state.done') + dateSuffix(doneDate))
93
+ : null, showClaim
94
+ ? createElement('button', { className: 'xy-btn xy-btn-inline', disabled: busy, onClick: () => run('claim') }, t('action.claim'))
95
+ : null, showCheckIn
96
+ ? createElement('button', { className: 'xy-btn xy-btn-primary xy-btn-inline', disabled: busy, onClick: () => run('checkin') }, t('action.checkin'))
97
+ : null), createElement('div', { className: 'xy-meta' }, `${cycleLabel(task.checkInCycle)}${task.dueDate !== undefined ? ` · ${t('task.due', { date: task.dueDate })}` : ''} · ${durationText(task.completedDays, task.requiredDays)}`), deleted === false && preview !== undefined
98
+ ? createElement('div', { className: 'xy-preview' }, preview)
99
+ : null);
100
+ }
101
+ function MicroView(props) {
102
+ const t = useXyT();
103
+ const event = props.node.data.data;
104
+ const cleared = event.op === 'restarted';
105
+ const finished = event.op === 'finished' || event.currentStepNumber === null;
106
+ const doneCount = event.steps.filter((s) => s.completed).length;
107
+ return createElement('div', { className: 'xy-card xy-micro' }, createElement('div', { className: 'xy-card-head' }, createElement('span', { className: 'xy-badge xy-badge-micro' }, t('badge.micro')), createElement('span', { className: 'xy-title' }, cleared ? t('micro.cleared', { task: event.taskName })
108
+ : finished ? t('micro.finished', { task: event.taskName })
109
+ : t('micro.step', { task: event.taskName, current: event.currentStepNumber ?? 0, total: event.steps.length }))), cleared
110
+ ? null
111
+ : createElement('ol', { className: 'xy-microsteps' }, ...event.steps.map((step) => createElement('li', {
112
+ key: step.stepNumber,
113
+ className: `xy-microstep${step.completed ? ' xy-done' : step.skipped ? ' xy-skipped' : ''}`,
114
+ }, createElement('span', { className: 'xy-microstepnum' }, String(step.stepNumber)), createElement('span', { className: 'xy-microsteptext' }, step.instruction, step.rationale !== undefined ? createElement('span', { className: 'xy-meta' }, step.rationale) : null), step.completed
115
+ ? createElement('span', { className: 'xy-meta xy-microstate xy-glyph xy-glyph-ok' }, '✓')
116
+ : step.skipped
117
+ ? createElement('span', { className: 'xy-meta xy-microstate' }, t('micro.state.skipped'))
118
+ : step.stepNumber === event.currentStepNumber
119
+ ? createElement('span', { className: 'xy-meta xy-microstate' }, t('micro.state.current'))
120
+ : null))),
121
+ // 进度行置于 <ol> 之外:列表语义只允许 li 子元素,进度属卡片级信息
122
+ !cleared && !finished
123
+ ? createElement('div', { className: 'xy-meta' }, t('micro.progress', { done: doneCount, total: event.steps.length }))
124
+ : null);
125
+ }
126
+ function CheckinView(props) {
127
+ const t = useXyT();
128
+ const event = props.node.data.data;
129
+ const cancelled = event.op === 'cancelled';
130
+ return createElement('div', { className: 'xy-card xy-checkin' }, createElement('div', { className: 'xy-card-head' }, createElement('span', { className: `xy-glyph ${cancelled ? 'xy-glyph-back' : 'xy-glyph-ok'}`, 'aria-hidden': 'true' }, cancelled ? '↩' : '✓'), createElement('span', { className: 'xy-title' }, cancelled
131
+ ? t('checkin.cancelled', { task: event.taskName, date: event.date })
132
+ : t('checkin.success', { task: event.taskName }))), createElement('div', { className: 'xy-meta' }, `${event.date}${event.wishName !== undefined ? ` · ${event.wishName}` : ''} · ${durationText(event.completedDays, event.requiredDays)}`));
133
+ }
134
+ function ChartView(props) {
135
+ const t = useXyT();
136
+ const event = props.node.data.data;
137
+ return createElement('div', { className: 'xy-card xy-chart' }, createElement('div', { className: 'xy-card-head' }, createElement('span', { className: 'xy-badge xy-badge-chart' }, t('badge.chart')), createElement('span', { className: 'xy-title' }, event.title), event.subtitle !== undefined ? createElement('span', { className: 'xy-meta' }, event.subtitle) : null), chartBody(event, t('chart.noData')));
138
+ }
139
+ /** 图表卡主体:极简 SVG 柱线混合(颜色走主题变量,深浅色自适应);分组序列附图例。 */
140
+ function chartBody(event, noDataText) {
141
+ const { chartType, data } = event;
142
+ if (data.length === 0)
143
+ return createElement('div', { className: 'xy-meta' }, noDataText);
144
+ const max = Math.max(...data.map((d) => d.value), 1);
145
+ if (chartType === 'arcbars') {
146
+ const ratio = data[0].ratio ?? 0;
147
+ return createElement('div', { className: 'xy-arcwrap' }, createElement('div', { className: 'xy-arcnum' }, `${Math.round(ratio * 100)}%`), createElement('div', { className: 'xy-bar' }, createElement('div', { className: 'xy-bar-fill', style: { width: `${Math.round(ratio * 100)}%` } })));
148
+ }
149
+ if (chartType === 'pie' || chartType === 'radar') {
150
+ // 占比分母守卫:总和为 0 时跳过百分比(避免 NaN%)
151
+ const sum = data.reduce((s, x) => s + x.value, 0);
152
+ return createElement('ul', { className: 'xy-rows' }, ...data.map((d, i) => createElement('li', { key: i, className: 'xy-row' }, createElement('span', null, d.label), createElement('span', { className: 'xy-rowval' }, `${d.value}${chartType === 'pie' && sum > 0 ? `(${Math.round((d.value / sum) * 100)}%)` : ''}`))));
153
+ }
154
+ if (chartType === 'heatmap') {
155
+ return createElement('div', null, createElement('div', { className: 'xy-heat', 'aria-hidden': 'true' }, ...data.map((d, i) => createElement('span', {
156
+ key: i,
157
+ className: 'xy-heatcell',
158
+ title: `${d.label}:${d.value}`,
159
+ style: { opacity: String(0.25 + 0.75 * (d.value / max)) },
160
+ }))),
161
+ // 热力格纯视觉(title 不构成可访问名):数据以屏内隐藏文本整体提供给读屏
162
+ createElement('span', { className: 'xy-visually-hidden' }, data.map((d) => `${d.label} ${d.value}`).join(';')));
163
+ }
164
+ const seriesList = [...new Set(data.map((d) => d.series).filter((s) => s !== undefined))];
165
+ const seriesColor = (index) => index <= 0 ? 'var(--xyd-accent)' : 'var(--dsw-alias-label-secondary)';
166
+ const width = 560;
167
+ const height = 140;
168
+ const step = width / Math.max(data.length, 1);
169
+ const bars = data.map((d, i) => {
170
+ const h = Math.round((d.value / max) * (height - 24));
171
+ const seriesIdx = d.series !== undefined ? seriesList.indexOf(d.series) : -1;
172
+ return createElement('rect', {
173
+ key: i, x: i * step + 2, y: height - 18 - h, width: Math.max(step - 4, 2), height: h,
174
+ rx: 2,
175
+ style: { fill: seriesIdx > 0 ? seriesColor(seriesIdx) : 'var(--xyd-accent)', opacity: seriesIdx > 0 ? 0.55 : 0.85 },
176
+ },
177
+ // 每根柱子带 <title>:悬停可见数值,读屏可逐条播报
178
+ createElement('title', null, `${d.label}:${d.value}`));
179
+ });
180
+ // 标签用原始下标定位:indexOf 会把重复标签(如分组系列的「周一」×2)全部钉到首个位置
181
+ const labelStep = data.length <= 10 ? 1 : Math.ceil(data.length / 8);
182
+ const labels = data
183
+ .map((d, i) => ({ d, i }))
184
+ .filter(({ i }) => i % labelStep === 0)
185
+ .map(({ d, i }) => createElement('text', {
186
+ key: `l${i}`, x: i * step + step / 2, y: height - 4,
187
+ fontSize: 10, textAnchor: 'middle', style: { fill: 'var(--dsw-alias-label-secondary)' },
188
+ }, d.label));
189
+ const legend = seriesList.length > 1
190
+ ? createElement('div', { className: 'xy-chart-legend' }, ...seriesList.map((name, idx) => createElement('span', { key: name }, createElement('i', { className: 'xy-dot', style: { background: seriesColor(idx), opacity: idx > 0 ? 0.55 : 1 } }), name)))
191
+ : null;
192
+ return createElement('div', null, legend,
193
+ // 图表整体作为一张图暴露给读屏;柱级数值经 <title> 补充
194
+ createElement('svg', {
195
+ viewBox: `0 0 ${width} ${height}`, className: 'xy-svg',
196
+ role: 'img', 'aria-label': event.title,
197
+ }, createElement('line', { x1: 0, y1: height - 18, x2: width, y2: height - 18, style: { stroke: 'var(--dsw-alias-border-l2)' } }), ...bars, ...labels));
198
+ }
199
+ export const CARD_VIEWS = {
200
+ WishView,
201
+ TaskView,
202
+ CheckinView,
203
+ ChartView,
204
+ MicroView,
205
+ };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 页面共享 Hooks:取数三态(usePageData)、动作防重入(useActionGuard)、
3
+ * 滚动条槽位稳定(useStableScrollbar)、本地日期口径(localYmd)、近 N 天窗口。
4
+ * 六页取数样板统一收口——新页面取数 ≤3 行起步(T2-1)。
5
+ */
6
+ import { useCallback, useEffect, useRef, useState } from 'react';
7
+ import { getJson, describeError } from './api.js';
8
+ import { toastError } from './ui.js';
9
+ /** 本地时区 yyyy-MM-dd(与机会日计算器的本地「今天」同口径;禁用 toISOString 的 UTC 偏移)。 */
10
+ export function localYmd(date) {
11
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
12
+ }
13
+ /** 区间查询用的本地日期窗口(近 n 天,含今天)。 */
14
+ export function recentRangeDays(days) {
15
+ const now = new Date();
16
+ return {
17
+ start: localYmd(new Date(now.getFullYear(), now.getMonth(), now.getDate() - (days - 1))),
18
+ end: localYmd(now),
19
+ };
20
+ }
21
+ /**
22
+ * 取数三态统一:挂载(或 deps 变化)即拉取,error 存本地化文案,
23
+ * reload 供错误态按钮与动作后刷新复用。path 支持函数形态(如日历按月取参);
24
+ * 内置序号守卫:快速翻页/连点时慢的旧响应不得覆盖新状态。
25
+ */
26
+ export function usePageData(path, deps = []) {
27
+ const [data, setData] = useState(undefined);
28
+ const [error, setError] = useState(undefined);
29
+ const seqRef = useRef(0);
30
+ const pathRef = useRef(path);
31
+ pathRef.current = path;
32
+ const load = useCallback(() => {
33
+ const seq = ++seqRef.current;
34
+ setError(undefined);
35
+ return getJson(typeof pathRef.current === 'function' ? pathRef.current() : pathRef.current)
36
+ .then((payload) => { if (seq === seqRef.current)
37
+ setData(payload); })
38
+ .catch((e) => { if (seq === seqRef.current)
39
+ setError(describeError(e)); });
40
+ }, deps);
41
+ useEffect(() => { void load(); }, [load]);
42
+ return { data, error, reload: load };
43
+ }
44
+ /**
45
+ * 写操作防重入:busy 期间 guard 直接吞掉重复调用(按钮同时置 disabled),
46
+ * 结束后自动复位;失败经 toast 非阻塞提示(describeError 本地化)。
47
+ */
48
+ export function useActionGuard() {
49
+ const [busy, setBusy] = useState(false);
50
+ const busyRef = useRef(false);
51
+ const guardRef = useRef((run) => {
52
+ if (busyRef.current)
53
+ return;
54
+ busyRef.current = true;
55
+ setBusy(true);
56
+ run().catch(toastError).finally(() => {
57
+ busyRef.current = false;
58
+ setBusy(false);
59
+ });
60
+ });
61
+ return { busy, guard: guardRef.current };
62
+ }
63
+ /**
64
+ * 把祖先滚动容器的滚动条槽位钉成常驻(scrollbar-gutter: stable)。
65
+ * 壳的输入框浮层模式会把真实滚动容器(*_scrollBody)的 gutter 放开为 auto,
66
+ * 经典滚动条按需出现/消失会使页面宽度抖动约 15px,等分布局随之整格跳变。
67
+ * 因此每次提交后沿祖先链把所有潜在滚动容器全部重钉,卸载时统一还原。
68
+ */
69
+ export function useStableScrollbar() {
70
+ const nodeRef = useRef(null);
71
+ const undoRef = useRef(new Map());
72
+ const pin = useCallback(() => {
73
+ const node = nodeRef.current;
74
+ if (node === null)
75
+ return;
76
+ const undo = undoRef.current;
77
+ let el = node.parentElement;
78
+ for (let depth = 0; el !== null && depth < 16; depth += 1, el = el.parentElement) {
79
+ const overflowY = getComputedStyle(el).overflowY;
80
+ if ((overflowY === 'auto' || overflowY === 'scroll') && !undo.has(el)) {
81
+ undo.set(el, el.style.scrollbarGutter);
82
+ el.style.scrollbarGutter = 'stable';
83
+ }
84
+ }
85
+ }, []);
86
+ // 无依赖数组:每次提交后执行,捕捉壳侧节点替换与结构变化(幂等,已钉的跳过)
87
+ useEffect(() => { pin(); });
88
+ // 仅最终卸载时还原,避免逐次提交的样式抖动
89
+ useEffect(() => () => {
90
+ for (const [el, prev] of undoRef.current)
91
+ el.style.scrollbarGutter = prev;
92
+ undoRef.current.clear();
93
+ nodeRef.current = null;
94
+ }, []);
95
+ return useCallback((node) => {
96
+ if (node === null)
97
+ return;
98
+ nodeRef.current = node;
99
+ pin();
100
+ }, [pin]);
101
+ }
102
+ /** 破坏性轻操作的防误触确认(撤销/预勾未来日);正向操作不拦。 */
103
+ export function softConfirm(message) {
104
+ return window.confirm(message);
105
+ }