dsh-dock 0.9.8 → 0.10.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 +56 -31
- package/client.js +1839 -1474
- package/features/animation/host.js +16 -557
- package/features/animation/view.jsx +57 -642
- package/features/modelconfig/host.js +4 -1
- package/features/notify/host.js +374 -0
- package/features/notify/view.jsx +751 -0
- package/features/runstate/host.js +64 -0
- package/features/runstate/view.jsx +269 -0
- package/index.js +25 -5
- package/package.json +7 -2
- package/src/client.jsx +17 -12
- package/src/host-core.js +74 -3
- package/src/shared.js +36 -11
- package/src/task-track.js +359 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
// dsh-dock · 功能模块【任务通知】· 客户端视图
|
|
2
|
+
//
|
|
3
|
+
// 三部分:
|
|
4
|
+
// 1. View —— 功能坞面板页:通知事件 / 呈现方式 / 钉钉推送 / 飞书推送 四组设置;
|
|
5
|
+
// 2. HomeStat —— 首页总揽概要(当前通知事件与推送通道);
|
|
6
|
+
// 3. Overlay —— 全局浮层(shell.overlay 常驻,功能启用即挂载):轮询 Host 状态,
|
|
7
|
+
// 任务结束弹通知卡片(可选提示音与浏览器系统通知),工具等待确认时弹常驻提醒卡。
|
|
8
|
+
//
|
|
9
|
+
// 与【任务动画】【运行状态】完全独立:动效在 features/animation/、任务状态一览在
|
|
10
|
+
// features/runstate/,本模块只管通知(页内卡片 / 提示音 / 系统通知 / 钉钉飞书群机器人推送)。
|
|
11
|
+
// 模块启停 = 功能坞里的开关(独立菜单项),本页不再有第二个总开关。
|
|
12
|
+
//
|
|
13
|
+
// Host 通信:fetch('/dsh-dock/notify/<method>')(见 features/notify/host.js)。
|
|
14
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
15
|
+
|
|
16
|
+
// ---------- Host RPC 桥接 ----------
|
|
17
|
+
function rpcCall(method, args) {
|
|
18
|
+
return fetch("/dsh-dock/notify/" + method, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: { "content-type": "application/json" },
|
|
21
|
+
body: JSON.stringify(args === undefined ? {} : args),
|
|
22
|
+
})
|
|
23
|
+
.then(async (res) => {
|
|
24
|
+
const data = await res.json().catch(() => ({}));
|
|
25
|
+
if (res.ok && data && data.ok === true) return data.data;
|
|
26
|
+
// 宿主侧没有通知路由(405/404):两种可能——宿主进程是旧版本,或本功能在宿主侧
|
|
27
|
+
// 还没 setup(开关没同步过去)。给可操作提示,而不是裸状态码。
|
|
28
|
+
if (res.status === 405 || res.status === 404) {
|
|
29
|
+
throw new Error("宿主侧没有「任务通知」接口:宿主进程可能是旧版本,或该功能在宿主侧未启用(重启 dsh web,或把功能坞里的开关关掉再打开)");
|
|
30
|
+
}
|
|
31
|
+
if (data && data.ok === false) throw new Error((data.error && data.error.message) || ("HTTP " + res.status));
|
|
32
|
+
throw new Error("HTTP " + res.status + (data && data.error && data.error.message ? ": " + data.error.message : ""));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------- 结束原因 / 阶段 ----------
|
|
37
|
+
const END_LABELS = {
|
|
38
|
+
completed: { label: "完成", cls: "ok" },
|
|
39
|
+
error: { label: "出错", cls: "err" },
|
|
40
|
+
aborted: { label: "已中止", cls: "warn" },
|
|
41
|
+
blocked: { label: "受阻", cls: "warn" },
|
|
42
|
+
"max-tokens": { label: "达输出上限", cls: "warn" },
|
|
43
|
+
interrupted: { label: "中断", cls: "warn" },
|
|
44
|
+
};
|
|
45
|
+
function endInfo(reason) {
|
|
46
|
+
return END_LABELS[reason] || END_LABELS.completed;
|
|
47
|
+
}
|
|
48
|
+
function isSuccessReason(reason) {
|
|
49
|
+
return !reason || reason === "completed";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 工具名 → 中文场景("需确认"通知里说清楚是什么在等确认)
|
|
53
|
+
function toolLabel(name) {
|
|
54
|
+
const n = String(name || "").toLowerCase();
|
|
55
|
+
if (/bash|shell|terminal|cmd|pwsh|powershell|exec/.test(n)) return "执行命令";
|
|
56
|
+
if (/write|edit|patch|apply|create|mkdir|remove|delete/.test(n)) return "修改文件";
|
|
57
|
+
if (/web|fetch|browser|navigate|search/.test(n)) return "访问网页";
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------- 格式化 ----------
|
|
62
|
+
function fmtNum(n) { return (Number(n) || 0).toLocaleString("en-US"); }
|
|
63
|
+
// 毫秒 → "3分21秒" / "1小时2分"
|
|
64
|
+
function fmtDur(ms) {
|
|
65
|
+
const s = Math.max(0, Math.round((ms || 0) / 1000));
|
|
66
|
+
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
|
67
|
+
if (h > 0) return h + "小时" + m + "分" + sec + "秒";
|
|
68
|
+
if (m > 0) return m + "分" + sec + "秒";
|
|
69
|
+
return sec + "秒";
|
|
70
|
+
}
|
|
71
|
+
function fmtTime(ts) {
|
|
72
|
+
if (!ts) return "";
|
|
73
|
+
const d = new Date(ts);
|
|
74
|
+
const p = (x) => String(x).padStart(2, "0");
|
|
75
|
+
return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
|
|
76
|
+
}
|
|
77
|
+
function truncate(str, max) {
|
|
78
|
+
if (!str) return "";
|
|
79
|
+
return String(str).length > max ? String(str).slice(0, max) + "…" : str;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------- 提示音(WebAudio 合成;macOS/Windows 通用,无音频文件) ----------
|
|
83
|
+
// 音效库:每种含完成音(done)、异常音(err)与确认音(ask)三套音符序列。
|
|
84
|
+
// 音符 [频率, 起始秒, 时长秒, 波形?],波形缺省 sine。确认音双音上扬、偏长,
|
|
85
|
+
// 听感是"来人搭话",和完成音的收束感、异常音的下坠感区分开。
|
|
86
|
+
const SOUND_LIBRARY = {
|
|
87
|
+
chime: { name: "清脆双音", notes: { done: [[659.25, 0, .14], [880, .13, .24]], err: [[220, 0, .16], [164.81, .15, .3]], ask: [[587.33, 0, .16], [880, .19, .34]] } },
|
|
88
|
+
ding: { name: "叮", notes: { done: [[987.77, 0, .35]], err: [[246.94, 0, .4]], ask: [[783.99, 0, .18], [1174.66, .2, .42]] } },
|
|
89
|
+
coin: { name: "金币", notes: { done: [[988, 0, .08], [1319, .08, .35], [988, 0, .08, "square"], [1319, .08, .3, "square"]], err: [[196, 0, .12], [147, .11, .35]], ask: [[880, 0, .09], [1108.73, .1, .12], [1318.51, .22, .3]] } },
|
|
90
|
+
bell: { name: "钟声", notes: { done: [[523.25, 0, .5], [659.25, .02, .45], [783.99, .04, .4]], err: [[174.61, 0, .5], [130.81, .05, .5]], ask: [[659.25, 0, .4], [523.25, .05, .55]] } },
|
|
91
|
+
pulse: { name: "脉冲", notes: { done: [[440, 0, .09], [440, .14, .09], [440, .28, .16]], err: [[174.61, 0, .1], [174.61, .14, .1], [174.61, .28, .18]], ask: [[523.25, 0, .09], [523.25, .15, .09], [659.25, .3, .22]] } },
|
|
92
|
+
arp: { name: "琶音", notes: { done: [[523.25, 0, .12], [659.25, .09, .12], [783.99, .18, .12], [1046.5, .27, .3]], err: [[392, 0, .12], [329.63, .1, .12], [261.63, .2, .12], [196, .3, .32]], ask: [[440, 0, .11], [554.37, .12, .11], [659.25, .24, .11], [880, .36, .34]] } },
|
|
93
|
+
};
|
|
94
|
+
// AudioContext 按需创建(浏览器自动播放策略:首次用户交互后才能出声,静默失败不报错)
|
|
95
|
+
let soundCtx = null;
|
|
96
|
+
function playTone(seq) {
|
|
97
|
+
try {
|
|
98
|
+
if (typeof window === "undefined" || !window.AudioContext && !window.webkitAudioContext) return;
|
|
99
|
+
const AC = window.AudioContext || window.webkitAudioContext;
|
|
100
|
+
if (!soundCtx) soundCtx = new AC();
|
|
101
|
+
if (soundCtx.state === "suspended") { soundCtx.resume().catch(() => {}); }
|
|
102
|
+
const t0 = soundCtx.currentTime;
|
|
103
|
+
for (const [f, at, dur, wave] of seq) {
|
|
104
|
+
const osc = soundCtx.createOscillator();
|
|
105
|
+
const gain = soundCtx.createGain();
|
|
106
|
+
osc.type = wave || "sine";
|
|
107
|
+
osc.frequency.value = f;
|
|
108
|
+
gain.gain.setValueAtTime(0, t0 + at);
|
|
109
|
+
gain.gain.linearRampToValueAtTime(0.18, t0 + at + 0.015);
|
|
110
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + at + dur);
|
|
111
|
+
osc.connect(gain).connect(soundCtx.destination);
|
|
112
|
+
osc.start(t0 + at);
|
|
113
|
+
osc.stop(t0 + at + dur + 0.05);
|
|
114
|
+
}
|
|
115
|
+
} catch { /* 音频不可用静默 */ }
|
|
116
|
+
}
|
|
117
|
+
// 按音效播放:完成音 / 异常音 / 确认音(未知音效回退 chime)
|
|
118
|
+
function playDoneSound(success, effect) {
|
|
119
|
+
const lib = SOUND_LIBRARY[effect] || SOUND_LIBRARY.chime;
|
|
120
|
+
playTone(success ? lib.notes.done : lib.notes.err);
|
|
121
|
+
}
|
|
122
|
+
function playAskSound(effect) {
|
|
123
|
+
const lib = SOUND_LIBRARY[effect] || SOUND_LIBRARY.chime;
|
|
124
|
+
playTone(lib.notes.ask || lib.notes.done);
|
|
125
|
+
}
|
|
126
|
+
// 试听:完成音 + 异常音 连播(间隔 0.55s)
|
|
127
|
+
function previewSound(effect) {
|
|
128
|
+
const lib = SOUND_LIBRARY[effect] || SOUND_LIBRARY.chime;
|
|
129
|
+
playTone(lib.notes.done);
|
|
130
|
+
const delayed = lib.notes.err.map(([f, at, dur, wave]) => [f, at + 0.55, dur, wave]);
|
|
131
|
+
playTone(delayed);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------- 共享快照:浮层(轮询) / 面板页 / 首页概要共用一份数据 ----------
|
|
135
|
+
const notifyStore = {
|
|
136
|
+
snap: { status: null, loading: false, error: null },
|
|
137
|
+
listeners: new Set(),
|
|
138
|
+
subscribe(fn) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; },
|
|
139
|
+
emit() { for (const fn of this.listeners) fn(); },
|
|
140
|
+
applyConfig(cfg) {
|
|
141
|
+
if (this.snap.status) {
|
|
142
|
+
this.snap = Object.assign({}, this.snap, { status: Object.assign({}, this.snap.status, { config: cfg }) });
|
|
143
|
+
this.emit();
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
refresh() {
|
|
147
|
+
if (this.snap.loading) return Promise.resolve();
|
|
148
|
+
this.snap = Object.assign({}, this.snap, { loading: true });
|
|
149
|
+
this.emit();
|
|
150
|
+
return rpcCall("status")
|
|
151
|
+
.then((data) => {
|
|
152
|
+
this.snap = { status: data, loading: false, error: null };
|
|
153
|
+
this.emit();
|
|
154
|
+
})
|
|
155
|
+
.catch((e) => {
|
|
156
|
+
this.snap = Object.assign({}, this.snap, { loading: false, error: (e && e.message) || String(e) });
|
|
157
|
+
this.emit();
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
function useNotify() {
|
|
162
|
+
const [snap, setSnap] = useState(notifyStore.snap);
|
|
163
|
+
useEffect(() => notifyStore.subscribe(() => setSnap(notifyStore.snap)), []);
|
|
164
|
+
return snap;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------- 通知卡片 ----------
|
|
168
|
+
function Toast(props) {
|
|
169
|
+
const t = props.t;
|
|
170
|
+
const [closing, setClosing] = useState(false);
|
|
171
|
+
const close = useCallback(() => { if (!closing) setClosing(true); }, [closing]);
|
|
172
|
+
// 停留时长(stayMs=0 常驻,仅手动关闭)
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!t.stayMs) return;
|
|
175
|
+
const timer = setTimeout(close, t.stayMs);
|
|
176
|
+
return () => clearTimeout(timer);
|
|
177
|
+
}, []);
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (!closing) return;
|
|
180
|
+
const timer = setTimeout(() => props.onClose(t.id), 240);
|
|
181
|
+
return () => clearTimeout(timer);
|
|
182
|
+
}, [closing]);
|
|
183
|
+
const markColor = t.kind === "success"
|
|
184
|
+
? "var(--dk-ok)"
|
|
185
|
+
: t.kind === "error"
|
|
186
|
+
? "var(--dk-err)"
|
|
187
|
+
: "var(--dk-warn)";
|
|
188
|
+
return (
|
|
189
|
+
<div className={"dknt-toast" + (t.kind === "confirm" ? " dknt-toast-confirm" : "") + (closing ? " out" : "")}>
|
|
190
|
+
<div className="dknt-toast-head">
|
|
191
|
+
<span className="dknt-toast-mark" style={{ background: markColor }} />
|
|
192
|
+
<span className="dknt-toast-title" title={t.title}>{t.kind === "confirm" ? "✋ " : ""}{t.title}</span>
|
|
193
|
+
<button type="button" className="dknt-toast-close" aria-label="关闭" onClick={close}>✕</button>
|
|
194
|
+
</div>
|
|
195
|
+
{t.body ? <div className="dknt-toast-body">{t.body}</div> : null}
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------- 全局浮层:轮询 + 通知(功能启用即常驻) ----------
|
|
201
|
+
export function NotifyOverlay(props) {
|
|
202
|
+
const snap = useNotify();
|
|
203
|
+
const [toasts, setToasts] = useState([]);
|
|
204
|
+
const prevActiveRef = useRef(null);
|
|
205
|
+
const remindedApprovalsRef = useRef(new Map()); // sessionId -> 提醒过的 approvalId 集合
|
|
206
|
+
const toastSeqRef = useRef(0);
|
|
207
|
+
|
|
208
|
+
const pushToast = useCallback((toast) => {
|
|
209
|
+
setToasts((prev) => prev.concat([toast]).slice(-4));
|
|
210
|
+
}, []);
|
|
211
|
+
|
|
212
|
+
// 轮询:任务中 850ms,实时捕捉结束/待确认;空闲 6s / 出错 15s;页面切回立即刷新。
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
let stopped = false;
|
|
215
|
+
let timer = null;
|
|
216
|
+
const loop = async () => {
|
|
217
|
+
await notifyStore.refresh();
|
|
218
|
+
if (stopped) return;
|
|
219
|
+
const s = notifyStore.snap;
|
|
220
|
+
timer = setTimeout(loop, s.error ? 15000 : (s.status && s.status.active && s.status.active.length > 0 ? 850 : 6000));
|
|
221
|
+
};
|
|
222
|
+
loop();
|
|
223
|
+
const onVisible = () => {
|
|
224
|
+
if (!stopped && typeof document !== "undefined" && document.visibilityState === "visible") notifyStore.refresh();
|
|
225
|
+
};
|
|
226
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
227
|
+
return () => { stopped = true; if (timer) clearTimeout(timer); document.removeEventListener("visibilitychange", onVisible); };
|
|
228
|
+
}, []);
|
|
229
|
+
|
|
230
|
+
// 任务结束检测:上一轮活跃、本轮消失 → 通知 + 提示音 + 系统通知(找最近完成记录补全信息)
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
const st = snap.status;
|
|
233
|
+
if (!st || !st.active) return;
|
|
234
|
+
const prev = prevActiveRef.current;
|
|
235
|
+
if (prev) {
|
|
236
|
+
const currIds = new Set(st.active.map((x) => x.sessionId));
|
|
237
|
+
for (const task of prev) {
|
|
238
|
+
if (currIds.has(task.sessionId)) continue;
|
|
239
|
+
const record = (st.recent || []).find((r) => r.sessionId === task.sessionId) || null;
|
|
240
|
+
handleTaskEnd(task, record, st.config || {});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
prevActiveRef.current = st.active.slice();
|
|
244
|
+
}, [snap.status]);
|
|
245
|
+
|
|
246
|
+
// ===== 需确认检测:工具等待用户批准时通知(页内卡片 + 确认音 + 后台系统通知) =====
|
|
247
|
+
// host 从会话流 approval/asked 收集待确认项,approval/decided 后移除;
|
|
248
|
+
// 这里对"任务首次出现待确认"触发一次提醒,已提醒过的会话不再重复打扰。
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
const st = snap.status;
|
|
251
|
+
if (!st || !st.active) return;
|
|
252
|
+
const cfg = st.config || {};
|
|
253
|
+
const activeIds = new Set(st.active.map((x) => x.sessionId));
|
|
254
|
+
for (const [sid, seen] of remindedApprovalsRef.current) {
|
|
255
|
+
if (!activeIds.has(sid)) remindedApprovalsRef.current.delete(sid);
|
|
256
|
+
}
|
|
257
|
+
for (const task of st.active) {
|
|
258
|
+
const approvals = Array.isArray(task.approvals) ? task.approvals : [];
|
|
259
|
+
if (approvals.length === 0) continue;
|
|
260
|
+
let seen = remindedApprovalsRef.current.get(task.sessionId);
|
|
261
|
+
if (!seen) { seen = new Set(); remindedApprovalsRef.current.set(task.sessionId, seen); }
|
|
262
|
+
const fresh = approvals.filter((a) => a && !seen.has(a.id));
|
|
263
|
+
if (fresh.length === 0) continue;
|
|
264
|
+
for (const a of fresh) seen.add(a.id);
|
|
265
|
+
// 提醒开关在"收到新确认项"时检查,保证开关中途打开对下一项生效
|
|
266
|
+
if (cfg.notifyOnConfirm === false) continue;
|
|
267
|
+
const toolName = fresh[0].toolName || "";
|
|
268
|
+
const scene = toolLabel(toolName);
|
|
269
|
+
const lines = [
|
|
270
|
+
"任务:" + (task.title || "(无标题)"),
|
|
271
|
+
"工具:" + (toolName || "未知") + (scene ? "(" + scene + ")" : ""),
|
|
272
|
+
];
|
|
273
|
+
if (fresh[0].reason) lines.push("说明:" + truncate(fresh[0].reason, 140));
|
|
274
|
+
lines.push(fresh.length > 1 ? "共 " + fresh.length + " 项等待你的确认" : "请在会话里确认后继续");
|
|
275
|
+
pushToast({
|
|
276
|
+
id: ++toastSeqRef.current,
|
|
277
|
+
kind: "confirm",
|
|
278
|
+
title: "任务需要确认",
|
|
279
|
+
body: lines.join("\n"),
|
|
280
|
+
// 需确认常驻到用户处理(notifyStayMs=0 同款语义),不会自动消失
|
|
281
|
+
stayMs: 0,
|
|
282
|
+
});
|
|
283
|
+
if (cfg.soundNotify !== false) playAskSound(cfg.soundEffect);
|
|
284
|
+
if (cfg.systemNotify && typeof document !== "undefined" && document.hidden
|
|
285
|
+
&& typeof Notification !== "undefined" && Notification.permission === "granted") {
|
|
286
|
+
try {
|
|
287
|
+
new Notification("dsh 任务需要确认", {
|
|
288
|
+
body: (task.title || "(无标题)") + " · " + (toolName || "工具") + " 等待确认",
|
|
289
|
+
tag: "dsh-dock-approval-" + task.sessionId,
|
|
290
|
+
});
|
|
291
|
+
} catch { /* 系统通知失败不影响页面内通知 */ }
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}, [snap.status]);
|
|
295
|
+
|
|
296
|
+
const handleTaskEnd = (task, record, cfg) => {
|
|
297
|
+
const reason = (record && record.endReason) || "";
|
|
298
|
+
const success = isSuccessReason(reason);
|
|
299
|
+
const info = endInfo(reason);
|
|
300
|
+
const wanted = success ? cfg.notifyOnComplete : cfg.notifyOnError;
|
|
301
|
+
if (wanted === false) return;
|
|
302
|
+
const models = record && record.models && record.models.length ? record.models.join(", ")
|
|
303
|
+
: (task.models && task.models.length ? task.models.join(", ") : "未知模型");
|
|
304
|
+
const provider = (record && record.provider) || task.provider || "";
|
|
305
|
+
const startTs = (record && record.startTime) || task.startTime;
|
|
306
|
+
const endTs = (record && record.endTime) || Date.now();
|
|
307
|
+
const lines = [
|
|
308
|
+
"任务:" + ((record && record.title) || task.title || "(无标题)"),
|
|
309
|
+
"模型:" + models + (provider ? "(" + provider + ")" : ""),
|
|
310
|
+
"耗时:" + fmtDur((record && record.duration) || (endTs - startTs))
|
|
311
|
+
+ "(" + fmtTime(startTs) + " → " + fmtTime(endTs) + ")",
|
|
312
|
+
"回合 " + ((record && record.turns) || task.turns || 0)
|
|
313
|
+
+ " · 步骤 " + ((record && record.steps) || task.steps || 0)
|
|
314
|
+
+ (((record && record.toolCalls) || task.toolCalls) ? " · 工具 " + ((record && record.toolCalls) || task.toolCalls) + " 次" : ""),
|
|
315
|
+
"Token:输入 " + fmtNum((record && record.inputTokens) || task.inputTokens)
|
|
316
|
+
+ " / 输出 " + fmtNum((record && record.outputTokens) || task.outputTokens),
|
|
317
|
+
];
|
|
318
|
+
if (record && record.lastText) lines.push("摘要:" + truncate(record.lastText, 140));
|
|
319
|
+
if (!success && record && record.errorMessage) lines.push("错误:" + truncate(record.errorMessage, 120));
|
|
320
|
+
const title = success ? "任务完成" : "任务" + info.label;
|
|
321
|
+
pushToast({
|
|
322
|
+
id: ++toastSeqRef.current,
|
|
323
|
+
kind: success ? "success" : (info.cls === "err" ? "error" : "warn"),
|
|
324
|
+
title,
|
|
325
|
+
body: lines.join("\n"),
|
|
326
|
+
stayMs: typeof cfg.notifyStayMs === "number" ? cfg.notifyStayMs : 8000,
|
|
327
|
+
});
|
|
328
|
+
// 提示音:任务结束时播放(完成/异常配套音);与系统通知独立开关
|
|
329
|
+
if (cfg.soundNotify !== false) playDoneSound(success, cfg.soundEffect);
|
|
330
|
+
// 系统通知:仅页面处于后台时推送,避免前台重复打扰
|
|
331
|
+
if (cfg.systemNotify && typeof document !== "undefined" && document.hidden
|
|
332
|
+
&& typeof Notification !== "undefined" && Notification.permission === "granted") {
|
|
333
|
+
try {
|
|
334
|
+
new Notification("dsh " + title, {
|
|
335
|
+
body: ((record && record.title) || task.title || "(无标题)") + " · " + fmtDur((record && record.duration) || (endTs - startTs)),
|
|
336
|
+
tag: "dsh-dock-notify-" + task.sessionId,
|
|
337
|
+
});
|
|
338
|
+
} catch { /* 系统通知失败不影响页面内通知 */ }
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// Host 不可用(旧宿主未重启等):浮层整体静默,面板页会给提示
|
|
343
|
+
if (!snap.status) return null;
|
|
344
|
+
if (toasts.length === 0) return null;
|
|
345
|
+
return (
|
|
346
|
+
<div className="dknt-toasts">
|
|
347
|
+
{toasts.map((t) => <Toast key={t.id} t={t} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />)}
|
|
348
|
+
</div>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------- 面板页 ----------
|
|
353
|
+
function NotifyView(props) {
|
|
354
|
+
const snap = useNotify();
|
|
355
|
+
const [cfg, setCfg] = useState(null); // null = 尚未加载
|
|
356
|
+
const [saveErr, setSaveErr] = useState("");
|
|
357
|
+
const [testing, setTesting] = useState(false);
|
|
358
|
+
const [testState, setTestState] = useState(null); // { ok, msg } 钉钉测试结果
|
|
359
|
+
const [feishuTesting, setFeishuTesting] = useState(false);
|
|
360
|
+
const [feishuTestState, setFeishuTestState] = useState(null); // { ok, msg } 飞书测试结果
|
|
361
|
+
const cfgRef = useRef(null);
|
|
362
|
+
const pendingSavesRef = useRef(0); // 进行中的保存(轮询回包不覆盖乐观值)
|
|
363
|
+
const editingWebhookRef = useRef(false); // 钉钉 Webhook 输入中(轮询不覆盖草稿)
|
|
364
|
+
const editingFeishuRef = useRef(false); // 飞书 Webhook 输入中(轮询不覆盖草稿)
|
|
365
|
+
// 拉到新配置(含保存回包)后同步本地编辑态;保存中/输入中不覆盖
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
const c = snap.status && snap.status.config;
|
|
368
|
+
if (!c) return;
|
|
369
|
+
if (pendingSavesRef.current > 0 || editingWebhookRef.current || editingFeishuRef.current) return;
|
|
370
|
+
if (c !== cfgRef.current) {
|
|
371
|
+
cfgRef.current = c;
|
|
372
|
+
setCfg(Object.assign({}, c));
|
|
373
|
+
}
|
|
374
|
+
}, [snap.status]);
|
|
375
|
+
// 面板打开时兜底拉一次(浮层通常已在轮询)
|
|
376
|
+
useEffect(() => {
|
|
377
|
+
if (!notifyStore.snap.status) notifyStore.refresh();
|
|
378
|
+
}, []);
|
|
379
|
+
// 乐观更新 + 立即持久化(每个开关独立保存)
|
|
380
|
+
const patch = (p) => {
|
|
381
|
+
setSaveErr("");
|
|
382
|
+
setCfg(Object.assign({}, cfg, p));
|
|
383
|
+
pendingSavesRef.current++;
|
|
384
|
+
return rpcCall("config", p)
|
|
385
|
+
.then((d) => {
|
|
386
|
+
pendingSavesRef.current--;
|
|
387
|
+
notifyStore.applyConfig(d && d.config);
|
|
388
|
+
})
|
|
389
|
+
.catch((e) => {
|
|
390
|
+
pendingSavesRef.current--;
|
|
391
|
+
setSaveErr("保存失败:" + ((e && e.message) || String(e)));
|
|
392
|
+
});
|
|
393
|
+
};
|
|
394
|
+
// Webhook 草稿保存:清编辑态后立即对齐一次(同步 effect 在编辑期被跳过)
|
|
395
|
+
const saveWebhook = async () => {
|
|
396
|
+
if (!editingWebhookRef.current) return;
|
|
397
|
+
const hook = String(cfg.dingtalkWebhook || "").trim();
|
|
398
|
+
await patch({ dingtalkWebhook: hook });
|
|
399
|
+
editingWebhookRef.current = false;
|
|
400
|
+
const c = notifyStore.snap.status && notifyStore.snap.status.config;
|
|
401
|
+
if (c && c !== cfgRef.current) {
|
|
402
|
+
cfgRef.current = c;
|
|
403
|
+
setCfg(Object.assign({}, c));
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
const saveFeishuWebhook = async () => {
|
|
407
|
+
if (!editingFeishuRef.current) return;
|
|
408
|
+
const hook = String(cfg.feishuWebhook || "").trim();
|
|
409
|
+
await patch({ feishuWebhook: hook });
|
|
410
|
+
editingFeishuRef.current = false;
|
|
411
|
+
const c = notifyStore.snap.status && notifyStore.snap.status.config;
|
|
412
|
+
if (c && c !== cfgRef.current) {
|
|
413
|
+
cfgRef.current = c;
|
|
414
|
+
setCfg(Object.assign({}, c));
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
// 钉钉测试:草稿未保存先保存,再发测试消息
|
|
418
|
+
const runDingtalkTest = async () => {
|
|
419
|
+
setTesting(true);
|
|
420
|
+
setTestState(null);
|
|
421
|
+
try {
|
|
422
|
+
if (editingWebhookRef.current) {
|
|
423
|
+
const hook = String(cfg.dingtalkWebhook || "").trim();
|
|
424
|
+
if (!hook) throw new Error("请先填写 Webhook 地址");
|
|
425
|
+
const d = await rpcCall("config", { dingtalkWebhook: hook });
|
|
426
|
+
notifyStore.applyConfig(d && d.config);
|
|
427
|
+
editingWebhookRef.current = false;
|
|
428
|
+
cfgRef.current = (d && d.config) || cfgRef.current;
|
|
429
|
+
setCfg(Object.assign({}, cfg, { dingtalkWebhook: hook }));
|
|
430
|
+
}
|
|
431
|
+
const r = await rpcCall("test");
|
|
432
|
+
setTestState(r && r.sent
|
|
433
|
+
? { ok: true, msg: "测试消息已发送,去群里看看" }
|
|
434
|
+
: { ok: false, msg: (r && r.error) || "发送失败" });
|
|
435
|
+
} catch (e) {
|
|
436
|
+
setTestState({ ok: false, msg: (e && e.message) || String(e) });
|
|
437
|
+
} finally {
|
|
438
|
+
setTesting(false);
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
// 飞书测试:草稿未保存先保存,再发测试消息
|
|
442
|
+
const runFeishuTest = async () => {
|
|
443
|
+
setFeishuTesting(true);
|
|
444
|
+
setFeishuTestState(null);
|
|
445
|
+
try {
|
|
446
|
+
if (editingFeishuRef.current) {
|
|
447
|
+
const hook = String(cfg.feishuWebhook || "").trim();
|
|
448
|
+
if (!hook) throw new Error("请先填写 Webhook 地址");
|
|
449
|
+
const d = await rpcCall("config", { feishuWebhook: hook });
|
|
450
|
+
notifyStore.applyConfig(d && d.config);
|
|
451
|
+
editingFeishuRef.current = false;
|
|
452
|
+
cfgRef.current = (d && d.config) || cfgRef.current;
|
|
453
|
+
setCfg(Object.assign({}, cfg, { feishuWebhook: hook }));
|
|
454
|
+
}
|
|
455
|
+
const r = await rpcCall("test", { target: "feishu" });
|
|
456
|
+
setFeishuTestState(r && r.sent
|
|
457
|
+
? { ok: true, msg: "测试消息已发送,去群里看看" }
|
|
458
|
+
: { ok: false, msg: (r && r.error) || "发送失败" });
|
|
459
|
+
} catch (e) {
|
|
460
|
+
setFeishuTestState({ ok: false, msg: (e && e.message) || String(e) });
|
|
461
|
+
} finally {
|
|
462
|
+
setFeishuTesting(false);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
const enableSystemNotify = async (next) => {
|
|
466
|
+
if (next && typeof Notification !== "undefined" && Notification.permission !== "granted") {
|
|
467
|
+
try {
|
|
468
|
+
const perm = await Notification.requestPermission(); // 开关点击即用户手势
|
|
469
|
+
if (perm !== "granted") {
|
|
470
|
+
setSaveErr("浏览器未授权系统通知(可在地址栏权限设置里重新允许)");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
} catch {
|
|
474
|
+
setSaveErr("浏览器不支持系统通知");
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
patch({ systemNotify: next });
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const permNote = typeof Notification === "undefined"
|
|
482
|
+
? "当前浏览器不支持系统通知"
|
|
483
|
+
: Notification.permission === "granted" ? "已授权 · 仅页面后台时推送"
|
|
484
|
+
: Notification.permission === "denied" ? "已被浏览器拒绝(需在浏览器权限设置里重新允许)" : "未授权 · 开启时会请求授权,仅页面后台时推送";
|
|
485
|
+
|
|
486
|
+
const rows = [];
|
|
487
|
+
if (!cfg) {
|
|
488
|
+
rows.push(<div key="load" className="dknt-note">{snap.error ? "状态不可用:" + snap.error : (snap.loading ? "正在拉取通知状态…" : "等待通知状态")}</div>);
|
|
489
|
+
} else {
|
|
490
|
+
rows.push(
|
|
491
|
+
<div key="events" className="dknt-sec">
|
|
492
|
+
<div className="dknt-sec-head">
|
|
493
|
+
<span className="dknt-sec-title">通知事件</span>
|
|
494
|
+
<span className="dknt-sec-sub">哪些时刻提醒你;与【任务动画】的动效互不依赖</span>
|
|
495
|
+
</div>
|
|
496
|
+
<div className="dknt-rows-narrow">
|
|
497
|
+
<div className="dknt-row">
|
|
498
|
+
<span className="dknt-row-label">完成通知</span>
|
|
499
|
+
<button type="button" className={"dknt-miniswitch" + (cfg.notifyOnComplete ? " on" : "")}
|
|
500
|
+
onClick={() => patch({ notifyOnComplete: !cfg.notifyOnComplete })}>
|
|
501
|
+
{cfg.notifyOnComplete ? "开" : "关"}
|
|
502
|
+
</button>
|
|
503
|
+
<span className="dknt-row-sub">任务正常完成时通知</span>
|
|
504
|
+
</div>
|
|
505
|
+
<div className="dknt-row">
|
|
506
|
+
<span className="dknt-row-label">异常通知</span>
|
|
507
|
+
<button type="button" className={"dknt-miniswitch" + (cfg.notifyOnError ? " on" : "")}
|
|
508
|
+
onClick={() => patch({ notifyOnError: !cfg.notifyOnError })}>
|
|
509
|
+
{cfg.notifyOnError ? "开" : "关"}
|
|
510
|
+
</button>
|
|
511
|
+
<span className="dknt-row-sub">出错 / 中止 / 达输出上限时通知</span>
|
|
512
|
+
</div>
|
|
513
|
+
<div className="dknt-row">
|
|
514
|
+
<span className="dknt-row-label">需确认提醒</span>
|
|
515
|
+
<button type="button" className={"dknt-miniswitch" + (cfg.notifyOnConfirm !== false ? " on" : "")}
|
|
516
|
+
onClick={() => patch({ notifyOnConfirm: cfg.notifyOnConfirm === false })}>
|
|
517
|
+
{cfg.notifyOnConfirm !== false ? "开" : "关"}
|
|
518
|
+
</button>
|
|
519
|
+
<span className="dknt-row-sub">工具等待你批准时弹卡片并响确认音(常驻不自动消失)</span>
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
</div>,
|
|
523
|
+
<div key="style" className="dknt-sec">
|
|
524
|
+
<div className="dknt-sec-head">
|
|
525
|
+
<span className="dknt-sec-title">提醒方式</span>
|
|
526
|
+
<span className="dknt-sec-sub">页内卡片、提示音、浏览器系统通知可自由组合</span>
|
|
527
|
+
</div>
|
|
528
|
+
<div className="dknt-rows-narrow">
|
|
529
|
+
<div className="dknt-row">
|
|
530
|
+
<span className="dknt-row-label">停留时长</span>
|
|
531
|
+
<select className="dknt-select" value={String(cfg.notifyStayMs)}
|
|
532
|
+
onChange={(e) => patch({ notifyStayMs: Number(e.target.value) })}>
|
|
533
|
+
<option value="4000">4 秒</option>
|
|
534
|
+
<option value="8000">8 秒</option>
|
|
535
|
+
<option value="15000">15 秒</option>
|
|
536
|
+
<option value="30000">30 秒</option>
|
|
537
|
+
<option value="0">常驻(手动关闭)</option>
|
|
538
|
+
</select>
|
|
539
|
+
<span className="dknt-row-sub">卡片自动消失的时间(需确认提醒恒为常驻)</span>
|
|
540
|
+
</div>
|
|
541
|
+
<div className="dknt-row">
|
|
542
|
+
<span className="dknt-row-label">系统通知</span>
|
|
543
|
+
<button type="button" className={"dknt-miniswitch" + (cfg.systemNotify ? " on" : "")}
|
|
544
|
+
onClick={() => enableSystemNotify(!cfg.systemNotify)}>
|
|
545
|
+
{cfg.systemNotify ? "开" : "关"}
|
|
546
|
+
</button>
|
|
547
|
+
<span className="dknt-row-sub">{permNote}</span>
|
|
548
|
+
</div>
|
|
549
|
+
<div className="dknt-row">
|
|
550
|
+
<span className="dknt-row-label">提示音</span>
|
|
551
|
+
<button type="button" className={"dknt-miniswitch" + (cfg.soundNotify !== false ? " on" : "")}
|
|
552
|
+
onClick={() => patch({ soundNotify: cfg.soundNotify === false })}>
|
|
553
|
+
{cfg.soundNotify !== false ? "开" : "关"}
|
|
554
|
+
</button>
|
|
555
|
+
<span className="dknt-row-sub">任务结束/需确认时播放(试听为先播完成音、后播异常音)</span>
|
|
556
|
+
</div>
|
|
557
|
+
{cfg.soundNotify !== false ? (
|
|
558
|
+
<div className="dknt-sounds">
|
|
559
|
+
{Object.keys(SOUND_LIBRARY).map((key) => (
|
|
560
|
+
<button type="button" key={key}
|
|
561
|
+
className={"dknt-sound" + (cfg.soundEffect === key ? " on" : "")}
|
|
562
|
+
onClick={() => patch({ soundEffect: key })}>
|
|
563
|
+
<span className="dknt-sound-name">
|
|
564
|
+
{SOUND_LIBRARY[key].name}
|
|
565
|
+
{cfg.soundEffect === key ? <span className="dknt-sound-cur">✓</span> : null}
|
|
566
|
+
</span>
|
|
567
|
+
<span className="dknt-sound-play" role="button" tabIndex={0}
|
|
568
|
+
title={"试听 " + SOUND_LIBRARY[key].name}
|
|
569
|
+
onClick={(e) => { e.stopPropagation(); previewSound(key); }}
|
|
570
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); previewSound(key); } }}>▶</span>
|
|
571
|
+
</button>
|
|
572
|
+
))}
|
|
573
|
+
</div>
|
|
574
|
+
) : null}
|
|
575
|
+
</div>
|
|
576
|
+
</div>,
|
|
577
|
+
<div key="dingtalk" className="dknt-sec">
|
|
578
|
+
<div className="dknt-sec-head">
|
|
579
|
+
<span className="dknt-sec-title">钉钉推送</span>
|
|
580
|
+
<span className="dknt-sec-sub">任务结束推送到钉钉群机器人(宿主直发,浏览器关着也能推;事件跟随上方完成/异常开关)</span>
|
|
581
|
+
<span className="dknt-sec-sw">
|
|
582
|
+
<span className={"dknt-sec-swlabel" + (cfg.dingtalkEnabled ? " on" : "")}>{cfg.dingtalkEnabled ? "已开启" : "已关闭"}</span>
|
|
583
|
+
<button type="button" className={"dock-sw" + (cfg.dingtalkEnabled ? " on" : "")}
|
|
584
|
+
role="switch" aria-checked={cfg.dingtalkEnabled} aria-label="开关钉钉推送"
|
|
585
|
+
title={cfg.dingtalkEnabled ? "关闭钉钉推送" : "开启钉钉推送"}
|
|
586
|
+
onClick={() => patch({ dingtalkEnabled: !cfg.dingtalkEnabled })} />
|
|
587
|
+
</span>
|
|
588
|
+
</div>
|
|
589
|
+
{cfg.dingtalkEnabled ? <div className="dknt-rows-narrow">
|
|
590
|
+
<div className="dknt-row dknt-row-webhook">
|
|
591
|
+
<span className="dknt-row-label">Webhook</span>
|
|
592
|
+
<input type="text" className="dknt-input" spellCheck={false}
|
|
593
|
+
value={cfg.dingtalkWebhook || ""}
|
|
594
|
+
placeholder="https://oapi.dingtalk.com/robot/send?access_token=…"
|
|
595
|
+
onChange={(e) => {
|
|
596
|
+
editingWebhookRef.current = true;
|
|
597
|
+
setCfg(Object.assign({}, cfg, { dingtalkWebhook: e.target.value }));
|
|
598
|
+
}} />
|
|
599
|
+
<button type="button" className="dknt-btn" disabled={!editingWebhookRef.current}
|
|
600
|
+
onClick={saveWebhook}>
|
|
601
|
+
{editingWebhookRef.current ? "保存" : "已保存"}
|
|
602
|
+
</button>
|
|
603
|
+
</div>
|
|
604
|
+
<div className="dknt-row">
|
|
605
|
+
<span className="dknt-row-label">连通测试</span>
|
|
606
|
+
<button type="button" className="dknt-btn" disabled={testing} onClick={runDingtalkTest}>
|
|
607
|
+
{testing ? "发送中…" : "发送测试消息"}
|
|
608
|
+
</button>
|
|
609
|
+
{testState ? <span className={"dknt-row-sub" + (testState.ok ? " dknt-ok" : " dknt-err")}>
|
|
610
|
+
{testState.ok ? "✓ " : "✗ "}{testState.msg}
|
|
611
|
+
</span> : <span className="dknt-row-sub">用当前保存的 Webhook 发一条测试消息</span>}
|
|
612
|
+
</div>
|
|
613
|
+
<div className="dknt-note">机器人创建:钉钉群 → 设置 → 智能群助手 → 添加机器人 → 自定义(Webhook),
|
|
614
|
+
安全设置选「自定义关键词」填「任务」或「dsh」(推送标题含「任务」即可命中)。</div>
|
|
615
|
+
</div> : <div className="dknt-note">未开启——任务结束不推送钉钉。</div>}
|
|
616
|
+
</div>,
|
|
617
|
+
<div key="feishu" className="dknt-sec">
|
|
618
|
+
<div className="dknt-sec-head">
|
|
619
|
+
<span className="dknt-sec-title">飞书推送</span>
|
|
620
|
+
<span className="dknt-sec-sub">任务结束推送到飞书群机器人(宿主直发,浏览器关着也能推;事件跟随上方完成/异常开关)</span>
|
|
621
|
+
<span className="dknt-sec-sw">
|
|
622
|
+
<span className={"dknt-sec-swlabel" + (cfg.feishuEnabled ? " on" : "")}>{cfg.feishuEnabled ? "已开启" : "已关闭"}</span>
|
|
623
|
+
<button type="button" className={"dock-sw" + (cfg.feishuEnabled ? " on" : "")}
|
|
624
|
+
role="switch" aria-checked={cfg.feishuEnabled} aria-label="开关飞书推送"
|
|
625
|
+
title={cfg.feishuEnabled ? "关闭飞书推送" : "开启飞书推送"}
|
|
626
|
+
onClick={() => patch({ feishuEnabled: !cfg.feishuEnabled })} />
|
|
627
|
+
</span>
|
|
628
|
+
</div>
|
|
629
|
+
{cfg.feishuEnabled ? <div className="dknt-rows-narrow">
|
|
630
|
+
<div className="dknt-row dknt-row-webhook">
|
|
631
|
+
<span className="dknt-row-label">Webhook</span>
|
|
632
|
+
<input type="text" className="dknt-input" spellCheck={false}
|
|
633
|
+
value={cfg.feishuWebhook || ""}
|
|
634
|
+
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/…"
|
|
635
|
+
onChange={(e) => {
|
|
636
|
+
editingFeishuRef.current = true;
|
|
637
|
+
setCfg(Object.assign({}, cfg, { feishuWebhook: e.target.value }));
|
|
638
|
+
}} />
|
|
639
|
+
<button type="button" className="dknt-btn" disabled={!editingFeishuRef.current}
|
|
640
|
+
onClick={saveFeishuWebhook}>
|
|
641
|
+
{editingFeishuRef.current ? "保存" : "已保存"}
|
|
642
|
+
</button>
|
|
643
|
+
</div>
|
|
644
|
+
<div className="dknt-row">
|
|
645
|
+
<span className="dknt-row-label">连通测试</span>
|
|
646
|
+
<button type="button" className="dknt-btn" disabled={feishuTesting} onClick={runFeishuTest}>
|
|
647
|
+
{feishuTesting ? "发送中…" : "发送测试消息"}
|
|
648
|
+
</button>
|
|
649
|
+
{feishuTestState ? <span className={"dknt-row-sub" + (feishuTestState.ok ? " dknt-ok" : " dknt-err")}>
|
|
650
|
+
{feishuTestState.ok ? "✓ " : "✗ "}{feishuTestState.msg}
|
|
651
|
+
</span> : <span className="dknt-row-sub">用当前保存的 Webhook 发一条测试消息</span>}
|
|
652
|
+
</div>
|
|
653
|
+
<div className="dknt-note">机器人创建:飞书群 → 设置 → 群机器人 → 添加机器人 → 自定义机器人(获取 Webhook 地址);
|
|
654
|
+
安全设置如选「自定义关键词」填「任务」或「dsh」(推送标题含「任务」即可命中)。</div>
|
|
655
|
+
</div> : <div className="dknt-note">未开启——任务结束不推送飞书。</div>}
|
|
656
|
+
</div>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
if (saveErr) rows.push(<div key="err" className="dknt-note dknt-err">{saveErr}</div>);
|
|
660
|
+
// 任务运行状态(进行中 / 最近完成 / 等待确认)已独立成左侧「运行状态」菜单项,这里只留一句指路
|
|
661
|
+
rows.push(
|
|
662
|
+
<div key="hint" className="dknt-note">
|
|
663
|
+
任务运行状态(进行中任务、等待确认与最近完成)已独立成左侧「运行状态」菜单;本页只负责结束与需确认时的提醒。
|
|
664
|
+
</div>
|
|
665
|
+
);
|
|
666
|
+
return <div className="dknt-root">{rows}</div>;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ---------- 首页总揽概要 ----------
|
|
670
|
+
// 任务进行中 / 空闲这类任务信息归【运行状态】模块,这里只报通知自身的状态:
|
|
671
|
+
// 通知事件 + 提醒方式 + 已开启的推送通道,与面板页开关一一对应。
|
|
672
|
+
function NotifyStat(props) {
|
|
673
|
+
const snap = useNotify(props && props.ctx);
|
|
674
|
+
const cfg = snap.status && snap.status.config;
|
|
675
|
+
if (snap.error) return <span className="dknt-err">通知状态不可用(宿主需重启加载通知路由)</span>;
|
|
676
|
+
if (!cfg) return <span>等待通知配置…</span>;
|
|
677
|
+
const events = [cfg.notifyOnComplete ? "完成" : "", cfg.notifyOnError ? "异常" : "", cfg.notifyOnConfirm !== false ? "需确认" : ""].filter(Boolean);
|
|
678
|
+
const extras = [cfg.soundNotify !== false ? "提示音" : "", cfg.systemNotify ? "系统通知" : ""].filter(Boolean);
|
|
679
|
+
const push = [cfg.dingtalkEnabled ? "钉钉" : "", cfg.feishuEnabled ? "飞书" : ""].filter(Boolean);
|
|
680
|
+
return <span>{(events.length ? "通知:" + events.join("/") : "通知事件全关")
|
|
681
|
+
+ (extras.length ? " · " + extras.join("/") : "")
|
|
682
|
+
+ (push.length ? " · 推送:" + push.join("/") : "")}</span>;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ---------- 样式(dknt- 前缀;全部走主题变量,暗/亮色自适应) ----------
|
|
686
|
+
const css = [
|
|
687
|
+
// 通知卡片栈(右上角,不遮 dsh 自身 UI)
|
|
688
|
+
".dknt-toasts{position:fixed;top:16px;right:16px;z-index:9995;display:flex;flex-direction:column;gap:8px;width:min(380px,calc(100vw - 32px));}",
|
|
689
|
+
".dknt-toast{border-radius:12px;padding:12px 14px;pointer-events:auto;background:color-mix(in srgb,var(--dsw-alias-bg-layer-2) 88%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--dsw-alias-border-l1);box-shadow:0 10px 36px rgb(0 0 0 / .22);animation:dknt-toast-in .32s var(--ds-ease-in-out);}",
|
|
690
|
+
".dknt-toast.out{animation:dknt-toast-out .24s var(--ds-ease-in-out) forwards;}",
|
|
691
|
+
"@keyframes dknt-toast-in{from{opacity:0;transform:translateX(14px)}to{opacity:1;transform:none}}",
|
|
692
|
+
"@keyframes dknt-toast-out{to{opacity:0;transform:translateX(10px)}}",
|
|
693
|
+
".dknt-toast-head{display:flex;align-items:center;gap:8px;margin-bottom:4px;}",
|
|
694
|
+
".dknt-toast-mark{width:8px;height:8px;border-radius:50%;flex:none;}",
|
|
695
|
+
".dknt-toast-title{font-weight:600;font-size:13px;color:var(--dsw-alias-label-primary);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
696
|
+
".dknt-toast-close{cursor:pointer;flex:none;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);border-radius:6px;width:22px;height:22px;font-size:11px;line-height:1;display:inline-flex;align-items:center;justify-content:center;font-family:inherit;}",
|
|
697
|
+
".dknt-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}",
|
|
698
|
+
".dknt-toast-body{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.7;white-space:pre-line;word-break:break-word;}",
|
|
699
|
+
// 需确认卡片:琥珀描边 + 缓慢呼吸光晕,常驻提醒直到用户处理
|
|
700
|
+
".dknt-toast-confirm{border-color:color-mix(in srgb,var(--dk-warn) 55%,transparent);animation:dknt-toast-in .32s var(--ds-ease-in-out),dknt-confirm-glow 2.4s ease-in-out 0.4s infinite;}",
|
|
701
|
+
"@keyframes dknt-confirm-glow{0%,100%{box-shadow:0 10px 36px rgb(0 0 0 / .22),0 0 0 0 color-mix(in srgb,var(--dk-warn) 30%,transparent)}50%{box-shadow:0 10px 36px rgb(0 0 0 / .22),0 0 14px 2px color-mix(in srgb,var(--dk-warn) 32%,transparent)}}",
|
|
702
|
+
"@media (prefers-reduced-motion:reduce){.dknt-toast,.dknt-toast-confirm{animation:none}}",
|
|
703
|
+
// 面板页布局
|
|
704
|
+
".dknt-root{display:flex;flex-direction:column;gap:10px;}",
|
|
705
|
+
".dknt-note{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6;}",
|
|
706
|
+
".dknt-err{color:var(--dsw-alias-state-error-primary);}",
|
|
707
|
+
".dknt-ok{color:var(--dsw-alias-state-success-primary);}",
|
|
708
|
+
".dknt-sec{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:8px;}",
|
|
709
|
+
".dknt-sec-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}",
|
|
710
|
+
".dknt-sec-title{font-weight:600;font-size:13px;color:var(--dsw-alias-label-primary);flex:none;}",
|
|
711
|
+
".dknt-sec-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);flex:1;min-width:120px;}",
|
|
712
|
+
".dknt-sec-sw{margin-left:auto;flex:none;display:inline-flex;align-items:center;gap:8px;}",
|
|
713
|
+
".dknt-sec-swlabel{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;}",
|
|
714
|
+
".dknt-sec-swlabel.on{color:var(--dsw-alias-state-success-primary);}",
|
|
715
|
+
// 子选项行
|
|
716
|
+
".dknt-rows-narrow{display:flex;flex-direction:column;gap:6px;}",
|
|
717
|
+
".dknt-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary);flex-wrap:wrap;}",
|
|
718
|
+
".dknt-row-label{flex:none;min-width:60px;color:var(--dsw-alias-label-primary);}",
|
|
719
|
+
".dknt-row-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);}",
|
|
720
|
+
".dknt-miniswitch{cursor:pointer;flex:none;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 12px;font-family:inherit;font-size:11px;line-height:18px;}",
|
|
721
|
+
".dknt-miniswitch.on{color:var(--dsw-alias-state-success-primary);border-color:currentColor;}",
|
|
722
|
+
".dknt-select{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:3px 8px;font-size:12px;font-family:inherit;}",
|
|
723
|
+
// 音效选择卡(名称 + 播放键;选中态描边)
|
|
724
|
+
".dknt-sounds{display:flex;gap:6px;flex-wrap:wrap;}",
|
|
725
|
+
".dknt-sound{flex:1;min-width:104px;display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);cursor:pointer;font-family:inherit;transition:border-color .15s var(--ds-ease-in-out);}",
|
|
726
|
+
".dknt-sound:hover{border-color:var(--dk-accent);}",
|
|
727
|
+
".dknt-sound.on{border-color:var(--dk-accent);box-shadow:0 0 0 1px color-mix(in srgb,var(--dk-accent) 40%,transparent);}",
|
|
728
|
+
".dknt-sound-name{font-size:12px;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:5px;}",
|
|
729
|
+
".dknt-sound-cur{color:var(--dk-accent);font-size:11px;}",
|
|
730
|
+
".dknt-sound-play{flex:none;cursor:pointer;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1;border-radius:50%;width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l2);}",
|
|
731
|
+
".dknt-sound-play:hover{color:var(--dsw-alias-label-primary);border-color:currentColor;}",
|
|
732
|
+
".dknt-input{flex:1;min-width:220px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 8px;font-size:12px;font-family:inherit;}",
|
|
733
|
+
".dknt-input:focus{outline:none;border-color:var(--dk-accent);}",
|
|
734
|
+
".dknt-row-webhook{flex-wrap:nowrap;}",
|
|
735
|
+
".dknt-row-webhook .dknt-input{min-width:0;}",
|
|
736
|
+
".dknt-btn{cursor:pointer;flex:none;color:var(--dsw-alias-label-primary);background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 12px;font-family:inherit;font-size:12px;}",
|
|
737
|
+
".dknt-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}",
|
|
738
|
+
".dknt-btn[disabled]{opacity:.5;cursor:default;}",
|
|
739
|
+
].join("\n");
|
|
740
|
+
|
|
741
|
+
export const feature = {
|
|
742
|
+
id: "notify",
|
|
743
|
+
name: "任务通知",
|
|
744
|
+
order: 140,
|
|
745
|
+
accent: "#f59e0b",
|
|
746
|
+
description: "任务完成/异常/需确认通知:页内卡片、提示音、系统通知、钉钉/飞书群机器人推送(独立开关)",
|
|
747
|
+
css,
|
|
748
|
+
View: NotifyView,
|
|
749
|
+
HomeStat: NotifyStat,
|
|
750
|
+
Overlay: NotifyOverlay,
|
|
751
|
+
};
|