@starwish-ai/xingyuan-dsh 0.5.1 → 0.5.3
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.
- package/CHANGELOG.md +121 -0
- package/README.en-US.md +2 -1
- package/README.md +3 -1
- package/lib/client/i18n.js +24 -0
- package/lib/client/index.js +12 -13
- package/lib/client/pages/settings.js +154 -22
- package/lib/client/pages/today.js +7 -2
- package/lib/client/tab-hint.js +38 -0
- package/lib/client/tab-visibility.js +58 -0
- package/lib/client.js +366 -63
- package/lib/domain.js +2 -1
- package/lib/index.js +9 -1
- package/lib/pref-policy.js +38 -0
- package/lib/pref-settings.js +27 -0
- package/lib/preset/side.js +12 -23
- package/lib/preset/tools.js +16 -5
- package/lib/tab-policy.js +28 -0
- package/lib/types/client/i18n.d.ts +12 -0
- package/lib/types/client/pages/settings.d.ts +30 -6
- package/lib/types/client/tab-hint.d.ts +19 -0
- package/lib/types/client/tab-visibility.d.ts +20 -0
- package/lib/types/domain.d.ts +4 -1
- package/lib/types/pref-policy.d.ts +34 -0
- package/lib/types/pref-settings.d.ts +34 -0
- package/lib/types/preset/side.d.ts +6 -7
- package/lib/types/tab-policy.d.ts +24 -0
- package/lib/types/ui-settings.d.ts +27 -0
- package/lib/ui-settings.js +22 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,127 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.3] - 2026-08-28
|
|
9
|
+
|
|
10
|
+
> **Note:** this is a patch release but it carries one breaking change — the `xingyuan`
|
|
11
|
+
> settings namespace is replaced by `xingyuan-pref` (see *Removed*). Consumers of the
|
|
12
|
+
> `./domain` subpath must also adapt to `makeXingyuanStore()` taking a second, required
|
|
13
|
+
> argument. Read *Upgrading from 0.5.2 or earlier* before updating.
|
|
14
|
+
|
|
15
|
+
**Chat preferences are now always editable.** Settings → XingYuan rendered the
|
|
16
|
+
write-confirmation toggle and the memory-injection limit as permanently disabled until a
|
|
17
|
+
XingYuan session had been opened once since the last dsh start; clicking them did nothing
|
|
18
|
+
and produced no error. Both preferences lived in the preset-layer namespace, which only
|
|
19
|
+
comes into existence when the preset is first mounted, so they now live in a
|
|
20
|
+
bundle-resident namespace registered whenever the bundle row is active.
|
|
21
|
+
|
|
22
|
+
### Upgrading from 0.5.2 or earlier
|
|
23
|
+
|
|
24
|
+
The two chat preferences moved from the `xingyuan` block of `$DSH_HOME/settings.yaml` to a
|
|
25
|
+
new `xingyuan-pref` block. **A value you had customised is not carried over** and falls back
|
|
26
|
+
to the default: `confirmWrites` returns to `true` (the safe direction — write actions ask for
|
|
27
|
+
confirmation again) and `memoryInjectLimit` to `40`. Re-apply your choices under
|
|
28
|
+
**Settings → XingYuan → Chat preferences**. The leftover `xingyuan:` block is inert and is
|
|
29
|
+
safe to delete.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
- **`xingyuan-pref` settings namespace** (`src/pref-settings.ts`), installed by the bundle
|
|
33
|
+
layer next to the existing `xingyuan-ui` namespace, carrying `confirmWrites` (default
|
|
34
|
+
`true`) and `memoryInjectLimit` (default `40`, integer `5`–`200`). The schema declares
|
|
35
|
+
`.step(1)`, so the Host also rejects fractional values that a hand-edited settings
|
|
36
|
+
document or a direct RPC write could otherwise smuggle in.
|
|
37
|
+
- **Shared preference policy module** (`src/pref-policy.ts`) holding the bounds, defaults
|
|
38
|
+
and input parser for both halves — the host module depends on
|
|
39
|
+
`@deepseek-ai/dsh-settings` and must never reach the browser bundle. This also removes
|
|
40
|
+
the `5`/`200`/`40` literals that were previously repeated in the settings page. Covered
|
|
41
|
+
by `test/pref-policy.test.ts`.
|
|
42
|
+
- **Write verification**: `scope.set()` resolves rather than rejects on failure (the client
|
|
43
|
+
controller catches, recovers and returns silently), so `toastError` never fired. The page
|
|
44
|
+
now compares the snapshot after each write and raises a "did not save" toast when the
|
|
45
|
+
value did not change. Both controls share one scope — and therefore one write queue — so
|
|
46
|
+
the comparison is gated on a per-component write sequence number to stay honest under
|
|
47
|
+
interleaved writes (see below).
|
|
48
|
+
- **Regression lock** (`test/pref-settings.test.ts`): five tests pinning that the bundle
|
|
49
|
+
layer registers `xingyuan-pref` as soon as the settings service is available, that the
|
|
50
|
+
read thunk resolves fresh on every call, that the headless path falls back to the schema
|
|
51
|
+
defaults, and — structurally — that **no namespace bound by the settings page is ever
|
|
52
|
+
registered from the preset layer**. That last one is the abstract shape of this bug and is
|
|
53
|
+
what stops it from coming back.
|
|
54
|
+
- **Four-way readiness notice** for the preferences card: loading, remote/temporary
|
|
55
|
+
connection (`mode === 'memory'`), namespace not registered, and read-only document. The
|
|
56
|
+
previous code only covered the first two, leaving the rest as silently disabled controls.
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
- The two preferences could not be changed before the first XingYuan session of the process
|
|
60
|
+
— the root cause described above.
|
|
61
|
+
- The write-confirmation toggle rendered as **on** whenever its value was unavailable
|
|
62
|
+
(`value?.confirmWrites !== false` reads "unknown" as "true"), which is the worst possible
|
|
63
|
+
default for a safety switch. It now falls back to the schema default explicitly.
|
|
64
|
+
- Namespace-unavailable and read-only states disabled the controls with no explanation.
|
|
65
|
+
- The "did not save" toast could fire on a write that **had** been saved. Both controls share
|
|
66
|
+
one settings scope, and the client controller fences writes with a generation counter: when
|
|
67
|
+
a write is superseded by a newer one it only records a pending revision and does **not**
|
|
68
|
+
fold the result into the snapshot, while the superseded write's `.then` still runs first and
|
|
69
|
+
sees the stale value. Verification is now skipped for superseded writes, and the number
|
|
70
|
+
input got the pending guard the toggle already had.
|
|
71
|
+
- Clearing the memory-injection-limit field and blurring reported "enter an integer between 5
|
|
72
|
+
and 200" while simultaneously refilling the field with the saved value — two contradictory
|
|
73
|
+
messages at once. An empty field is again treated as abandoning the edit.
|
|
74
|
+
- Tool descriptions for the creation / check-in / cancel-check-in tools asserted that a system
|
|
75
|
+
confirmation card would appear. Toggle **Confirm write actions** off and the model kept
|
|
76
|
+
telling users a card had been shown when none was. Tool descriptions are baked in at
|
|
77
|
+
registration and dsh-tools requires a plain string (no getter), so they are now worded to
|
|
78
|
+
hold in both states instead of asserting the card. Delete tools are unaffected — deletes
|
|
79
|
+
always confirm.
|
|
80
|
+
|
|
81
|
+
### Changed
|
|
82
|
+
- The preset layer no longer registers a settings namespace. It reads preferences through
|
|
83
|
+
`ctx.xingyuan.prefs()` — a thunk that resolves the current value on every call, so edits
|
|
84
|
+
still take effect immediately without re-registering tools or prompts. The remaining
|
|
85
|
+
`Config` fields (`batchWishLimit`, `chartTrendDays`, …) have no settings UI and stay as
|
|
86
|
+
composition-only entry config, which matches the upstream rule that a namespace should
|
|
87
|
+
carry only the user-editable subset.
|
|
88
|
+
- **Breaking for `./domain` consumers**: `makeXingyuanStore(domain)` now takes a second,
|
|
89
|
+
required argument — `makeXingyuanStore(domain, readPrefs)`, where `readPrefs` is a thunk
|
|
90
|
+
returning the resolved chat preferences. The required-ness is deliberate: every call site
|
|
91
|
+
(one in production, five in tests) is surfaced by the compiler rather than silently
|
|
92
|
+
missing the wiring. `XingyuanStore` gained a matching `prefs()` method.
|
|
93
|
+
|
|
94
|
+
### Removed
|
|
95
|
+
- The preset-layer `xingyuan` settings namespace and the `confirmWrites` /
|
|
96
|
+
`memoryInjectLimit` fields of the preset `Config`. **A value customised under the old
|
|
97
|
+
`xingyuan:` section of `$DSH_HOME/settings.yaml` is no longer read and falls back to the
|
|
98
|
+
default** — `confirmWrites` therefore returns to `true`, the conservative direction. The
|
|
99
|
+
stale section is left in the document and is harmless.
|
|
100
|
+
|
|
101
|
+
## [0.5.2] - 2026-08-27
|
|
102
|
+
|
|
103
|
+
### Added
|
|
104
|
+
- **Controllable conversation-view tabs**: the six tabs (Today / Wishes / Tasks /
|
|
105
|
+
Calendar / Growth / Memory) are no longer always present. Default mode **follows the
|
|
106
|
+
session preset** — tabs appear only in sessions composed from the `xingyuan` preset and
|
|
107
|
+
stay out of every other conversation. Settings → XingYuan gains a **Tab visibility**
|
|
108
|
+
card with three modes (`Follow session` / `Always show` / `Always hide`) plus
|
|
109
|
+
per-tab toggles (`hiddenTabs`, all shown by default). The preference namespace
|
|
110
|
+
(`xingyuan-ui`) is installed by the bundle layer, so it works before any XingYuan
|
|
111
|
+
session exists and stays reachable even when tabs are fully hidden (a preset-layer
|
|
112
|
+
namespace would be unavailable exactly when needed). Today's page shows a one-line
|
|
113
|
+
notice when "Always show" puts the tabs into a non-XingYuan session (pages stay fully
|
|
114
|
+
browsable and operable; only the chat-side capabilities are absent). Read-only URL
|
|
115
|
+
pages (`/xingyuan/*`) and chat cards are unaffected.
|
|
116
|
+
- New tab-visibility policy (`visibleTabIds`) with dirty-value tolerance (unknown/duplicate
|
|
117
|
+
hidden ids are ignored); covered by `test/tab-policy.test.ts`.
|
|
118
|
+
|
|
119
|
+
### Fixed
|
|
120
|
+
- Client boot crash: the tab-visibility controller subscribed to the injected `sessions`
|
|
121
|
+
service itself, which has no `subscribe` — the session-list snapshot lives on
|
|
122
|
+
`sessions.list`. The client half failed to load entirely ("Failed to load plugins"),
|
|
123
|
+
which also hid the Settings → XingYuan card. Subscriptions now target
|
|
124
|
+
`sessions.list` (`getSnapshot`/`subscribe`).
|
|
125
|
+
- Tab-visibility toggles had inverted semantics: the check chips rendered "hidden" as
|
|
126
|
+
"checked", so ticking a tab produced no change. `shown` now reads
|
|
127
|
+
`!hiddenTabs.includes(id)` (checked = shown).
|
|
128
|
+
|
|
8
129
|
## [0.5.1] - 2026-08-27
|
|
9
130
|
|
|
10
131
|
Patch release. Two hardening changes: sessions that contain `xingyuan/*` card events
|
package/README.en-US.md
CHANGED
|
@@ -14,7 +14,7 @@ XingYuan is a wish & habit-building companion, shipped as a DeepSeek Harness plu
|
|
|
14
14
|
- **Opportunity-day check-ins**: check-in, make-up, cancel, and future pre-checks; progress always recomputed against today
|
|
15
15
|
- **Micro-actions**: break an overwhelming goal into 3–7 tiny steps and walk through them guided
|
|
16
16
|
- **Growth system**: levels Lv.1–Lv.10, streak bonuses, stat cards, 30-day bar chart
|
|
17
|
-
- **Session view tabs**: Today / Wishes / Tasks / Calendar / Growth / Memory — buttons call action endpoints directly
|
|
17
|
+
- **Session view tabs**: Today / Wishes / Tasks / Calendar / Growth / Memory — buttons call action endpoints directly; shown only in XingYuan-preset sessions by default, switchable to always show/hide and per-tab in Settings
|
|
18
18
|
- **Memory**: important memories injected into context (limit configurable), full CRUD
|
|
19
19
|
- **Charts**: 15 chart types rendered as cards
|
|
20
20
|
- **Safe writes**: write operations confirmed by default (configurable); deletion always confirmed
|
|
@@ -42,6 +42,7 @@ Business data lives at `~/.dsh/xingyuan/xingyuan.sqlite` and survives uninstall
|
|
|
42
42
|
| User profile | Nickname, occupation, interests |
|
|
43
43
|
| Write confirmation | Whether create / check-in writes need confirmation (deletion always does) |
|
|
44
44
|
| Memory injection limit | Max memories injected per turn |
|
|
45
|
+
| Tab visibility | Three modes (follow session / always show / always hide) plus per-tab toggles; defaults to follow session |
|
|
45
46
|
|
|
46
47
|
## Development
|
|
47
48
|
|
package/README.md
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
- **机会日打卡**:周期任务打卡、补卡、取消、未来预勾,进度跨天恒为最新
|
|
15
15
|
- **微行动拆解**:把无从下手的目标拆成 3–7 个小步,引导式逐个完成或跳过
|
|
16
16
|
- **成长体系**:等级 Lv.1–Lv.10、连击加成、统计卡与近 30 天柱状图
|
|
17
|
-
- **会话视图页**:今日 / 愿望 / 任务 / 日历 / 成长 / 记忆
|
|
17
|
+
- **会话视图页**:今日 / 愿望 / 任务 / 日历 / 成长 / 记忆 六个标签页,页面按钮直连动作接口;
|
|
18
|
+
默认仅星愿预设的会话显示,可在设置里切换「始终显示 / 始终隐藏」并按标签勾选
|
|
18
19
|
- **记忆**:重要记忆自动注入上下文(上限可配),支持增删改查
|
|
19
20
|
- **图表**:15 种统计图表自动渲染为卡片
|
|
20
21
|
- **安全确认**:写操作默认二次确认(可关闭),删除始终确认
|
|
@@ -42,6 +43,7 @@ dsh plugin --profile web add @starwish-ai/xingyuan-dsh
|
|
|
42
43
|
| 用户画像 | 昵称、职业、兴趣 |
|
|
43
44
|
| 写操作二次确认 | 创建 / 打卡类写操作是否确认(删除始终确认) |
|
|
44
45
|
| 记忆注入上限 | 每次注入上下文的记忆条数上限 |
|
|
46
|
+
| 标签页显示 | 三态模式(跟随会话 / 始终显示 / 始终隐藏)+ 六个标签逐个勾选;默认跟随会话 |
|
|
45
47
|
|
|
46
48
|
## 开发
|
|
47
49
|
|
package/lib/client/i18n.js
CHANGED
|
@@ -230,12 +230,24 @@ const ZH_DICT = {
|
|
|
230
230
|
'settings.pref.title': '对话偏好',
|
|
231
231
|
'settings.pref.unavailable': '当前连接不支持持久化偏好(远程或临时模式),以下两项不可用。',
|
|
232
232
|
'settings.pref.loading': '偏好加载中…',
|
|
233
|
+
'settings.pref.notRegistered': '偏好暂不可用(组件未就绪),请稍后重试。',
|
|
234
|
+
'settings.pref.readOnly': '当前设置文档为只读,无法保存修改。',
|
|
233
235
|
'settings.pref.confirmWrites': '写操作二次确认',
|
|
234
236
|
'settings.pref.confirmWritesHint': '创建愿望/任务、打卡、取消打卡时先弹应用内确认卡(删除始终确认);关闭后对话中的这类操作将直接执行。',
|
|
235
237
|
'settings.pref.memoryLimit': '记忆注入上限',
|
|
236
238
|
'settings.pref.memoryLimitHint': '每次对话自动注入上下文的记忆条数上限(5-200,默认 40);失焦后保存。',
|
|
237
239
|
'settings.pref.limitInvalid': '请输入 5-200 的整数',
|
|
240
|
+
'settings.pref.writeFailed': '保存未生效,请重试。',
|
|
241
|
+
'settings.tabs.title': '标签页显示',
|
|
242
|
+
'settings.tabs.mode.follow': '跟随会话',
|
|
243
|
+
'settings.tabs.mode.show': '始终显示',
|
|
244
|
+
'settings.tabs.mode.hide': '始终隐藏',
|
|
245
|
+
'settings.tabs.chooseTab': '显示的标签页',
|
|
246
|
+
'settings.tabs.loading': '标签页选项加载中…',
|
|
247
|
+
'settings.tabs.unavailable': '当前连接不支持持久化设置,标签页显示选项不可用。',
|
|
248
|
+
'settings.tabs.hint': '跟随会话:仅星愿预设的会话显示标签页;其他会话自动隐藏。可单独勾选/取消各标签。',
|
|
238
249
|
'settings.dataHint': '业务数据存于本机 ~/.dsh/xingyuan/,备份即拷贝该目录。',
|
|
250
|
+
'today.noPresetHint': '当前会话未启用星愿预设:页面数据可浏览、操作可用,但对话中的星愿能力不可用。',
|
|
239
251
|
// --- 微行动卡 ---
|
|
240
252
|
'micro.cleared': '已清除「{task}」的拆解',
|
|
241
253
|
'micro.finished': '「{task}」微行动完成(可打卡了)',
|
|
@@ -527,12 +539,24 @@ const EN_DICT = {
|
|
|
527
539
|
'settings.pref.title': 'Chat preferences',
|
|
528
540
|
'settings.pref.unavailable': 'This connection cannot persist preferences (remote or temporary mode); the two options below are disabled.',
|
|
529
541
|
'settings.pref.loading': 'Loading preferences…',
|
|
542
|
+
'settings.pref.notRegistered': 'Preferences are unavailable right now (component not ready); please retry later.',
|
|
543
|
+
'settings.pref.readOnly': 'The settings document is read-only; changes cannot be saved.',
|
|
530
544
|
'settings.pref.confirmWrites': 'Confirm write actions',
|
|
531
545
|
'settings.pref.confirmWritesHint': 'Show an in-app confirmation before creating wishes/tasks, checking in or canceling (deletes always confirm). Turn off to execute directly.',
|
|
532
546
|
'settings.pref.memoryLimit': 'Memory injection limit',
|
|
533
547
|
'settings.pref.memoryLimitHint': 'Max memories auto-injected per chat (5-200, default 40); saved on blur.',
|
|
534
548
|
'settings.pref.limitInvalid': 'Enter an integer between 5 and 200',
|
|
549
|
+
'settings.pref.writeFailed': 'The change did not save; please try again.',
|
|
550
|
+
'settings.tabs.title': 'Tab visibility',
|
|
551
|
+
'settings.tabs.mode.follow': 'Follow session',
|
|
552
|
+
'settings.tabs.mode.show': 'Always show',
|
|
553
|
+
'settings.tabs.mode.hide': 'Always hide',
|
|
554
|
+
'settings.tabs.chooseTab': 'Visible tabs',
|
|
555
|
+
'settings.tabs.loading': 'Loading tab options…',
|
|
556
|
+
'settings.tabs.unavailable': 'This connection cannot persist settings; tab visibility options are unavailable.',
|
|
557
|
+
'settings.tabs.hint': 'Follow session: tabs appear only in sessions using the XingYuan preset; other sessions hide them automatically. Toggle individual tabs below.',
|
|
535
558
|
'settings.dataHint': 'Data lives locally at ~/.dsh/xingyuan/ — back it up by copying the folder.',
|
|
559
|
+
'today.noPresetHint': 'This session is not using the XingYuan preset: pages stay browsable and usable, but XingYuan chat capabilities are unavailable.',
|
|
536
560
|
'micro.cleared': 'Cleared the micro-step plan of “{task}”',
|
|
537
561
|
'micro.finished': 'Micro-steps of “{task}” finished (ready to check in)',
|
|
538
562
|
'micro.step': '“{task}” micro-steps · step {current}/{total}',
|
package/lib/client/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { t, XY_NS, setupLocale } from './i18n.js';
|
|
|
2
2
|
import { disposeConfirms, disposeToasts } from './ui.js';
|
|
3
3
|
import { STYLE_TEXT } from './styles.js';
|
|
4
4
|
import { CARD_VIEWS } from './cards.js';
|
|
5
|
-
import {
|
|
5
|
+
import { installTabVisibility } from './tab-visibility.js';
|
|
6
6
|
import { SettingsSection } from './pages/settings.js';
|
|
7
7
|
export const inject = ['slots', 'settingsScope', 'conversationEvents'];
|
|
8
8
|
/** 事件类型 → 渲染 kind。 */
|
|
@@ -73,19 +73,18 @@ export function apply(ctx) {
|
|
|
73
73
|
ctx.slots.register({ name: 'conversation.chat.node', key: 'xy-chart' }, CARD_VIEWS.ChartView),
|
|
74
74
|
ctx.slots.register({ name: 'conversation.chat.node', key: 'xy-micro' }, CARD_VIEWS.MicroView),
|
|
75
75
|
]);
|
|
76
|
-
// 会话视图环标签页:今日 / 愿望 / 任务 / 日历 / 成长 /
|
|
76
|
+
// 会话视图环标签页:今日 / 愿望 / 任务 / 日历 / 成长 / 记忆。
|
|
77
|
+
// 显隐由 tab-visibility 控制器按「界面偏好设置 × 会话预设」动态维护:
|
|
78
|
+
// 默认跟随会话(星愿预设的会话才显示),设置可切始终显示/始终隐藏并按标签勾选。
|
|
77
79
|
// label 用 thunk 跟随当前语言;locale 声明让壳在语言切换时刷新标签行。
|
|
78
|
-
ctx.slots.inject('conversation.view', () =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
ctx.slots.register({ name: 'conversation.view', id: 'xy-growth', order: 25, label: () => t('tab.growth'), locale: XY_NS }, GrowthPage),
|
|
84
|
-
ctx.slots.register({ name: 'conversation.view', id: 'xy-memory', order: 26, label: () => t('tab.memory'), locale: XY_NS }, MemoryPage),
|
|
85
|
-
]);
|
|
86
|
-
// 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限(设置命名空间)
|
|
80
|
+
ctx.slots.inject('conversation.view', () => installTabVisibility(ctx));
|
|
81
|
+
// 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限
|
|
82
|
+
// (bundle 常驻命名空间 xingyuan-pref)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui)。
|
|
83
|
+
// 两个偏好命名空间都挂在常驻层:整页由本文件无条件注册,命名空间若随 preset 懒加载
|
|
84
|
+
// 缺席,就会出现「整页可见但两项写不进去且静默失败」。
|
|
87
85
|
ctx.slots.inject('settings.section', () => {
|
|
88
|
-
const
|
|
89
|
-
|
|
86
|
+
const prefscope = ctx.settingsScope.bind({ namespace: 'xingyuan-pref' });
|
|
87
|
+
const uiscope = ctx.settingsScope.bind({ namespace: 'xingyuan-ui' });
|
|
88
|
+
return ctx.slots.register({ name: 'settings.section', id: 'xingyuan', order: 60, label: () => t('settings.tabLabel'), locale: XY_NS }, () => SettingsSection({ scope: prefscope, uiscope }));
|
|
90
89
|
});
|
|
91
90
|
}
|
|
@@ -1,17 +1,39 @@
|
|
|
1
|
-
/** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+
|
|
1
|
+
/** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限
|
|
2
|
+
* (bundle 常驻命名空间 xingyuan-pref)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,
|
|
3
|
+
* 未选星愿也可调)。 */
|
|
2
4
|
import { createElement, useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
3
5
|
import { getJson, postJson } from '../api.js';
|
|
4
6
|
import { toastError } from '../ui.js';
|
|
5
7
|
import { useXyT, activeLocale } from '../i18n.js';
|
|
8
|
+
import { TAB_IDS } from '../../tab-policy.js';
|
|
9
|
+
import { MEMORY_LIMIT_MAX, MEMORY_LIMIT_MIN, PREF_DEFAULTS, parseMemoryLimit, } from '../../pref-policy.js';
|
|
6
10
|
const COACH_IDS = ['gentle', 'strict', 'humorous'];
|
|
7
11
|
const COACH_KEYS = ['settings.coach.gentle', 'settings.coach.strict', 'settings.coach.humorous'];
|
|
12
|
+
/** 显隐三态(顺序即分段按钮顺序;与 tab-policy 字面量同源)。 */
|
|
13
|
+
const MODE_OPTIONS = [
|
|
14
|
+
{ mode: 'follow', key: 'settings.tabs.mode.follow' },
|
|
15
|
+
{ mode: 'show', key: 'settings.tabs.mode.show' },
|
|
16
|
+
{ mode: 'hide', key: 'settings.tabs.mode.hide' },
|
|
17
|
+
];
|
|
18
|
+
/** 六个标签的显示名键(与 tab-visibility 的 entry labelKey 同源)。 */
|
|
19
|
+
const TAB_LABEL_KEYS = {
|
|
20
|
+
today: 'tab.today',
|
|
21
|
+
wishes: 'tab.wishes',
|
|
22
|
+
tasks: 'tab.tasks',
|
|
23
|
+
calendar: 'tab.calendar',
|
|
24
|
+
growth: 'tab.growth',
|
|
25
|
+
memory: 'tab.memory',
|
|
26
|
+
};
|
|
8
27
|
export function SettingsSection(props) {
|
|
9
28
|
const t = useXyT();
|
|
10
29
|
const scope = props.scope;
|
|
11
30
|
// 正确订阅:useSyncExternalStore + getSnapshot(scope 契约见 dsh-client-runtime contract/settings-scope)
|
|
12
31
|
const snap = useSyncExternalStore((listener) => scope.subscribe(listener), () => scope.getSnapshot());
|
|
13
32
|
const writable = snap.status === 'ready' && snap.writable;
|
|
14
|
-
const limit = String(snap.value?.memoryInjectLimit ??
|
|
33
|
+
const limit = String(snap.value?.memoryInjectLimit ?? PREF_DEFAULTS.memoryInjectLimit);
|
|
34
|
+
// 界面偏好命名空间(标签页显隐)独立订阅:两个命名空间各自走自己的快照
|
|
35
|
+
const uisnap = useSyncExternalStore((listener) => props.uiscope.subscribe(listener), () => props.uiscope.getSnapshot());
|
|
36
|
+
const uiWritable = uisnap.status === 'ready' && uisnap.writable;
|
|
15
37
|
// 教练风格与画像存于星愿数据库 global 单例(与对话侧工具同一数据源),经 /xingyuan/api/profile 读写
|
|
16
38
|
const [profile, setProfile] = useState(undefined);
|
|
17
39
|
const [profileError, setProfileError] = useState(undefined);
|
|
@@ -21,15 +43,63 @@ export function SettingsSection(props) {
|
|
|
21
43
|
// 记忆注入上限:草稿态编辑(null=显示快照值),失焦才校验提交——避免逐键持久化与非法中间值死锁
|
|
22
44
|
const [limitDraft, setLimitDraft] = useState(null);
|
|
23
45
|
const [limitError, setLimitError] = useState(false);
|
|
46
|
+
// 记忆注入上限:写入在途的乐观值(与开关的 pendingToggle 同款,用于禁用控件并顶住回显,
|
|
47
|
+
// 否则提交后到快照回折前会闪回旧值)
|
|
48
|
+
const [pendingLimit, setPendingLimit] = useState(undefined);
|
|
24
49
|
const [saving, setSaving] = useState(false);
|
|
25
50
|
const [savedMsg, setSavedMsg] = useState('');
|
|
26
51
|
// 二次确认开关:本地乐观值(undefined=跟随远端快照)——写入在途时 UI 立即响应,失败回滚
|
|
27
52
|
const [pendingToggle, setPendingToggle] = useState(undefined);
|
|
53
|
+
// 标签页显隐:模式与勾选各自乐观(写入在途时禁用对应控件,失败回滚 + toast)
|
|
54
|
+
const [pendingMode, setPendingMode] = useState(undefined);
|
|
55
|
+
const [pendingHidden, setPendingHidden] = useState(undefined);
|
|
28
56
|
const savedTimer = useRef(undefined);
|
|
29
57
|
// 档案加载代数守卫:慢响应不得覆盖用户已开始的草稿
|
|
30
58
|
const profileSeqRef = useRef(0);
|
|
31
|
-
//
|
|
32
|
-
|
|
59
|
+
// 偏好写序号:两个控件共用同一个 scope(即同一条写队列)。控制器以 writeGeneration
|
|
60
|
+
// 做栅栏——一次写被更新的写取代时只记 pendingRevision、不回折快照,而其 .then 又
|
|
61
|
+
// 先于后继写执行,此时比对快照必然读到旧值。故只有最后结算的那次有权判定成败。
|
|
62
|
+
const writeSeqRef = useRef(0);
|
|
63
|
+
// 开关显示值:乐观本地值优先(写入在途),否则远端快照,值缺席回落 schema 默认。
|
|
64
|
+
// 不可写成 `!== false`——那样「值未知」会被渲染成「已开启」,安全策略类开关尤其不能撒谎。
|
|
65
|
+
const confirmWrites = pendingToggle ?? snap.value?.confirmWrites ?? PREF_DEFAULTS.confirmWrites;
|
|
66
|
+
// 偏好区提示:判定顺序不可换——memory 模式下 status 同样是 unavailable,
|
|
67
|
+
// 先判 mode 才不会把「远程/临时模式」误报成「命名空间未就绪」
|
|
68
|
+
const prefNoticeKey = snap.status === 'loading' ? 'settings.pref.loading'
|
|
69
|
+
: snap.mode === 'memory' ? 'settings.pref.unavailable'
|
|
70
|
+
: snap.status === 'unavailable' ? 'settings.pref.notRegistered'
|
|
71
|
+
: !snap.writable ? 'settings.pref.readOnly'
|
|
72
|
+
: undefined;
|
|
73
|
+
// 标签页显隐显示值:乐观优先,否则远端快照,再兜底 schema 默认
|
|
74
|
+
const tabMode = pendingMode ?? uisnap.value?.tabVisibilityMode ?? 'follow';
|
|
75
|
+
const hiddenTabs = pendingHidden ?? uisnap.value?.hiddenTabs ?? [];
|
|
76
|
+
/** 切换显隐模式:乐观写,失效即回滚(分段按钮视觉与持久化口径一致)。 */
|
|
77
|
+
const switchTabMode = (next) => {
|
|
78
|
+
if (!uiWritable || pendingMode !== undefined || next === tabMode)
|
|
79
|
+
return;
|
|
80
|
+
setPendingMode(next);
|
|
81
|
+
void props.uiscope.set('tabVisibilityMode', next)
|
|
82
|
+
.then(() => setPendingMode(undefined))
|
|
83
|
+
.catch((err) => {
|
|
84
|
+
setPendingMode(undefined);
|
|
85
|
+
toastError(err);
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
/** 勾选单个标签(勾选 = 显示):成员关系按 TAB_IDS 稳定序重算,写入整体数组。 */
|
|
89
|
+
const toggleTab = (id, willShow) => {
|
|
90
|
+
if (!uiWritable || pendingHidden !== undefined || tabMode === 'hide')
|
|
91
|
+
return;
|
|
92
|
+
const next = willShow
|
|
93
|
+
? TAB_IDS.filter((tid) => tid !== id && hiddenTabs.includes(tid))
|
|
94
|
+
: TAB_IDS.filter((tid) => tid === id || hiddenTabs.includes(tid));
|
|
95
|
+
setPendingHidden(next);
|
|
96
|
+
void props.uiscope.set('hiddenTabs', next)
|
|
97
|
+
.then(() => setPendingHidden(undefined))
|
|
98
|
+
.catch((err) => {
|
|
99
|
+
setPendingHidden(undefined);
|
|
100
|
+
toastError(err);
|
|
101
|
+
});
|
|
102
|
+
};
|
|
33
103
|
const loadProfile = () => {
|
|
34
104
|
const seq = ++profileSeqRef.current;
|
|
35
105
|
setProfileError(undefined);
|
|
@@ -63,28 +133,59 @@ export function SettingsSection(props) {
|
|
|
63
133
|
setSaving(true);
|
|
64
134
|
run().catch(toastError).finally(() => setSaving(false));
|
|
65
135
|
};
|
|
136
|
+
/**
|
|
137
|
+
* 校验偏好是否真的落盘:scope.set() 失败时是 resolve 而非 reject(内部 catch 后静默
|
|
138
|
+
* recover),故只能在写入结算后比对快照——值没变即视为未保存,须显式告知用户。
|
|
139
|
+
*
|
|
140
|
+
* seq 为发起写时取的号:只有仍是队列里最后一次写才校验,否则快照本就不会回折本次
|
|
141
|
+
* 的结果(被更新的写取代),比对必然误报。见 writeSeqRef。
|
|
142
|
+
*/
|
|
143
|
+
const verifyWritten = (seq, field, value) => {
|
|
144
|
+
if (seq !== writeSeqRef.current)
|
|
145
|
+
return;
|
|
146
|
+
if (scope.getSnapshot().value?.[field] !== value)
|
|
147
|
+
toastError(new Error(t('settings.pref.writeFailed')));
|
|
148
|
+
};
|
|
66
149
|
const commitLimit = () => {
|
|
67
150
|
const raw = limitDraft;
|
|
68
151
|
setLimitDraft(null);
|
|
69
152
|
setLimitError(false);
|
|
70
|
-
if (raw === null || !writable
|
|
153
|
+
if (raw === null || !writable)
|
|
71
154
|
return;
|
|
72
|
-
|
|
155
|
+
// 空串视为放弃编辑:静默回显保存值。若在此报错,会同时出现「已填回旧值」与
|
|
156
|
+
// 「请输入 5-200 的整数」两条互相矛盾的信息。
|
|
157
|
+
if (raw.trim() === '')
|
|
158
|
+
return;
|
|
159
|
+
const parsed = parseMemoryLimit(raw);
|
|
73
160
|
// 非法输入就地报错(不再静默回弹):用户明确知道哪里错、该怎么改
|
|
74
|
-
if (
|
|
161
|
+
if (parsed === undefined) {
|
|
75
162
|
setLimitError(true);
|
|
76
163
|
return;
|
|
77
164
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
165
|
+
if (parsed.clamped) {
|
|
166
|
+
// 越界但可夹取:按夹取值提交并回显,同时给出行内提示
|
|
167
|
+
const seq = ++writeSeqRef.current;
|
|
168
|
+
setPendingLimit(parsed.value);
|
|
169
|
+
void scope.set('memoryInjectLimit', parsed.value)
|
|
170
|
+
.then(() => {
|
|
171
|
+
setLimitDraft(String(parsed.value));
|
|
172
|
+
setPendingLimit(undefined);
|
|
173
|
+
verifyWritten(seq, 'memoryInjectLimit', parsed.value);
|
|
174
|
+
})
|
|
175
|
+
.catch((err) => { setPendingLimit(undefined); toastError(err); });
|
|
82
176
|
setLimitError(true);
|
|
83
177
|
return;
|
|
84
178
|
}
|
|
85
|
-
if (
|
|
179
|
+
if (parsed.value === snap.value?.memoryInjectLimit)
|
|
86
180
|
return;
|
|
87
|
-
|
|
181
|
+
const seq = ++writeSeqRef.current;
|
|
182
|
+
setPendingLimit(parsed.value);
|
|
183
|
+
void scope.set('memoryInjectLimit', parsed.value)
|
|
184
|
+
.then(() => {
|
|
185
|
+
setPendingLimit(undefined);
|
|
186
|
+
verifyWritten(seq, 'memoryInjectLimit', parsed.value);
|
|
187
|
+
})
|
|
188
|
+
.catch((err) => { setPendingLimit(undefined); toastError(err); });
|
|
88
189
|
};
|
|
89
190
|
const coachLabel = (id) => {
|
|
90
191
|
const index = COACH_IDS.indexOf(id);
|
|
@@ -145,11 +246,11 @@ export function SettingsSection(props) {
|
|
|
145
246
|
className: 'xy-btn xy-btn-primary', disabled: saving, onClick: saveProfile,
|
|
146
247
|
}, saving ? t('settings.profile.saving') : t('settings.profile.save')), savedMsg !== '' ? createElement('span', { className: 'xy-saved', role: 'status' },
|
|
147
248
|
// 勾形装饰对读屏隐藏(语义由文案承担),与卡片完成态同一语法
|
|
148
|
-
createElement('span', { 'aria-hidden': 'true' }, '✓ '), savedMsg) : null), createElement('p', { className: 'xy-hint' }, t('settings.profile.sharedHint'))), createElement('section', { className: 'xy-panel' }, createElement('h3', { className: 'xy-panel-head' }, t('settings.pref.title')),
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
249
|
+
createElement('span', { 'aria-hidden': 'true' }, '✓ '), savedMsg) : null), createElement('p', { className: 'xy-hint' }, t('settings.profile.sharedHint'))), createElement('section', { className: 'xy-panel' }, createElement('h3', { className: 'xy-panel-head' }, t('settings.pref.title')),
|
|
250
|
+
// 整页 section 没有官方卡片那套「按命名空间自动显隐」的保护,失败呈现归注册方
|
|
251
|
+
prefNoticeKey !== undefined
|
|
252
|
+
? createElement('p', { className: 'xy-hint' }, t(prefNoticeKey))
|
|
253
|
+
: null, createElement('label', { className: 'xy-field' }, createElement('span', { className: 'xy-field-head' }, createElement('input', {
|
|
153
254
|
type: 'checkbox',
|
|
154
255
|
className: 'xy-toggle',
|
|
155
256
|
name: 'confirmWrites',
|
|
@@ -161,19 +262,23 @@ export function SettingsSection(props) {
|
|
|
161
262
|
// 乐观写:先落 UI 再等持久化;失败回滚到快照口径并 toast
|
|
162
263
|
const next = e.target.checked;
|
|
163
264
|
setPendingToggle(next);
|
|
265
|
+
const seq = ++writeSeqRef.current;
|
|
164
266
|
void scope.set('confirmWrites', next)
|
|
165
|
-
.then(() => setPendingToggle(undefined))
|
|
267
|
+
.then(() => { setPendingToggle(undefined); verifyWritten(seq, 'confirmWrites', next); })
|
|
166
268
|
.catch((err) => {
|
|
167
269
|
setPendingToggle(undefined);
|
|
168
270
|
toastError(err);
|
|
169
271
|
});
|
|
170
272
|
},
|
|
171
273
|
}), t('settings.pref.confirmWrites')), createElement('p', { className: 'xy-hint' }, t('settings.pref.confirmWritesHint'))), createElement('label', { className: 'xy-field' }, createElement('span', { className: 'xy-field-head' }, t('settings.pref.memoryLimit')), createElement('input', {
|
|
172
|
-
type: 'number', min:
|
|
274
|
+
type: 'number', min: MEMORY_LIMIT_MIN, max: MEMORY_LIMIT_MAX,
|
|
275
|
+
className: 'xy-input xy-input-num', name: 'memoryInjectLimit', inputMode: 'numeric',
|
|
173
276
|
'aria-label': t('settings.pref.memoryLimit'),
|
|
174
277
|
'aria-invalid': limitError || undefined,
|
|
175
278
|
...(limitError ? { 'aria-describedby': 'xy-limit-error' } : {}),
|
|
176
|
-
|
|
279
|
+
// 写入在途时顶住乐观值并禁用(与开关同款),避免闪回旧值与并发写交错
|
|
280
|
+
value: limitDraft ?? (pendingLimit !== undefined ? String(pendingLimit) : limit),
|
|
281
|
+
disabled: !writable || pendingLimit !== undefined,
|
|
177
282
|
onChange: (e) => { setLimitDraft(e.target.value); setLimitError(false); },
|
|
178
283
|
onBlur: commitLimit,
|
|
179
284
|
onKeyDown: (e) => {
|
|
@@ -182,5 +287,32 @@ export function SettingsSection(props) {
|
|
|
182
287
|
},
|
|
183
288
|
}), createElement('span', { className: 'xy-hint' }, t('settings.pref.memoryLimitHint')), limitError
|
|
184
289
|
? createElement('span', { id: 'xy-limit-error', className: 'xy-field-err', role: 'alert' }, t('settings.pref.limitInvalid'))
|
|
185
|
-
: null)),
|
|
290
|
+
: null)),
|
|
291
|
+
// 标签页显示:模式三态(跟随会话/始终显示/始终隐藏)+ 六个标签勾选 chips。
|
|
292
|
+
// 与教练风格卡同一 xy-seg 视觉语法;「始终隐藏」时勾选区整组禁用置灰。
|
|
293
|
+
// 命名空间常驻于 bundle 层(未选星愿预设也可调),见 src/ui-settings.ts 头注。
|
|
294
|
+
createElement('section', { className: 'xy-panel' }, createElement('h3', { className: 'xy-panel-head' }, t('settings.tabs.title')), uisnap.status === 'unavailable'
|
|
295
|
+
? createElement('p', { className: 'xy-hint' }, t('settings.tabs.unavailable'))
|
|
296
|
+
: uisnap.status === 'loading'
|
|
297
|
+
? createElement('p', { className: 'xy-hint' }, t('settings.tabs.loading'))
|
|
298
|
+
: null, createElement('div', { className: 'xy-seg', role: 'group', 'aria-label': t('settings.tabs.title') }, ...MODE_OPTIONS.map((opt) => createElement('button', {
|
|
299
|
+
key: opt.mode,
|
|
300
|
+
className: `xy-seg-btn${tabMode === opt.mode ? ' xy-on' : ''}`,
|
|
301
|
+
'aria-pressed': tabMode === opt.mode,
|
|
302
|
+
disabled: !uiWritable || pendingMode !== undefined,
|
|
303
|
+
onClick: () => switchTabMode(opt.mode),
|
|
304
|
+
}, t(opt.key)))), createElement('div', {
|
|
305
|
+
className: 'xy-seg',
|
|
306
|
+
role: 'group',
|
|
307
|
+
'aria-label': t('settings.tabs.chooseTab'),
|
|
308
|
+
}, ...TAB_IDS.map((id) => {
|
|
309
|
+
const shown = !hiddenTabs.includes(id);
|
|
310
|
+
return createElement('button', {
|
|
311
|
+
key: id,
|
|
312
|
+
className: `xy-seg-btn${shown ? ' xy-on' : ''}`,
|
|
313
|
+
'aria-pressed': shown,
|
|
314
|
+
disabled: !uiWritable || pendingHidden !== undefined || tabMode === 'hide',
|
|
315
|
+
onClick: () => toggleTab(id, !shown),
|
|
316
|
+
}, t(TAB_LABEL_KEYS[id]));
|
|
317
|
+
})), createElement('p', { className: 'xy-hint' }, t('settings.tabs.hint'))), createElement('p', { className: 'xy-hint' }, t('settings.dataHint')));
|
|
186
318
|
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/** 今日页:今日打卡总览 + 一键打卡/领取 + 已完成撤销。 */
|
|
2
|
-
import { createElement } from 'react';
|
|
2
|
+
import { createElement, useSyncExternalStore } from 'react';
|
|
3
3
|
import { postAction } from '../api.js';
|
|
4
4
|
import { toast } from '../ui.js';
|
|
5
5
|
import { useXyT } from '../i18n.js';
|
|
6
6
|
import { softConfirm, useActionGuard, usePageData, useStableScrollbar, localYmd } from '../hooks.js';
|
|
7
7
|
import { PageEmpty, PageError, PageSkeleton } from '../ui.js';
|
|
8
8
|
import { cycleLabel, dateSuffix, formatFriendlyDate } from './format.js';
|
|
9
|
+
import { todayHintStore } from '../tab-hint.js';
|
|
9
10
|
export function TodayPage() {
|
|
10
11
|
const t = useXyT();
|
|
11
12
|
const stabilize = useStableScrollbar();
|
|
13
|
+
// 「始终显示 × 非星愿会话」轻提示:控制器写值,本页订阅渲染(默认 false 零开销)
|
|
14
|
+
const showNoPresetHint = useSyncExternalStore(todayHintStore.subscribe, todayHintStore.getSnapshot);
|
|
12
15
|
const overview = usePageData('/xingyuan/api/overview');
|
|
13
16
|
const day = usePageData(() => `/xingyuan/api/day?date=${localYmd(new Date())}`);
|
|
14
17
|
const { busy, guard } = useActionGuard();
|
|
@@ -73,7 +76,9 @@ export function TodayPage() {
|
|
|
73
76
|
'aria-valuenow': ratio,
|
|
74
77
|
'aria-label': heroTitle,
|
|
75
78
|
}, createElement('div', { className: 'xy-bar-fill', style: { transform: `scaleX(${ratio / 100})` } }))
|
|
76
|
-
: null, allDone ? createElement('div', { className: 'xy-banner-ok' }, t('today.allDone')) : null),
|
|
79
|
+
: null, allDone ? createElement('div', { className: 'xy-banner-ok' }, t('today.allDone')) : null),
|
|
80
|
+
// 始终显示模式下非星愿会话:概览卡下一行轻提示(页面可浏览/操作,对话能力受限)
|
|
81
|
+
showNoPresetHint ? createElement('p', { className: 'xy-hint' }, t('today.noPresetHint')) : null, rows.length > 0 ? createElement('section', { className: 'xy-group' }, createElement('h3', { className: 'xy-group-head' }, createElement('span', { className: 'xy-group-dot xy-group-dot-warn', 'aria-hidden': 'true' }), createElement('span', null, t('today.sectionOpen')), createElement('span', { className: 'xy-group-count' }, String(rows.length))), createElement('ul', { className: 'xy-grouplist' }, rows)) : null, doneRows.length > 0 ? createElement('section', { className: 'xy-group' }, createElement('h3', { className: 'xy-group-head' }, createElement('span', { className: 'xy-group-dot xy-group-dot-ok', 'aria-hidden': 'true' }), createElement('span', null, t('today.sectionDone')), createElement('span', { className: 'xy-group-count' }, String(doneRows.length))), createElement('ul', { className: 'xy-grouplist' }, doneRows)) : null, data.total === 0
|
|
77
82
|
? createElement(PageEmpty, { title: t('today.empty.title'), hint: t('today.empty.hint') })
|
|
78
83
|
: null);
|
|
79
84
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「今日」页非星愿会话提示行的快照 store。
|
|
3
|
+
*
|
|
4
|
+
* 语义:仅「始终显示」模式 × 当前会话非星愿预设时为 true(该模式下标签页
|
|
5
|
+
* 出现在每个会话里,页面数据与操作可用,但对话中的星愿能力不可用——一行
|
|
6
|
+
* 轻提示点明落差)。跟随会话模式下非星愿会话标签页整体隐藏,无提示可言。
|
|
7
|
+
*
|
|
8
|
+
* 独立成模块:tab-visibility 控制器写入、今日页订阅,避免控制器 ↔ 页面
|
|
9
|
+
* 双向依赖环;写侧只在值变化时通知,订阅侧 useSyncExternalStore 直接消费。
|
|
10
|
+
*/
|
|
11
|
+
let hintVisible = false;
|
|
12
|
+
const listeners = new Set();
|
|
13
|
+
function publish(value) {
|
|
14
|
+
if (value === hintVisible)
|
|
15
|
+
return;
|
|
16
|
+
hintVisible = value;
|
|
17
|
+
for (const fn of [...listeners])
|
|
18
|
+
fn();
|
|
19
|
+
}
|
|
20
|
+
/** 今日页只读入口(getSnapshot/subscribe 满足 useSyncExternalStore 契约)。 */
|
|
21
|
+
export const todayHintStore = {
|
|
22
|
+
getSnapshot: () => hintVisible,
|
|
23
|
+
subscribe(fn) {
|
|
24
|
+
listeners.add(fn);
|
|
25
|
+
return () => {
|
|
26
|
+
listeners.delete(fn);
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
/** 控制器在每次显隐重算后写入;值未变化时零通知。 */
|
|
31
|
+
export function setTodayHint(value) {
|
|
32
|
+
publish(value);
|
|
33
|
+
}
|
|
34
|
+
/** 控制器卸载时清场(值复位 + 断订;今日页随模块重载自动取新模块实例)。 */
|
|
35
|
+
export function clearTodayHint() {
|
|
36
|
+
listeners.clear();
|
|
37
|
+
hintVisible = false;
|
|
38
|
+
}
|