dsh-contextos-devops 0.0.0-stage → 1.48.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -2
  3. package/assets/avatar.jpg +0 -0
  4. package/assets/client.css +3316 -0
  5. package/assets/icon-180.png +0 -0
  6. package/build/client.mjs +64 -0
  7. package/client.js +7622 -0
  8. package/cordis.patch.yml +3 -0
  9. package/icon.svg +15 -0
  10. package/index.js +345 -0
  11. package/lib/base/env.js +22 -0
  12. package/lib/base/field.js +25 -0
  13. package/lib/base/http.js +140 -0
  14. package/lib/base/platform.js +33 -0
  15. package/lib/base/registry.js +186 -0
  16. package/lib/base/state.js +66 -0
  17. package/lib/base/table.js +47 -0
  18. package/lib/base/texttable.js +26 -0
  19. package/lib/base/url.js +178 -0
  20. package/lib/core/README.md +17 -0
  21. package/lib/core/apps.js +675 -0
  22. package/lib/core/auth.js +110 -0
  23. package/lib/core/avatar.js +289 -0
  24. package/lib/core/backup.js +105 -0
  25. package/lib/core/codegen.js +401 -0
  26. package/lib/core/copilot.js +419 -0
  27. package/lib/core/data.js +157 -0
  28. package/lib/core/device.js +148 -0
  29. package/lib/core/files.js +152 -0
  30. package/lib/core/gobuild.js +63 -0
  31. package/lib/core/iam.js +122 -0
  32. package/lib/core/identity.js +22 -0
  33. package/lib/core/nodes.js +627 -0
  34. package/lib/core/ops.js +306 -0
  35. package/lib/core/project.js +393 -0
  36. package/lib/core/spaces.js +406 -0
  37. package/lib/core/terminal.js +137 -0
  38. package/lib/core/terminal2.js +141 -0
  39. package/lib/core/users.js +51 -0
  40. package/lib/core/vocab.js +158 -0
  41. package/lib/misc/async.js +15 -0
  42. package/lib/misc/shell.js +27 -0
  43. package/lib/misc/static.js +33 -0
  44. package/locale/en.json +10 -0
  45. package/locale/zh.json +10 -0
  46. package/package.json +92 -4
  47. package/src/client/README.md +37 -0
  48. package/src/client/parts/00-prelude.js +315 -0
  49. package/src/client/parts/05-bootstrap.js +61 -0
  50. package/src/client/parts/08-store.js +85 -0
  51. package/src/client/parts/10-ui.js +188 -0
  52. package/src/client/parts/12-debug.js +361 -0
  53. package/src/client/parts/15-layout.js +86 -0
  54. package/src/client/parts/20-devworkspace.js +789 -0
  55. package/src/client/parts/25-editor-ui.js +28 -0
  56. package/src/client/parts/30-modals.js +85 -0
  57. package/src/client/parts/32-cards.js +176 -0
  58. package/src/client/parts/34-forms.js +176 -0
  59. package/src/client/parts/36-data.js +267 -0
  60. package/src/client/parts/40-project.js +430 -0
  61. package/src/client/parts/45-map.js +109 -0
  62. package/src/client/parts/46-pm.js +158 -0
  63. package/src/client/parts/47-three.js +324 -0
  64. package/src/client/parts/49-msg.js +167 -0
  65. package/src/client/parts/50-chat.js +1422 -0
  66. package/src/client/parts/51-pmselection.js +31 -0
  67. package/src/client/parts/52-pmdata.js +29 -0
  68. package/src/client/parts/55-pm.js +146 -0
  69. package/src/client/parts/58-config.js +381 -0
  70. package/src/client/parts/59-monitor.js +262 -0
  71. package/src/client/parts/60-page.js +1518 -0
  72. package/src/client/styles/kit.css +20 -0
  73. package/src/client/styles/main.css +3294 -0
  74. package/src/client/styles/tokens.css +2 -0
  75. package/vendor/leaflet/leaflet.css +661 -0
  76. package/vendor/leaflet/leaflet.js +6 -0
  77. package/vendor/three/three.module.js +53044 -0
  78. package/vendor/xterm/addon-fit.js +2 -0
  79. package/vendor/xterm/xterm.css +218 -0
  80. package/vendor/xterm/xterm.js +2 -0
@@ -0,0 +1,31 @@
1
+ // Persisted PM selection. The component only owns rendering; restoring and
2
+ // saving the five selected ids is one concern, so it lives here.
3
+ function usePmSelection({ avatarUid }) {
4
+ const [targetId, setTargetId] = useState('');
5
+ const [planId, setPlanId] = useState('');
6
+ const [taskId, setTaskId] = useState('');
7
+ const [reportId, setReportId] = useState('');
8
+ const [feedbackId, setFeedbackId] = useState('');
9
+ const ownerRef = useRef('');
10
+ useEffect(() => {
11
+ const uid = avatarUid || '';
12
+ if (!uid || ownerRef.current === uid) return;
13
+ ownerRef.current = uid;
14
+ let saved = (cxSessionGet() || {}).pm || {};
15
+ if (!saved || !Object.keys(saved).length) saved = cxRead('pm', {})[uid] || {};
16
+ setTargetId(saved.target || ''); setPlanId(saved.plan || ''); setTaskId(saved.task || ''); setReportId(saved.report || ''); setFeedbackId(saved.feedback || '');
17
+ }, [avatarUid]);
18
+ useEffect(() => {
19
+ const uid = avatarUid || '';
20
+ if (!uid || ownerRef.current !== uid) return;
21
+ if (!targetId && !planId && !taskId && !reportId && !feedbackId) return;
22
+ const shaped = { target: targetId, plan: planId, task: taskId, report: reportId, feedback: feedbackId };
23
+ cxSessionSave({ pm: shaped });
24
+ try {
25
+ const all = cxRead('pm', {});
26
+ all[uid] = shaped;
27
+ cxWrite('pm', all);
28
+ } catch {}
29
+ }, [avatarUid, targetId, planId, taskId, reportId, feedbackId]);
30
+ return { targetId, setTargetId, planId, setPlanId, taskId, setTaskId, reportId, setReportId, feedbackId, setFeedbackId };
31
+ }
@@ -0,0 +1,29 @@
1
+ // Single PM data source for the chat panel. Selection switches pass their
2
+ // ids through this hook; no component performs ad-hoc PM fetches.
3
+ function usePmData({ node, avatarUid, locale, targetId, planId, taskId, reportId }) {
4
+ const [pm, setPm] = useState(null);
5
+ const [pmError, setPmError] = useState('');
6
+ const [pmRefreshing, setPmRefreshing] = useState(false);
7
+ const seqRef = useRef(0);
8
+ const loadPm = useCallback(async () => {
9
+ if (!node || !avatarUid) {
10
+ setPm(null);
11
+ setPmError(!node ? '请选择服务节点' : '等待分身加载…');
12
+ return;
13
+ }
14
+ const seq = ++seqRef.current;
15
+ setPmRefreshing(true);
16
+ setPmError('');
17
+ try {
18
+ const d = await api('pmList', { node, uid: avatarUid, lang: locale, target_uid: targetId, plan_uid: planId, task_uid: taskId, report_uid: reportId });
19
+ if (seq === seqRef.current) setPm(d || null);
20
+ activity(`加载项目管理:目标 ${(d?.targets || []).length} · 计划 ${(d?.plans || []).length} · 任务 ${(d?.tasks || []).length}`, 'ok');
21
+ } catch (e) {
22
+ if (seq === seqRef.current) { setPm(null); setPmError(String(e?.message || e)); }
23
+ } finally {
24
+ if (seq === seqRef.current) setPmRefreshing(false);
25
+ }
26
+ }, [node, avatarUid, locale, targetId, planId, taskId, reportId]);
27
+ useEffect(() => { loadPm(); }, [loadPm]);
28
+ return { pm, pmError, pmRefreshing, loadPm };
29
+ }
@@ -0,0 +1,146 @@
1
+ const PM_KIND_LABEL = { target: '目标 TARGET', plan: '计划 PLAN', task: '任务 TASK', report: '报告 REPORT', feedback: '反馈 FEEDBACK' };
2
+ const PM_SHORT = { target: '目标', plan: '计划', task: '任务', report: '报告', feedback: '反馈' };
3
+ const PM_ACTION_LABEL = { planCreate: '+ 计划', taskCreate: '+ 任务', reportCreate: '+ 报告', feedbackCreate: '+ 反馈', submit: '提交', approve: '通过', reject: '驳回', process: '开始', finish: '完成', cancel: '取消', modify: '编辑', remove: '删除', handover: '交接', open: '打开链接' };
4
+ // Shared PM card. Keep status/action presentation in one place so every
5
+ // entity type has the same selection, badge, effect and context-menu UI.
6
+ const PM_MENU_ACTIONS = new Set(['modify', 'remove', 'cancel', 'reject', 'handover']);
7
+
8
+
9
+
10
+ // 甘特图:只显示进行中的计划;计划条内嵌任务条(未完成画到最右)
11
+ function Gantt({ plans, activeUid }) {
12
+ const ts = (v) => { if (!v) return null; const d = new Date(String(v).replace(' ', 'T')); return Number.isNaN(d.getTime()) ? null : d.getTime(); };
13
+ const rows = (plans || []).filter((p) => p.status_name === 'process');
14
+ if (!rows.length) return h('div', { className: 'cx-gantt-empty' }, T('没有进行中的计划'));
15
+ let min = Infinity, max = -Infinity;
16
+ for (const p of rows) {
17
+ for (const v of [p.begin_time, p.end_time]) { const t = ts(v); if (t != null) { min = Math.min(min, t); max = Math.max(max, t); } }
18
+ }
19
+ if (!Number.isFinite(min) || max <= min) return h('div', { className: 'cx-gantt-empty' }, T('计划缺少起止时间'));
20
+ const span = max - min;
21
+ const pct = (t) => Math.max(0, Math.min(100, ((t - min) / span) * 100));
22
+ /* 行高与任务间距按“计划数 + 任务总数”自适应,目标是把所有计划和任务都放进 168px 可视区 */
23
+ const rowCount = rows.length;
24
+ const totalTasks = rows.reduce((a, p) => a + Math.min(8, (p.tasks || []).length), 0);
25
+ const step = Math.max(4, Math.min(11, Math.floor((168 - rowCount * 10 - (rowCount - 1) * 3) / Math.max(1, totalTasks))));
26
+ const label = (t) => { const d = new Date(t); return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
27
+ const now = Date.now();
28
+ const nowPct = Math.max(0, Math.min(100, ((now - min) / span) * 100)); /* 超出范围时贴边显示 */
29
+ return h('div', { className: 'cx-gantt' },
30
+ h('div', { className: 'cx-gantt-axis' }, h('span', null, label(min)), h('span', { className: 'mid' }, T('进行中的计划')), h('span', null, label(max))),
31
+ h('div', { className: 'cx-gantt-body' },
32
+ nowPct != null ? h('div', { className: 'cx-gantt-now', style: { left: nowPct + '%' }, title: T('当前时间') }) : null,
33
+ rows.map((p) => {
34
+ const pb = ts(p.begin_time), pe = ts(p.end_time);
35
+ const left = pct(pb ?? min), right = pct(pe ?? max);
36
+ const width = Math.max(2, right - left);
37
+ const isActive = p.uid === activeUid;
38
+ const tasks = (p.tasks || []).slice(0, 8);
39
+ const top0 = 5;
40
+ const rowH = Math.max(18, top0 + tasks.length * step + 4 + (isActive ? 4 : 0));
41
+ return h('div', { className: `cx-gantt-row${isActive ? ' on' : ''}`, key: p.uid },
42
+ h('div', { className: 'cx-gantt-track', style: { height: rowH + 'px', minHeight: rowH + 'px' } },
43
+ h('div', { className: `cx-gantt-plan${isActive ? ' on' : ''}`, style: { left: left + '%', width: width + '%', height: rowH + 'px' } },
44
+ tasks.map((t, i) => {
45
+ const tb = ts(t.begin_time) ?? ts(t.created_at) ?? pb ?? min;
46
+ const done = t.status_name === 'finished' || t.status_name === 'canceled';
47
+ const te = done ? (ts(t.finished_time) ?? ts(t.process_time) ?? max) : max; /* 未完成 → 最右 */
48
+ const l = Math.max(pb ?? min, Math.min(tb, te));
49
+ const r = Math.min(pe ?? max, Math.max(tb, te));
50
+ const lp = Math.max(0, ((l - (pb ?? min)) / Math.max(1, (pe ?? max) - (pb ?? min))) * 100);
51
+ const wp = Math.max(1.5, ((Math.max(r, l + 1) - l) / Math.max(1, (pe ?? max) - (pb ?? min))) * 100);
52
+ return h('div', {
53
+ key: t.uid || i,
54
+ className: `cx-gantt-task ${t.status_name || 'created'}`,
55
+ title: `${t.title || ''} · ${t.status_name || ''}`,
56
+ style: { left: lp + '%', width: Math.min(100 - lp, wp) + '%', top: (top0 + i * step) + 'px', height: Math.max(3, Math.min(6, step - 3)) + 'px' },
57
+ });
58
+ }))));
59
+ }))
60
+ );
61
+ }
62
+
63
+ function PmFilter({ label, options, value, onChange }) {
64
+ if (!(options || []).length) return null;
65
+ return h('div', { className: 'cx-pm-tools' }, h(MultiFilter, { label, options, value, onChange }));
66
+ }
67
+ function PmEmpty({ children, label, onCreate }) { return h('div', { className: 'cx-pm-empty' }, children, onCreate ? h('button', { className: 'cx-pm-create-big', type: 'button', onClick: onCreate }, h(UiIcon, { name: 'plus', size: 18 }), label) : null); }
68
+ function PmCreateButton({ kind, allowed, onCreate }) {
69
+ return allowed ? h(IconButton, { name: 'plus', className: 'cx-pm-op', onClick: onCreate, title: `Create ${kind}` }) : null;
70
+ }
71
+ function PmSectionHead({ icon, title, count, folded, onToggle, onRefresh, tools, style }) {
72
+ return h('div', { className: 'cx-pm-h', style: style || null, onClick: (e) => { if (e.target.closest('button, select, input, .cx-menu, .cx-sel')) return; onToggle && onToggle(); } },
73
+ h('div', { className: 'cx-pm-titleline' },
74
+ h(IconButton, { name: folded ? 'chevronRight' : 'chevronDown', className: 'cx-pm-caret', 'aria-expanded': !folded, title: folded ? T('展开') : T('折叠'), onClick: (e) => { e.stopPropagation(); onToggle && onToggle(); } }),
75
+ h('span', { className: 'cx-pm-kind' }, h(Ico, { name: icon })),
76
+ h('span', null, title),
77
+ count != null ? h('span', { className: 'cx-pm-count' }, count) : null),
78
+ h('span', { className: 'cx-pm-ops' }, onRefresh ? h(IconButton, { name: 'refresh', className: 'cx-pm-refresh', onClick: onRefresh }) : null, tools || null));
79
+ }
80
+ function pmFilterOptions(list, key) {
81
+ const labelKey = key === 'type_name' ? 'type_label' : `${key}_label`;
82
+ return [...new Set((list || []).map((x) => x[key]).filter(Boolean))].map((value) => ({ value, label: (list || []).find((x) => x[key] === value)?.[labelKey] || (key === 'status_name' ? pmStatusText(value) : value) }));
83
+ }
84
+ const PM_STATUS_ORDER = ['created', 'submited', 'approved', 'process', 'finished', 'canceled', 'rejected'];
85
+ function sortPmItems(items) {
86
+ const rank = new Map(PM_STATUS_ORDER.map((name, i) => [name, i]));
87
+ return (items || []).slice().sort((a, b) => {
88
+ const ra = rank.has(a.status_name) ? rank.get(a.status_name) : 99;
89
+ const rb = rank.has(b.status_name) ? rank.get(b.status_name) : 99;
90
+ if (ra !== rb) return ra - rb;
91
+ return String(b.created_at || b.updated_at || '').localeCompare(String(a.created_at || a.updated_at || ''));
92
+ });
93
+ }
94
+ function PmActionMenu({ actions, onAction }) {
95
+ if (!(actions || []).length) return null;
96
+ return h(MenuButton, { items: actions.map((action) => ({ label: PM_ACTION_LABEL[action] || action, danger: ['remove', 'cancel', 'reject'].includes(action), action: () => onAction(action) })) });
97
+ }
98
+ // 卡片出场特效:新卡/变更卡每次刷新按 1..5 轮换一种动画
99
+ let CX_ANIM_SEQ = 0;
100
+ function cxCardSig(item) {
101
+ return [item.title, item.content, item.status_key, item.status_label, item.score, item.begin_time, item.end_time, item.updated_at, item.type_name, item.uid].join('|');
102
+ }
103
+ function cxCardAnimRef() {
104
+ CX_ANIM_SEQ = (CX_ANIM_SEQ % 5) + 1;
105
+ return CX_ANIM_SEQ;
106
+ }
107
+
108
+ // 计划卡片:只显示 MM-DD(同年)或 YYYY-MM-DD(跨年),有时间才带 HH:mm
109
+ function shortDate(v) {
110
+ const s = String(v || '');
111
+ if (!s) return '';
112
+ const d = s.slice(0, 10);
113
+ const sameYear = d.slice(0, 4) === String(new Date().getFullYear());
114
+ const day = sameYear ? d.slice(5) : d;
115
+ const hasTime = s.length > 10 && !/^\d{4}-\d{2}-\d{2}[ T]00:00/.test(s);
116
+ return hasTime ? `${day} ${s.slice(11, 16)}` : day;
117
+ }
118
+
119
+ function PmCard({ item, kind, active, sub, traceIds, traceLog, onSelect, onAction }) {
120
+ const statusKey = item.status_name || item.type_name || (kind === 'report' ? 'report' : '');
121
+ const statusLabel = item.status_label ? T(item.status_label) : (item.status_name ? pmStatusText(item.status_name) : '');
122
+ const score = Number(item.score);
123
+ const stars = kind === 'feedback' && Number.isFinite(score) && item.score !== '' && item.score != null
124
+ ? `${'★'.repeat(Math.max(0, Math.min(5, score)))}${'☆'.repeat(Math.max(0, 5 - Math.max(0, Math.min(5, score))))}`
125
+ : '';
126
+ const animRef = useRef(0);
127
+ if (!animRef.current) animRef.current = cxCardAnimRef();
128
+ const anim = animRef.current;
129
+ const actions = item.actions || [];
130
+ const primary = actions.filter((a) => !PM_MENU_ACTIONS.has(a));
131
+ const secondary = actions.filter((a) => PM_MENU_ACTIONS.has(a));
132
+ return h('div', { key: `${item.uid}:${cxCardSig(item)}`, className: `cx-pm-card${active ? ' on' : ''}${(traceIds || []).includes(item.uid) ? ' trace' : ''}${anim ? ` cx-in cx-in-${anim}` : ''}`, onClick: onSelect, role: 'button' },
133
+ h(DoubleLineTitle, {
134
+ image: item.image || item.avatar || item.icon,
135
+ icon: h(Ico, { name: kind, size: 24 }),
136
+ title: item.title || item.uid,
137
+ status: statusLabel,
138
+ statusTone: statusKey === 'finished' || statusKey === 'approved' ? 'ok' : statusKey === 'process' ? 'warn' : 'off',
139
+ meta: [kind === 'plan' && item.begin_time ? `${shortDate(item.begin_time)} → ${shortDate(item.end_time)}` : (item.created_at ? String(item.created_at).slice(0, 16) : ''), sub, (traceIds || []).includes(item.uid) ? `#${String(traceLog || '').slice(0, 8)}` : ''].filter(Boolean).join(' · '),
140
+ }),
141
+ item.content ? h('div', { className: 'cx-pm-content', title: item.content }, item.content) : null,
142
+ stars ? h('div', { className: 'cx-pm-stars', title: `${score} / 5` }, stars) : null,
143
+ h('div', { className: 'cx-pm-card-actions' },
144
+ primary.map((action) => h('button', { key: action, className: `cx-pm-act${action === 'remove' ? ' danger' : ''}`, onClick: (e) => { e.stopPropagation(); onAction(action); } }, T(PM_ACTION_LABEL[action] || action))),
145
+ h(PmActionMenu, { actions: secondary, onAction })));
146
+ }
@@ -0,0 +1,381 @@
1
+ // ── ContextOS 配置中心:全局 / 会话(node space app avatar scene mode) ──
2
+ const CTX_CFG_KEYS = ['node', 'space', 'app', 'avatar', 'scene', 'mode'];
3
+ const CTX_CFG_DEFAULT = { node: null, space: '', app: '', avatar: '', scene: '', mode: 'factory' };
4
+ const CTX_CFG_NS = 'dsh-contextos-devops:cfg';
5
+ const CTX_CALLS_NS = 'dsh-contextos-devops:calls';
6
+ const CTX_UI = { open: false, tab: 'global', sessionId: '', subs: new Set() };
7
+ const CTX_DATA = { nodes: [], spaces: {}, apps: {}, avatars: [], scenes: [], loading: false };
8
+ function ctxCfgEmit() { CTX_UI.subs.forEach((f) => { try { f(); } catch {} }); }
9
+ function ctxCfgUse() {
10
+ const [, set] = useState(0);
11
+ useEffect(() => { const f = () => set((x) => x + 1); CTX_UI.subs.add(f); return () => CTX_UI.subs.delete(f); }, []);
12
+ }
13
+ function ctxCfgLoad() {
14
+ let d = null;
15
+ d = cxRead(CTX_CFG_NS, null);
16
+ if (!d || typeof d !== 'object') d = {};
17
+ d.global = { ...CTX_CFG_DEFAULT, ...(d.global || {}) };
18
+ d.sessions = d.sessions || {};
19
+ d.commands = d.commands || {};
20
+ d.dev = { space: '', zone: 'devops', template: 'Hash', compile: true, registerAsTool: true, fallback: '', ...(d.dev || {}) };
21
+ return d;
22
+ }
23
+ let CTX_CFG = ctxCfgLoad();
24
+ function ctxCfgSave() {
25
+ cxWrite(CTX_CFG_NS, CTX_CFG);
26
+ try { api('stateSet', { name: 'contextos-config', value: CTX_CFG }).catch(() => {}); } catch {}
27
+ ctxCfgEmit();
28
+ }
29
+ function ctxCfgResolve(sessionId) { return { ...CTX_CFG.global, ...((sessionId && CTX_CFG.sessions[sessionId]) || {}) }; }
30
+ function ctxCfgSession(sessionId) { return (sessionId && CTX_CFG.sessions[sessionId]) || {}; }
31
+ function ctxCfgSetGlobal(k, v) { CTX_CFG.global[k] = v; ctxCfgSave(); }
32
+ function ctxCfgSetSession(sid, k, v) { if (!sid) return; CTX_CFG.sessions[sid] = { ...ctxCfgSession(sid), [k]: v }; ctxCfgSave(); }
33
+ function ctxCallsLoad() { return cxRead(CTX_CALLS_NS, []); }
34
+ function ctxCallLog(entry) {
35
+ const list = ctxCallsLoad();
36
+ list.unshift({ t: new Date().toISOString(), ...entry });
37
+ cxWrite(CTX_CALLS_NS, list.slice(0, 500));
38
+ try { api('stateSet', { name: 'contextos-calls', value: list.slice(0, 500) }).catch(() => {}); } catch {}
39
+ ctxCfgEmit();
40
+ }
41
+ async function ctxLoadNodes() {
42
+ try { const d = await api('nodes'); CTX_DATA.nodes = d.nodes || []; } catch { CTX_DATA.nodes = []; }
43
+ ctxCfgEmit();
44
+ }
45
+ async function ctxLoadSpaces(node) {
46
+ if (!node) return;
47
+ try { const d = await api('spaces', { node }); CTX_DATA.spaces[node] = d || []; } catch { CTX_DATA.spaces[node] = []; }
48
+ ctxCfgEmit();
49
+ }
50
+ async function ctxLoadApps(node, space) {
51
+ if (!node) return;
52
+ const key = `${node}:${space || ''}`;
53
+ try { const d = await api('apps', { node, space: space || '', uid: '' }); CTX_DATA.apps[key] = d || []; } catch { CTX_DATA.apps[key] = []; }
54
+ ctxCfgEmit();
55
+ }
56
+ async function ctxLoadAvatars(node) {
57
+ if (!node) return;
58
+ try { const d = await api('avatars', { node }); CTX_DATA.avatars = d.items || []; } catch { CTX_DATA.avatars = []; }
59
+ ctxCfgEmit();
60
+ }
61
+ async function ctxLoadScenes(node) {
62
+ if (!node) return;
63
+ try { const d = await api('backgrounds', { node }); CTX_DATA.scenes = d.items || []; } catch { CTX_DATA.scenes = []; }
64
+ ctxCfgEmit();
65
+ }
66
+
67
+ // 首次进入某个模式时的「开门」过场
68
+ const MODE_INTRO_SEEN = new Set();
69
+ function ModeIntro({ mode, onDone }) {
70
+ const label = mode === 'chat' ? 'CONVERSATION' : mode === 'manage' ? 'MANAGEMENT' : mode === 'monitor' ? 'MONITORING' : 'DEVELOPMENT';
71
+ useEffect(() => { const t = setTimeout(onDone, 3200); return () => clearTimeout(t); }, []);
72
+ return h('div', { className: `cx-intro cx-intro-${mode}` },
73
+ h('div', { className: 'cx-intro-doors' }, Array.from({ length: 5 }, (_, i) => h('i', { key: i, style: { animationDelay: `${i * 0.09}s` } }))),
74
+ h('div', { className: 'cx-intro-txt' },
75
+ h('div', { className: 'cx-intro-w' }, 'WELCOME TO'),
76
+ h('div', { className: 'cx-intro-m' }, label),
77
+ h('div', { className: 'cx-intro-s' }, 'CONTEXTOS · ' + mode.toUpperCase() + ' MODE')));
78
+ }
79
+
80
+ // 进入/离开初始化时的欢迎过场
81
+ function WelcomeBurst({ title, sub, onDone }) {
82
+ useEffect(() => { const t = setTimeout(onDone, 1800); return () => clearTimeout(t); }, []);
83
+ return h('div', { className: 'cx-intro cx-welcome', 'data-ready': String(title === 'READY') },
84
+ h('div', { className: 'cx-intro-doors' }, Array.from({ length: 5 }, (_, i) => h('i', { key: i, style: { animationDelay: `${i * 0.07}s` } }))),
85
+ h('div', { className: 'cx-intro-txt' },
86
+ h('div', { className: 'cx-intro-w' }, 'WELCOME TO'),
87
+ h('div', { className: 'cx-intro-m' }, title),
88
+ h('div', { className: 'cx-intro-s' }, sub)));
89
+ }
90
+
91
+ // 鼠标彗星拖尾(canvas)
92
+ function CursorComet() {
93
+ const ref = useRef(null);
94
+ useEffect(() => {
95
+ const canvas = ref.current;
96
+ const host = canvas && canvas.parentElement;
97
+ if (!canvas || !host) return;
98
+ const ctx = canvas.getContext('2d');
99
+ if (!ctx) return;
100
+ if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
101
+ let raf = 0, w = 0, h = 0;
102
+ const parts = [];
103
+ const ripples = [];
104
+ const slow = [];
105
+ let lastPos = { x: 0, y: 0 }, lastMoveAt = Date.now();
106
+ const resize = () => { const r = host.getBoundingClientRect(); w = canvas.width = Math.max(1, Math.round(r.width)); h = canvas.height = Math.max(1, Math.round(r.height)); };
107
+ resize();
108
+ const ro = new ResizeObserver(resize); ro.observe(host);
109
+ let last = { x: 0, y: 0 };
110
+ const onMove = (e) => {
111
+ const r = host.getBoundingClientRect();
112
+ const x = e.clientX - r.left, y = e.clientY - r.top;
113
+ const vx = x - last.x, vy = y - last.y; last = { x, y };
114
+ lastPos = { x, y }; lastMoveAt = Date.now(); schedule();
115
+ const steps = Math.min(4, 1 + Math.round(Math.hypot(vx, vy) / 18));
116
+ for (let i = 0; i < steps; i++) {
117
+ parts.push({ x: x - vx * (i / steps), y: y - vy * (i / steps), vx: -vx * 0.02 + (Math.random() - 0.5) * 0.5, vy: -vy * 0.02 + (Math.random() - 0.5) * 0.5, life: 1, size: 3 + Math.random() * 6 });
118
+ }
119
+ if (parts.length > 120) parts.splice(0, parts.length - 120);
120
+ };
121
+ host.addEventListener('pointermove', onMove, { passive: true });
122
+ const onDown = (e) => {
123
+ const r = host.getBoundingClientRect();
124
+ ripples.push({ x: e.clientX - r.left, y: e.clientY - r.top, r: 6, life: 1 });
125
+ if (ripples.length > 8) ripples.shift();
126
+ schedule();
127
+ };
128
+ host.addEventListener('pointerdown', onDown, true);
129
+ const slowTimer = setInterval(() => {
130
+ if (Date.now() - lastMoveAt < 55000) return;
131
+ slow.push({ x: lastPos.x, y: lastPos.y, r: 10, life: 1 });
132
+ if (slow.length > 2) slow.shift();
133
+ }, 60000);
134
+ window.__cxSlowRipple = () => { slow.push({ x: lastPos.x, y: lastPos.y, r: 10, life: 1 }); if (slow.length > 2) slow.shift(); schedule(); };
135
+ let frame = 0;
136
+ const schedule = () => {
137
+ if (raf || document.hidden) return;
138
+ raf = requestAnimationFrame(tick);
139
+ };
140
+ const tick = () => {
141
+ raf = 0;
142
+ /* 空闲 2.5s 且无粒子时停帧,等输入再唤醒 */
143
+ const idleNow = Date.now() - lastMoveAt > 2500 && !parts.length && !ripples.length && !slow.length;
144
+ if (idleNow) return;
145
+ raf = requestAnimationFrame(tick);
146
+ frame++;
147
+ if (document.hidden) return; /* 后台不绘制,省电 */
148
+ const shell = host.closest('.cx-shell');
149
+ if (shell && shell.dataset.view === 'dev') return; /* 开发模式不画拖尾 */
150
+ if (frame % 2) return; /* 降到 ~30fps */
151
+ ctx.clearRect(0, 0, w, h);
152
+ ctx.globalCompositeOperation = 'lighter';
153
+ for (let i = slow.length - 1; i >= 0; i--) {
154
+ const s2 = slow[i];
155
+ s2.life -= 0.0035;
156
+ s2.r += 1.7;
157
+ if (s2.life <= 0) { slow.splice(i, 1); continue; }
158
+ ctx.globalCompositeOperation = 'lighter';
159
+ ctx.lineWidth = 6 * s2.life + 1;
160
+ ctx.strokeStyle = `rgba(120,240,255,${0.32 * s2.life})`;
161
+ ctx.shadowBlur = 40 * s2.life;
162
+ ctx.shadowColor = 'rgba(62,231,255,.55)';
163
+ ctx.beginPath(); ctx.arc(s2.x, s2.y, s2.r, 0, Math.PI * 2); ctx.stroke();
164
+ ctx.shadowBlur = 0;
165
+ ctx.strokeStyle = `rgba(170,255,240,${0.16 * s2.life})`;
166
+ ctx.beginPath(); ctx.arc(s2.x, s2.y, s2.r * 0.6, 0, Math.PI * 2); ctx.stroke();
167
+ }
168
+ for (let i = ripples.length - 1; i >= 0; i--) {
169
+ const rp = ripples[i];
170
+ rp.life -= 0.022;
171
+ rp.r += 9;
172
+ if (rp.life <= 0) { ripples.splice(i, 1); continue; }
173
+ ctx.globalCompositeOperation = 'lighter';
174
+ ctx.lineWidth = 2.5 * rp.life + 0.5;
175
+ ctx.strokeStyle = `rgba(120,240,255,${0.75 * rp.life})`;
176
+ ctx.shadowBlur = 18 * rp.life;
177
+ ctx.shadowColor = 'rgba(62,231,255,.8)';
178
+ ctx.beginPath(); ctx.arc(rp.x, rp.y, rp.r, 0, Math.PI * 2); ctx.stroke();
179
+ ctx.shadowBlur = 0;
180
+ ctx.strokeStyle = `rgba(255,255,255,${0.35 * rp.life})`;
181
+ ctx.beginPath(); ctx.arc(rp.x, rp.y, rp.r * 0.72, 0, Math.PI * 2); ctx.stroke();
182
+ }
183
+ for (let i = parts.length - 1; i >= 0; i--) {
184
+ const p = parts[i];
185
+ p.life -= 0.018;
186
+ if (p.life <= 0) { parts.splice(i, 1); continue; }
187
+ p.x += p.vx; p.y += p.vy;
188
+ const R = p.size * 2.6;
189
+ const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, R);
190
+ g.addColorStop(0, `rgba(220,255,255,${0.45 * p.life})`);
191
+ g.addColorStop(0.3, `rgba(62,231,255,${0.32 * p.life})`);
192
+ g.addColorStop(1, 'rgba(62,231,255,0)');
193
+ ctx.fillStyle = g;
194
+ ctx.beginPath(); ctx.arc(p.x, p.y, R, 0, Math.PI * 2); ctx.fill();
195
+ }
196
+ };
197
+ tick();
198
+ return () => { cancelAnimationFrame(raf); clearInterval(slowTimer); try { delete window.__cxSlowRipple; } catch {} host.removeEventListener('pointermove', onMove); host.removeEventListener('pointerdown', onDown, true); ro.disconnect(); };
199
+ }, []);
200
+ return h('canvas', { className: 'cx-comet', ref, 'aria-hidden': 'true' });
201
+ }
202
+
203
+ function CtxChip({ sessionId }) {
204
+ ctxCfgUse();
205
+ const [open, setOpen] = useState(false);
206
+ const eff = ctxCfgResolve(sessionId);
207
+ return h(React.Fragment, null,
208
+ h('button', {
209
+ className: 'cx-ctx-chip', title: 'ContextOS 控制台',
210
+ onPointerDown: (e) => { e.stopPropagation(); },
211
+ onClick: (e) => { e.stopPropagation(); setOpen(true); },
212
+ },
213
+ h('span', { className: 'cx-ctx-chip-k' }, 'ContextOS'),
214
+ h('span', { className: 'cx-ctx-chip-v' }, eff.node ? `:${eff.node}` : T('未绑定')),
215
+ eff.space ? h('span', { className: 'cx-ctx-chip-s' }, eff.space) : null),
216
+ open ? h(CtxCenter, { sessionId: sessionId || '', onClose: () => setOpen(false) }) : null);
217
+ }
218
+
219
+ function CtxHeaderButton({ sessionId }) {
220
+ ctxCfgUse();
221
+ const [open, setOpen] = useState(false);
222
+ const eff = ctxCfgResolve(sessionId);
223
+ return h(React.Fragment, null,
224
+ h('button', {
225
+ className: 'cx-ctx-hbtn', title: `ContextOS ${eff.node ? ':' + eff.node : ''}`,
226
+ onPointerDown: (e) => { e.stopPropagation(); },
227
+ onClick: (e) => { e.stopPropagation(); setOpen(true); },
228
+ }, h('span', { className: 'cx-ctx-hdot' }), 'ContextOS'),
229
+ open ? h(CtxCenter, { sessionId: sessionId || '', onClose: () => setOpen(false) }) : null);
230
+ }
231
+
232
+ function CtxField({ label, children }) { return h('label', { className: 'cx-field' }, label, children); }
233
+ function CtxSelect({ label, value, options, onChange, placeholder }) {
234
+ return h(CtxField, { label },
235
+ h('select', { className: 'cx-input', value: value == null ? '' : String(value), onChange: (e) => onChange(e.target.value) },
236
+ h('option', { value: '' }, placeholder || '—'),
237
+ (options || []).map((o) => h('option', { key: String(o.value), value: String(o.value) }, o.label))));
238
+ }
239
+
240
+ function CtxConfigForm({ scope, sessionId }) {
241
+ const eff = scope === 'global' ? CTX_CFG.global : ctxCfgResolve(sessionId);
242
+ const set = (k, v) => (scope === 'global' ? ctxCfgSetGlobal(k, v) : ctxCfgSetSession(sessionId || '', k, v));
243
+ const node = Number(eff.node) || null;
244
+ const spaces = (CTX_DATA.spaces[node] || []).map((s) => ({ value: s.name, label: s.name }));
245
+ const apps = (CTX_DATA.apps[`${node}:${eff.space || ''}`] || []).map((a) => ({ value: a.command || a.name, label: a.title || a.command || a.name }));
246
+ const avatars = (CTX_DATA.avatars || []).map((a) => ({ value: a.uid, label: a.name || a.uid }));
247
+ const scenes = (CTX_DATA.scenes || []).map((b) => ({ value: b.id, label: b.name || b.id }));
248
+ return h('div', { className: 'cx-ctx-form' },
249
+ h(CtxSelect, { label: 'node', value: eff.node, placeholder: T('选择节点'), options: (CTX_DATA.nodes || []).map((n) => ({ value: n.port, label: `:${n.port} · ${n.name || (n.state === 'running' ? 'running' : 'offline')}` })), onChange: (v) => { set('node', Number(v) || null); ctxLoadSpaces(Number(v) || null); } }),
250
+ h(CtxSelect, { label: 'space', value: eff.space, placeholder: T('选择空间'), options: spaces, onChange: (v) => { set('space', v); ctxLoadApps(node, v); } }),
251
+ h(CtxSelect, { label: 'app', value: eff.app, placeholder: T('选择应用'), options: apps, onChange: (v) => set('app', v) }),
252
+ h(CtxSelect, { label: 'avatar', value: eff.avatar, placeholder: T('选择分身'), options: avatars, onChange: (v) => set('avatar', v) }),
253
+ h(CtxSelect, { label: 'scene', value: eff.scene, placeholder: T('选择场景'), options: scenes, onChange: (v) => set('scene', v) }),
254
+ h(CtxSelect, { label: 'mode', value: eff.mode, placeholder: 'factory', options: [{ value: 'factory', label: 'factory' }, { value: 'avatar', label: 'avatar' }, { value: 'iot', label: 'iot' }], onChange: (v) => set('mode', v) }),
255
+ scope === 'session' ? h(CtxField, { label: 'project' }, h('input', { className: 'cx-input', value: ctxCfgSession(sessionId || '').project || '', placeholder: '项目管理 place_uid', onChange: (e) => ctxCfgSetSession(sessionId || '', 'project', e.target.value) })) : null,
256
+ h('div', { className: 'cx-ctx-actions' },
257
+ h('button', { className: 'cx-btn', onClick: () => { if (scope === 'session' && sessionId) { delete CTX_CFG.sessions[sessionId]; ctxCfgSave(); } else { CTX_CFG.global = { ...CTX_CFG_DEFAULT }; ctxCfgSave(); } } }, T('重置')),
258
+ h('button', { className: 'cx-btn pri', onClick: () => { ctxCfgSave(); ctxCallLog({ op: 'config.save', scope, node: eff.node, space: eff.space }); } }, T('保存'))));
259
+ }
260
+
261
+ function CtxCommands({ node, space, sessionId }) {
262
+ const key = `${node}:${space || ''}`;
263
+ const apps = CTX_DATA.apps[key] || [];
264
+ const map = (sessionId && CTX_CFG.commands[sessionId]) || {};
265
+ const setCmd = (cmd, patch) => { if (!sessionId) return; CTX_CFG.commands[sessionId] = { ...map, [cmd]: { ...(map[cmd] || {}), ...patch } }; ctxCfgSave(); };
266
+ if (!node) return h('div', { className: 'cx-ctx-empty' }, T('先绑定节点'));
267
+ if (!apps.length) return h('div', { className: 'cx-ctx-empty' }, T('该节点/空间暂无命令,点右上角刷新'));
268
+ return h('div', { className: 'cx-ctx-list' },
269
+ apps.map((a) => {
270
+ const cmd = a.command || a.name;
271
+ const st = map[cmd] || { enabled: true, purpose: a.title || a.help || '' };
272
+ return h('div', { key: cmd, className: 'cx-ctx-cmd' },
273
+ h('input', { type: 'checkbox', checked: st.enabled !== false, onChange: (e) => setCmd(cmd, { enabled: e.target.checked }) }),
274
+ h('span', { className: 'nm' }, cmd),
275
+ h('input', { className: 'cx-input', value: st.purpose || '', placeholder: '这个命令用来做什么', onChange: (e) => setCmd(cmd, { purpose: e.target.value }) }));
276
+ }));
277
+ }
278
+
279
+ function CtxDev() {
280
+ const dev = CTX_CFG.dev;
281
+ const set = (k, v) => { CTX_CFG.dev[k] = v; ctxCfgSave(); };
282
+ return h('div', { className: 'cx-ctx-form' },
283
+ h(CtxField, { label: '生成空间' }, h('input', { className: 'cx-input', value: dev.space || '', placeholder: '留空则用会话空间', onChange: (e) => set('space', e.target.value) })),
284
+ h(CtxField, { label: 'zone' }, h('input', { className: 'cx-input', value: dev.zone || '', onChange: (e) => set('zone', e.target.value) })),
285
+ h(CtxField, { label: 'template' }, h('input', { className: 'cx-input', value: dev.template || '', onChange: (e) => set('template', e.target.value) })),
286
+ h(CtxField, { label: '自动编译' }, h('input', { type: 'checkbox', checked: !!dev.compile, onChange: (e) => set('compile', e.target.checked) })),
287
+ h(CtxField, { label: '注册为工具' }, h('input', { type: 'checkbox', checked: !!dev.registerAsTool, onChange: (e) => set('registerAsTool', e.target.checked) })),
288
+ h(CtxField, { label: '失败回退' }, h('input', { className: 'cx-input', value: dev.fallback || '', onChange: (e) => set('fallback', e.target.value) })));
289
+ }
290
+
291
+ function CtxApps({ node, space }) {
292
+ const apps = CTX_DATA.apps[`${node}:${space || ''}`] || [];
293
+ if (!node) return h('div', { className: 'cx-ctx-empty' }, T('先绑定节点'));
294
+ if (!apps.length) return h('div', { className: 'cx-ctx-empty' }, T('暂无应用'));
295
+ return h('table', { className: 'cx-console-table' },
296
+ h('thead', null, h('tr', null, ['command', 'title', 'space', 'place'].map((k) => h('th', { key: k }, k)))),
297
+ h('tbody', null, apps.map((a, i) => h('tr', { key: i },
298
+ h('td', null, a.command || a.name || ''),
299
+ h('td', null, a.title || a.help || ''),
300
+ h('td', null, a.space || ''),
301
+ h('td', null, a.place_uid || '')))));
302
+ }
303
+
304
+ function ctxEsc(v) { return String(v == null ? '' : v).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
305
+ function ctxConsoleHtml(eff, apps, calls) {
306
+ const tr = (cells) => `<tr>${cells.map((c) => `<td>${ctxEsc(c)}</td>`).join('')}</tr>`;
307
+ const cfg = ['node', 'space', 'app', 'avatar', 'scene', 'mode'].map((k) => tr([k, eff[k]])).join('');
308
+ const appRows = (apps || []).map((a) => tr([a.command || a.name, a.title || a.help, a.space, a.place_uid])).join('');
309
+ const logRows = (calls || []).slice(0, 100).map((c) => tr([String(c.t || '').slice(11, 19), c.op, c.node, c.space, c.ok === false ? 'ERR' : 'OK'])).join('');
310
+ const ok = (calls || []).filter((c) => c.ok !== false).length;
311
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>ContextOS 控制台</title><style>
312
+ body{margin:0;padding:20px 24px;background:#050a12;color:#d9f4ff;font:12px/1.6 ui-monospace,Menlo,monospace}
313
+ h1{font-size:15px;letter-spacing:.18em;color:#3ee7ff;margin:0 0 4px}
314
+ .meta{color:#6f9bb3;margin-bottom:16px}
315
+ h2{font-size:11px;letter-spacing:.12em;color:#3ee7ff;margin:18px 0 6px;border-bottom:1px solid #16384d;padding-bottom:4px}
316
+ table{border-collapse:collapse;width:100%;font-size:11px}
317
+ td,th{border:1px solid #16384d;padding:4px 8px;text-align:left}
318
+ th{color:#8fb8c9;background:#08131f}
319
+ .stat{display:inline-block;min-width:110px;margin-right:10px;padding:8px 12px;border:1px solid #16384d;border-radius:4px}
320
+ .stat b{display:block;font-size:18px;color:#3ee7ff}
321
+ </style></head><body>
322
+ <h1>CONTEXTOS · 控制台</h1>
323
+ <div class="meta">节点 ${ctxEsc(eff.node)} · 空间 ${ctxEsc(eff.space)} · v__CLIENT_CSS_VERSION__</div>
324
+ <h2>配置</h2><table><tbody>${cfg}</tbody></table>
325
+ <h2>调用统计</h2>
326
+ <div><span class="stat"><b>${(calls || []).length}</b>调用</span><span class="stat"><b>${ok}</b>成功</span><span class="stat"><b>${(calls || []).length - ok}</b>失败</span></div>
327
+ <h2>应用</h2><table><thead><tr><th>command</th><th>title</th><th>space</th><th>place</th></tr></thead><tbody>${appRows || '<tr><td colspan="4">暂无</td></tr>'}</tbody></table>
328
+ <h2>调用日志</h2><table><thead><tr><th>时间</th><th>操作</th><th>节点</th><th>空间</th><th>结果</th></tr></thead><tbody>${logRows || '<tr><td colspan="5">暂无</td></tr>'}</tbody></table>
329
+ </body></html>`;
330
+ }
331
+
332
+ function CtxCenter({ sessionId, onClose }) {
333
+ ctxCfgUse();
334
+ const [tab, setTab] = useState('global');
335
+ const [msg, setMsg] = useState('');
336
+ const sid = sessionId || '';
337
+ const close = () => { try { onClose && onClose(); } catch {} };
338
+ const eff = ctxCfgResolve(sid);
339
+ const node = Number(eff.node) || null;
340
+ useEffect(() => {
341
+ let alive = true;
342
+ (async () => {
343
+ if (!CTX_DATA.nodes.length) await ctxLoadNodes();
344
+ if (node) { await ctxLoadSpaces(node); await ctxLoadAvatars(node); await ctxLoadScenes(node); await ctxLoadApps(node, eff.space); }
345
+ })();
346
+ return () => { alive = false; };
347
+ }, [node, eff.space]);
348
+ const calls = ctxCallsLoad();
349
+ const tabs = [['global', '全局配置'], ['session', '会话配置'], ['commands', '命令配置'], ['dev', '开发配置'], ['apps', '应用列表'], ['stats', '调用统计'], ['logs', '调用日志']];
350
+ const body =
351
+ tab === 'global' ? h(CtxConfigForm, { scope: 'global' }) :
352
+ tab === 'session' ? h(CtxConfigForm, { scope: 'session', sessionId: sid }) :
353
+ tab === 'commands' ? h(CtxCommands, { node, space: eff.space, sessionId: sid }) :
354
+ tab === 'dev' ? h(CtxDev) :
355
+ tab === 'apps' ? h(CtxApps, { node, space: eff.space }) :
356
+ tab === 'stats' ? h('div', { className: 'cx-ctx-stats' },
357
+ [['调用次数', calls.length], ['成功', calls.filter((c) => c.ok !== false).length], ['失败', calls.filter((c) => c.ok === false).length], ['节点', new Set(calls.map((c) => c.node).filter(Boolean)).size], ['空间', new Set(calls.map((c) => c.space).filter(Boolean)).size]].map(([k, v]) => h('div', { key: k, className: 'cx-ctx-stat' }, h('b', null, String(v)), h('span', null, k)))) :
358
+ h('div', { className: 'cx-ctx-list' },
359
+ h('div', { className: 'cx-ctx-actions' }, h('button', { className: 'cx-btn', onClick: () => { cxRemove(CTX_CALLS_NS); ctxCfgEmit(); } }, T('清空'))),
360
+ calls.length ? h('table', { className: 'cx-console-table' },
361
+ h('thead', null, h('tr', null, ['时间', '操作', '节点', '空间', '结果'].map((k) => h('th', { key: k }, k)))),
362
+ h('tbody', null, calls.slice(0, 100).map((c, i) => h('tr', { key: i }, h('td', null, String(c.t || '').slice(11, 19)), h('td', null, c.op || ''), h('td', null, c.node || ''), h('td', null, c.space || ''), h('td', null, c.ok === false ? 'ERR' : 'OK'))))) : h('div', { className: 'cx-ctx-empty' }, T('暂无调用')));
363
+ return h('div', { className: 'cx-ctx-drawer', onMouseDown: (e) => { if (e.target === e.currentTarget) close(); } },
364
+ h('div', { className: 'cx-ctx-panel' },
365
+ h('div', { className: 'cx-ctx-head' },
366
+ h('div', { className: 'cx-ctx-logo' }, 'CTX'),
367
+ h('div', { className: 'cx-ctx-titles' },
368
+ h('div', { className: 'cx-ctx-title' }, 'CONTEXTOS'),
369
+ h('div', { className: 'cx-ctx-sub' }, `${T('控制台')} · v__CLIENT_CSS_VERSION__`)),
370
+ h('div', { className: 'cx-ctx-acts' },
371
+ h('button', { className: 'cx-icon-btn', title: T('刷新'), onClick: () => { ctxLoadNodes(); if (node) { ctxLoadSpaces(node); ctxLoadApps(node, eff.space); } } }, '↻'),
372
+ h('button', { className: 'cx-ctx-x', title: T('关闭'), onClick: close }, '✕'))),
373
+ h('div', { className: 'cx-ctx-sum' },
374
+ h('span', { className: `cx-ctx-pill${eff.node ? ' on' : ''}` }, eff.node ? `:${eff.node}` : T('未绑定节点')),
375
+ h('span', { className: 'cx-ctx-pill' }, eff.space || T('未选空间')),
376
+ h('span', { className: 'cx-ctx-pill' }, eff.mode || 'factory'),
377
+ h('span', { className: 'cx-ctx-pill dim' }, eff.app || T('未选应用'))),
378
+ h('div', { className: 'cx-ctx-tabs' }, tabs.map(([k, n]) => h('button', { key: k, className: `cx-ctx-tab${tab === k ? ' on' : ''}`, onClick: () => setTab(k) }, T(n)))),
379
+ msg ? h('div', { className: 'cx-ctx-msg' }, msg) : null,
380
+ h('div', { className: 'cx-ctx-body' }, body)));
381
+ }