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,158 @@
1
+ function PMModal({ kind, mode, item, parent, node, avatar, onClose, onDone }) {
2
+ const [title, setTitle] = useState(item?.title || '');
3
+ const [content, setContent] = useState(item?.content || '');
4
+ const [begin, setBegin] = useState(String(item?.begin_time || new Date().toISOString().slice(0, 10)).slice(0, 10));
5
+ const [end, setEnd] = useState(String(item?.end_time || new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10)).slice(0, 10));
6
+ const [reportType, setReportType] = useState(item?.type_name || item?.report_type || 'daily');
7
+ const [score, setScore] = useState(String(item?.score || 5));
8
+ const [feedbackStatus, setFeedbackStatus] = useState(String(item?.feedback_status ?? item?.status_name ?? item?.status ?? ''));
9
+ const [busy, setBusy] = useState(false);
10
+ const [err, setErr] = useState('');
11
+ const [fieldOpts, setFieldOpts] = useState({});
12
+ const project = avatar?.project || item?.project || null;
13
+ const command = `web.team.xiangmuguanli.${kind}`;
14
+ const formFields = [
15
+ 'title', 'content',
16
+ ...(kind === 'plan' ? ['begin', 'end'] : []),
17
+ ...(kind === 'report' ? ['report_type'] : []),
18
+ ...(kind === 'feedback' ? ['score', 'feedback_status'] : []),
19
+ ];
20
+ const selectFields = new Set(['report_type', 'feedback_status', 'score']);
21
+ const requiredFields = new Set([
22
+ 'title',
23
+ ...(kind === 'plan' ? ['begin', 'end'] : []),
24
+ ...(kind === 'report' ? ['report_type'] : []),
25
+ ...(kind === 'feedback' ? ['score', 'feedback_status'] : []),
26
+ ]);
27
+ const fieldLabels = { title: '标题', content: '内容', begin: '开始时间', end: '结束时间', report_type: '报告类型', score: '评分', feedback_status: '反馈状态' };
28
+ const fieldInflight = useRef(new Set());
29
+ const loadField = useCallback(async (field) => {
30
+ if (!node || !project?.space || fieldOpts[field] || fieldInflight.current.has(field)) return;
31
+ fieldInflight.current.add(field);
32
+ try {
33
+ const d = await api('fieldOptions', { node, space: project.space, command, field, project_uid: project.place_uid || '', uid: avatar?.uid || '' });
34
+ if (d?.options?.length) setFieldOpts((cur) => ({ ...cur, [field]: d }));
35
+ } catch { /* free-text field */ }
36
+ finally { fieldInflight.current.delete(field); }
37
+ }, [node, project?.space, project?.place_uid, avatar?.uid, command, fieldOpts]);
38
+ useEffect(() => { formFields.forEach((field) => loadField(field)); }, [kind, node, project?.space, project?.place_uid, avatar?.uid, command]);
39
+ useEffect(() => {
40
+ if (kind === 'report' && !item && fieldOpts.report_type?.options?.length) setReportType(String(fieldOpts.report_type.options[0].value ?? ''));
41
+ if (kind === 'feedback' && !item && fieldOpts.feedback_status?.options?.length) setFeedbackStatus(String(fieldOpts.feedback_status.options[0].value ?? ''));
42
+ }, [kind, item, fieldOpts]);
43
+ const missing = [...requiredFields].filter((field) => {
44
+ const value = field === 'title' ? title : field === 'content' ? content : field === 'begin' ? begin : field === 'end' ? end : field === 'report_type' ? reportType : field === 'score' ? score : field === 'feedback_status' ? feedbackStatus : '';
45
+ return !String(value ?? '').trim();
46
+ });
47
+ const control = (field, value, setValue, { full = false, type = 'text', multiline = false } = {}) => {
48
+ const opts = fieldOpts[field]?.options || [];
49
+ const metaLabel = fieldOpts[field]?.field_label;
50
+ const label = (metaLabel && metaLabel !== field && /[^\x00-\x7F]/.test(metaLabel)) ? metaLabel : (fieldLabels[field] || metaLabel || field);
51
+ const required = requiredFields.has(field);
52
+ const common = { className: 'cx-input', value, onFocus: () => loadField(field), 'aria-required': required || undefined };
53
+ const input = selectFields.has(field)
54
+ ? h('select', { ...common, onChange: (e) => setValue(e.target.value) },
55
+ opts.length ? opts.map((o) => h('option', { key: String(o.value), value: String(o.value) }, T(o.label || o.name || String(o.value)))) : h('option', { value: '' }, '加载中…'))
56
+ : h(React.Fragment, null,
57
+ multiline
58
+ ? h('textarea', { ...common, rows: 3, onChange: (e) => setValue(e.target.value) })
59
+ : h('input', { ...common, type, list: opts.length ? `cx-dl-${field}` : undefined, onChange: (e) => setValue(e.target.value) }),
60
+ opts.length ? h('datalist', { id: `cx-dl-${field}` }, opts.map((o) => h('option', { key: o.value, value: o.name || o.value }, o.label))) : null);
61
+ return h('label', { className: `cx-field${full ? ' full' : ''}${required ? ' req' : ''}` },
62
+ h('span', { className: 'cx-field-lb' }, label, required ? h('b', { className: 'cx-req' }, ' *') : null),
63
+ input);
64
+ };
65
+ const submit = async () => {
66
+ setBusy(true); setErr('');
67
+ try {
68
+ const body = { node, uid: avatar?.uid || '', kind, ...(parent || {}) };
69
+ // 只取当前类型表单里真实存在的字段,其它一律不采
70
+ const norm = (field, raw) => {
71
+ const list = fieldOpts[field]?.options || [];
72
+ const hit = list.find((o) => String(o.value) === String(raw) || String(o.name) === String(raw) || String(o.label) === String(raw));
73
+ return hit ? String(hit.value) : String(raw ?? '');
74
+ };
75
+ const values = { title, content, begin_time: begin, end_time: end, report_type: norm('report_type', reportType), score, feedback_status: norm('feedback_status', feedbackStatus) };
76
+ for (const f of formFields) {
77
+ const key = f === 'begin' ? 'begin_time' : f === 'end' ? 'end_time' : f;
78
+ body[key] = values[key];
79
+ }
80
+ body.uid_options = Object.fromEntries(Object.entries(parent || {}).filter(([k, v]) => /_uid$/.test(k) && k !== 'user_uid' && v));
81
+ if (mode === 'edit') { body.item_uid = item.uid; body.uid_options = uidFieldMap(item); await api('pmModify', body); }
82
+ else await api('pmCreate', body);
83
+ onDone();
84
+ } catch (e) { setErr(String(e?.message || e)); } finally { setBusy(false); }
85
+ };
86
+ return h(Modal, { title: `${mode === 'edit' ? T('编辑') : T('新建')} ${PM_KIND_LABEL[kind] || kind}`, desc: parent ? '继承当前选择作为上级 UID' : '', onClose },
87
+ h('div', { className: 'cx-form-hint' }, h('b', null, '*'), ' 为必填;输入时自动从接口 inputs 获取候选值,select 字段只能从下拉列表选择。'),
88
+ h('div', { className: 'cx-form' },
89
+ control('title', title, setTitle, { full: true }),
90
+ control('content', content, setContent, { full: true, multiline: true }),
91
+ kind === 'plan' ? control('begin', begin, setBegin, { type: 'date' }) : null,
92
+ kind === 'plan' ? control('end', end, setEnd, { type: 'date' }) : null,
93
+ kind === 'report' ? control('report_type', reportType, setReportType, { full: true }) : null,
94
+ kind === 'feedback' ? control('score', score, setScore) : null,
95
+ kind === 'feedback' ? control('feedback_status', feedbackStatus, setFeedbackStatus) : null),
96
+ missing.length ? h('div', { className: 'cx-form-warn' }, `还有 ${missing.length} 个必填项未填写`) : null,
97
+ err ? h(ErrorBox, { text: err }) : null,
98
+ h('div', { className: 'cx-foot' }, h('button', { className: 'cx-btn', onClick: onClose }, T('取消')), h('button', { className: 'cx-btn pri', disabled: busy || missing.length > 0, onClick: submit }, busy ? T('保存中…') : mode === 'edit' ? T('保存') : T('创建'))));
99
+ }
100
+
101
+ function mdInline(text) {
102
+ const out = [];
103
+ const re = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g;
104
+ let last = 0, m;
105
+ while ((m = re.exec(text))) {
106
+ if (m.index > last) out.push(text.slice(last, m.index));
107
+ const token = m[0];
108
+ if (token.startsWith('**')) out.push(h('strong', { key: out.length }, token.slice(2, -2)));
109
+ else if (token.startsWith('`')) out.push(h('code', { key: out.length, className: 'cx-md-inline' }, token.slice(1, -1)));
110
+ else {
111
+ const mm = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
112
+ out.push(mm ? h('a', { key: out.length, className: 'cx-md-a', href: mm[2], target: '_blank', rel: 'noreferrer', onClick: (e) => { if (OPEN_LINK) { e.preventDefault(); e.stopPropagation(); OPEN_LINK(mm[2], mm[1]); } } }, mm[1]) : token);
113
+ }
114
+ last = m.index + token.length;
115
+ }
116
+ if (last < text.length) out.push(text.slice(last));
117
+ return out;
118
+ }
119
+
120
+ const RichText = React.memo(function RichText({ text }) {
121
+ const src = String(text || '');
122
+ const parts = [];
123
+ const re = /```([A-Za-z0-9_-]*)\n?([\s\S]*?)```/g;
124
+ let last = 0, m, key = 0;
125
+ const renderText = (chunk) => {
126
+ const lines = chunk.split('\n');
127
+ const nodes = [];
128
+ let list = null, ordered = false;
129
+ const flush = () => { if (list) { nodes.push(h(ordered ? 'ol' : 'ul', { key: `l${key++}`, className: 'cx-md-list' }, list)); list = null; } };
130
+ for (const line of lines) {
131
+ const hd = line.match(/^(#{1,4})\s+(.*)$/);
132
+ const ul = line.match(/^[-*]\s+(.*)$/);
133
+ const ol = line.match(/^\d+\.\s+(.*)$/);
134
+ const quote = line.match(/^>\s?(.*)$/);
135
+ if (hd) { flush(); nodes.push(h('div', { key: `h${key++}`, className: `cx-md-h h${hd[1].length}` }, mdInline(hd[2]))); }
136
+ else if (ul || ol) {
137
+ const isOrdered = !!ol;
138
+ if (!list || ordered !== isOrdered) { flush(); list = []; ordered = isOrdered; }
139
+ list.push(h('li', { key: `i${key++}` }, mdInline((ul || ol)[1])));
140
+ } else if (quote) { flush(); nodes.push(h('blockquote', { key: `q${key++}`, className: 'cx-md-quote' }, mdInline(quote[1]))); }
141
+ else if (!line.trim()) { flush(); }
142
+ else { flush(); nodes.push(h('div', { key: `p${key++}`, className: 'cx-md-p' }, mdInline(line))); }
143
+ }
144
+ flush();
145
+ return nodes;
146
+ };
147
+ while ((m = re.exec(src))) {
148
+ if (m.index > last) parts.push(h('div', { key: `t${key++}` }, renderText(src.slice(last, m.index))));
149
+ const lang = m[1] || 'code';
150
+ const code = m[2].replace(/\n$/, '');
151
+ parts.push(h('div', { key: `c${key++}`, className: 'cx-md-codebox' },
152
+ h('div', { className: 'cx-md-codebar' }, h('span', null, lang), h('button', { onClick: () => { try { navigator.clipboard.writeText(code); } catch {} } }, T('复制'))),
153
+ h('pre', { className: 'cx-md-code' }, h('code', null, code))));
154
+ last = re.lastIndex;
155
+ }
156
+ if (last < src.length) parts.push(h('div', { key: `t${key++}` }, renderText(src.slice(last))));
157
+ return h('div', { className: 'cx-rich' }, parts);
158
+ });
@@ -0,0 +1,324 @@
1
+ const ThreeAvatar = React.memo(function ThreeAvatar({ image }) {
2
+ const src = image || CYBER_AVATAR;
3
+ const [tilt, setTilt] = useState({ x: 0, y: 0 });
4
+ const [zoom, setZoom] = useState(1);
5
+ const [pulse, setPulse] = useState(0);
6
+ const [glitch, setGlitch] = useState(0);
7
+ const [hover, setHover] = useState(false);
8
+ const drag = useRef(null);
9
+ const down = (e) => {
10
+ drag.current = { x: e.clientX, y: e.clientY, ox: tilt.x, oy: tilt.y };
11
+ try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
12
+ };
13
+ const move = (e) => {
14
+ if (!drag.current) return;
15
+ setTilt({
16
+ x: Math.max(-10, Math.min(10, drag.current.ox - (e.clientY - drag.current.y) * 0.08)),
17
+ y: Math.max(-18, Math.min(18, drag.current.oy + (e.clientX - drag.current.x) * 0.1)),
18
+ });
19
+ };
20
+ const up = () => { drag.current = null; };
21
+ return h('div', { className: `cx-avatar3d${hover ? ' hover' : ''}`, onPointerEnter: () => setHover(true), onPointerLeave: () => { setHover(false); if (!drag.current) setTilt({ x: 0, y: 0 }); }, onPointerDown: down, onPointerMove: move, onPointerUp: up, onPointerCancel: up, onClick: () => { setPulse((x) => x + 1); setGlitch((x) => x + 1); }, onDoubleClick: () => { setTilt({ x: 0, y: 0 }); setZoom(1); }, onWheel: (e) => { e.preventDefault(); setZoom((z) => Math.max(0.92, Math.min(1.16, z - e.deltaY * 0.0008))); } },
22
+ h('div', { className: 'cx-avatar-halo' }),
23
+ h('div', { className: 'cx-avatar-ring r1' }),
24
+ h('div', { className: 'cx-avatar-ring r2' }),
25
+ h('div', { className: 'cx-avatar-ring r3' }),
26
+ h('div', { className: 'cx-avatar-ghost' }, h('img', { src, alt: '', draggable: false })),
27
+ h('div', { className: 'cx-avatar3d-inner', style: { transform: `perspective(900px) rotateX(${tilt.x}deg) rotateY(${tilt.y}deg) scale(${zoom})` } },
28
+ h('img', { src, alt: 'ContextOS female robot', draggable: false }),
29
+ h('div', { className: 'cx-avatar-scan' }),
30
+ h('div', { className: 'cx-avatar-hud' },
31
+ h('span', null, '● NEURAL LINK'), h('span', null, 'SYNC 99.8%')),
32
+ h('div', { className: 'cx-avatar-coords' }, '22.5431°N · 114.0579°E'),
33
+ h('div', { className: 'cx-avatar-wave' }, Array.from({ length: 9 }, (_, i) => h('i', { key: i, style: { animationDelay: `${i * 0.12}s` } })))),
34
+ h('div', { className: 'cx-avatar-base' }),
35
+ h('div', { className: 'cx-avatar-particles' }, Array.from({ length: 12 }, (_, i) => h('i', { key: i, style: { left: `${8 + (i * 37) % 84}%`, top: `${12 + (i * 53) % 72}%`, animationDelay: `${i * 0.3}s` } }))),
36
+ pulse ? h('div', { key: `p${pulse}`, className: 'cx-avatar-pulse' }) : null,
37
+ glitch ? h('div', { key: `g${glitch}`, className: 'cx-avatar-glitch' }) : null);
38
+ });
39
+
40
+ function ThreeCity() {
41
+ const ref = useRef(null);
42
+ const [ready, setReady] = useState(false);
43
+ useEffect(() => {
44
+ let disposed = false;
45
+ let cleanup = () => {};
46
+ let observer = null;
47
+ import('/dsh-contextos-devops/three/three.module.js').then((THREE) => {
48
+ if (disposed || !ref.current) return;
49
+ const host = ref.current;
50
+ const scene = new THREE.Scene();
51
+ scene.fog = new THREE.FogExp2(0x02070d, 0.026);
52
+ const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 200);
53
+ camera.position.set(0, 9, 24);
54
+ camera.lookAt(0, 2.5, 0);
55
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' });
56
+ renderer.setPixelRatio(Math.min(1.4, window.devicePixelRatio || 1));
57
+ renderer.setClearColor(0x02070d, 0.86);
58
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
59
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
60
+ renderer.toneMappingExposure = 1.1;
61
+ host.appendChild(renderer.domElement);
62
+ renderer.domElement.className = 'cx-canvas-city';
63
+
64
+ scene.add(new THREE.AmbientLight(0x3f88aa, 1.2));
65
+ const key = new THREE.DirectionalLight(0x8feaff, 2.2); key.position.set(8, 18, 10); scene.add(key);
66
+ const violet = new THREE.PointLight(0x8b7bff, 18, 60); violet.position.set(-14, 8, -10); scene.add(violet);
67
+ const green = new THREE.PointLight(0x42f5a7, 12, 50); green.position.set(14, 5, 8); scene.add(green);
68
+
69
+ const city = new THREE.Group();
70
+ scene.add(city);
71
+ const ground = new THREE.Mesh(new THREE.PlaneGeometry(90, 90), new THREE.MeshStandardMaterial({ color: 0x030b12, metalness: 0.65, roughness: 0.42 }));
72
+ ground.rotation.x = -Math.PI / 2; ground.position.y = -0.03; city.add(ground);
73
+ const grid = new THREE.GridHelper(64, 64, 0x1a6b86, 0x0b2a3a);
74
+ grid.material.transparent = true; grid.material.opacity = 0.42; grid.position.y = 0.005; city.add(grid);
75
+
76
+ // roads
77
+ const roadMat = new THREE.MeshBasicMaterial({ color: 0x0b3a4e, transparent: true, opacity: 0.7 });
78
+ for (const x of [-16, -8, 0, 8, 16]) {
79
+ const road = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.02, 64), roadMat);
80
+ road.position.set(x, 0.015, 0); city.add(road);
81
+ }
82
+ for (const z of [-16, -8, 0, 8, 16]) {
83
+ const road = new THREE.Mesh(new THREE.BoxGeometry(64, 0.02, 1.2), roadMat);
84
+ road.position.set(0, 0.018, z); city.add(road);
85
+ }
86
+
87
+ const dark = new THREE.MeshStandardMaterial({ color: 0x071824, metalness: 0.72, roughness: 0.3 });
88
+ const dark2 = new THREE.MeshStandardMaterial({ color: 0x0a2534, metalness: 0.6, roughness: 0.38 });
89
+ const edge = new THREE.LineBasicMaterial({ color: 0x3ee7ff, transparent: true, opacity: 0.5 });
90
+ const edgeGreen = new THREE.LineBasicMaterial({ color: 0x42f5a7, transparent: true, opacity: 0.35 });
91
+ const windowMat = new THREE.MeshBasicMaterial({ color: 0x77f7ff, transparent: true, opacity: 0.8 });
92
+ const windowViolet = new THREE.MeshBasicMaterial({ color: 0x9d8bff, transparent: true, opacity: 0.75 });
93
+ let seed = 20261106;
94
+ const rnd = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; };
95
+
96
+ const blocks = [];
97
+ for (let x = -20; x <= 20; x += 4) {
98
+ for (let z = -20; z <= 20; z += 4) {
99
+ if (Math.abs(x) < 2.5 && Math.abs(z) < 2.5) continue;
100
+ const w = 1.5 + rnd() * 1.7, d = 1.5 + rnd() * 1.7;
101
+ const h = 1.2 + rnd() * 6.4;
102
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), rnd() > 0.5 ? dark : dark2);
103
+ mesh.position.set(x + (rnd() - 0.5) * 1.2, h / 2, z + (rnd() - 0.5) * 1.2);
104
+ city.add(mesh);
105
+ const edges = new THREE.LineSegments(new THREE.EdgesGeometry(mesh.geometry), rnd() > 0.8 ? edgeGreen : edge);
106
+ edges.position.copy(mesh.position); city.add(edges);
107
+ const rows = Math.max(1, Math.floor(h / 1.1));
108
+ for (let r = 0; r < rows; r++) {
109
+ if (rnd() < 0.28) continue;
110
+ const strip = new THREE.Mesh(new THREE.BoxGeometry(w * 0.78, 0.035, 0.02), rnd() > 0.84 ? windowViolet : windowMat);
111
+ strip.position.set(mesh.position.x, 0.45 + r * 1.02, mesh.position.z + d / 2 + 0.015);
112
+ city.add(strip);
113
+ }
114
+ if (h > 5.5) {
115
+ const ant = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 1.2 + rnd(), 6), windowMat);
116
+ ant.position.set(mesh.position.x, h + 0.6, mesh.position.z); city.add(ant);
117
+ }
118
+ blocks.push(mesh);
119
+ }
120
+ }
121
+
122
+ // Shenzhen landmark skyline
123
+ const landmarkMat = new THREE.MeshStandardMaterial({ color: 0x0a2130, metalness: 0.8, roughness: 0.24 });
124
+ const landmarkEdge = new THREE.LineBasicMaterial({ color: 0x6ff7ff, transparent: true, opacity: 0.75 });
125
+ const landmark = (x, z, type) => {
126
+ const g = new THREE.Group();
127
+ g.position.set(x, 0, z);
128
+ const box = (w, h, d, y, mat = landmarkMat) => {
129
+ const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat); m.position.y = y; g.add(m);
130
+ const e = new THREE.LineSegments(new THREE.EdgesGeometry(m.geometry), landmarkEdge); e.position.y = y; g.add(e); return m;
131
+ };
132
+ if (type === 'pingan') {
133
+ box(2.4, 8, 2.4, 4); box(1.9, 7, 1.9, 11.5); box(1.3, 3.5, 1.3, 16.7);
134
+ const spire = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.2, 3.2, 8), new THREE.MeshBasicMaterial({ color: 0x9ffcff })); spire.position.y = 20; g.add(spire);
135
+ } else if (type === 'kk100') {
136
+ const t = new THREE.Mesh(new THREE.CylinderGeometry(1.1, 1.8, 13, 10), landmarkMat); t.position.y = 6.5; g.add(t);
137
+ const cap = new THREE.Mesh(new THREE.SphereGeometry(1.1, 16, 10, 0, Math.PI * 2, 0, Math.PI / 2), landmarkEdge); cap.position.y = 13; g.add(cap);
138
+ const ring = new THREE.Mesh(new THREE.TorusGeometry(1.4, 0.03, 8, 32), new THREE.MeshBasicMaterial({ color: 0x3ee7ff })); ring.rotation.x = Math.PI / 2; ring.position.y = 10; g.add(ring);
139
+ } else if (type === 'spring') {
140
+ const t = new THREE.Mesh(new THREE.ConeGeometry(2.2, 13, 20, 1, true), new THREE.MeshStandardMaterial({ color: 0x0a2130, metalness: 0.8, roughness: 0.25, wireframe: false })); t.position.y = 6.5; g.add(t);
141
+ for (let i = 0; i < 5; i++) {
142
+ const r = new THREE.Mesh(new THREE.TorusGeometry(1.9 - i * 0.3, 0.025, 8, 32), new THREE.MeshBasicMaterial({ color: i % 2 ? 0x42f5a7 : 0x6ff7ff, transparent: true, opacity: 0.8 })); r.rotation.x = Math.PI / 2; r.position.y = 2 + i * 2.3; g.add(r);
143
+ }
144
+ } else if (type === 'tencent') {
145
+ box(1.8, 9, 1.8, 4.5, landmarkMat); box(1.8, 10, 1.8, 5, landmarkMat);
146
+ const bridge = new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.25, 0.4), new THREE.MeshBasicMaterial({ color: 0x6ff7ff })); bridge.position.set(0, 7.5, 0); g.add(bridge);
147
+ [0, 1].forEach((i) => { g.children[i].position.x = -1.15; });
148
+ [2, 3].forEach((i) => { g.children[i].position.x = 1.15; });
149
+ } else if (type === 'diwang') {
150
+ box(2.2, 8, 2.2, 4); box(1.7, 4, 1.7, 9.5);
151
+ const top = new THREE.Mesh(new THREE.ConeGeometry(1.2, 2.2, 4), landmarkMat); top.position.y = 12.6; top.rotation.y = Math.PI / 4; g.add(top);
152
+ }
153
+ const ring = new THREE.Mesh(new THREE.TorusGeometry(2.4, 0.025, 8, 48), new THREE.MeshBasicMaterial({ color: 0x3ee7ff, transparent: true, opacity: 0.55 })); ring.rotation.x = Math.PI / 2; ring.position.y = 0.15; g.add(ring);
154
+ city.add(g);
155
+ };
156
+ landmark(-12, -8, 'pingan');
157
+ landmark(11, -10, 'kk100');
158
+ landmark(0, -16, 'spring');
159
+ landmark(15, 3, 'tencent');
160
+ landmark(-17, 5, 'diwang');
161
+
162
+ // central data tower
163
+ const tower = new THREE.Mesh(new THREE.CylinderGeometry(2.1, 2.8, 12, 8, 1, true), new THREE.MeshStandardMaterial({ color: 0x071a29, metalness: 0.8, roughness: 0.24, wireframe: true, transparent: true, opacity: 0.75 }));
164
+ tower.position.y = 6; city.add(tower);
165
+ const core = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.32, 12, 12), new THREE.MeshBasicMaterial({ color: 0x3ee7ff, transparent: true, opacity: 0.85 }));
166
+ core.position.y = 6; city.add(core);
167
+ for (let i = 0; i < 5; i++) {
168
+ const ring = new THREE.Mesh(new THREE.TorusGeometry(3 + i * 0.55, 0.025, 8, 72), new THREE.MeshBasicMaterial({ color: i % 2 ? 0x42f5a7 : 0x3ee7ff, transparent: true, opacity: 0.65 }));
169
+ ring.rotation.x = Math.PI / 2; ring.position.y = 2 + i * 2.1; city.add(ring);
170
+ }
171
+
172
+ // data pulses along roads
173
+ const movers = [];
174
+ const pulseMat = new THREE.MeshBasicMaterial({ color: 0x7ff7ff });
175
+ const pulseMat2 = new THREE.MeshBasicMaterial({ color: 0x8b7bff });
176
+ for (let i = 0; i < 28; i++) {
177
+ const m = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 8), i % 3 ? pulseMat : pulseMat2);
178
+ const axis = i % 2 ? 'x' : 'z';
179
+ const line = [-16, -8, 0, 8, 16][i % 5];
180
+ m.position.set(axis === 'x' ? -30 : line, 0.18, axis === 'x' ? line : -30);
181
+ m.userData = { axis, speed: 0.6 + rnd() * 1.6 };
182
+ city.add(m); movers.push(m);
183
+ }
184
+
185
+ // stars / data particles
186
+ const starsGeo = new THREE.BufferGeometry();
187
+ const starPos = [];
188
+ for (let i = 0; i < 260; i++) starPos.push((rnd() - 0.5) * 80, 4 + rnd() * 30, (rnd() - 0.5) * 80);
189
+ starsGeo.setAttribute('position', new THREE.Float32BufferAttribute(starPos, 3));
190
+ city.add(new THREE.Points(starsGeo, new THREE.PointsMaterial({ color: 0x68dff1, size: 0.05, transparent: true, opacity: 0.7 })));
191
+
192
+ let mouseX = 0, mouseY = 0, camX = 0, camY = 9, dragging = false, lastX = 0, lastY = 0, spin = 0, targetSpin = 0, zoom = 24;
193
+ const onPointer = (e) => { mouseX = (e.clientX / window.innerWidth - 0.5) * 2; mouseY = (e.clientY / window.innerHeight - 0.5) * 2; };
194
+ const onDown = (e) => { dragging = true; lastX = e.clientX; lastY = e.clientY; };
195
+ const onMove = (e) => { if (!dragging) return; targetSpin += (e.clientX - lastX) * 0.002; lastX = e.clientX; lastY = e.clientY; };
196
+ const onUp = () => { dragging = false; };
197
+ const onWheel = (e) => { if (!e.shiftKey) return; e.preventDefault(); zoom = Math.max(16, Math.min(36, zoom + e.deltaY * 0.02)); };
198
+ const onHostClick = () => {
199
+ host.classList.remove('cx-city-burst');
200
+ void host.offsetWidth;
201
+ host.classList.add('cx-city-burst');
202
+ clearTimeout(host.__cxBurst);
203
+ host.__cxBurst = setTimeout(() => host.classList.remove('cx-city-burst'), 720);
204
+ };
205
+ host.addEventListener('click', onHostClick);
206
+ window.addEventListener('pointermove', onPointer);
207
+ window.addEventListener('pointerdown', onDown);
208
+ window.addEventListener('pointermove', onMove);
209
+ window.addEventListener('pointerup', onUp);
210
+ window.addEventListener('wheel', onWheel, { passive: false });
211
+
212
+ const resize = () => {
213
+ const w = host.clientWidth || window.innerWidth, h = host.clientHeight || window.innerHeight;
214
+ renderer.setSize(w, h, false);
215
+ camera.aspect = w / h; camera.updateProjectionMatrix();
216
+ };
217
+ resize();
218
+ observer = new ResizeObserver(resize); observer.observe(host);
219
+
220
+ let raf = 0, paused = false, mapFull = false, animate = null;
221
+ const setPaused = () => { paused = mapFull || document.hidden; if (!paused && !raf && animate) raf = requestAnimationFrame(animate); };
222
+ const onMapFull = (e) => { mapFull = !!e.detail; setPaused(); };
223
+ const onVisibility = () => setPaused();
224
+ window.addEventListener('cx-map-full', onMapFull);
225
+ document.addEventListener('visibilitychange', onVisibility);
226
+ const clock = new THREE.Clock();
227
+ let lastInput = Date.now();
228
+ const bump = () => { lastInput = Date.now(); if (!raf && !paused) setPaused(); };
229
+ window.addEventListener('pointermove', bump, { passive: true });
230
+ window.addEventListener('keydown', bump);
231
+ animate = () => {
232
+ raf = 0;
233
+ if (paused) return;
234
+ /* 3s 无输入就停帧,交互时再唤醒 */
235
+ if (Date.now() - lastInput > 3000 && !dragging) return;
236
+ raf = requestAnimationFrame(animate);
237
+ const shellEl = document.querySelector('.cx-shell');
238
+ if (shellEl && shellEl.dataset.view === 'dev') return; /* 开发模式暂停城市渲染 */
239
+ const t = clock.getElapsedTime();
240
+ if (!dragging) targetSpin += 0.00045;
241
+ spin += (targetSpin - spin) * 0.04;
242
+ city.rotation.y = spin;
243
+ camX += (mouseX * 1.8 - camX) * 0.03;
244
+ camY += (9 + mouseY * 1.2 - camY) * 0.03;
245
+ camera.position.x = camX;
246
+ camera.position.y = camY;
247
+ camera.position.z += (zoom - camera.position.z) * 0.03;
248
+ camera.lookAt(0, 3.2, 0);
249
+ for (const m of movers) {
250
+ if (m.userData.axis === 'x') {
251
+ m.position.x += m.userData.speed;
252
+ if (m.position.x > 32) m.position.x = -32;
253
+ } else {
254
+ m.position.z += m.userData.speed;
255
+ if (m.position.z > 32) m.position.z = -32;
256
+ }
257
+ }
258
+ // city breathing
259
+ city.position.y = Math.sin(t * 0.25) * 0.02;
260
+ renderer.render(scene, camera);
261
+ };
262
+ animate();
263
+ setReady(true);
264
+ cleanup = () => {
265
+ cancelAnimationFrame(raf);
266
+ if (observer) observer.disconnect();
267
+ host.removeEventListener('click', onHostClick);
268
+ clearTimeout(host.__cxBurst);
269
+ window.removeEventListener('pointermove', onPointer);
270
+ window.removeEventListener('pointerdown', onDown);
271
+ window.removeEventListener('pointermove', onMove);
272
+ window.removeEventListener('pointerup', onUp);
273
+ window.removeEventListener('wheel', onWheel);
274
+ window.removeEventListener('cx-map-full', onMapFull);
275
+ document.removeEventListener('visibilitychange', onVisibility);
276
+ scene.traverse((obj) => {
277
+ try { obj.geometry?.dispose?.(); } catch {}
278
+ try {
279
+ const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
280
+ for (const mat of mats) {
281
+ if (!mat) continue;
282
+ for (const v of Object.values(mat)) if (v && v.isTexture) v.dispose?.();
283
+ mat.dispose?.();
284
+ }
285
+ } catch {}
286
+ });
287
+ renderer.dispose();
288
+ if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
289
+ };
290
+ }).catch(() => { /* fallback SVG */ });
291
+ return () => { disposed = true; cleanup(); };
292
+ }, []);
293
+ return h('div', { className: 'cx-city3d', ref },
294
+ !ready ? h(TechBackdrop) : null,
295
+ h('div', { className: 'cx-city-hud' },
296
+ h('span', null, h('i', { className: 'dot' }), 'SECTOR 07 · ONLINE'),
297
+ h('span', null, 'GRID SYNC 99.2%'),
298
+ h('span', null, '22.5431°N 114.0579°E')),
299
+ h('div', { className: 'cx-city-labels' },
300
+ h('span', null, '平安金融中心'), h('span', null, 'KK100'), h('span', null, '春笋'), h('span', null, '腾讯滨海大厦'), h('span', null, '地王大厦')));
301
+ }
302
+
303
+ function TechBackdrop() {
304
+ return h('div', { className: 'cx-tech-bg', 'aria-hidden': 'true' },
305
+ h('svg', { viewBox: '0 0 1440 900', preserveAspectRatio: 'xMidYMid slice' },
306
+ h('defs', null,
307
+ h('radialGradient', { id: 'tbGlobe', cx: '.4', cy: '.35', r: '.7' }, h('stop', { offset: '0', stopColor: '#0e4a5f' }), h('stop', { offset: '1', stopColor: '#02080f' })),
308
+ h('linearGradient', { id: 'tbLine', x1: '0', y1: '0', x2: '1', y2: '1' }, h('stop', { offset: '0', stopColor: '#3ee7ff' }), h('stop', { offset: '1', stopColor: '#42f5a7' })),
309
+ h('filter', { id: 'tbGlow' }, h('feGaussianBlur', { stdDeviation: '3', result: 'b' }), h('feMerge', null, h('feMergeNode', { in: 'b' }), h('feMergeNode', { in: 'SourceGraphic' })))),
310
+ h('circle', { cx: 1110, cy: 430, r: 205, fill: 'url(#tbGlobe)', stroke: 'rgba(62,231,255,.5)', strokeWidth: 1.5 }),
311
+ h('ellipse', { cx: 1110, cy: 430, rx: 205, ry: 62, fill: 'none', stroke: 'rgba(62,231,255,.25)' }),
312
+ h('ellipse', { cx: 1110, cy: 430, rx: 205, ry: 120, fill: 'none', stroke: 'rgba(62,231,255,.18)' }),
313
+ h('ellipse', { cx: 1110, cy: 430, rx: 66, ry: 205, fill: 'none', stroke: 'rgba(62,231,255,.22)' }),
314
+ h('ellipse', { cx: 1110, cy: 430, rx: 130, ry: 205, fill: 'none', stroke: 'rgba(62,231,255,.16)' }),
315
+ h('path', { d: 'M905 430 Q1110 280 1315 430', fill: 'none', stroke: 'url(#tbLine)', strokeWidth: 2, className: 'cx-dash', filter: 'url(#tbGlow)' }),
316
+ h('path', { d: 'M905 430 Q1110 580 1315 430', fill: 'none', stroke: 'url(#tbLine)', strokeWidth: 1.2, className: 'cx-dash', opacity: '.7' }),
317
+ h('path', { d: 'M960 300 Q1110 180 1260 300', fill: 'none', stroke: '#42f5a7', strokeWidth: 1.2, className: 'cx-dash', opacity: '.6' }),
318
+ h('path', { d: 'M960 560 Q1110 680 1260 560', fill: 'none', stroke: '#3ee7ff', strokeWidth: 1.2, className: 'cx-dash', opacity: '.6' }),
319
+ [[905,430],[1315,430],[1110,225],[1110,635],[1010,285],[1210,575],[985,565],[1235,295]].map(([x,y],i) => h('circle', { key: i, cx: x, cy: y, r: i % 2 ? 3 : 2, fill: '#42f5a7', className: 'cx-pulse' })),
320
+ h('path', { d: 'M1110 225 L1010 285 L905 430 L985 565 L1110 635 L1210 575 L1315 430 L1235 295 Z', fill: 'none', stroke: 'rgba(62,231,255,.18)' }),
321
+ h('path', { d: 'M300 430 Q500 280 760 380 T1110 430', fill: 'none', stroke: 'rgba(62,231,255,.22)', strokeWidth: 1, className: 'cx-dash' }),
322
+ h('path', { d: 'M220 620 Q520 500 760 560 T1110 430', fill: 'none', stroke: 'rgba(66,245,167,.16)', strokeWidth: 1, className: 'cx-dash' }),
323
+ h('circle', { cx: 300, cy: 430, r: 4, fill: '#3ee7ff', className: 'cx-pulse' })));
324
+ }