sage-clockwork 0.1.0
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/README.md +76 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +767 -0
- package/lib/index.js +23 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# sage-clockwork(座钟)
|
|
2
|
+
|
|
3
|
+
DSH 会话头部的一个时间模块:**现在时间 / 距你上次发消息 / 闹钟 / 倒计时**。
|
|
4
|
+
|
|
5
|
+
收起时只占一小条,点开向下拉出面板;闹钟到点时模块边框会呼吸发亮。
|
|
6
|
+
|
|
7
|
+
纯前端插件(`dsh.client.platform = web`):
|
|
8
|
+
|
|
9
|
+
- 时间是浏览器本地时间
|
|
10
|
+
- 闹钟与倒计时存在浏览器 `localStorage`
|
|
11
|
+
- 到点提示 = **模块边框呼吸灯 + 界面浮条**
|
|
12
|
+
- **host 半不参与任何事** —— 不发系统通知、不读文件、不建网络连接
|
|
13
|
+
|
|
14
|
+
## 界面
|
|
15
|
+
|
|
16
|
+
收起态:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
┌──────────────────────────────┐
|
|
20
|
+
│ 2026年9月18日 ┌─────┐│
|
|
21
|
+
│ 周五 21:47 │01:27││ ← 右侧是最近一个到期的计时
|
|
22
|
+
└───────────────────────└─────┘┘
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
展开态分三段:现在时间(含秒)、闹钟、倒计时。
|
|
26
|
+
|
|
27
|
+
- 「还剩多久」**只在 10 分钟内**转成金色,其余时候是灰的
|
|
28
|
+
- 条目的操作按钮 hover 才浮出来
|
|
29
|
+
- 模块右边的小胶囊 = 最近一个要响的东西,不用展开就能看见
|
|
30
|
+
|
|
31
|
+
## 用法
|
|
32
|
+
|
|
33
|
+
- **点模块**展开面板,`Esc` 或再点一次收起
|
|
34
|
+
- **闹钟** → `+ 新建`:名称、时间(时/分**滚轮**,不接受文字输入)、重复规则
|
|
35
|
+
- `只一次` / `每天` / `每周`
|
|
36
|
+
- 选「每周」时,七个星期格**可以任意多选**(例如每周二、四、五)
|
|
37
|
+
- 已有闹钟点 `⋯` 改、`⏸` 临时关掉;编辑页左下角是 `删除`
|
|
38
|
+
- **倒计时** → `+ 新建`:名称、时长(分/秒**滚轮**)
|
|
39
|
+
- 列表里可 `⏸` 暂停/继续、`×` 删除,带进度条
|
|
40
|
+
|
|
41
|
+
## 安装
|
|
42
|
+
|
|
43
|
+
```jsonc
|
|
44
|
+
// profile 的 package.json
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"sage-clockwork": "link:<你的路径>/sage-clockwork" // 本地目录用 link:,发布版写版本号
|
|
47
|
+
},
|
|
48
|
+
"dsh": { "profile": { "bundles": [ ..., "sage-clockwork" ] } }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
然后在该 profile 目录跑 `pnpm install`,重启(或刷新页面)。
|
|
52
|
+
|
|
53
|
+
## 设计上值得一提的两处
|
|
54
|
+
|
|
55
|
+
**一、面板挂在模块自己的容器上。** 不是开一个全屏 overlay,而是
|
|
56
|
+
「模块 + 下拉面板」包在同一个 `position: relative` 的容器里,面板绝对定位到它 ——
|
|
57
|
+
于是与模块左边缘天然对齐、跟着模块走,不需要算坐标。
|
|
58
|
+
(面板比模块宽,是刻意的:模块收窄到和旁边的 chip 一个量级,面板要放得下列表。)
|
|
59
|
+
|
|
60
|
+
**二、会话事件窗口要「订阅」而不是「读一次」。**
|
|
61
|
+
「距你上次发消息」取自 `sessions.binding(sessionId).eventSource`。
|
|
62
|
+
实测它第一次 `getSnapshot()` 拿到的 `entries` 是**空数组**,要 `subscribe()` 等事件填进来。
|
|
63
|
+
用户消息的事件名是 `user/message`。
|
|
64
|
+
拿不到就整行隐藏 —— 不编假数据。
|
|
65
|
+
|
|
66
|
+
## 已知限制
|
|
67
|
+
|
|
68
|
+
- 闹钟数据存在**浏览器本地**:换浏览器看不到,清浏览器缓存会丢。
|
|
69
|
+
想更稳就得加一个 host 半存文件,目前没做(这条是当面确认过的取舍)。
|
|
70
|
+
- 「距你上次发消息」依赖 DSH 客户端内部接口(`sessions` 服务 + 会话事件流)。
|
|
71
|
+
接口若变,这一行会安静地消失,不影响其余功能。
|
|
72
|
+
- 不含系统通知。到点提醒只有界面内的呼吸灯与浮条。
|
|
73
|
+
|
|
74
|
+
## 许可
|
|
75
|
+
|
|
76
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-clockwork(座钟)— 浏览器半。
|
|
3
|
+
*
|
|
4
|
+
* 手写 ModuleLoader bundle(与 dsh-timeclock / sage-livingroom 同格式):
|
|
5
|
+
* window.__ModuleLoader__.load({ id, factory })
|
|
6
|
+
* factory(require) 返回 { apply, inject }
|
|
7
|
+
*
|
|
8
|
+
* 形制要点:
|
|
9
|
+
* - 纯 client:时间是浏览器本地时间,闹钟/倒计时存 localStorage,
|
|
10
|
+
* 到点提示 = 本模块边框呼吸灯 + 界面浮条。host 半没有任何参与。
|
|
11
|
+
* - 入口挂在 conversation.session.header.actions(order 30,坐在「客厅」31 左边)。
|
|
12
|
+
* 槽位 standardProps 直接给 sessionId,用它去问 sessions 服务要事件流。
|
|
13
|
+
* - 面板不另开 overlay:整个「模块 + 下拉」包在同一个 .ck-wrap 里,
|
|
14
|
+
* 面板绝对定位到它 → 与模块左对齐,且天然跟着模块走。
|
|
15
|
+
*
|
|
16
|
+
* 「距你上次发消息」是尽力而为:DSH 的会话事件类型没有随包发出来,
|
|
17
|
+
* 所以这里不硬编码事件名,而是运行时宽松探测(见 extractLastUserMs)。
|
|
18
|
+
* 探不到就隐藏那一行,不留假数据。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
window.__ModuleLoader__.load({
|
|
22
|
+
id: "sage-clockwork",
|
|
23
|
+
factory: (require) => {
|
|
24
|
+
var module = { exports: {} };
|
|
25
|
+
var exports = module.exports;
|
|
26
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
27
|
+
let react = require("react");
|
|
28
|
+
const h = react.createElement;
|
|
29
|
+
|
|
30
|
+
/* ══════════════════════════════════════════════════════════
|
|
31
|
+
0 · 开关与常量
|
|
32
|
+
══════════════════════════════════════════════════════════ */
|
|
33
|
+
const STORE_KEY = "sage-clockwork/v1";
|
|
34
|
+
const WEEK_LABEL = ["日", "一", "二", "三", "四", "五", "六"]; // 下标 = Date.getDay()
|
|
35
|
+
const WEEK_FULL = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
|
36
|
+
const GOLD = "#e8b84b";
|
|
37
|
+
const WARN_MS = 10 * 60 * 1000; // 10 分钟内转金色
|
|
38
|
+
|
|
39
|
+
function pad2(n) { return (n < 10 ? "0" : "") + n; }
|
|
40
|
+
|
|
41
|
+
function uid() {
|
|
42
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* ══════════════════════════════════════════════════════════
|
|
46
|
+
1 · 存储(浏览器本地;清缓存会丢 —— 这是当面确认过的取舍)
|
|
47
|
+
══════════════════════════════════════════════════════════ */
|
|
48
|
+
function readStore() {
|
|
49
|
+
try {
|
|
50
|
+
const raw = window.localStorage.getItem(STORE_KEY);
|
|
51
|
+
if (!raw) return { alarms: [], timers: [], lastFired: null };
|
|
52
|
+
const d = JSON.parse(raw);
|
|
53
|
+
return {
|
|
54
|
+
alarms: Array.isArray(d && d.alarms) ? d.alarms : [],
|
|
55
|
+
timers: Array.isArray(d && d.timers) ? d.timers : [],
|
|
56
|
+
lastFired: (d && d.lastFired) || null,
|
|
57
|
+
};
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return { alarms: [], timers: [], lastFired: null };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeStore(s) {
|
|
64
|
+
try {
|
|
65
|
+
window.localStorage.setItem(STORE_KEY, JSON.stringify({
|
|
66
|
+
alarms: s.alarms || [],
|
|
67
|
+
timers: s.timers || [],
|
|
68
|
+
lastFired: s.lastFired || null,
|
|
69
|
+
}));
|
|
70
|
+
} catch (e) {
|
|
71
|
+
/* 隐私模式 / 配额满:静默降级为内存态,不炸界面 */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ══════════════════════════════════════════════════════════
|
|
76
|
+
2 · 闹钟引擎
|
|
77
|
+
alarm = { id, name, hh, mm, repeat:'once'|'daily'|'weekly',
|
|
78
|
+
days:[0..6], date:'YYYY-MM-DD', enabled:bool }
|
|
79
|
+
══════════════════════════════════════════════════════════ */
|
|
80
|
+
|
|
81
|
+
/** 下一次响铃的绝对时间(ms);不会再响返回 null。 */
|
|
82
|
+
function nextFire(alarm, from) {
|
|
83
|
+
if (!alarm || !alarm.enabled) return null;
|
|
84
|
+
const base = new Date(from);
|
|
85
|
+
const at = (y, m, d) => new Date(y, m, d, alarm.hh, alarm.mm, 0, 0).getTime();
|
|
86
|
+
|
|
87
|
+
if (alarm.repeat === "once") {
|
|
88
|
+
if (!alarm.date) return null;
|
|
89
|
+
const p = String(alarm.date).split("-").map(Number);
|
|
90
|
+
if (p.length !== 3 || p.some(isNaN)) return null;
|
|
91
|
+
const t = at(p[0], p[1] - 1, p[2]);
|
|
92
|
+
return t > from ? t : null;
|
|
93
|
+
}
|
|
94
|
+
if (alarm.repeat === "weekly" && Array.isArray(alarm.days) && alarm.days.length) {
|
|
95
|
+
for (let i = 0; i < 8; i++) {
|
|
96
|
+
const cand = new Date(base.getFullYear(), base.getMonth(), base.getDate() + i);
|
|
97
|
+
if (alarm.days.indexOf(cand.getDay()) < 0) continue;
|
|
98
|
+
const t = at(cand.getFullYear(), cand.getMonth(), cand.getDate());
|
|
99
|
+
if (t > from) return t;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
// daily(默认)
|
|
104
|
+
let t = at(base.getFullYear(), base.getMonth(), base.getDate());
|
|
105
|
+
if (t <= from) {
|
|
106
|
+
const tm = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1);
|
|
107
|
+
t = at(tm.getFullYear(), tm.getMonth(), tm.getDate());
|
|
108
|
+
}
|
|
109
|
+
return t;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 重复规则的中文摘要,如「每天 21:40」「每周 二/四/五 21:40」。 */
|
|
113
|
+
function repeatText(alarm) {
|
|
114
|
+
const hm = pad2(alarm.hh) + ":" + pad2(alarm.mm);
|
|
115
|
+
if (alarm.repeat === "once") return (alarm.date || "某天") + " " + hm;
|
|
116
|
+
if (alarm.repeat === "weekly" && Array.isArray(alarm.days) && alarm.days.length) {
|
|
117
|
+
const ds = alarm.days.slice().sort((a, b) => a - b)
|
|
118
|
+
.map((d) => WEEK_LABEL[d]).join(" / ");
|
|
119
|
+
return "每周 " + ds + " " + hm;
|
|
120
|
+
}
|
|
121
|
+
return "每天 " + hm;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 「还有 N 分钟」这类剩余描述。 */
|
|
125
|
+
function fmtRemain(ms) {
|
|
126
|
+
if (ms <= 0) return "到点了";
|
|
127
|
+
const s = Math.floor(ms / 1000);
|
|
128
|
+
const d = Math.floor(s / 86400);
|
|
129
|
+
const hh = Math.floor((s % 86400) / 3600);
|
|
130
|
+
const mm = Math.floor((s % 3600) / 60);
|
|
131
|
+
if (d > 0) return "还有 " + d + " 天 " + hh + " 小时";
|
|
132
|
+
if (hh > 0) return "还有 " + hh + " 小时 " + mm + " 分钟";
|
|
133
|
+
if (mm > 0) return "还有 " + mm + " 分钟";
|
|
134
|
+
return "还有 " + (s % 60) + " 秒";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 倒计时读秒:mm:ss(超 1 小时给 h:mm:ss)。 */
|
|
138
|
+
function fmtClock(ms) {
|
|
139
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
140
|
+
const hh = Math.floor(s / 3600);
|
|
141
|
+
const mm = Math.floor((s % 3600) / 60);
|
|
142
|
+
const ss = s % 60;
|
|
143
|
+
return hh > 0 ? hh + ":" + pad2(mm) + ":" + pad2(ss) : pad2(mm) + ":" + pad2(ss);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ══════════════════════════════════════════════════════════
|
|
147
|
+
3 · 「距你上次发消息」——宽松探测
|
|
148
|
+
DSH 的会话事件类型没随包发布,事件名无从查证,所以不猜名字,
|
|
149
|
+
而是按形状找:type 里带 user/prompt/input 且不带 assistant/tool,
|
|
150
|
+
并从常见字段名里取时间戳。找不到就返回 null(UI 隐藏该行)。
|
|
151
|
+
══════════════════════════════════════════════════════════ */
|
|
152
|
+
const TIME_FIELDS = ["time", "timestamp", "ts", "at", "createdAt", "occurredAt"];
|
|
153
|
+
|
|
154
|
+
function pickTime(obj, depth) {
|
|
155
|
+
if (!obj || typeof obj !== "object" || depth > 3) return null;
|
|
156
|
+
for (let i = 0; i < TIME_FIELDS.length; i++) {
|
|
157
|
+
const v = obj[TIME_FIELDS[i]];
|
|
158
|
+
if (typeof v === "number" && v > 1000000000000) return v; // 毫秒级时间戳
|
|
159
|
+
}
|
|
160
|
+
// 往下找一层(事件多半把载荷放在 data / payload 里)
|
|
161
|
+
const subs = [obj.data, obj.payload, obj.body];
|
|
162
|
+
for (let i = 0; i < subs.length; i++) {
|
|
163
|
+
const t = pickTime(subs[i], depth + 1);
|
|
164
|
+
if (t) return t;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function looksLikeUserMessage(type) {
|
|
170
|
+
const t = String(type || "").toLowerCase();
|
|
171
|
+
if (!t) return false;
|
|
172
|
+
if (/assistant|tool|system|compaction|checkpoint/.test(t)) return false;
|
|
173
|
+
return /user|prompt|human|input/.test(t);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 从事件窗口里倒着找最近一条「用户消息」的时间戳。 */
|
|
177
|
+
function extractLastUserMs(entries) {
|
|
178
|
+
if (!Array.isArray(entries)) return null;
|
|
179
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
180
|
+
const wrap = entries[i];
|
|
181
|
+
const ev = wrap && wrap.event ? wrap.event : wrap;
|
|
182
|
+
if (!ev) continue;
|
|
183
|
+
if (!looksLikeUserMessage(ev.type)) continue;
|
|
184
|
+
const t = pickTime(ev, 0);
|
|
185
|
+
if (t) return t;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ══════════════════════════════════════════════════════════
|
|
191
|
+
4 · 样式(data-plugin-css 幂等注入;类名统一 ck- 前缀)
|
|
192
|
+
══════════════════════════════════════════════════════════ */
|
|
193
|
+
const CSS = [
|
|
194
|
+
/* — 模块(收起态)— */
|
|
195
|
+
".ck-wrap{position:relative;display:inline-flex}",
|
|
196
|
+
".ck-mod{display:inline-flex;align-items:center;gap:7px;height:34px;padding:0 9px 0 10px;",
|
|
197
|
+
" border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));border-radius:9px;",
|
|
198
|
+
" background:var(--dsw-alias-bg-layer-1,transparent);color:inherit;cursor:pointer;",
|
|
199
|
+
" font-family:inherit;white-space:nowrap;transition:border-color .15s,background .15s,box-shadow .3s}",
|
|
200
|
+
".ck-mod:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.16));background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.04))}",
|
|
201
|
+
".ck-mod.open{border-color:rgba(232,184,75,.5)}",
|
|
202
|
+
/* 到点提醒:边框呼吸灯(替代系统通知) */
|
|
203
|
+
".ck-mod.alarm{border-color:rgba(232,184,75,.75);animation:ck-breath 1.7s ease-in-out infinite}",
|
|
204
|
+
"@keyframes ck-breath{",
|
|
205
|
+
" 0%,100%{box-shadow:0 0 0 1px rgba(232,184,75,.22),0 0 8px rgba(232,184,75,.16)}",
|
|
206
|
+
" 50%{box-shadow:0 0 0 1px rgba(232,184,75,.6),0 0 20px rgba(232,184,75,.45)}}",
|
|
207
|
+
".ck-mod.alarm .ck-date,.ck-mod.alarm .ck-time{color:#f0d08a}",
|
|
208
|
+
".ck-lines{flex:1;min-width:0;display:flex;flex-direction:column;justify-content:center;line-height:1.25}",
|
|
209
|
+
".ck-date{font-size:9.5px;letter-spacing:.02em;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45));font-variant-numeric:tabular-nums}",
|
|
210
|
+
".ck-time{font-size:11.5px;font-weight:500;letter-spacing:.01em;color:var(--dsw-alias-label-primary,inherit);font-variant-numeric:tabular-nums}",
|
|
211
|
+
".ck-badge{display:inline-flex;align-items:center;height:18px;padding:0 6px;border-radius:999px;",
|
|
212
|
+
" background:rgba(232,184,75,.12);border:1px solid rgba(232,184,75,.32);color:#e8b84b;",
|
|
213
|
+
" font-size:10.5px;font-variant-numeric:tabular-nums}",
|
|
214
|
+
".ck-caret{color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45));font-size:8px;transition:transform .18s}",
|
|
215
|
+
".ck-mod.open .ck-caret{transform:rotate(180deg)}",
|
|
216
|
+
|
|
217
|
+
/* — 下拉面板 — */
|
|
218
|
+
".ck-panel{position:absolute;top:calc(100% + 6px);left:0;width:288px;z-index:60;",
|
|
219
|
+
" background:var(--dsw-alias-bg-layer-2,#222);border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.12));",
|
|
220
|
+
" border-radius:12px;box-shadow:0 18px 48px rgba(0,0,0,.55);overflow:hidden;",
|
|
221
|
+
" opacity:0;transform:translateY(-6px);pointer-events:none;",
|
|
222
|
+
" transition:opacity .16s ease-out,transform .18s cubic-bezier(.2,.9,.3,1.1)}",
|
|
223
|
+
".ck-panel.open{opacity:1;transform:none;pointer-events:auto}",
|
|
224
|
+
".ck-sec{padding:11px 12px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08))}",
|
|
225
|
+
".ck-sec:last-child{border-bottom:none}",
|
|
226
|
+
".ck-sechead{display:flex;align-items:center;gap:6px;margin-bottom:8px}",
|
|
227
|
+
".ck-secname{font-size:11px;font-weight:600;letter-spacing:.08em;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45))}",
|
|
228
|
+
".ck-seccount{font-size:10px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45));opacity:.6}",
|
|
229
|
+
".ck-add{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));",
|
|
230
|
+
" border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));border-radius:6px;padding:1px 7px;cursor:pointer;background:none;font-family:inherit}",
|
|
231
|
+
".ck-add:hover{color:var(--dsw-alias-label-primary,inherit)}",
|
|
232
|
+
|
|
233
|
+
".ck-now{font-size:19px;font-weight:500;letter-spacing:.02em;font-variant-numeric:tabular-nums;line-height:1.2}",
|
|
234
|
+
".ck-nowsub{font-size:11.5px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));margin-top:3px}",
|
|
235
|
+
".ck-since{margin-top:9px;display:flex;align-items:baseline;gap:6px;font-size:11.5px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45))}",
|
|
236
|
+
".ck-since b{font-weight:500;color:var(--dsw-alias-label-primary,inherit);font-variant-numeric:tabular-nums}",
|
|
237
|
+
|
|
238
|
+
".ck-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:8px}",
|
|
239
|
+
".ck-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.05))}",
|
|
240
|
+
".ck-dot{width:6px;height:6px;border-radius:50%;flex:none;background:var(--dsw-alias-label-secondary,rgba(255,255,255,.3))}",
|
|
241
|
+
".ck-item.on .ck-dot{background:#e8b84b;box-shadow:0 0 0 3px rgba(232,184,75,.16)}",
|
|
242
|
+
".ck-item.off{opacity:.45}",
|
|
243
|
+
".ck-main{min-width:0;flex:1}",
|
|
244
|
+
".ck-name{font-size:12.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
245
|
+
".ck-meta{font-size:10.5px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45));font-variant-numeric:tabular-nums;display:block;margin-top:1px;opacity:.72}",
|
|
246
|
+
".ck-left{font-size:12.5px;font-variant-numeric:tabular-nums;white-space:nowrap}",
|
|
247
|
+
".ck-left.warn{color:#e8b84b}",
|
|
248
|
+
".ck-acts{display:flex;gap:2px;opacity:0;transition:opacity .12s}",
|
|
249
|
+
".ck-item:hover .ck-acts{opacity:1}",
|
|
250
|
+
".ck-mini{width:20px;height:20px;border-radius:5px;border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));",
|
|
251
|
+
" color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));font-size:11px;display:inline-flex;",
|
|
252
|
+
" align-items:center;justify-content:center;cursor:pointer;background:none;font-family:inherit}",
|
|
253
|
+
".ck-mini:hover{color:var(--dsw-alias-label-primary,inherit)}",
|
|
254
|
+
".ck-empty{font-size:11.5px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45));padding:4px 8px 2px}",
|
|
255
|
+
|
|
256
|
+
".ck-cd{padding:8px 8px 9px;display:block}",
|
|
257
|
+
".ck-cdtop{display:flex;align-items:baseline;gap:8px}",
|
|
258
|
+
".ck-cdname{font-size:12.5px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
259
|
+
".ck-cdleft{font-size:14px;font-variant-numeric:tabular-nums;color:#e8b84b;letter-spacing:.02em}",
|
|
260
|
+
".ck-bar{height:3px;border-radius:2px;background:var(--dsw-alias-bg-layer-3,rgba(255,255,255,.12));margin-top:7px;overflow:hidden}",
|
|
261
|
+
".ck-bar > i{display:block;height:100%;background:#e8b84b;border-radius:2px;transition:width .9s linear}",
|
|
262
|
+
|
|
263
|
+
/* — 编辑视图 — */
|
|
264
|
+
".ck-edit{display:none}",
|
|
265
|
+
".ck-panel.editing .ck-list{display:none}",
|
|
266
|
+
".ck-panel.editing .ck-edit{display:block}",
|
|
267
|
+
".ck-ehead{display:flex;align-items:center;gap:8px;padding:11px 12px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08))}",
|
|
268
|
+
".ck-back{width:22px;height:22px;border-radius:6px;border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));",
|
|
269
|
+
" color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));font-size:12px;display:inline-flex;",
|
|
270
|
+
" align-items:center;justify-content:center;cursor:pointer;background:none;font-family:inherit}",
|
|
271
|
+
".ck-etitle{font-size:12.5px;font-weight:600}",
|
|
272
|
+
".ck-field{padding:10px 12px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08))}",
|
|
273
|
+
".ck-flabel{display:block;font-size:10.5px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45));opacity:.75;margin-bottom:6px}",
|
|
274
|
+
".ck-input{width:100%;height:30px;padding:0 9px;border-radius:8px;",
|
|
275
|
+
" border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));background:var(--dsw-alias-bg-layer-1,rgba(0,0,0,.2));",
|
|
276
|
+
" color:var(--dsw-alias-label-primary,inherit);font-size:12.5px;font-family:inherit;box-sizing:border-box}",
|
|
277
|
+
".ck-input:focus{outline:none;border-color:rgba(232,184,75,.5)}",
|
|
278
|
+
".ck-seg{display:flex;gap:4px}",
|
|
279
|
+
".ck-seg button{flex:1;height:26px;border-radius:7px;border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));",
|
|
280
|
+
" color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));font-size:11.5px;cursor:pointer;background:none;font-family:inherit}",
|
|
281
|
+
".ck-seg button.on{background:rgba(232,184,75,.14);border-color:rgba(232,184,75,.45);color:#e8b84b}",
|
|
282
|
+
".ck-days{display:flex;gap:4px;margin-top:8px}",
|
|
283
|
+
".ck-days button{flex:1;height:26px;border-radius:7px;border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));",
|
|
284
|
+
" color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));font-size:11.5px;padding:0;cursor:pointer;background:none;font-family:inherit}",
|
|
285
|
+
".ck-days button.on{background:rgba(232,184,75,.14);border-color:rgba(232,184,75,.45);color:#e8b84b}",
|
|
286
|
+
".ck-hint{font-size:10.5px;color:#e8b84b;opacity:.85;margin:-1px 0 8px}",
|
|
287
|
+
".ck-hint b{font-weight:500}",
|
|
288
|
+
".ck-foot{display:flex;align-items:center;gap:6px;padding:10px 12px}",
|
|
289
|
+
".ck-spacer{flex:1}",
|
|
290
|
+
".ck-btn{height:28px;padding:0 11px;border-radius:8px;font-size:11.5px;cursor:pointer;font-family:inherit;",
|
|
291
|
+
" border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));background:none}",
|
|
292
|
+
".ck-btn.primary{background:#e8b84b;border-color:transparent;color:#1a1408;font-weight:600}",
|
|
293
|
+
".ck-btn.danger{color:#e57373;border-color:rgba(229,115,115,.35)}",
|
|
294
|
+
" color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));font-size:11.5px;cursor:pointer;background:none;font-family:inherit}",
|
|
295
|
+
|
|
296
|
+
/* — 时间滚轮 — */
|
|
297
|
+
".ck-picker{position:relative;display:flex;justify-content:center;gap:44px;height:152px;overflow:hidden;",
|
|
298
|
+
" -webkit-mask-image:linear-gradient(180deg,transparent 0,#000 26%,#000 74%,transparent 100%);",
|
|
299
|
+
" mask-image:linear-gradient(180deg,transparent 0,#000 26%,#000 74%,transparent 100%)}",
|
|
300
|
+
".ck-picker::before{content:'';position:absolute;left:4px;right:4px;top:50%;transform:translateY(-50%);height:40px;",
|
|
301
|
+
" border-radius:10px;background:var(--dsw-alias-bg-layer-1,rgba(0,0,0,.2));",
|
|
302
|
+
" border-top:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));border-bottom:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08))}",
|
|
303
|
+
".ck-pcol{position:relative;display:flex;flex-direction:column;align-items:center}",
|
|
304
|
+
".ck-pk{height:30px;line-height:30px;font-size:16px;font-variant-numeric:tabular-nums;cursor:pointer;",
|
|
305
|
+
" color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));opacity:.32;user-select:none}",
|
|
306
|
+
".ck-pk.near{opacity:.55}",
|
|
307
|
+
".ck-pk.on{font-size:21px;font-weight:600;color:#e8b84b;opacity:1}",
|
|
308
|
+
".ck-pcol .ck-pk:first-child{margin-top:11px}",
|
|
309
|
+
|
|
310
|
+
/* — 到点浮条(固定定位,贴头部下方居中)— */
|
|
311
|
+
".ck-toast{position:fixed;left:50%;transform:translateX(-50%);top:70px;z-index:900;",
|
|
312
|
+
" display:inline-flex;align-items:center;gap:10px;padding:9px 14px;border-radius:10px;",
|
|
313
|
+
" background:linear-gradient(180deg,rgba(232,184,75,.18),rgba(232,184,75,.07));",
|
|
314
|
+
" border:1px solid rgba(232,184,75,.42);color:#f0d08a;font-size:12.5px;",
|
|
315
|
+
" box-shadow:0 12px 32px rgba(0,0,0,.45);animation:ck-fadein .2s ease-out}",
|
|
316
|
+
"@keyframes ck-fadein{from{opacity:0;transform:translateX(-50%) translateY(-6px)}to{opacity:1;transform:translateX(-50%)}}",
|
|
317
|
+
".ck-toast button{font-size:11.5px;color:#e8b84b;text-decoration:underline;text-underline-offset:2px;",
|
|
318
|
+
" cursor:pointer;background:none;border:none;font-family:inherit}",
|
|
319
|
+
].join("\n");
|
|
320
|
+
|
|
321
|
+
function injectCss() {
|
|
322
|
+
const tagId = "sage-clockwork/panel.css";
|
|
323
|
+
if (typeof document === "undefined") return;
|
|
324
|
+
if (document.querySelector('style[data-plugin-css="' + tagId + '"]')) return;
|
|
325
|
+
const tag = document.createElement("style");
|
|
326
|
+
tag.dataset.plugin = "sage-clockwork";
|
|
327
|
+
tag.dataset.pluginCss = tagId;
|
|
328
|
+
tag.textContent = CSS;
|
|
329
|
+
document.head.appendChild(tag);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/* ══════════════════════════════════════════════════════════
|
|
333
|
+
5 · 小组件
|
|
334
|
+
══════════════════════════════════════════════════════════ */
|
|
335
|
+
|
|
336
|
+
/** 单列滚轮:中间是当前值,上下各两格;点上下格或滚轮都能转。 */
|
|
337
|
+
function Picker(props) {
|
|
338
|
+
const value = props.value;
|
|
339
|
+
const max = props.max;
|
|
340
|
+
const onChange = props.onChange;
|
|
341
|
+
const items = [];
|
|
342
|
+
for (let i = -2; i <= 2; i++) {
|
|
343
|
+
const v = ((value + i) % max + max) % max;
|
|
344
|
+
items.push({ off: i, v: v });
|
|
345
|
+
}
|
|
346
|
+
return h("div", {
|
|
347
|
+
className: "ck-pcol",
|
|
348
|
+
onWheel: (e) => {
|
|
349
|
+
e.preventDefault();
|
|
350
|
+
onChange(((value + (e.deltaY > 0 ? 1 : -1)) % max + max) % max);
|
|
351
|
+
},
|
|
352
|
+
}, items.map((it) => h("span", {
|
|
353
|
+
key: it.off,
|
|
354
|
+
className: "ck-pk" + (it.off === 0 ? " on" : (Math.abs(it.off) === 1 ? " near" : "")),
|
|
355
|
+
onClick: () => onChange(((value + it.off) % max + max) % max),
|
|
356
|
+
}, pad2(it.v))));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* ══════════════════════════════════════════════════════════
|
|
360
|
+
6 · 主组件
|
|
361
|
+
══════════════════════════════════════════════════════════ */
|
|
362
|
+
function ClockModule(props) {
|
|
363
|
+
const sessionId = props && props.sessionId;
|
|
364
|
+
|
|
365
|
+
const [now, setNow] = react.useState(() => Date.now());
|
|
366
|
+
const [open, setOpen] = react.useState(false);
|
|
367
|
+
const [store, setStore] = react.useState(readStore);
|
|
368
|
+
const [view, setView] = react.useState("list"); // list | alarm | timer
|
|
369
|
+
const [editing, setEditing] = react.useState(null); // 正在编辑的闹钟
|
|
370
|
+
const [lastUserMs, setLastUserMs] = react.useState(null);
|
|
371
|
+
|
|
372
|
+
/* — 每秒心跳 — */
|
|
373
|
+
react.useEffect(() => {
|
|
374
|
+
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
375
|
+
return () => clearInterval(t);
|
|
376
|
+
}, []);
|
|
377
|
+
|
|
378
|
+
/* — 读「距上次发消息」(尽力而为) —
|
|
379
|
+
实测:binding().eventSource.getSnapshot() 通道是通的,但**首次快照 entries 为空**
|
|
380
|
+
(事件窗口按需填充)。所以这里订阅它,窗口填进来之后再取。 */
|
|
381
|
+
react.useEffect(() => {
|
|
382
|
+
if (!sessionId) return undefined;
|
|
383
|
+
let es = null;
|
|
384
|
+
try {
|
|
385
|
+
const sessions = appCtx && appCtx.get ? appCtx.get("sessions") : null;
|
|
386
|
+
const binding = sessions && typeof sessions.binding === "function"
|
|
387
|
+
? sessions.binding(sessionId) : null;
|
|
388
|
+
es = binding && binding.eventSource;
|
|
389
|
+
} catch (e) { es = null; }
|
|
390
|
+
if (!es || typeof es.getSnapshot !== "function") return undefined;
|
|
391
|
+
const read = () => {
|
|
392
|
+
let snap = null;
|
|
393
|
+
try { snap = es.getSnapshot(); } catch (e) { snap = null; }
|
|
394
|
+
const t = extractLastUserMs(snap && snap.entries);
|
|
395
|
+
if (t) setLastUserMs(t);
|
|
396
|
+
};
|
|
397
|
+
read();
|
|
398
|
+
let un = null;
|
|
399
|
+
try { un = typeof es.subscribe === "function" ? es.subscribe(read) : null; } catch (e) { un = null; }
|
|
400
|
+
return () => { if (typeof un === "function") un(); };
|
|
401
|
+
}, [sessionId]);
|
|
402
|
+
|
|
403
|
+
/* — 持久化 — */
|
|
404
|
+
react.useEffect(() => { writeStore(store); }, [store]);
|
|
405
|
+
|
|
406
|
+
/* — 到点检测(闹钟 / 倒计时)——
|
|
407
|
+
触发记录写进 store.lastFired,**不用组件 state**:实测组件会被重新挂载,
|
|
408
|
+
用 useState 记「正在响」会丢(timer 已被消费、提示却不见了,2026-09-18 实测)。 */
|
|
409
|
+
react.useEffect(() => {
|
|
410
|
+
const quiet = store.lastFired && (now - store.lastFired.at) < 60000;
|
|
411
|
+
if (quiet) return;
|
|
412
|
+
for (let i = 0; i < store.alarms.length; i++) {
|
|
413
|
+
const a = store.alarms[i];
|
|
414
|
+
if (!a.enabled) continue;
|
|
415
|
+
const fire = nextFire(a, now - 1000);
|
|
416
|
+
if (fire && fire <= now) {
|
|
417
|
+
const nm = a.name || "闹钟";
|
|
418
|
+
setStore((s) => ({
|
|
419
|
+
...s,
|
|
420
|
+
alarms: a.repeat === "once"
|
|
421
|
+
? s.alarms.map((x) => x.id === a.id ? { ...x, enabled: false } : x)
|
|
422
|
+
: s.alarms,
|
|
423
|
+
lastFired: { name: nm, at: Date.now() },
|
|
424
|
+
}));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
for (let i = 0; i < store.timers.length; i++) {
|
|
429
|
+
const t = store.timers[i];
|
|
430
|
+
if (t.paused) continue;
|
|
431
|
+
if (t.endsAt && t.endsAt <= now) {
|
|
432
|
+
const nm = t.name || "倒计时";
|
|
433
|
+
setStore((s) => ({
|
|
434
|
+
...s,
|
|
435
|
+
timers: s.timers.filter((x) => x.id !== t.id),
|
|
436
|
+
lastFired: { name: nm, at: Date.now() },
|
|
437
|
+
}));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}, [now, store]);
|
|
442
|
+
|
|
443
|
+
/* — 收起面板的副作用(Esc) — */
|
|
444
|
+
react.useEffect(() => {
|
|
445
|
+
if (!open) return undefined;
|
|
446
|
+
const onKey = (e) => { if (e.key === "Escape") { setOpen(false); setView("list"); } };
|
|
447
|
+
document.addEventListener("keydown", onKey);
|
|
448
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
449
|
+
}, [open]);
|
|
450
|
+
|
|
451
|
+
/* 「正在响」由 lastFired 的时间推导,不占组件 state —— 重挂载也不丢。
|
|
452
|
+
响后 60 秒内持续提示;静默期同时防止 daily/weekly 闹钟在同一分钟内反复触发。 */
|
|
453
|
+
const firing = (store.lastFired && (now - store.lastFired.at) < 60000) ? store.lastFired : null;
|
|
454
|
+
|
|
455
|
+
const d = new Date(now);
|
|
456
|
+
const dateStr = d.getFullYear() + "年" + (d.getMonth() + 1) + "月" + d.getDate() + "日";
|
|
457
|
+
const week = WEEK_FULL[d.getDay()];
|
|
458
|
+
const hm = pad2(d.getHours()) + ":" + pad2(d.getMinutes());
|
|
459
|
+
|
|
460
|
+
/* 收起态右侧徽标:最近一个要响的东西 */
|
|
461
|
+
let badge = null;
|
|
462
|
+
let badgeMs = null;
|
|
463
|
+
store.alarms.forEach((a) => {
|
|
464
|
+
if (!a.enabled) return;
|
|
465
|
+
const t = nextFire(a, now);
|
|
466
|
+
if (t && (badgeMs === null || t - now < badgeMs)) badgeMs = t - now;
|
|
467
|
+
});
|
|
468
|
+
store.timers.forEach((t) => {
|
|
469
|
+
const ms = t.paused ? (t.remainingMs || 0) : Math.max(0, (t.endsAt || 0) - now);
|
|
470
|
+
if (badgeMs === null || ms < badgeMs) badgeMs = ms;
|
|
471
|
+
});
|
|
472
|
+
if (badgeMs !== null) badge = fmtClock(badgeMs);
|
|
473
|
+
|
|
474
|
+
/* ── 数据操作 ── */
|
|
475
|
+
function saveAlarm(a) {
|
|
476
|
+
setStore((s) => {
|
|
477
|
+
const exists = s.alarms.some((x) => x.id === a.id);
|
|
478
|
+
return { ...s, alarms: exists ? s.alarms.map((x) => x.id === a.id ? a : x) : s.alarms.concat([a]) };
|
|
479
|
+
});
|
|
480
|
+
setView("list");
|
|
481
|
+
setEditing(null);
|
|
482
|
+
}
|
|
483
|
+
function deleteAlarm(id) {
|
|
484
|
+
setStore((s) => ({ ...s, alarms: s.alarms.filter((x) => x.id !== id) }));
|
|
485
|
+
setView("list");
|
|
486
|
+
setEditing(null);
|
|
487
|
+
}
|
|
488
|
+
function toggleAlarm(id) {
|
|
489
|
+
setStore((s) => ({ ...s, alarms: s.alarms.map((x) => x.id === id ? { ...x, enabled: !x.enabled } : x) }));
|
|
490
|
+
}
|
|
491
|
+
function addTimer(name, totalMs) {
|
|
492
|
+
const timer = { id: uid(), name: name, totalMs: totalMs, endsAt: Date.now() + totalMs, paused: false, remainingMs: 0 };
|
|
493
|
+
setStore((s) => ({ ...s, timers: s.timers.concat([timer]) }));
|
|
494
|
+
setView("list");
|
|
495
|
+
}
|
|
496
|
+
function pauseTimer(id) {
|
|
497
|
+
setStore((s) => ({
|
|
498
|
+
...s,
|
|
499
|
+
timers: s.timers.map((t) => {
|
|
500
|
+
if (t.id !== id) return t;
|
|
501
|
+
if (t.paused) return { ...t, paused: false, endsAt: Date.now() + (t.remainingMs || 0), remainingMs: 0 };
|
|
502
|
+
return { ...t, paused: true, remainingMs: Math.max(0, (t.endsAt || 0) - Date.now()) };
|
|
503
|
+
}),
|
|
504
|
+
}));
|
|
505
|
+
}
|
|
506
|
+
function removeTimer(id) {
|
|
507
|
+
setStore((s) => ({ ...s, timers: s.timers.filter((t) => t.id !== id) }));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/* ── 收起态模块 ── */
|
|
511
|
+
const modCls = "ck-mod" + (open ? " open" : "") + (firing ? " alarm" : "");
|
|
512
|
+
const moduleEl = h("button", {
|
|
513
|
+
type: "button",
|
|
514
|
+
className: modCls,
|
|
515
|
+
"aria-expanded": open ? "true" : "false",
|
|
516
|
+
title: "座钟 — 现在时间 / 闹钟 / 倒计时",
|
|
517
|
+
onClick: () => { setOpen(!open); if (open) setView("list"); },
|
|
518
|
+
},
|
|
519
|
+
h("span", { className: "ck-lines" },
|
|
520
|
+
h("span", { className: "ck-date" }, dateStr),
|
|
521
|
+
h("span", { className: "ck-time" }, week + " " + hm)),
|
|
522
|
+
badge ? h("span", { className: "ck-badge" }, badge) : null,
|
|
523
|
+
h("span", { className: "ck-caret" }, "▼"));
|
|
524
|
+
|
|
525
|
+
/* ── 面板:列表视图 ── */
|
|
526
|
+
const listKids = [];
|
|
527
|
+
listKids.push(h("div", { className: "ck-sec", key: "now" },
|
|
528
|
+
h("div", { className: "ck-now" }, hm + ":" + pad2(d.getSeconds())),
|
|
529
|
+
h("div", { className: "ck-nowsub" }, dateStr + " " + week),
|
|
530
|
+
lastUserMs !== null
|
|
531
|
+
? h("div", { className: "ck-since" }, "距你上次发消息 ",
|
|
532
|
+
h("b", null, fmtRemain(now - lastUserMs).replace("还有 ", "")))
|
|
533
|
+
: null));
|
|
534
|
+
|
|
535
|
+
/* 闹钟段 */
|
|
536
|
+
const alarmKids = [];
|
|
537
|
+
if (store.alarms.length === 0) {
|
|
538
|
+
alarmKids.push(h("div", { className: "ck-empty", key: "e" }, "还没有闹钟"));
|
|
539
|
+
}
|
|
540
|
+
store.alarms.slice().sort((a, b) => (nextFire(a, now) || Infinity) - (nextFire(b, now) || Infinity))
|
|
541
|
+
.forEach((a) => {
|
|
542
|
+
const t = nextFire(a, now);
|
|
543
|
+
const ms = t ? t - now : null;
|
|
544
|
+
const cls = "ck-item" + (a.enabled ? " on" : " off");
|
|
545
|
+
alarmKids.push(h("div", { className: cls, key: a.id },
|
|
546
|
+
h("span", { className: "ck-dot" }),
|
|
547
|
+
h("span", { className: "ck-main" },
|
|
548
|
+
h("span", { className: "ck-name" }, a.name || "(未命名)"),
|
|
549
|
+
h("span", { className: "ck-meta" }, repeatText(a))),
|
|
550
|
+
h("span", {
|
|
551
|
+
className: "ck-left" + (a.enabled && ms !== null && ms <= WARN_MS ? " warn" : ""),
|
|
552
|
+
}, a.enabled ? (t ? fmtRemain(ms) : "已过期") : "已关闭"),
|
|
553
|
+
h("span", { className: "ck-acts" },
|
|
554
|
+
h("button", { className: "ck-mini", title: a.enabled ? "关掉" : "启用", onClick: () => toggleAlarm(a.id) }, a.enabled ? "⏸" : "▶"),
|
|
555
|
+
h("button", { className: "ck-mini", title: "编辑", onClick: () => { setEditing(a); setView("alarm"); } }, "⋯"))));
|
|
556
|
+
});
|
|
557
|
+
listKids.push(h("div", { className: "ck-sec", key: "alarms" },
|
|
558
|
+
h("div", { className: "ck-sechead" },
|
|
559
|
+
h("span", { className: "ck-secname" }, "闹钟"),
|
|
560
|
+
h("span", { className: "ck-seccount" }, store.alarms.length + " 个"),
|
|
561
|
+
h("button", { className: "ck-add", onClick: () => { setEditing(null); setView("alarm"); } }, "+ 新建")),
|
|
562
|
+
alarmKids));
|
|
563
|
+
|
|
564
|
+
/* 倒计时段 */
|
|
565
|
+
const timerKids = [];
|
|
566
|
+
if (store.timers.length === 0) {
|
|
567
|
+
timerKids.push(h("div", { className: "ck-empty", key: "e" }, "还没有倒计时"));
|
|
568
|
+
}
|
|
569
|
+
store.timers.forEach((t) => {
|
|
570
|
+
const ms = t.paused ? (t.remainingMs || 0) : Math.max(0, (t.endsAt || 0) - now);
|
|
571
|
+
const pct = t.totalMs ? Math.max(0, Math.min(100, (1 - ms / t.totalMs) * 100)) : 0;
|
|
572
|
+
timerKids.push(h("div", { className: "ck-item ck-cd on", key: t.id },
|
|
573
|
+
h("div", { className: "ck-cdtop" },
|
|
574
|
+
h("span", { className: "ck-cdname" }, t.name || "倒计时"),
|
|
575
|
+
h("span", { className: "ck-cdleft" }, fmtClock(ms)),
|
|
576
|
+
h("span", { className: "ck-acts" },
|
|
577
|
+
h("button", { className: "ck-mini", title: t.paused ? "继续" : "暂停", onClick: () => pauseTimer(t.id) }, t.paused ? "▶" : "⏸"),
|
|
578
|
+
h("button", { className: "ck-mini", title: "删除", onClick: () => removeTimer(t.id) }, "×"))),
|
|
579
|
+
h("div", { className: "ck-bar" }, h("i", { style: { width: pct + "%" } }))));
|
|
580
|
+
});
|
|
581
|
+
listKids.push(h("div", { className: "ck-sec", key: "timers" },
|
|
582
|
+
h("div", { className: "ck-sechead" },
|
|
583
|
+
h("span", { className: "ck-secname" }, "倒计时"),
|
|
584
|
+
h("span", { className: "ck-seccount" }, store.timers.length + " 个"),
|
|
585
|
+
h("button", { className: "ck-add", onClick: () => setView("timer") }, "+ 新建")),
|
|
586
|
+
timerKids));
|
|
587
|
+
|
|
588
|
+
/* ── 面板:闹钟编辑 ── */
|
|
589
|
+
const editEl = h(AlarmEditor, {
|
|
590
|
+
key: "edit",
|
|
591
|
+
alarm: editing,
|
|
592
|
+
onSave: saveAlarm,
|
|
593
|
+
onDelete: deleteAlarm,
|
|
594
|
+
onCancel: () => { setView("list"); setEditing(null); },
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
/* ── 面板:倒计时新建 ── */
|
|
598
|
+
const timerEl = h(TimerEditor, {
|
|
599
|
+
key: "tedit",
|
|
600
|
+
onStart: addTimer,
|
|
601
|
+
onCancel: () => setView("list"),
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const panelCls = "ck-panel" + (open ? " open" : "") + (view !== "list" ? " editing" : "");
|
|
605
|
+
const panelEl = h("div", { className: panelCls },
|
|
606
|
+
h("div", { className: "ck-list" }, listKids),
|
|
607
|
+
view === "alarm" ? editEl : null,
|
|
608
|
+
view === "timer" ? timerEl : null);
|
|
609
|
+
|
|
610
|
+
/* ── 到点浮条 ── */
|
|
611
|
+
const toastEl = firing ? h("div", { className: "ck-toast" },
|
|
612
|
+
h("span", null, "⏰"),
|
|
613
|
+
h("span", null, h("b", null, firing.name), " 到点了"),
|
|
614
|
+
h("button", { onClick: () => setStore((s) => ({ ...s, lastFired: null })) }, "知道了")) : null;
|
|
615
|
+
|
|
616
|
+
return h("span", { className: "ck-wrap" }, moduleEl, panelEl, toastEl);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/* ══════════════════════════════════════════════════════════
|
|
620
|
+
7 · 闹钟编辑器(新建 / 修改 / 删除)
|
|
621
|
+
══════════════════════════════════════════════════════════ */
|
|
622
|
+
function AlarmEditor(props) {
|
|
623
|
+
const a = props.alarm;
|
|
624
|
+
const isNew = !a;
|
|
625
|
+
|
|
626
|
+
const [name, setName] = react.useState(a ? (a.name || "") : "");
|
|
627
|
+
const [hh, setHh] = react.useState(a ? a.hh : 21);
|
|
628
|
+
const [mm, setMm] = react.useState(a ? a.mm : 40);
|
|
629
|
+
const [repeat, setRepeat] = react.useState(a ? (a.repeat || "daily") : "daily");
|
|
630
|
+
const [days, setDays] = react.useState(a && Array.isArray(a.days) ? a.days : []);
|
|
631
|
+
|
|
632
|
+
function toggleDay(d) {
|
|
633
|
+
setDays((cur) => cur.indexOf(d) >= 0 ? cur.filter((x) => x !== d) : cur.concat([d]).sort());
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/* 预览:按当前表单算下一次响铃 */
|
|
637
|
+
const draft = {
|
|
638
|
+
hh: hh, mm: mm, enabled: true,
|
|
639
|
+
repeat: repeat,
|
|
640
|
+
days: repeat === "weekly" ? (days.length ? days : [new Date().getDay()]) : days,
|
|
641
|
+
date: repeat === "once" ? new Date().toISOString().slice(0, 10) : null,
|
|
642
|
+
};
|
|
643
|
+
const nextT = nextFire(draft, Date.now());
|
|
644
|
+
const hintText = nextT ? "距下次响铃 " + fmtRemain(nextT - Date.now()).replace("还有 ", "") : "这个时间已经过去了";
|
|
645
|
+
|
|
646
|
+
function submit() {
|
|
647
|
+
const next = {
|
|
648
|
+
id: a ? a.id : uid(),
|
|
649
|
+
name: name.trim() || "闹钟",
|
|
650
|
+
hh: hh, mm: mm,
|
|
651
|
+
repeat: repeat,
|
|
652
|
+
days: repeat === "weekly" ? (days.length ? days : [new Date().getDay()]) : [],
|
|
653
|
+
date: repeat === "once" ? new Date().toISOString().slice(0, 10) : null,
|
|
654
|
+
enabled: true,
|
|
655
|
+
};
|
|
656
|
+
props.onSave(next);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return h("div", { className: "ck-edit" },
|
|
660
|
+
h("div", { className: "ck-ehead" },
|
|
661
|
+
h("button", { className: "ck-back", onClick: props.onCancel }, "←"),
|
|
662
|
+
h("span", { className: "ck-etitle" }, isNew ? "新建闹钟" : "编辑闹钟")),
|
|
663
|
+
|
|
664
|
+
h("div", { className: "ck-field" },
|
|
665
|
+
h("span", { className: "ck-flabel" }, "名称"),
|
|
666
|
+
h("input", {
|
|
667
|
+
className: "ck-input",
|
|
668
|
+
value: name,
|
|
669
|
+
placeholder: "给这个闹钟起个名字",
|
|
670
|
+
onChange: (e) => setName(e.target.value),
|
|
671
|
+
})),
|
|
672
|
+
|
|
673
|
+
h("div", { className: "ck-field" },
|
|
674
|
+
h("span", { className: "ck-flabel" }, "时间"),
|
|
675
|
+
h("div", { className: "ck-hint" }, hintText),
|
|
676
|
+
h("div", { className: "ck-picker" },
|
|
677
|
+
h(Picker, { value: hh, max: 24, onChange: setHh }),
|
|
678
|
+
h(Picker, { value: mm, max: 60, onChange: setMm }))),
|
|
679
|
+
|
|
680
|
+
h("div", { className: "ck-field" },
|
|
681
|
+
h("span", { className: "ck-flabel" }, "重复"),
|
|
682
|
+
h("div", { className: "ck-seg" },
|
|
683
|
+
h("button", { className: repeat === "once" ? "on" : "", onClick: () => setRepeat("once") }, "只一次"),
|
|
684
|
+
h("button", { className: repeat === "daily" ? "on" : "", onClick: () => setRepeat("daily") }, "每天"),
|
|
685
|
+
h("button", { className: repeat === "weekly" ? "on" : "", onClick: () => setRepeat("weekly") }, "每周")),
|
|
686
|
+
repeat === "weekly" ? h("div", { className: "ck-days" },
|
|
687
|
+
[1, 2, 3, 4, 5, 6, 0].map((d) => h("button", {
|
|
688
|
+
key: d,
|
|
689
|
+
className: days.indexOf(d) >= 0 ? "on" : "",
|
|
690
|
+
onClick: () => toggleDay(d),
|
|
691
|
+
}, WEEK_LABEL[d]))) : null),
|
|
692
|
+
|
|
693
|
+
h("div", { className: "ck-foot" },
|
|
694
|
+
isNew ? null : h("button", { className: "ck-btn danger", onClick: () => props.onDelete(a.id) }, "删除"),
|
|
695
|
+
h("span", { className: "ck-spacer" }),
|
|
696
|
+
h("button", { className: "ck-btn", onClick: props.onCancel }, "取消"),
|
|
697
|
+
h("button", { className: "ck-btn primary", onClick: submit }, "保存")));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/* ══════════════════════════════════════════════════════════
|
|
701
|
+
8 · 倒计时新建(常用时长快捷键,够用且快)
|
|
702
|
+
══════════════════════════════════════════════════════════ */
|
|
703
|
+
/* 倒计时新建:时长用与闹钟同一套滚轮(分 / 秒)。
|
|
704
|
+
不用文字输入 —— 避免有人敲进非时间字符;也不再放一排快捷按钮,
|
|
705
|
+
保持两处「选时间」的交互一致。 */
|
|
706
|
+
function TimerEditor(props) {
|
|
707
|
+
const [name, setName] = react.useState("");
|
|
708
|
+
const [mm, setMm] = react.useState(10);
|
|
709
|
+
const [ss, setSs] = react.useState(0);
|
|
710
|
+
const totalMs = (mm * 60 + ss) * 1000;
|
|
711
|
+
return h("div", { className: "ck-edit" },
|
|
712
|
+
h("div", { className: "ck-ehead" },
|
|
713
|
+
h("button", { className: "ck-back", onClick: props.onCancel }, "←"),
|
|
714
|
+
h("span", { className: "ck-etitle" }, "新建倒计时")),
|
|
715
|
+
h("div", { className: "ck-field" },
|
|
716
|
+
h("span", { className: "ck-flabel" }, "名称"),
|
|
717
|
+
h("input", {
|
|
718
|
+
className: "ck-input", value: name,
|
|
719
|
+
placeholder: "例如:泡面、午休、专注",
|
|
720
|
+
onChange: (e) => setName(e.target.value),
|
|
721
|
+
})),
|
|
722
|
+
h("div", { className: "ck-field" },
|
|
723
|
+
h("span", { className: "ck-flabel" }, "时长"),
|
|
724
|
+
h("div", { className: "ck-hint" }, totalMs > 0 ? "计时 " + fmtClock(totalMs) : "先选一个时长"),
|
|
725
|
+
h("div", { className: "ck-picker" },
|
|
726
|
+
h(Picker, { value: mm, max: 100, onChange: setMm }),
|
|
727
|
+
h(Picker, { value: ss, max: 60, onChange: setSs }))),
|
|
728
|
+
h("div", { className: "ck-foot" },
|
|
729
|
+
h("span", { className: "ck-spacer" }),
|
|
730
|
+
h("button", { className: "ck-btn", onClick: props.onCancel }, "取消"),
|
|
731
|
+
h("button", {
|
|
732
|
+
className: "ck-btn primary",
|
|
733
|
+
onClick: () => {
|
|
734
|
+
if (totalMs <= 0) return;
|
|
735
|
+
props.onStart(name.trim() || ("倒计时 " + fmtClock(totalMs)), totalMs);
|
|
736
|
+
},
|
|
737
|
+
}, "开始")));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/* ══════════════════════════════════════════════════════════
|
|
741
|
+
9 · apply
|
|
742
|
+
══════════════════════════════════════════════════════════ */
|
|
743
|
+
let appCtx = null;
|
|
744
|
+
const inject = ["slots"];
|
|
745
|
+
|
|
746
|
+
async function apply(ctx) {
|
|
747
|
+
appCtx = ctx;
|
|
748
|
+
injectCss();
|
|
749
|
+
ctx.slots.inject("conversation.session.header.actions", () => {
|
|
750
|
+
ctx.slots.register(
|
|
751
|
+
{
|
|
752
|
+
name: "conversation.session.header.actions",
|
|
753
|
+
id: "sage-clockwork",
|
|
754
|
+
order: 30,
|
|
755
|
+
label: "座钟",
|
|
756
|
+
},
|
|
757
|
+
// 把槽位标准 props(含 sessionId)透传给组件
|
|
758
|
+
(props) => h(ClockModule, props || {}),
|
|
759
|
+
);
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
exports.apply = apply;
|
|
764
|
+
exports.inject = inject;
|
|
765
|
+
return module.exports;
|
|
766
|
+
},
|
|
767
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-clockwork(座钟)— host 半
|
|
3
|
+
*
|
|
4
|
+
* 这一半**故意是空的**:座钟显示的是浏览器本地时间,闹钟与倒计时数据存在
|
|
5
|
+
* 浏览器 localStorage,到点提示靠界面自身(模块边框呼吸灯 + 浮条)。
|
|
6
|
+
* 没有任何需要 host 侧参与的东西 —— 不读文件、不起进程、不建数据通道。
|
|
7
|
+
*
|
|
8
|
+
* 对比:sage-livingroom 要 SSH 拉灰云文件,所以它有 TypertRemoteService;
|
|
9
|
+
* 座钟没有这类需求,保持最小形状即可(与 dsh-timeclock 同款)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const name = 'sage-clockwork'
|
|
13
|
+
|
|
14
|
+
/** 不依赖任何 host 服务。 */
|
|
15
|
+
export const inject = []
|
|
16
|
+
|
|
17
|
+
export function apply() {
|
|
18
|
+
// 全部 UI 与逻辑都在浏览器半:lib/client.js
|
|
19
|
+
//
|
|
20
|
+
// 三级验证第③级的标记 —— 启动日志里出现这行,才证明 DSH 真加载了这份代码。
|
|
21
|
+
// 必须用 console.log:ctx.logger 的输出不进 stdout,会白等一场。
|
|
22
|
+
console.log('[sage-clockwork] host apply() called')
|
|
23
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sage-clockwork",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "座钟 — DSH 会话头部的时间模块:现在时间 / 距上次发言 / 闹钟 / 倒计时(纯 client 半,闹钟数据存浏览器本地)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib/index.js",
|
|
14
|
+
"lib/client.js",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"dsh": {
|
|
20
|
+
"bundle": {
|
|
21
|
+
"patch": "./cordis.patch.yml"
|
|
22
|
+
},
|
|
23
|
+
"client": {
|
|
24
|
+
"platform": "web"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@deepseek-ai/cordis": "*",
|
|
29
|
+
"react": "^18.0.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"react": "^18.0.0"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/gezi-wen/sage-clockwork.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/gezi-wen/sage-clockwork#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/gezi-wen/sage-clockwork/issues"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"dsh",
|
|
44
|
+
"deepseek-harness",
|
|
45
|
+
"plugin",
|
|
46
|
+
"clock",
|
|
47
|
+
"alarm",
|
|
48
|
+
"timer"
|
|
49
|
+
]
|
|
50
|
+
}
|