dsh-dock 0.1.0 → 0.4.1
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/LICENSE +12 -15
- package/README.md +61 -70
- package/client.js +2136 -97
- package/features/balance/host.js +494 -0
- package/features/balance/view.js +303 -0
- package/features/heartbeat/view.js +34 -0
- package/features/modelconfig/host.js +403 -0
- package/features/modelconfig/view.js +446 -0
- package/features/theme/view.js +35 -0
- package/features/tokenlog/host.js +678 -0
- package/features/tokenlog/view.jsx +568 -0
- package/features/visionproxy/host.js +301 -0
- package/index.js +50 -36
- package/package.json +22 -7
- package/screenshots/dock-chips.png +0 -0
- package/screenshots/dock-home.png +0 -0
- package/screenshots/dock-tokenlog.png +0 -0
- package/src/client.jsx +549 -0
- package/src/host-core.js +54 -0
- package/src/shared.js +108 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// dsh-dock · 功能模块【模型余额】· 客户端视图(v0.2.0 迁自 client.js,行为不变)
|
|
2
|
+
// 数据与刷新走共享 balanceStore:首页总揽、弹层与设置页共用同一份快照(挂载即拉取,5 分钟自动刷新)
|
|
3
|
+
import react from "react";
|
|
4
|
+
import { openPanel } from "../../src/shared.js";
|
|
5
|
+
|
|
6
|
+
// ---- 模型余额视图的配色与工具(沿用 dsh-balance-panel@0.1.1,MIT 同作者)----
|
|
7
|
+
const ACCENT_PALETTE = ['#4d9fff', '#34d399', '#fbbf24', '#a78bfa', '#f472b6', '#38bdf8', '#fb923c', '#4ade80', '#e879f9', '#22d3ee'];
|
|
8
|
+
const CURATED_ACCENTS = {
|
|
9
|
+
'deepseek-official': '#4d9fff',
|
|
10
|
+
deepseek: '#4d9fff',
|
|
11
|
+
'qwen-token-plan-cn': '#fbbf24',
|
|
12
|
+
fangzhou: '#a78bfa',
|
|
13
|
+
openai: '#10a37f',
|
|
14
|
+
anthropic: '#d97757',
|
|
15
|
+
'google-gemini': '#4285f4'
|
|
16
|
+
};
|
|
17
|
+
const C_TOTAL = '#34d399';
|
|
18
|
+
const C_GRANTED = '#22d3ee';
|
|
19
|
+
const C_TOPUP = '#fbbf24';
|
|
20
|
+
const C_OK = '#34d399';
|
|
21
|
+
const C_ERR = '#f87171';
|
|
22
|
+
|
|
23
|
+
function accentOf(id) {
|
|
24
|
+
if (CURATED_ACCENTS[id]) return CURATED_ACCENTS[id];
|
|
25
|
+
let h = 0;
|
|
26
|
+
const s = String(id);
|
|
27
|
+
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
|
28
|
+
return ACCENT_PALETTE[h % ACCENT_PALETTE.length];
|
|
29
|
+
}
|
|
30
|
+
const fmt = (v) => (v == null || v === '' ? '—' : String(v));
|
|
31
|
+
|
|
32
|
+
// ---- 共享快照:弹层/设置页/首页总揽共用一份数据与刷新 ----
|
|
33
|
+
const balanceStore = {
|
|
34
|
+
snap: { data: null, loading: false, error: null },
|
|
35
|
+
listeners: new Set(),
|
|
36
|
+
subscribe(fn) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; },
|
|
37
|
+
set(patch) { this.snap = Object.assign({}, this.snap, patch); for (const fn of this.listeners) fn(); },
|
|
38
|
+
load() {
|
|
39
|
+
if (this.snap.loading) return;
|
|
40
|
+
this.set({ loading: true });
|
|
41
|
+
fetch("/dsh-dock/balance", { signal: AbortSignal.timeout(20000) })
|
|
42
|
+
.then((res) => (res.ok ? res.json() : Promise.reject(new Error("余额接口 HTTP " + res.status))))
|
|
43
|
+
.then((data) => this.set({ data: data, loading: false, error: null }))
|
|
44
|
+
.catch((e) => this.set({ loading: false, error: (e && e.message) || String(e) }));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
function useBalance(ctx) {
|
|
48
|
+
const [snap, setSnap] = react.useState(balanceStore.snap);
|
|
49
|
+
react.useEffect(() => {
|
|
50
|
+
const off = balanceStore.subscribe(() => setSnap(balanceStore.snap));
|
|
51
|
+
if (!balanceStore.snap.data && !balanceStore.snap.loading) balanceStore.load();
|
|
52
|
+
const disposer = ctx && typeof ctx.interval === "function" ? ctx.interval(() => balanceStore.load(), 5 * 60 * 1000) : null;
|
|
53
|
+
return () => { off(); if (disposer) disposer(); };
|
|
54
|
+
}, []);
|
|
55
|
+
return snap;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function BalanceView(props) {
|
|
59
|
+
// 数据与刷新走共享 balanceStore(挂载即拉取,5 分钟自动刷新)
|
|
60
|
+
// props.params.provider(chips 点击带入):滚动到并高亮该 Provider 行
|
|
61
|
+
const snap = useBalance(props && props.ctx);
|
|
62
|
+
const load = () => balanceStore.load();
|
|
63
|
+
react.useEffect(() => {
|
|
64
|
+
const pid = props && props.params && props.params.provider;
|
|
65
|
+
if (!pid || typeof document === "undefined") return;
|
|
66
|
+
const t = setTimeout(() => {
|
|
67
|
+
try {
|
|
68
|
+
const el = document.querySelector('[data-dock-provider="' + pid + '"]');
|
|
69
|
+
if (el) {
|
|
70
|
+
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
71
|
+
el.classList.remove("dkb-flash");
|
|
72
|
+
void el.offsetWidth; // 重启动画
|
|
73
|
+
el.classList.add("dkb-flash");
|
|
74
|
+
}
|
|
75
|
+
} catch { /* 高亮失败不影响视图 */ }
|
|
76
|
+
}, 80);
|
|
77
|
+
return () => clearTimeout(t);
|
|
78
|
+
}, [props && props.params]);
|
|
79
|
+
|
|
80
|
+
const data = snap.data;
|
|
81
|
+
const providers = data && Array.isArray(data.providers) ? data.providers : [];
|
|
82
|
+
const okCount = providers.filter((p) => p.balance && p.balance.status === "ok").length;
|
|
83
|
+
const def = data && data.default ? data.default.provider : null;
|
|
84
|
+
const body = [];
|
|
85
|
+
|
|
86
|
+
if (snap.error) {
|
|
87
|
+
body.push(react.createElement("div", { key: "err", className: "dkb-note dkb-error" }, "余额查询失败:" + snap.error));
|
|
88
|
+
} else if (!data) {
|
|
89
|
+
body.push(react.createElement("div", { key: "loading", className: "dkb-note" }, snap.loading ? "正在拉取余额…" : "还没有数据,点击下方刷新"));
|
|
90
|
+
} else if (providers.length === 0) {
|
|
91
|
+
body.push(react.createElement("div", { key: "empty", className: "dkb-note" }, "没有找到已配置的模型 Provider"));
|
|
92
|
+
} else {
|
|
93
|
+
body.push(react.createElement("div", { key: "rows", className: "dkb-rows" },
|
|
94
|
+
providers.map((p) => {
|
|
95
|
+
const accent = accentOf(p.id);
|
|
96
|
+
const b = p.balance;
|
|
97
|
+
let badge = ["未知", ""];
|
|
98
|
+
if (b) {
|
|
99
|
+
if (b.status === "ok") badge = ["可用", "ok"];
|
|
100
|
+
else if (b.status === "unsupported") badge = ["不支持", ""];
|
|
101
|
+
else if (b.status === "no-credential") badge = ["未配置密钥", "warn"];
|
|
102
|
+
else if (b.status === "login-required") badge = ["需登录", "warn"];
|
|
103
|
+
else badge = ["查询失败", "err"];
|
|
104
|
+
}
|
|
105
|
+
const sub = ["ID: " + p.id, p.api ? "api: " + p.api : null, p.baseURL ? p.baseURL : null].filter(Boolean).join(" · ");
|
|
106
|
+
const cells = [];
|
|
107
|
+
let balBody = null;
|
|
108
|
+
if (b && b.status === "ok" && b.kind === "quota") {
|
|
109
|
+
cells.push(react.createElement("div", { key: "q", className: "dkb-bal" },
|
|
110
|
+
react.createElement("span", { className: "dkb-cur", style: { color: accent } }, "总额度"),
|
|
111
|
+
react.createElement("span", { className: "dkb-total", style: { color: C_TOTAL } }, "剩余 " + fmt(b.remaining) + (b.unit ? " " + b.unit : "")),
|
|
112
|
+
react.createElement("span", { className: "dkb-part" }, "已用 " + fmt(b.used) + " / 总 " + fmt(b.limit)),
|
|
113
|
+
b.resetTime
|
|
114
|
+
? react.createElement("span", { className: "dkb-part" }, "重置 " + String(b.resetTime).slice(0, 16))
|
|
115
|
+
: null));
|
|
116
|
+
(Array.isArray(b.dims) ? b.dims : []).forEach((d, i) => {
|
|
117
|
+
cells.push(react.createElement("div", { key: "d" + i, className: "dkb-bal" },
|
|
118
|
+
react.createElement("span", { className: "dkb-cur", style: { color: accent } }, d.window === "weekly" ? "周额度" : "小时额度"),
|
|
119
|
+
react.createElement("span", { className: "dkb-total", style: { color: C_TOTAL } }, "剩余 " + fmt(d.remaining)),
|
|
120
|
+
react.createElement("span", { className: "dkb-part" }, "已用 " + fmt(d.used) + " / " + fmt(d.limit)),
|
|
121
|
+
d.resetTime
|
|
122
|
+
? react.createElement("span", { className: "dkb-part" }, "重置 " + String(d.resetTime).slice(0, 16))
|
|
123
|
+
: null));
|
|
124
|
+
});
|
|
125
|
+
} else if (b && b.status === "ok") {
|
|
126
|
+
const infos = Array.isArray(b.infos) ? b.infos : [];
|
|
127
|
+
cells.push(infos.map((i, idx) =>
|
|
128
|
+
react.createElement("div", { key: idx, className: "dkb-bal" },
|
|
129
|
+
react.createElement("span", { className: "dkb-cur", style: { color: accent } }, i.currency),
|
|
130
|
+
react.createElement("span", { className: "dkb-total", style: { color: C_TOTAL } }, "总额 " + fmt(i.totalBalance)),
|
|
131
|
+
i.grantedBalance != null
|
|
132
|
+
? react.createElement("span", { className: "dkb-part", style: { color: C_GRANTED } }, "赠送 " + fmt(i.grantedBalance))
|
|
133
|
+
: null,
|
|
134
|
+
i.toppedUpBalance != null
|
|
135
|
+
? react.createElement("span", { className: "dkb-part", style: { color: C_TOPUP } }, "充值 " + fmt(i.toppedUpBalance))
|
|
136
|
+
: null)));
|
|
137
|
+
} else if (b && b.status === "login-required" && b.consoleUrl) {
|
|
138
|
+
balBody = react.createElement("div", { className: "dkb-bal" },
|
|
139
|
+
react.createElement("a", { href: b.consoleUrl, target: "_blank", rel: "noreferrer", className: "dkb-link", style: { color: accent } }, "去控制台查看余额 →"));
|
|
140
|
+
} else {
|
|
141
|
+
balBody = react.createElement("div", { className: "dkb-note" }, (b && b.message) || "未知状态");
|
|
142
|
+
}
|
|
143
|
+
if (cells.length > 0) balBody = react.createElement("div", { className: "dkb-rows" }, cells);
|
|
144
|
+
return react.createElement("div", { key: p.id, className: "dkb-row", "data-dock-provider": p.id },
|
|
145
|
+
react.createElement("div", { className: "dkb-row-head" },
|
|
146
|
+
react.createElement("span", { className: "dkb-dot", style: { background: accent } }),
|
|
147
|
+
react.createElement("span", { className: "dkb-name", style: { color: accent } }, p.displayName),
|
|
148
|
+
def === p.id ? react.createElement("span", { className: "dkb-default" }, "默认") : null,
|
|
149
|
+
react.createElement("span", { className: "dkb-badge" + (badge[1] ? " " + badge[1] : ""), style: badge[1] === "err" ? { color: C_ERR, borderColor: C_ERR } : null }, badge[0])),
|
|
150
|
+
sub ? react.createElement("div", { className: "dkb-sub" }, sub) : null,
|
|
151
|
+
p.apiKeyEnv
|
|
152
|
+
? react.createElement("div", { className: "dkb-sub" }, "密钥: " + p.apiKeyEnv + (p.credentialConfigured ? " ✓" : " ✗"))
|
|
153
|
+
: null,
|
|
154
|
+
p.models && p.models.length > 0
|
|
155
|
+
? react.createElement("div", { className: "dkb-chips" },
|
|
156
|
+
p.models.slice(0, 8).map((m) =>
|
|
157
|
+
react.createElement("span", { key: m, className: "dkb-chip", style: { border: "1px solid " + accent, color: accent, background: accent + "1a" } }, m)),
|
|
158
|
+
p.models.length > 8 ? react.createElement("span", { key: "more", className: "dkb-chip" }, "+" + (p.models.length - 8)) : null)
|
|
159
|
+
: null,
|
|
160
|
+
balBody);
|
|
161
|
+
})));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const now = new Date(data && data.generatedAt ? data.generatedAt : Date.now());
|
|
165
|
+
return react.createElement("div", { className: "dkb-rows" },
|
|
166
|
+
react.createElement("div", { className: "dkb-note" },
|
|
167
|
+
data && !snap.error && providers.length > 0
|
|
168
|
+
? okCount + "/" + providers.length + " 个 Provider 可查余额"
|
|
169
|
+
: "模型余额 · Host 半部实时拉取"),
|
|
170
|
+
body,
|
|
171
|
+
react.createElement("div", { className: "dkb-foot" },
|
|
172
|
+
react.createElement("span", null, snap.loading ? "刷新中…" : "更新于 " + now.toLocaleTimeString()),
|
|
173
|
+
react.createElement("button", { type: "button", className: "dkb-refresh", onClick: load }, "刷新")));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function BalanceStat(props) {
|
|
177
|
+
const snap = useBalance(props && props.ctx);
|
|
178
|
+
const data = snap.data;
|
|
179
|
+
const providers = data && Array.isArray(data.providers) ? data.providers : [];
|
|
180
|
+
if (snap.error) return react.createElement("span", { className: "dockm-err" }, "余额查询失败(点击进入查看详情)");
|
|
181
|
+
if (!data) return react.createElement("span", null, snap.loading ? "正在拉取余额…" : "等待拉取余额");
|
|
182
|
+
const ok = providers.filter((p) => p.balance && p.balance.status === "ok").length;
|
|
183
|
+
return react.createElement("span", null, providers.length + " 个 Provider · " + ok + " 个可查余额");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---- 会话输入区 chip:显示当前选中 Provider 的账户余额 ----
|
|
187
|
+
// 当前选中以会话级模型选择为准(client 服务 modelDirectories:选择器切换即时推送),
|
|
188
|
+
// 无该服务时回退余额快照里的默认选中(agentDefaultModel)。
|
|
189
|
+
// 点击打开功能坞并定位到模型余额页、高亮该 Provider 行。
|
|
190
|
+
function useCurrentProvider(ctx, sessionId) {
|
|
191
|
+
const [cur, setCur] = react.useState(null);
|
|
192
|
+
react.useEffect(() => {
|
|
193
|
+
let alive = true, off = null;
|
|
194
|
+
try {
|
|
195
|
+
const models = ctx && ctx.get ? ctx.get("modelDirectories") : undefined;
|
|
196
|
+
if (models && typeof models.directoryFor === "function" && sessionId) {
|
|
197
|
+
const dir = models.directoryFor(sessionId);
|
|
198
|
+
const face = dir && typeof dir.subscribe === "function" ? dir : (dir && dir.store);
|
|
199
|
+
const read = () => {
|
|
200
|
+
if (!alive) return;
|
|
201
|
+
try {
|
|
202
|
+
const snap = face && typeof face.getSnapshot === "function" ? face.getSnapshot() : null;
|
|
203
|
+
setCur(snap && snap.current ? snap.current : null);
|
|
204
|
+
} catch { /* 快照读取失败保持旧值 */ }
|
|
205
|
+
};
|
|
206
|
+
if (face && typeof face.subscribe === "function") {
|
|
207
|
+
read();
|
|
208
|
+
off = face.subscribe(read);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch { /* 服务不可用回退默认 */ }
|
|
212
|
+
return () => { alive = false; if (typeof off === "function") off(); };
|
|
213
|
+
}, [sessionId]);
|
|
214
|
+
return cur;
|
|
215
|
+
}
|
|
216
|
+
function chipBalanceText(p) {
|
|
217
|
+
const b = p && p.balance;
|
|
218
|
+
if (!b) return "余额 …";
|
|
219
|
+
if (b.status !== "ok") {
|
|
220
|
+
if (b.status === "login-required") return "余额·需登录";
|
|
221
|
+
if (b.status === "no-credential") return "余额·无密钥";
|
|
222
|
+
if (b.status === "unsupported") return "余额·不支持";
|
|
223
|
+
return "余额·查询失败";
|
|
224
|
+
}
|
|
225
|
+
if (b.kind === "quota") {
|
|
226
|
+
const v = Number(b.remaining);
|
|
227
|
+
const s = Number.isFinite(v)
|
|
228
|
+
? (v >= 10000 ? (v / 10000).toFixed(1) + "万" : String(Math.round(v)))
|
|
229
|
+
: fmt(b.remaining);
|
|
230
|
+
return "余额 剩 " + s + (b.unit ? " " + b.unit : "");
|
|
231
|
+
}
|
|
232
|
+
const info = Array.isArray(b.infos) && b.infos[0] ? b.infos[0] : null;
|
|
233
|
+
if (!info) return "余额 …";
|
|
234
|
+
const cur = info.currency === "USD" ? "$" : "¥";
|
|
235
|
+
const v = Number(info.totalBalance);
|
|
236
|
+
const s = Number.isFinite(v) ? (v >= 10000 ? (v / 10000).toFixed(2) + "万" : String(Math.round(v * 100) / 100)) : fmt(info.totalBalance);
|
|
237
|
+
return "余额 " + cur + s;
|
|
238
|
+
}
|
|
239
|
+
function BalanceChip(props) {
|
|
240
|
+
const snap = useBalance(props && props.ctx);
|
|
241
|
+
const data = snap.data;
|
|
242
|
+
const providers = data && Array.isArray(data.providers) ? data.providers : [];
|
|
243
|
+
// 会话级当前选中(模型选择器切换即时推送);无该服务时回退余额快照里的默认选中
|
|
244
|
+
const sel = useCurrentProvider(props && props.ctx, props && props.sessionId);
|
|
245
|
+
const selProvider = sel && (sel.provider || (sel.selected && sel.selected.provider)) || null;
|
|
246
|
+
const def = selProvider || (data && data.default ? data.default.provider : null);
|
|
247
|
+
const cur = def ? providers.find((p) => p.id === def) : null;
|
|
248
|
+
const accent = cur ? accentOf(cur.id) : "#94a3b8";
|
|
249
|
+
const text = snap.error ? "余额·失败" : (!data ? (snap.loading ? "余额 …" : "余额 —") : (cur ? chipBalanceText(cur) : "余额 —"));
|
|
250
|
+
const selModel = sel && (sel.model || (sel.selected && sel.selected.model)) || null;
|
|
251
|
+
const title = cur
|
|
252
|
+
? (cur.displayName || cur.id) + (selModel ? " · 当前模型 " + selModel : "") + " · 点击在功能坞查看余额详情"
|
|
253
|
+
: "点击打开功能坞 · 模型余额";
|
|
254
|
+
return react.createElement("button", {
|
|
255
|
+
type: "button",
|
|
256
|
+
className: "dockchip" + (snap.error || (cur && cur.balance && cur.balance.status === "error") ? " err" : ""),
|
|
257
|
+
title: title,
|
|
258
|
+
"aria-label": "模型余额",
|
|
259
|
+
onClick: () => openPanel("balance", def ? { provider: def } : null),
|
|
260
|
+
},
|
|
261
|
+
react.createElement("span", { className: "dockchip-dot", style: { background: accent } }),
|
|
262
|
+
react.createElement("span", null, text));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export const feature = {
|
|
266
|
+
id: "balance",
|
|
267
|
+
name: "模型余额",
|
|
268
|
+
order: 120,
|
|
269
|
+
accent: "#4d9fff",
|
|
270
|
+
description: "展示所有模型 Provider 账户余额(Host 拉取,5 分钟自动刷新)",
|
|
271
|
+
css: [
|
|
272
|
+
".dkb-note{color:var(--dsw-alias-label-secondary);font-size:12px;}",
|
|
273
|
+
".dkb-error{color:var(--dsw-alias-state-error-primary);}",
|
|
274
|
+
".dkb-rows{display:flex;flex-direction:column;gap:8px;}",
|
|
275
|
+
".dkb-row{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px 10px;display:flex;flex-direction:column;gap:5px;}",
|
|
276
|
+
".dkb-row-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}",
|
|
277
|
+
".dkb-dot{width:8px;height:8px;border-radius:50%;flex:none;}",
|
|
278
|
+
".dkb-name{font-weight:600;font-size:13px;color:var(--dsw-alias-label-primary);}",
|
|
279
|
+
".dkb-default{color:var(--dsw-alias-accent,#4d9fff);font-size:11px;border:1px solid currentColor;border-radius:999px;padding:0 6px;}",
|
|
280
|
+
".dkb-badge{font-size:11px;border-radius:999px;padding:1px 8px;color:var(--dsw-alias-label-tertiary);border:1px solid var(--dsw-alias-border-l2);}",
|
|
281
|
+
".dkb-badge.ok{color:var(--dsw-alias-state-success-primary);border-color:currentColor;}",
|
|
282
|
+
".dkb-badge.warn{color:var(--dsw-alias-state-warning-primary);border-color:currentColor;}",
|
|
283
|
+
".dkb-badge.err{color:var(--dsw-alias-state-error-primary);border-color:currentColor;}",
|
|
284
|
+
".dkb-sub{color:var(--dsw-alias-label-tertiary);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
285
|
+
".dkb-chips{display:flex;flex-wrap:wrap;gap:4px;}",
|
|
286
|
+
".dkb-chip{font-size:10px;border-radius:6px;padding:1px 6px;}",
|
|
287
|
+
".dkb-bal{display:flex;gap:10px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-primary);align-items:baseline;}",
|
|
288
|
+
".dkb-cur{font-weight:600;min-width:48px;}",
|
|
289
|
+
".dkb-total{font-weight:600;}",
|
|
290
|
+
".dkb-part{font-size:11px;color:var(--dsw-alias-label-tertiary);}",
|
|
291
|
+
".dkb-link{font-size:12px;text-decoration:none;}",
|
|
292
|
+
".dkb-link:hover{text-decoration:underline;}",
|
|
293
|
+
".dkb-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;color:var(--dsw-alias-label-tertiary);font-size:12px;}",
|
|
294
|
+
".dkb-refresh{cursor:pointer;color:var(--dsw-alias-label-primary);background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:2px 10px;font-family:inherit;font-size:12px;}",
|
|
295
|
+
".dkb-refresh:hover{background:var(--dsw-alias-interactive-bg-hover);}",
|
|
296
|
+
// chips 点击定位后的行高亮闪烁
|
|
297
|
+
".dkb-row.dkb-flash{animation:dkb-flash 1.8s var(--ds-ease-in-out);}",
|
|
298
|
+
"@keyframes dkb-flash{0%,55%{border-color:var(--dsw-alias-accent,#4d9fff);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-accent,#4d9fff) 25%,transparent);}100%{border-color:var(--dsw-alias-border-l1);box-shadow:none;}}"
|
|
299
|
+
].join("\n"),
|
|
300
|
+
View: BalanceView,
|
|
301
|
+
HomeStat: BalanceStat,
|
|
302
|
+
Chip: BalanceChip,
|
|
303
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// dsh-dock · 功能模块【心跳监视】· 客户端视图(纯 Client 功能,无宿主半部)
|
|
2
|
+
import react from "react";
|
|
3
|
+
|
|
4
|
+
// 面板加载时刻:心跳视图与首页总揽共用的运行时长基准(同一数字,不分谁先挂载)
|
|
5
|
+
const loadedAt = Date.now();
|
|
6
|
+
function uptimeText() {
|
|
7
|
+
const sec = Math.max(0, Math.floor((Date.now() - loadedAt) / 1000));
|
|
8
|
+
const m = Math.floor(sec / 60);
|
|
9
|
+
return "面板已运行 " + (m > 0 ? m + " 分 " : "") + (sec % 60) + " 秒";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function HeartbeatView(props) {
|
|
13
|
+
const ctx = props && props.ctx;
|
|
14
|
+
const [txt, setTxt] = react.useState(uptimeText());
|
|
15
|
+
react.useEffect(() => (ctx && typeof ctx.interval === "function" ? ctx.interval(() => setTxt(uptimeText()), 1000) : undefined), []);
|
|
16
|
+
return react.createElement("div", null, txt);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function HeartbeatStat(props) {
|
|
20
|
+
const ctx = props && props.ctx;
|
|
21
|
+
const [txt, setTxt] = react.useState(uptimeText());
|
|
22
|
+
react.useEffect(() => (ctx && typeof ctx.interval === "function" ? ctx.interval(() => setTxt(uptimeText()), 1000) : undefined), []);
|
|
23
|
+
return react.createElement("span", null, txt);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const feature = {
|
|
27
|
+
id: "heartbeat",
|
|
28
|
+
name: "心跳监视",
|
|
29
|
+
order: 140,
|
|
30
|
+
accent: "#34d399",
|
|
31
|
+
description: "示例功能:面板侧运行时长心跳(纯 Client)",
|
|
32
|
+
View: HeartbeatView,
|
|
33
|
+
HomeStat: HeartbeatStat,
|
|
34
|
+
};
|