app.js 74 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761
  1. import { VERSION, agents, projects, deliverables, tasks, statuses, events, initialMessages } from './data.js';
  2. const $ = (s, root = document) => root.querySelector(s);
  3. const esc = (value = '') => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  4. const icons = {
  5. chat: '<path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5H4l-2 2v-9.5A8.5 8.5 0 0 1 10.5 4H13a8 8 0 0 1 8 7.5Z"/><path d="M7 10h9M7 14h6"/>',
  6. contacts: '<rect x="4" y="3" width="16" height="18" rx="2"/><circle cx="12" cy="9" r="2.4"/><path d="M8 17v-1a4 4 0 0 1 8 0v1M2 8h3M2 16h3"/>',
  7. folder: '<path d="M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z"/><path d="M3 9h18"/>',
  8. events: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>',
  9. grid: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
  10. box: '<path d="m12 3 9 5v9l-9 5-9-5V8l9-5ZM3 8l9 5 9-5M12 13v9M7.5 5.5l9 5"/>',
  11. user: '<circle cx="12" cy="8" r="4"/><path d="M4 22v-3a8 8 0 0 1 16 0v3"/>',
  12. search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
  13. chevron: '<path d="m9 5 7 7-7 7"/>',
  14. down: '<path d="m6 9 6 6 6-6"/>',
  15. back: '<path d="m15 5-7 7 7 7"/>',
  16. arrow: '<path d="M4 12h16m-6-6 6 6-6 6"/>',
  17. external: '<path d="M14 3h7v7M21 3 11 13M10 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-5"/>',
  18. file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6Z"/><path d="M14 2v6h6M8 13h8M8 17h5"/>',
  19. download: '<path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/>',
  20. clip: '<path d="m8 13 7-7a3 3 0 0 1 4 4L9 20a5 5 0 0 1-7-7L13 2M6 15l9-9"/>',
  21. send: '<path d="m22 2-7 20-4-9-9-4 20-7ZM22 2 11 13"/>',
  22. plus: '<path d="M12 4v16M4 12h16"/>',
  23. check: '<path d="m5 12 4 4L19 6"/>',
  24. circleCheck: '<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
  25. alert: '<path d="m10.3 3.8-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3.2l-8-14a2 2 0 0 0-3.4 0Z"/><path d="M12 8v5M12 17h.01"/>',
  26. clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
  27. close: '<path d="m6 6 12 12M6 18 18 6"/>',
  28. refresh: '<path d="M20 7v5h-5M4 17v-5h5"/><path d="M6.3 6a8 8 0 0 1 13 2M4.7 16a8 8 0 0 0 13 2"/>',
  29. tasks: '<rect x="3" y="4" width="18" height="17" rx="2"/><path d="m7 10 1 1 2-2M13 10h4m-10 6 1 1 2-2M13 16h4M8 2v4M16 2v4"/>',
  30. home: '<path d="m3 10 9-8 9 8v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1V10Z"/>',
  31. info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7h.01"/>',
  32. bolt: '<path d="m13 2-9 12h7l-1 8 10-13h-8l1-7Z"/>',
  33. more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
  34. lock: '<rect x="4" y="10" width="16" height="12" rx="2"/><path d="M8 10V6a4 4 0 0 1 8 0v4M12 15v3"/>',
  35. monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 22h8M12 17v5"/>',
  36. phone: '<rect x="6" y="2" width="12" height="20" rx="2"/><path d="M11 18h2"/>',
  37. moon: '<path d="M21 13a9 9 0 0 1-10-10 9 9 0 1 0 10 10Z"/>',
  38. sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1 1M18 18l1 1M5 19l1-1M18 6l1-1"/>',
  39. logout: '<path d="M9 3H4v18h5M9 12h13m-5-5 5 5-5 5"/>',
  40. layers: '<path d="m12 2 10 6-10 6L2 8l10-6ZM2 12l10 6 10-6M2 16l10 6 10-6"/>',
  41. wifi: '<path d="M2 8a16 16 0 0 1 20 0M5 12a11 11 0 0 1 14 0M8 16a6 6 0 0 1 8 0M12 20h.01"/>',
  42. inbox: '<path d="M4 4h16l2 12v5H2v-5L4 4ZM2 16h6l2 3h4l2-3h6"/>',
  43. };
  44. const icon = name => `<svg class="icon" aria-hidden="true" viewBox="0 0 24 24">${icons[name] || icons.file}</svg>`;
  45. const mark = () => '<span class="brand-mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M5 20V4h16v4H9v3h9v4H9v5Z"/></svg></span>';
  46. const brand = () => `<div class="brand">${mark()}<span class="brand-word">fmode<span class="muted">.</span></span></div>`;
  47. const button = (label, action, value = '', kind = '', i = '') => `<button type="button" class="btn ${kind}" data-action="${action}" data-value="${esc(value)}">${i ? icon(i) : ''}${label}</button>`;
  48. const iconButton = (i, label, action, value = '', extra = '') => `<button type="button" class="icon-btn ${extra}" aria-label="${esc(label)}" title="${esc(label)}" data-action="${action}" data-value="${esc(value)}">${icon(i)}</button>`;
  49. const avatar = (a, size = '') => `<span class="avatar ${a.tone || 'person'} ${size}" aria-hidden="true">${esc(a.initials || a.name.slice(0, 1))}</span>`;
  50. const agentById = id => agents.find(a => a.id === id);
  51. const projectById = id => projects.find(p => p.id === id);
  52. const artifactById = id => deliverables.find(a => a.id === id);
  53. const taskById = id => tasks.find(t => t.id === id);
  54. const statusBadge = (text, style = '') => `<span class="badge ${style}"><span class="dot"></span>${esc(text)}</span>`;
  55. const agentStatus = a => statusBadge(a.statusText, a.status === 'blocked' ? 'danger' : a.status === 'working' ? 'accent' : '');
  56. const taskBadge = status => statusBadge(statuses[status], status === 'blocked' ? 'danger' : status === 'done' ? 'success' : status === 'running' ? 'accent' : '');
  57. const storage = {
  58. get(key, fallback) { try { return localStorage.getItem(`fmode-design-demo:${key}`) ?? fallback; } catch { return fallback; } },
  59. set(key, value) { try { localStorage.setItem(`fmode-design-demo:${key}`, value); } catch { toast('浏览器存储不可用,本次设置仅在当前页面生效。'); } },
  60. };
  61. const state = {
  62. mode: 'desktop', page: 'chat', id: '', agentId: 'zhixia',
  63. projectId: 'autumn', taskId: 'T-108', contactsTab: 'all', colleague: '',
  64. chatFilter: 'all', taskFilter: 'all', eventFilter: 'all', resultView: 'project',
  65. queries: {}, scenario: 'normal', homeStates: {}, connection: 'connected',
  66. failNext: false, messages: structuredClone(initialMessages), drafts: {},
  67. theme: storage.get('theme', 'light'), palette: storage.get('palette-v2', 'platinum'),
  68. loginMethod: 'phone', loginError: '', loginBusy: false, codeSent: false,
  69. scrolls: new Map(), overlay: null, focusReturn: null, uploading: false,
  70. };
  71. const pageLabels = { chat: '会话', messages: '会话', contacts: '通讯录', projects: '项目', events: '事件', workbench: '工作台', tasks: '任务看板', deliverables: '交付成果', mine: '我的', agent: '数字生命详情', home: '首页' };
  72. const navItems = [['chat', 'chat', '会话'], ['contacts', 'contacts', '通讯录'], ['projects', 'folder', '项目'], ['events', 'events', '事件'], ['workbench', 'grid', '工作台'], ['deliverables', 'box', '成果'], ['mine', 'user', '我的']];
  73. const mobileItems = [['home', 'home', '首页'], ['messages', 'chat', '会话'], ['contacts', 'contacts', '通讯录'], ['workbench', 'grid', '工作台'], ['mine', 'user', '我的']];
  74. const palettes = [
  75. { id: 'platinum', name: '铂金曜黑', description: '炭黑 × 铂金灰' },
  76. { id: 'ice', name: '冰白靛蓝', description: '冰白 × 靛青' },
  77. { id: 'clay', name: '暖瓷陶橘', description: '暖瓷 × 陶橘' },
  78. { id: 'slate', name: '岩灰鸢紫', description: '岩灰 × 鸢紫' },
  79. ];
  80. if (!palettes.some(palette => palette.id === state.palette)) state.palette = 'graphite';
  81. if (!['light', 'dark'].includes(state.theme)) state.theme = 'light';
  82. const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
  83. let renderedRoute = null;
  84. let routeAnimations = [];
  85. let actionTrigger = null;
  86. function motionOptions(token) {
  87. const style = getComputedStyle(document.documentElement);
  88. return { duration: reducedMotion.matches ? 0 : parseFloat(style.getPropertyValue(token)), easing: style.getPropertyValue('--ease-standard').trim(), fill: 'both' };
  89. }
  90. function animateRoute(previous, back = false) {
  91. routeAnimations.forEach(animation => animation.cancel());
  92. routeAnimations = [];
  93. if (!previous || reducedMotion.matches || previous.path === location.pathname) return;
  94. const samePage = previous.page === state.page && previous.mode === state.mode;
  95. let target = $('#main')?.firstElementChild;
  96. if (state.mode === 'desktop' && samePage) {
  97. target = $(state.page === 'chat' ? '.messages' : state.page === 'tasks' ? '.task-detail' : '.project-detail');
  98. }
  99. if (!target) return;
  100. const intoDetail = state.mode === 'mobile' && state.id && !previous.id;
  101. const fromDetail = state.mode === 'mobile' && previous.id && !state.id;
  102. const directional = state.mode === 'mobile' && (intoDetail || fromDetail);
  103. const offset = back || fromDetail ? -16 : 16;
  104. const frames = directional
  105. ? [{ opacity: .7, transform: `translateX(${offset}px)` }, { opacity: 1, transform: 'translateX(0)' }]
  106. : [{ opacity: .65 }, { opacity: 1 }];
  107. const animation = target.animate(frames, motionOptions(directional ? '--motion-detail' : '--motion-page'));
  108. routeAnimations.push(animation);
  109. animation.finished.then(() => animation.cancel(), () => {});
  110. }
  111. function initialMode() {
  112. const saved = storage.get('mode', '');
  113. if (['desktop', 'mobile'].includes(saved)) return saved;
  114. return /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent) || innerWidth < 768 ? 'mobile' : 'desktop';
  115. }
  116. function href(page, id = '') {
  117. return `/${state.mode}/${page}${id ? `/${encodeURIComponent(id)}` : ''}`;
  118. }
  119. function navigate(page, id = '', replace = false) {
  120. const previous = renderedRoute;
  121. const main = $('#main');
  122. state.scrolls.set(location.pathname, main?.scrollTop || 0);
  123. closeOverlay(false);
  124. const target = page.startsWith('/') ? page : href(page, id);
  125. if (target === location.pathname) { render(); return; }
  126. history[replace ? 'replaceState' : 'pushState']({}, '', target);
  127. readRoute();
  128. render(false);
  129. requestAnimationFrame(() => {
  130. const el = $('#main');
  131. if (el) el.scrollTop = state.scrolls.get(location.pathname) || 0;
  132. if (state.page === 'chat') scrollMessages();
  133. animateRoute(previous);
  134. });
  135. }
  136. function readRoute() {
  137. const parts = location.pathname.split('/').filter(Boolean).map(x => { try { return decodeURIComponent(x); } catch { return x; } });
  138. if (!parts.length || parts[0] === 'index.html') {
  139. const mode = initialMode();
  140. history.replaceState({}, '', `/${mode}/${mode === 'mobile' ? 'home' : 'chat'}`);
  141. return readRoute();
  142. }
  143. if (parts[0] === 'login') {
  144. state.mode = initialMode(); state.page = 'login'; state.id = '';
  145. return;
  146. }
  147. if (!['desktop', 'mobile'].includes(parts[0])) {
  148. state.mode = initialMode();
  149. const legacy = { messages: state.mode === 'mobile' ? 'messages' : 'chat', workbench: 'workbench', deliverables: 'deliverables', mine: 'mine', chat: 'chat', agent: 'agent', call: 'agent' };
  150. const mapped = legacy[parts[0]];
  151. if (mapped) {
  152. if (parts[0] === 'call') setTimeout(() => toast('语音通话功能暂未开放,已转至数字生命详情。'), 100);
  153. history.replaceState({}, '', href(mapped, parts[1] || ''));
  154. return readRoute();
  155. }
  156. state.page = 'not-found';
  157. return;
  158. }
  159. state.mode = parts[0];
  160. state.page = parts[1] || (state.mode === 'mobile' ? 'home' : 'chat');
  161. state.id = parts[2] || '';
  162. if (!parts[1]) history.replaceState({}, '', href(state.page));
  163. if (!pageLabels[state.page]) state.page = 'not-found';
  164. if (state.page === 'chat' && state.id && agentById(state.id)) state.agentId = state.id;
  165. if (state.page === 'tasks' && state.id && taskById(state.id)) {
  166. state.taskId = state.id;
  167. state.agentId = taskById(state.id).agentId;
  168. }
  169. if (state.page === 'projects' && projectById(state.id)) state.projectId = state.id;
  170. if (storage.get('signedOut', 'false') === 'true') {
  171. history.replaceState({}, '', `/login?redirect=${encodeURIComponent(location.pathname)}`);
  172. state.page = 'login';
  173. }
  174. }
  175. function applyTheme() {
  176. document.documentElement.dataset.theme = state.theme;
  177. document.documentElement.dataset.palette = state.palette;
  178. const colors = getComputedStyle(document.documentElement);
  179. $('meta[name="theme-color"]').content = colors.getPropertyValue('--bg').trim();
  180. const favicon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="${colors.getPropertyValue('--accent').trim()}"/><path d="M19 46V18h28v7H27v6h16v7H27v8" fill="${colors.getPropertyValue('--on-accent').trim()}"/></svg>`;
  181. $('link[rel="icon"]').href = `data:image/svg+xml,${encodeURIComponent(favicon)}`;
  182. }
  183. function toast(message) {
  184. const el = $('#toast');
  185. el.textContent = message;
  186. el.classList.add('visible');
  187. clearTimeout(toast.timer);
  188. toast.timer = setTimeout(() => el.classList.remove('visible'), 3600);
  189. }
  190. function markdown(text) {
  191. let inList = false;
  192. return text.split('\n').map(line => {
  193. const safe = esc(line);
  194. let prefix = '';
  195. if (!line.startsWith('- ') && inList) { prefix = '</ul>'; inList = false; }
  196. if (line.startsWith('# ')) return `${prefix}<h1>${safe.slice(2)}</h1>`;
  197. if (line.startsWith('## ')) return `${prefix}<h2>${safe.slice(3)}</h2>`;
  198. if (line.startsWith('- ')) { const open = inList ? '' : '<ul>'; inList = true; return `${open}<li>${safe.slice(2)}</li>`; }
  199. if (line === '---') return `${prefix}<div class="divider"></div>`;
  200. return `${prefix}${line ? `<p>${safe}</p>` : ''}`;
  201. }).join('') + (inList ? '</ul>' : '');
  202. }
  203. function fileIcon(a) {
  204. return `<span class="file-icon ${a.type === 'CSV' ? 'csv' : ''}">${icon('file')}<span>${esc(a.type)}</span></span>`;
  205. }
  206. function searchField(key, placeholder) {
  207. return `<label class="input-wrap">${icon('search')}<input id="search-${key}" data-search="${key}" value="${esc(state.queries[key] || '')}" type="search" aria-label="${placeholder}" placeholder="${placeholder}" autocomplete="off"></label>`;
  208. }
  209. function filterTabs(items, active, action, role = 'tablist') {
  210. return `<div class="tabs" role="${role}" aria-label="筛选视图">${items.map(([id, title, count]) => `<button type="button" class="tab ${active === id ? 'active' : ''}" role="${role === 'tablist' ? 'tab' : 'button'}" ${role === 'tablist' ? `aria-selected="${active === id}"` : `aria-pressed="${active === id}"`} data-action="${action}" data-value="${id}">${title}${count !== undefined ? `<span class="count">${count}</span>` : ''}</button>`).join('')}</div>`;
  211. }
  212. function panelState(scope, content, empty = false) {
  213. const scenario = state.homeStates[scope] || state.scenario;
  214. if (scenario === 'normal' && !empty) return content;
  215. if (scenario === 'loading') return `<div class="state-panel" data-state="loading" role="status" aria-label="正在加载"><div class="skeleton"><div class="skeleton-line"></div><div class="skeleton-line"></div><div class="skeleton-line"></div></div><p>正在加载,请稍候…</p></div>`;
  216. const kind = scenario === 'normal' ? 'empty' : scenario;
  217. const texts = {
  218. empty: ['inbox', '这里暂时没有内容', empty ? '试试其他筛选条件,或等待新的内容。' : '有新的内容后,会显示在这里。'],
  219. error: ['alert', '加载失败', '连接暂时不可用,已有内容不会因此被删除。'],
  220. forbidden: ['lock', '无权访问此内容', '当前演示账号没有此资源的访问权限。请联系管理员。'],
  221. };
  222. const [i, title, description] = texts[kind] || texts.empty;
  223. return `<div class="state-panel" data-state="${kind}" ${kind === 'error' || kind === 'forbidden' ? 'role="alert"' : ''}><div class="state-symbol">${icon(i)}</div><h3>${title}</h3><p>${description}</p>${kind === 'error' ? button('重新加载', 'retry-state', scope, 'btn-outline', 'refresh') : ''}${kind === 'forbidden' ? '<span class="mono muted">403 · 演示权限状态</span>' : ''}</div>`;
  224. }
  225. function pageHeader(title, description, actions = '') {
  226. return `<div class="page-heading"><div><h1>${title}</h1>${description ? `<p>${description}</p>` : ''}</div>${actions ? `<div class="row">${actions}</div>` : ''}</div>`;
  227. }
  228. function backHeader(page, label) {
  229. return `<div class="breadcrumbs">${button(label, 'navigate', page, '', 'back')}<span>/</span><span>详情</span></div>`;
  230. }
  231. function sidebar() {
  232. const current = state.page === 'tasks' ? 'workbench' : state.page;
  233. return `<aside class="sidebar" aria-label="主导航">${brand()}<div class="workspace-label">${icon('layers')}<span>团队协作空间</span></div><nav class="nav-items">${navItems.map(([page, i, title]) => `<a class="nav-link ${current === page ? 'active' : ''}" href="${href(page)}" data-route aria-label="${title}" ${current === page ? 'aria-current="page"' : ''}>${icon(i)}<span>${title}</span>${page === 'chat' ? '<span class="nav-count">3</span>' : ''}${page === 'events' ? '<span class="dot" style="color:var(--warning);margin-left:auto"></span>' : ''}</a>`).join('')}</nav><div class="nav-section-label">关注的项目</div>${projects.slice(0, 2).map(p => `<button class="nav-project" data-action="project" data-value="${p.id}"><span class="project-dot"></span><span class="truncate">${p.name}</span></button>`).join('')}<div class="sidebar-bottom"><div class="sidebar-help"><span class="dot" style="color:var(--accent)"></span> 本地演示空间<br><span>不连接真实业务服务</span></div><button class="profile-button" data-action="navigate" data-value="mine" aria-label="林悦,打开我的账号">${avatar({ name: '林悦' })}<span class="grow"><strong>林悦</strong><small>品牌与业务协作</small></span>${icon('more')}</button></div></aside>`;
  234. }
  235. function mobileNav() {
  236. const current = state.page === 'chat' ? 'messages' : state.page === 'tasks' ? 'workbench' : state.page;
  237. return `<nav class="mobile-nav" aria-label="底部主导航">${mobileItems.map(([page, i, title]) => `<a href="${href(page)}" data-route class="${current === page ? 'active' : ''}" ${current === page ? 'aria-current="page"' : ''}><span class="nav-icon">${icon(i)}</span><span>${title}</span></a>`).join('')}</nav>`;
  238. }
  239. function demoStrip() {
  240. return `<div class="demo-strip"><span class="demo-label">${icon('layers')}<span>交互原型 · 演示数据</span><span class="strip-detail">/ 不连接真实服务</span></span><button class="strip-control" data-action="lab" aria-label="打开演示控制台">演示控制台</button></div>`;
  241. }
  242. function chatList() {
  243. const query = (state.queries.chat || '').toLowerCase();
  244. const list = agents.filter(a => a.connected && (state.chatFilter !== 'unread' || a.unread) && a.name.toLowerCase().includes(query));
  245. return `<section class="conversation-list" aria-label="会话列表"><header class="list-title"><h1>会话</h1>${iconButton('plus', '从通讯录发起会话', 'navigate', 'contacts')}</header><div class="list-search">${searchField('chat', '搜索会话')}</div><div class="list-filter">${filterTabs([['all', '全部'], ['unread', '未读', agents.filter(a => a.unread).length]], state.chatFilter, 'chat-filter')}</div><div class="chat-list-scroll">${panelState('chats', list.map(a => chatRow(a)).join(''), !list.length)}</div><div class="list-foot">${icon('lock')}<span>仅你与数字生命的一对一会话</span></div></section>`;
  246. }
  247. function chatRow(a, home = false) {
  248. return `<button class="chat-row ${!home && state.mode === 'desktop' && state.agentId === a.id ? 'active' : ''}" data-action="agent-open" data-value="${a.id}" aria-label="与${a.name}的会话">${avatar(a)}<div class="grow"><div class="chat-row-title"><strong>${a.name}</strong><time>${a.time}</time></div><div class="row" style="gap:8px;align-items:flex-start"><p class="preview grow">${esc(a.last)}</p>${a.unread ? `<span class="unread">${a.unread}</span>` : ''}</div></div></button>`;
  249. }
  250. function messageFile(id) {
  251. const a = artifactById(id);
  252. return `<button class="message-file" data-action="preview" data-value="${a.id}">${fileIcon(a)}<span class="grow"><strong>${a.title}</strong><small>${a.type} · ${a.version} · ${a.size}</small></span>${icon('chevron')}</button>`;
  253. }
  254. function messageView(m, a) {
  255. return `<article class="message ${m.sender === 'me' ? 'me' : ''}" data-message-id="${esc(m.id)}">${avatar(m.sender === 'me' ? { name: '林悦' } : a)}<div class="message-body"><div class="message-meta"><strong>${m.sender === 'me' ? '你' : a.name}</strong><time>${esc(m.time)}</time></div>${m.text ? `<div class="bubble">${esc(m.text)}</div>` : ''}${m.artifactId ? messageFile(m.artifactId) : ''}${m.file ? `<div class="message-file">${icon('file')}<span class="grow"><strong>${esc(m.file.name)}</strong><small>${esc(m.file.size)} · 本地文件,不上传</small></span></div>` : ''}${m.taskId ? `<button class="message-task" data-action="task" data-value="${m.taskId}">${icon('alert')}查看阻塞任务${icon('chevron')}</button>` : ''}${m.failed ? `<div class="send-error">${icon('alert')}未发送${button('重试', 'retry-message', m.id)}</div>` : ''}${m.pending ? '<div class="upload-label">正在发送(模拟)…</div>' : ''}</div></article>`;
  256. }
  257. function chatMain() {
  258. const a = agentById(state.agentId);
  259. if (!a || (state.id && !agentById(state.id))) return notFound('这段会话不存在');
  260. if (!a.connected) return agentPage(a.id);
  261. const p = projects.find(p => p.agentIds.includes(a.id));
  262. const list = state.messages[a.id] || [];
  263. return `<section class="chat-main" aria-label="与${a.name}的会话"><header class="chat-header">${iconButton('back', '返回会话列表', 'navigate', 'messages', 'mobile-back')}${avatar(a)}<div class="grow"><h2>${a.name}</h2><p class="agent-description">${a.specialty}</p></div><div class="chat-header-tools">${iconButton('tasks', '查看此数字生命的任务', 'agent-tasks', a.id)}${iconButton('box', '打开交付成果', 'results-drawer', a.id, state.mode === 'mobile' ? '' : 'rail-toggle')}${iconButton('info', '查看数字生命详情', 'agent-detail', a.id, 'desktop-only')}</div></header>${state.connection !== 'connected' ? `<div class="connection-notice" role="status">${icon('wifi')}<span>${state.connection === 'connecting' ? '正在重新连接…' : '连接已断开,消息将保留'}</span>${state.connection === 'disconnected' ? button('重新连接', 'reconnect') : ''}</div>` : ''}<div class="chat-context">${icon('folder')}<span class="truncate grow">${p.name}</span><button data-action="project" data-value="${p.id}">查看项目</button></div><div class="messages" id="messages" role="log" aria-label="消息记录" aria-live="polite">${panelState('messages', `<div class="date-divider">今天 · 10 月 14 日</div>${list.map(m => messageView(m, a)).join('')}`, !list.length)}</div><div class="composer-area"><form id="composer-form" class="composer"><label class="hidden" for="message-input">发送给${a.name}的消息</label><textarea id="message-input" rows="2" placeholder="发消息给${a.name},一起推进工作…" maxlength="10000">${esc(state.drafts[a.id] || '')}</textarea><div id="upload-status"></div><div class="composer-actions">${iconButton('clip', '添加本地文件(仅模拟上传)', 'attach')}<input type="file" id="file-input" hidden><span class="hint">${state.mode === 'desktop' ? 'Enter 发送 · Shift + Enter 换行' : '文件仅在本地模拟'}</span><button class="btn btn-primary send-btn" type="submit" aria-label="发送消息">${icon('send')}<span class="desktop-only">发送</span></button></div></form><p class="composer-note">内容由数字生命生成,请核实重要信息。</p></div></section>`;
  264. }
  265. function railContent(agentId) {
  266. const a = agentById(agentId);
  267. const p = projects.find(p => p.agentIds.includes(agentId));
  268. const results = deliverables.filter(d => d.agentId === agentId);
  269. return `<div class="rail-section"><span class="eyebrow">当前项目</span><div class="rail-project"><strong>${p.name}</strong><p>${p.description}</p>${button('打开项目', 'project', p.id, 'text-link', 'arrow')}</div></div><div class="rail-section"><div class="spread"><span style="font-weight:500;font-size:var(--fs-caption)">来自${a.name}</span><span class="muted" style="font-size:var(--fs-xs)">${results.length} 份成果</span></div>${panelState('rail-results', results.map(d => `<button class="rail-file" data-action="preview" data-value="${d.id}"><div class="spread">${fileIcon(d)}<span class="badge">${d.version}</span></div><strong>${d.title}</strong><p>${d.subtitle}</p><div class="spread"><span>${d.day} ${d.time}</span>${icon('arrow')}</div></button>`).join(''), !results.length)}${button('查看全部成果', 'navigate', 'deliverables', 'text-link full-width', 'box')}</div><div class="rail-foot">${icon('info')} 成果与当前数字生命关联。<br>打开文件即可预览或下载演示内容。</div>`;
  270. }
  271. function chatPage() {
  272. if (state.mode === 'mobile') return state.page === 'messages' || !state.id ? chatList() : chatMain();
  273. return `<div class="conversation-layout">${chatList()}${chatMain()}<aside class="result-rail" aria-label="交付成果"><div class="rail-header">${icon('box')}<h2>交付成果</h2><span class="badge" style="margin-left:auto">${deliverables.filter(d => d.agentId === state.agentId).length}</span></div>${railContent(state.agentId)}</aside></div>`;
  274. }
  275. function artifactRow(d, extended = false) {
  276. const a = agentById(d.agentId);
  277. return `<button class="artifact-row" data-action="preview" data-value="${d.id}">${fileIcon(d)}<span class="grow"><strong>${d.title}</strong><p>${extended ? `${d.type} · ${d.version} · ${d.subtitle}` : `${a.name} · ${d.day} ${d.time} · ${d.version}`}</p></span>${extended ? `<span class="source">${avatar(a, 'small')}${a.name}</span>` : ''}<time class="artifact-time">${d.time}</time>${icon('chevron')}</button>`;
  278. }
  279. function homePage() {
  280. const recent = agents.filter(a => a.connected).slice(0, 6);
  281. return `<header class="mobile-header">${brand()}${iconButton('user', '打开我的账号', 'navigate', 'mine')}</header><div class="home-page"><div class="home-greeting"><span class="eyebrow">10 月 14 日,星期三</span><h1>上午好,林悦<span style="color:var(--accent)">。</span></h1><p>看看新的进展,继续手头的工作。</p></div>${panelState('home-events', `<button class="home-alert" data-action="navigate" data-value="events">${icon('alert')}<span class="grow"><strong>1 项任务需要关注</strong><small>栖云 · 预发布部署已阻塞 4 小时</small></span>${icon('chevron')}</button>`)}<div class="home-shortcuts"><button class="home-shortcut" data-action="navigate" data-value="projects">${icon('folder')}<span>项目<small>查看关联进展</small></span></button><button class="home-shortcut" data-action="agent-tasks" data-value="${state.agentId}">${icon('tasks')}<span>任务看板<small>追踪执行与阻塞</small></span></button></div><section class="home-section"><div class="section-heading"><h2>最新交付<span class="section-count">3</span></h2>${button('全部', 'navigate', 'deliverables', 'text-link', 'chevron')}</div>${panelState('home-results', deliverables.slice(0, 3).map(d => artifactRow(d)).join(''))}</section><section class="home-section"><div class="section-heading"><h2>最近会话<span class="section-count">${recent.length}</span></h2>${button('全部', 'navigate', 'messages', 'text-link', 'chevron')}</div>${panelState('home-chats', recent.map(a => chatRow(a, true)).join(''))}</section></div>`;
  282. }
  283. function contactsPage() {
  284. const q = (state.queries.contacts || '').toLowerCase();
  285. const list = agents.filter(a => a.name.toLowerCase().includes(q));
  286. const colleagues = ['林悦', '陈序', '沈言'].filter(name => name.toLowerCase().includes(q));
  287. const rows = list.map(a => `<button class="contact-row" data-action="agent-open" data-value="${a.id}">${avatar(a)}<div class="grow"><strong>${a.name}</strong><p class="specialty">${a.specialty}</p></div><span class="contact-owner">归属 ${a.owner}</span>${agentStatus(a)}${icon('chevron')}</button>`).join('');
  288. const people = colleagues.map(name => `<button class="contact-row" data-action="colleague" data-value="${name}" aria-expanded="${state.colleague === name}">${avatar({ name })}<div class="grow"><strong>${name}</strong><p class="specialty">${agents.filter(a => a.owner === name).length} 位数字生命</p></div>${icon(state.colleague === name ? 'down' : 'chevron')}</button>${state.colleague === name ? agents.filter(a => a.owner === name && (!q || a.name.toLowerCase().includes(q))).map(a => `<button class="contact-row contact-child" data-action="agent-open" data-value="${a.id}">${avatar(a, 'small')}<div class="grow"><strong>${a.name}</strong><p class="specialty">${a.specialty}</p></div>${agentStatus(a)}${icon('chevron')}</button>`).join('') : ''}`).join('');
  289. const content = `${state.contactsTab !== 'people' ? `<div class="contact-group">AI 数字生命 · ${list.length}</div>${rows}` : ''}${state.contactsTab !== 'ai' ? `<div class="contact-group">同事 · ${colleagues.length}</div>${people}` : ''}`;
  290. const empty = state.contactsTab === 'ai' ? !list.length : state.contactsTab === 'people' ? !colleagues.length : !list.length && !colleagues.length;
  291. return `<div class="page content-narrow">${pageHeader('通讯录', '找到你的数字生命,开始一段协作。')}<div class="toolbar">${filterTabs([['all', '全部'], ['ai', 'AI 数字生命', agents.length], ['people', '同事', 3]], state.contactsTab, 'contacts-tab')}${searchField('contacts', '搜索名称')}</div><div role="tabpanel" data-testid="tabpanel-${state.contactsTab}">${panelState('contacts', content, empty)}</div></div>`;
  292. }
  293. function projectDetail(p) {
  294. return `<article class="project-detail"><div class="spread"><span class="eyebrow">${p.code}</span>${statusBadge(p.status, p.status === '有阻塞' ? 'warning' : 'accent')}</div><h2>${p.name}</h2><p>${p.brief}</p><dl class="detail-facts"><div><dt>项目负责人</dt><dd>${p.owner}</dd></div><div><dt>目标交付</dt><dd>${p.due}</dd></div><div><dt>最近更新</dt><dd>${p.updated}</dd></div></dl><div class="section-heading"><h3>关联会话</h3><span class="muted" style="font-size:var(--fs-xs)">${p.agentIds.length} 位数字生命</span></div><div class="inline-agents">${p.agentIds.map(id => { const a = agentById(id); return `<button class="inline-agent" data-action="agent-open" data-value="${id}">${avatar(a, 'small')}<span>${a.name}</span>${icon('chevron')}</button>`; }).join('')}</div><div class="section-heading"><h3>交付成果</h3><span class="section-count">${deliverables.filter(d => d.projectId === p.id).length}</span></div>${panelState('project-results', deliverables.filter(d => d.projectId === p.id).map(d => artifactRow(d)).join(''))}<p class="muted" style="font-size:var(--fs-xs);margin-top:20px">每份成果均可展开预览,也可下载演示文件。</p></article>`;
  295. }
  296. function projectsPage() {
  297. const mobileDetail = state.mode === 'mobile' && state.id;
  298. const p = projectById(state.id || state.projectId);
  299. if (!p) return notFound('项目不存在');
  300. const list = projects.filter(p => p.name.includes(state.queries.projects || ''));
  301. return `<div class="page">${mobileDetail ? backHeader('projects', '全部项目') : pageHeader('项目', '把会话、进展与交付,放回同一个上下文。', button('交付成果', 'navigate', 'deliverables', 'btn-outline', 'box'))}${!mobileDetail ? `<div class="toolbar"><span class="secondary">全部项目 <span class="section-count">${list.length}</span></span>${searchField('projects', '搜索项目')}</div>` : ''}${panelState('projects', `<div class="split-workspace">${!mobileDetail ? `<div class="split-list">${list.map(p => `<button class="project-row ${state.projectId === p.id ? 'active' : ''}" data-action="project" data-value="${p.id}"><span class="eyebrow">${p.code}</span><h3>${p.name}</h3><p>${p.description}</p><div class="spread">${statusBadge(p.status, p.status === '有阻塞' ? 'warning' : 'accent')}<span>${p.updated}</span></div></button>`).join('')}</div>` : ''}${state.mode === 'desktop' || mobileDetail ? projectDetail(p) : ''}</div>`, !list.length && !mobileDetail)}</div>`;
  302. }
  303. function metric(label, value) {
  304. return `<div class="metric"><div class="spread"><span>${label}</span><strong>${value}%</strong></div><div class="bar"><span style="--value:${value}%"></span></div></div>`;
  305. }
  306. function agentCard(a) {
  307. return `<article class="agent-card"><div class="row">${avatar(a)}<button class="agent-name grow" data-action="agent-detail" data-value="${a.id}"><strong>${a.name}</strong><small>${a.specialty}</small></button></div><div class="agent-task"><div class="spread"><span class="eyebrow">当前任务</span>${agentStatus(a)}</div><p class="truncate" style="margin-top:8px">${a.task}</p></div><div class="metrics">${metric('CPU', a.cpu)}${metric('内存', a.memory)}</div><div class="metric-footer"><span>Token 用量 <span class="mono">${a.tokens}</span></span>${button(`¥ ${a.balance}`, 'balance', a.id, 'text-link', 'external')}</div><div class="agent-card-footer">${button(a.channel, 'channel', a.id, '', 'wifi')}${button('查看任务', 'agent-tasks', a.id, 'btn-soft', 'arrow')}</div></article>`;
  308. }
  309. function workbenchPage() {
  310. const q = state.queries.workbench || '';
  311. const list = agents.filter(a => a.name.includes(q));
  312. return `<div class="page">${pageHeader('工作台', '数字生命的运行实况,一眼了解。', button('刷新实况', 'refresh', '', 'btn-outline', 'refresh'))}<div class="view-switch">${button('数字生命', 'navigate', 'workbench', 'tab active', 'grid')}${button('任务看板', 'agent-tasks', state.agentId, 'tab', 'tasks')}</div>${panelState('workbench', `<div class="overview-stats"><div class="overview-stat"><span>数字生命</span><strong>5<small>位</small></strong></div><div class="overview-stat"><span>正在执行</span><strong>2<small>位</small></strong></div><div class="overview-stat"><span>需要关注</span><strong style="color:var(--warning)">1<small>位</small></strong></div><div class="overview-stat"><span>今日交付</span><strong>3<small>份</small></strong></div></div><div class="banner">${icon('alert')}<div class="grow"><strong>栖云的部署任务已阻塞 4 小时</strong><p>环境凭证已失效,连续失败 3 次。需要负责人确认后继续。</p></div>${button('查看原因', 'task', 'T-106', '', 'arrow')}</div><div class="toolbar"><div class="secondary">全部数字生命 <span class="section-count">${list.length}</span></div>${searchField('workbench', '搜索数字生命')}</div>${!list.length ? panelState('workbench-search', '', true) : `<div class="agent-grid">${list.map(a => agentCard(a)).join('')}</div>`}<p class="muted" style="font-size:var(--fs-xs);margin-top:24px">最近更新 10:42:08 · 双端使用同一组演示实况 · 余额仅演示,不发生扣费</p>`)}</div>`;
  313. }
  314. function taskDetail(t) {
  315. return `<article class="task-detail"><div class="spread"><span class="mono muted">${t.id}</span>${taskBadge(t.status)}</div><h2>${t.title}</h2>${t.status === 'blocked' ? `<div class="banner">${icon('alert')}<div><strong>已阻塞 ${t.blockedHours} 小时 · 连续失败 ${t.failures} 次</strong><p>原因:环境凭证失效</p><p class="mono">${t.blockKind}</p></div></div>` : ''}<dl class="detail-facts"><div><dt>数字生命 / assignee</dt><dd>${agentById(t.agentId).name}</dd></div><div><dt>负责人</dt><dd>${t.owner}</dd></div><div><dt>最近更新</dt><dd>${t.time}</dd></div><div><dt>分支</dt><dd class="mono">${t.branch}</dd></div></dl><div class="markdown">${markdown(t.body)}</div><div class="detail-section"><h3>运行流水 <span class="section-count">${t.runs.length}</span></h3>${t.runs.length ? t.runs.map(r => `<div class="run-row"><span class="run-node ${r.status === 'blocked' ? 'warning' : ''}">${icon(r.status === 'done' ? 'circleCheck' : r.status === 'blocked' ? 'alert' : 'clock')}</span><div class="grow"><strong>${r.title}</strong><p>${r.detail}</p></div><time>${r.time}</time></div>`).join('') : '<p class="empty-inline">任务尚未运行,暂无记录。</p>'}</div><div class="detail-section"><h3>附件 <span class="section-count">${t.attachments.length}</span></h3>${t.attachments.length ? t.attachments.map(id => artifactRow(artifactById(id))).join('') : '<p class="empty-inline">暂无附件。</p>'}</div><div class="detail-section"><h3>历史评论 <span class="badge">只读</span></h3>${t.comments.length ? t.comments.map(c => `<p class="comment">${esc(c)}</p>`).join('') : '<p class="empty-inline">暂无历史评论。</p>'}</div><div class="detail-section">${button('进入会话', 'agent-open', t.agentId, 'btn-outline', 'chat')}<p class="muted" style="font-size:var(--fs-xs);margin-top:10px">任务看板仅供查看,指令通过一对一会话沟通。</p></div></article>`;
  316. }
  317. function tasksPage() {
  318. const t = taskById(state.id || state.taskId);
  319. if (state.id && !t) return notFound('任务不存在');
  320. const mobileDetail = state.mode === 'mobile' && state.id;
  321. const list = tasks.filter(t => t.agentId === state.agentId && (state.taskFilter === 'all' || t.status === state.taskFilter)).sort((a, b) => b.time.localeCompare(a.time));
  322. const selected = list.find(x => x.id === state.taskId) || list[0];
  323. const detail = mobileDetail ? t : selected;
  324. return `<div class="page">${mobileDetail ? backHeader('tasks', '任务看板') : pageHeader('任务看板', '关注真实任务的状态、运行过程与阻塞原因。', '<span class="badge">只读视图</span>')}${!mobileDetail ? `<div class="view-switch">${button('数字生命', 'navigate', 'workbench', 'tab', 'grid')}${button('任务看板', 'navigate', 'tasks', 'tab active', 'tasks')}</div><div class="task-toolbar"><label for="task-agent" class="secondary">数字生命</label><select id="task-agent" aria-label="按数字生命筛选任务">${agents.map(a => `<option value="${a.id}" ${a.id === state.agentId ? 'selected' : ''}>${a.name} · ${a.specialty}</option>`).join('')}</select><span class="muted" style="font-size:var(--fs-xs)">仅显示当前数字生命的任务</span></div><div class="toolbar">${filterTabs([['all', '全部', tasks.filter(t => t.agentId === state.agentId).length], ...Object.entries(statuses).map(([s, label]) => [s, label, tasks.filter(t => t.agentId === state.agentId && t.status === s).length])], state.taskFilter, 'task-filter', 'group')}</div>` : ''}${panelState('tasks', `<div class="task-layout">${!mobileDetail ? `<div class="task-list"><div class="task-list-head"><span>${list.length} 项任务</span><span>按更新时间倒序</span></div>${list.map(t => `<button class="task-row ${detail?.id === t.id ? 'active' : ''}" data-action="task" data-value="${t.id}"><div class="spread"><span class="mono muted">${t.id}</span>${taskBadge(t.status)}</div><h3>${t.title}</h3><div class="task-meta"><span>${t.owner}</span><span>${t.time.slice(5)}</span></div>${t.status === 'blocked' ? `<div class="blocked-note">${icon('alert')}已阻塞 ${t.blockedHours} 小时 · ${t.failures} 次失败</div>` : ''}</button>`).join('')}</div>` : ''}${detail && (state.mode === 'desktop' || mobileDetail) ? taskDetail(detail) : ''}</div>`, !list.length && !mobileDetail)}<p class="muted" style="font-size:var(--fs-xs);margin-top:20px">演示数据 · 真实任务依赖云函数接入,本原型不读取容器或任务数据库。</p></div>`;
  325. }
  326. function eventsPage() {
  327. const list = events.filter(e => state.eventFilter === 'all' || e.category === state.eventFilter);
  328. return `<div class="page content-narrow">${pageHeader('事件', '交付、执行与异常,按发生时间汇集。', button('刷新', 'refresh', '', 'btn-outline', 'refresh'))}<div class="toolbar">${filterTabs([['all', '全部', events.length], ['delivery', '交付', 4], ['execution', '执行', 1], ['alert', '告警', 1]], state.eventFilter, 'event-filter', 'group')}</div>${panelState('events', ['今天', '昨天'].map(day => {
  329. const dayEvents = list.filter(e => e.day === day);
  330. if (!dayEvents.length) return '';
  331. return `<section class="event-group"><div class="event-date"><strong>${day}</strong>${dayEvents[0].date}</div>${dayEvents.map(e => `<button class="event-row" data-category="${e.category}" data-action="${e.taskId ? 'task' : 'preview'}" data-value="${e.taskId || e.artifactId}"><span class="event-icon ${e.category}">${icon(e.category === 'alert' ? 'alert' : e.category === 'execution' ? 'bolt' : 'box')}</span><span class="grow"><h3>${e.title}</h3><p>${e.description}</p></span><time>${e.time}</time>${icon('chevron')}</button>`).join('')}</section>`;
  332. }).join(''), !list.length)}<p class="muted" style="font-size:var(--fs-xs);margin-top:28px">事件由交付与运行状态生成,不作为独立审计记录。</p></div>`;
  333. }
  334. function resultsPage() {
  335. const q = state.queries.results || '';
  336. const list = deliverables.filter(d => d.title.includes(q));
  337. const grouped = state.resultView === 'project' ? projects.map(p => [p.name, list.filter(d => d.projectId === p.id)]) : ['今天', '昨天'].map(day => [day === '今天' ? '今天 · 10 月 14 日' : '昨天 · 10 月 13 日', list.filter(d => d.day === day)]);
  338. return `<div class="page">${pageHeader('交付成果', '每一次协作,都有可查阅的交付。', `<span class="muted desktop-only" style="font-size:var(--fs-caption)">${deliverables.length} 份成果</span>`)}<div class="toolbar"><div class="segmented">${filterTabs([['project', '按项目'], ['timeline', '按时间线']], state.resultView, 'result-view')}</div>${searchField('results', '搜索交付成果')}</div><div role="tabpanel" data-testid="results-${state.resultView}">${panelState('results', grouped.filter(([, data]) => data.length).map(([title, data]) => `<section class="results-group"><div class="results-group-header">${icon(state.resultView === 'project' ? 'folder' : 'clock')}<h2>${title}</h2><span class="section-count">${data.length}</span></div><div class="result-table-labels"><span>成果名称</span><span>来自</span><span>交付时间</span><span></span></div>${data.map(d => artifactRow(d, true)).join('')}</section>`).join(''), !list.length)}</div></div>`;
  339. }
  340. function agentPage(id = state.id) {
  341. const a = agentById(id);
  342. if (!a) return notFound('数字生命不存在');
  343. return `<div class="page content-narrow">${backHeader('contacts', '通讯录')}${panelState('agent', `<div class="agent-profile-hero">${avatar(a, 'large')}<div class="grow"><div class="row"><h1>${a.name}</h1>${agentStatus(a)}</div><p class="secondary" style="margin-top:6px">${a.specialty}</p><p class="muted" style="font-size:var(--fs-xs);margin-top:5px">归属 ${a.owner} · 数字生命</p></div>${a.connected ? button('进入会话', 'agent-open', a.id, 'btn-primary', 'chat') : '<button class="btn btn-outline" disabled aria-describedby="not-connected">尚未接入会话</button>'}</div>${!a.connected ? '<div class="banner" id="not-connected" style="margin-top:22px"><span>尚未接入 FMODE,完成接入后即可开始会话。</span></div>' : ''}<div class="profile-metrics"><div class="metric"><span>CPU 使用率</span><strong>${a.cpu}%</strong></div><div class="metric"><span>内存使用率</span><strong>${a.memory}%</strong></div><div class="metric"><span>Token 用量</span><strong>${a.tokens}</strong></div><div class="metric"><span>账户余额</span><strong>${a.balance === '—' ? '—' : `¥${a.balance}`}</strong></div></div><div class="setting-row" style="padding:20px 0"><div><h3>当前任务</h3><p>${a.task}</p></div>${button('查看任务', 'agent-tasks', a.id, 'btn-outline', 'tasks')}</div><div class="setting-row"><div><h3>连接通道</h3><p>${a.channel} · 最近更新 今天 10:42:08</p></div>${button('通道详情', 'channel', a.id, '', 'wifi')}</div><div class="detail-section"><h3>交付记录</h3>${deliverables.filter(d => d.agentId === a.id).length ? deliverables.filter(d => d.agentId === a.id).map(d => artifactRow(d)).join('') : '<p class="empty-inline">暂无交付记录。</p>'}</div>`)}</div>`;
  344. }
  345. function minePage() {
  346. const themeChoices = [['light', '浅色', 'sun'], ['dark', '深色', 'moon']].map(([id, name, i]) => `
  347. <button class="btn tab ${state.theme === id ? 'active' : ''}" data-action="theme" data-value="${id}" aria-pressed="${state.theme === id}">
  348. ${icon(i)}${name}
  349. </button>`).join('');
  350. const paletteChoices = palettes.map(palette => `
  351. <button class="palette-choice ${state.palette === palette.id ? 'selected' : ''}" data-action="palette" data-value="${palette.id}" aria-pressed="${state.palette === palette.id}" aria-label="${palette.name}">
  352. <span class="palette-thumbnail" data-preview-palette="${palette.id}" data-preview-theme="${state.theme}" aria-hidden="true">
  353. <span class="palette-thumb-nav"><span></span><span></span><span></span></span><span class="palette-thumb-content"><span></span><span></span><span></span></span><span class="palette-thumb-rail"></span>
  354. </span>
  355. <span class="palette-choice-label">${palette.name}<span class="theme-radio">${state.palette === palette.id ? icon('check') : ''}</span></span>
  356. <span class="palette-choice-description">${palette.description}</span>
  357. </button>`).join('');
  358. return `<div class="page settings-page">
  359. ${pageHeader('我的', '管理账号、界面外观与使用偏好。')}
  360. <div class="settings-layout">
  361. <aside class="account-panel" aria-label="账号信息">
  362. <div class="identity-block">${avatar({ name: '林悦' }, 'large')}<div><h2>林悦</h2><p>品牌与业务协作</p></div></div>
  363. <span class="badge">演示账号</span>
  364. <dl class="account-facts"><div><dt>手机号</dt><dd>138 **** 8000</dd></div><div><dt>所属组织</dt><dd>Fmode 演示团队</dd></div><div><dt>工作空间</dt><dd>团队协作空间</dd></div></dl>
  365. <div class="account-note">${icon('lock')}<p>当前为独立演示账号,<br>不会连接真实业务服务。</p></div>
  366. ${button('退出登录', 'logout', '', 'btn-outline account-logout', 'logout')}
  367. </aside>
  368. <div class="settings-content">
  369. <section class="settings-section">
  370. <div class="settings-section-heading">${icon('sun')}<h3>界面外观</h3></div>
  371. <div class="settings-appearance">
  372. <div class="setting-copy"><h4>显示模式</h4><p>选择适合当前环境的界面。</p></div>
  373. <div class="segmented theme-switch" role="group" aria-label="显示模式">${themeChoices}</div>
  374. </div>
  375. <div class="settings-palettes">
  376. <div class="setting-copy"><h4>整体配色</h4><p>导航、内容与控件统一切换。</p></div>
  377. <div class="palette-options" role="group" aria-label="整体配色方案">${paletteChoices}</div>
  378. </div>
  379. </section>
  380. <section class="settings-section">
  381. <div class="settings-section-heading">${icon('monitor')}<h3>使用端</h3></div>
  382. <div class="setting-row"><div class="setting-copy"><h4>当前为${state.mode === 'mobile' ? '手机' : '桌面'}端</h4><p>切换后保留偏好,不随窗口变化跳转。</p></div>${button(state.mode === 'mobile' ? '切换桌面端' : '切换手机端', 'switch-mode', state.mode === 'mobile' ? 'desktop' : 'mobile', 'btn-outline', state.mode === 'mobile' ? 'monitor' : 'phone')}</div>
  383. <div class="setting-row"><div class="setting-copy"><h4>自动识别设备</h4><p>恢复默认的设备访问方式。</p></div>${button('恢复自动识别', 'auto-mode', '', '', 'refresh')}</div>
  384. </section>
  385. <section class="settings-section">
  386. <div class="settings-section-heading">${icon('lock')}<h3>账号与凭证</h3></div>
  387. <div class="setting-row"><div class="setting-copy"><h4>登录凭证</h4><p>原型不保存真实 Token。</p></div>${button('修复 Token', 'repair-token', '', 'btn-outline', 'refresh')}</div>
  388. </section>
  389. <div class="settings-about"><span>Fmode <span class="mono">${VERSION}</span></span>${button('演示说明', 'about', '', 'text-link', 'info')}</div>
  390. </div>
  391. </div>
  392. </div>`;
  393. }
  394. function loginPage() {
  395. return `<div class="login-shell"><section class="login-story">${brand()}<div class="login-story-content"><span class="eyebrow">YOUR DIGITAL WORKSPACE</span><h1>连接数字生命,<br>让协作持续发生。</h1><p>从一次对话开始,了解工作进展,<br>收下每一份交付成果。</p><div class="login-flow"><span>开始会话</span>${icon('arrow')}<span>跟进任务</span>${icon('arrow')}<span>查收成果</span></div></div><span class="muted" style="font-size:var(--fs-xs)">Fmode · 人与数字生命的协作空间</span></section><section class="login-form-area"><form class="login-form" id="login-form"><div class="mobile-only">${brand()}</div><h2>欢迎回来</h2><p>登录你的协作空间</p>${filterTabs([['phone', '手机验证码'], ['token', 'Token 登录']], state.loginMethod, 'login-method')}${state.loginMethod === 'phone' ? `<div class="form-field"><label for="phone">手机号</label><input id="phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel" placeholder="请输入手机号" maxlength="11"></div><div class="form-field"><label for="code">验证码</label><div class="row"><input id="code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位验证码" maxlength="6"><button class="btn btn-outline" type="button" data-action="send-code" ${state.codeSent ? 'disabled' : ''}>${state.codeSent ? '已发送(模拟)' : '获取验证码'}</button></div></div>` : '<div class="form-field"><label for="token">登录 Token</label><input id="token" name="token" type="password" autocomplete="off" placeholder="请输入演示 Token"></div>'}<p class="form-error" id="login-error" role="alert">${esc(state.loginError)}</p><button class="btn btn-primary login-submit" type="submit" ${state.loginBusy ? 'disabled' : ''}>${state.loginBusy ? '正在登录…' : '进入协作空间'}${icon('arrow')}</button><div class="demo-credentials">仅模拟登录,不发送短信或请求真实服务。<br>手机号:13800138000<br>验证码:123456 · Token:demo-fmode<br>过期状态演示:验证码 000000</div></form></section></div>`;
  396. }
  397. function notFound(title = '这个页面不存在') {
  398. return `<div class="page"><div class="state-panel"><div class="state-symbol">${icon('search')}</div><h1>${title}</h1><p>检查地址,或回到协作空间继续浏览。</p>${button('返回首页', 'navigate', state.mode === 'mobile' ? 'home' : 'chat', 'btn-primary')}</div></div>`;
  399. }
  400. // Reconcile stable nodes so typing, selection, scroll, and focus survive updates.
  401. function nodeKey(node) {
  402. if (node.nodeType !== Node.ELEMENT_NODE) return node.nodeName;
  403. const id = node.id || node.getAttribute('data-message-id') || node.getAttribute('data-testid');
  404. const action = node.getAttribute('data-action');
  405. return `${node.nodeName}:${id || (action ? `${action}:${node.getAttribute('data-value') || ''}` : node.getAttribute('href') || node.getAttribute('class')?.split(' ')[0] || '')}`;
  406. }
  407. function reconcile(current, next) {
  408. if (current.nodeType !== Node.ELEMENT_NODE && current.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
  409. if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
  410. return;
  411. }
  412. if (current.nodeType === Node.ELEMENT_NODE) {
  413. for (const attribute of [...current.attributes]) {
  414. if (!next.hasAttribute(attribute.name)) current.removeAttribute(attribute.name);
  415. }
  416. for (const attribute of next.attributes) {
  417. if (current.getAttribute(attribute.name) !== attribute.value) current.setAttribute(attribute.name, attribute.value);
  418. }
  419. if (current instanceof HTMLTextAreaElement) {
  420. if (current.value !== next.value) current.value = next.value;
  421. return;
  422. }
  423. if (current instanceof HTMLInputElement) {
  424. if (next.hasAttribute('value') && current.type !== 'file' && current.value !== next.value) current.value = next.value;
  425. return;
  426. }
  427. }
  428. const unused = new Set(current.childNodes);
  429. let cursor = current.firstChild;
  430. for (const child of [...next.childNodes]) {
  431. const match = [...unused].find(node => nodeKey(node) === nodeKey(child));
  432. if (match) {
  433. unused.delete(match);
  434. if (match !== cursor) current.insertBefore(match, cursor);
  435. reconcile(match, child);
  436. cursor = match.nextSibling;
  437. } else {
  438. const added = child.cloneNode(true);
  439. current.insertBefore(added, cursor);
  440. cursor = added.nextSibling;
  441. }
  442. }
  443. for (const node of unused) node.remove();
  444. if (current instanceof HTMLSelectElement) current.value = next.value;
  445. }
  446. function render(preserve = true) {
  447. routeAnimations.forEach(animation => animation.cancel());
  448. routeAnimations = [];
  449. const active = document.activeElement;
  450. const focus = active?.id;
  451. const start = active?.selectionStart;
  452. const end = active?.selectionEnd;
  453. const scroll = preserve ? $('#main')?.scrollTop || 0 : 0;
  454. const msgScroll = preserve ? $('#messages')?.scrollTop : undefined;
  455. applyTheme();
  456. document.body.dataset.mode = state.mode;
  457. const pages = { chat: chatPage, messages: chatPage, home: homePage, contacts: contactsPage, projects: projectsPage, workbench: workbenchPage, tasks: tasksPage, events: eventsPage, deliverables: resultsPage, agent: agentPage, mine: minePage, 'not-found': notFound };
  458. const isChat = state.mode === 'mobile' && state.page === 'chat' && state.id;
  459. document.title = `${pageLabels[state.page] || '登录'} · Fmode 演示`;
  460. const template = document.createElement('template');
  461. template.innerHTML = `<div id="app">${demoStrip() + (state.page === 'login' ? loginPage() : `<div class="app-shell ${state.mode === 'mobile' ? 'mobile' : ''} ${isChat ? 'is-chat' : ''}">${state.mode === 'desktop' ? sidebar() : ''}<main class="workspace" id="main" tabindex="-1">${(pages[state.page] || notFound)()}</main>${state.mode === 'mobile' ? mobileNav() : `<div class="narrow-desktop"><div>${brand()}<h2 style="margin-top:24px">当前窗口适合手机端</h2><p>桌面端需要至少 768px 的宽度。你的端偏好不会被自动改变。</p>${button('切换到手机端', 'switch-mode', 'mobile', 'btn-primary', 'phone')}</div></div>`}</div>`)}</div>`;
  462. reconcile($('#app'), template.content.firstElementChild);
  463. $('#app').inert = !!state.overlay;
  464. if ($('#main')) $('#main').scrollTop = scroll;
  465. if (msgScroll !== undefined && $('#messages')) $('#messages').scrollTop = msgScroll;
  466. if (!active?.isConnected && focus && document.getElementById(focus)) {
  467. const el = document.getElementById(focus);
  468. el.focus({ preventScroll: true });
  469. if (typeof el.setSelectionRange === 'function' && start !== null && start !== undefined) {
  470. try { el.setSelectionRange(start, end); } catch { /* Number-like inputs do not support text selection. */ }
  471. }
  472. }
  473. autoHeight();
  474. renderedRoute = { mode: state.mode, page: state.page, id: state.id, path: location.pathname };
  475. }
  476. function scrollMessages() {
  477. const el = $('#messages');
  478. if (el) el.scrollTop = el.scrollHeight;
  479. }
  480. function autoHeight() {
  481. const el = $('#message-input');
  482. if (!el) return;
  483. el.style.height = 'auto';
  484. el.style.height = `${Math.min(el.scrollHeight, state.mode === 'mobile' ? 100 : 160)}px`;
  485. }
  486. function openAgent(id) {
  487. const a = agentById(id);
  488. if (!a) return;
  489. state.agentId = id;
  490. navigate(a.connected ? 'chat' : 'agent', id);
  491. }
  492. function showOverlay(title, content, kind = '') {
  493. if (!state.overlay) state.focusReturn = actionTrigger || document.activeElement;
  494. state.overlay = { title, content, kind };
  495. $('#app').inert = true;
  496. $('#overlay-root').innerHTML = `<div class="overlay-backdrop ${kind === 'drawer' ? 'drawer-backdrop' : ''}" data-backdrop><section class="dialog ${kind === 'drawer' ? 'drawer' : kind === 'preview' ? 'preview-dialog' : ''}" role="dialog" aria-modal="true" aria-labelledby="dialog-title" tabindex="-1"><header class="dialog-head"><h2 id="dialog-title">${title}</h2>${iconButton('close', '关闭弹窗', 'close')}</header><div class="dialog-body">${content}</div></section></div>`;
  497. const overlay = state.overlay;
  498. const dialog = $('.dialog');
  499. const startTransform = kind === 'drawer' ? (state.mode === 'mobile' ? 'translateY(100%)' : 'translateX(100%)') : 'none';
  500. overlay.transform = startTransform;
  501. if (!reducedMotion.matches) {
  502. const backdropAnimation = $('.overlay-backdrop').animate([{ opacity: 0 }, { opacity: 1 }], motionOptions('--motion-page'));
  503. const dialogAnimation = dialog.animate([{ transform: startTransform }, { transform: 'none' }], motionOptions(kind === 'drawer' ? '--motion-panel' : '--motion-page'));
  504. overlay.animations = [backdropAnimation, dialogAnimation];
  505. overlay.animations.forEach(animation => animation.finished.then(() => animation.cancel(), () => {}));
  506. }
  507. requestAnimationFrame(() => { if (state.overlay === overlay) dialog.focus({ preventScroll: true }); });
  508. }
  509. function closeOverlay(restore = true) {
  510. if (!state.overlay) return;
  511. const closing = state.overlay;
  512. const finish = () => {
  513. if (state.overlay !== closing) return;
  514. $('#overlay-root').innerHTML = '';
  515. state.overlay = null;
  516. $('#app').inert = false;
  517. if (restore && state.focusReturn?.isConnected) state.focusReturn.focus({ preventScroll: true });
  518. };
  519. if (!restore || reducedMotion.matches) { finish(); return; }
  520. if (closing.closing) return;
  521. closing.closing = true;
  522. const backdrop = $('.overlay-backdrop');
  523. const dialog = $('.dialog');
  524. const startOpacity = getComputedStyle(backdrop).opacity;
  525. const startTransform = getComputedStyle(dialog).transform;
  526. closing.animations?.forEach(animation => animation.cancel());
  527. const animations = [
  528. backdrop.animate([{ opacity: startOpacity }, { opacity: 0 }], motionOptions('--motion-exit')),
  529. dialog.animate([{ transform: startTransform }, { transform: closing.transform }], motionOptions('--motion-exit')),
  530. ];
  531. Promise.allSettled(animations.map(animation => animation.finished)).then(finish);
  532. }
  533. function preview(id) {
  534. const d = artifactById(id);
  535. if (!d) return;
  536. showOverlay(d.title, '', 'preview');
  537. const content = d.type === 'CSV' ? `<div style="overflow:auto"><table class="csv-table"><thead><tr>${d.content.split('\n')[0].split(',').map(c => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>${d.content.split('\n').slice(1).map(row => `<tr>${row.split(',').map(c => `<td>${esc(c)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>` : `<div class="markdown">${markdown(d.content)}</div>`;
  538. $('.preview-dialog').innerHTML = `<header class="dialog-head"><div class="grow"><h2 id="dialog-title">${d.title}</h2><small class="muted">${d.version} · 演示文件</small></div><div class="preview-toolbar">${button('下载', 'download', id, 'btn-outline', 'download')}${iconButton('external', '在新标签页打开演示文件', 'open-file', id)}${iconButton('close', '关闭预览', 'close')}</div></header><div class="preview-body"><article class="document-paper"><div class="document-brand">FMODE / DELIVERABLE</div>${content}</article></div>`;
  539. }
  540. function download(id, open = false) {
  541. const d = artifactById(id);
  542. if (!d) return;
  543. const html = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>${esc(d.title)}</title><body><p>FMODE · 演示文件,不代表真实交付</p><h1>${esc(d.title)}</h1><pre style="white-space:pre-wrap;font:16px/1.9 sans-serif">${esc(d.content)}</pre></body></html>`;
  544. const blob = new Blob([open ? html : '\uFEFF' + d.content], { type: open ? 'text/html;charset=utf-8' : d.type === 'CSV' ? 'text/csv;charset=utf-8' : 'text/markdown;charset=utf-8' });
  545. const url = URL.createObjectURL(blob);
  546. const a = document.createElement('a');
  547. a.href = url;
  548. if (open) { a.target = '_blank'; a.rel = 'noopener'; } else a.download = d.filename;
  549. a.click();
  550. setTimeout(() => URL.revokeObjectURL(url), 60000);
  551. if (!open) toast('演示文件已开始下载。');
  552. }
  553. function lab() {
  554. const choices = [['normal', '正常内容'], ['loading', '加载中'], ['empty', '空状态'], ['error', '加载失败'], ['forbidden', '权限不足']];
  555. showOverlay('演示控制台', `<p>所有场景均为本地模拟,不影响真实账号。可以切换页面状态、连接状态和使用端。</p><h3 style="font-size:var(--fs-caption);margin-bottom:12px">页面数据状态</h3><div class="lab-grid">${choices.map(([id, label]) => button(label, 'scenario', id, state.scenario === id ? 'btn-soft' : 'btn-outline', state.scenario === id ? 'check' : '')).join('')}</div><div class="lab-group"><h3>会话状态</h3><div class="lab-grid">${button('模拟断线', 'disconnect', '', 'btn-outline', 'wifi')}${button(state.failNext ? '下次发送将失败' : '下次发送失败', 'fail-next', '', state.failNext ? 'btn-soft' : 'btn-outline', 'alert')}</div></div><div class="lab-group"><h3>手机首页 · 分区独立状态</h3>${[['home-events', '事件提醒'], ['home-results', '最新交付'], ['home-chats', '最近会话']].map(([key, label]) => `<div class="setting-row"><label for="state-${key}">${label}</label><select id="state-${key}" data-home-state="${key}"><option value="">跟随全局</option>${choices.map(([id, name]) => `<option value="${id}" ${state.homeStates[key] === id ? 'selected' : ''}>${name}</option>`).join('')}</select></div>`).join('')}</div><div class="lab-group"><h3>预览与登录</h3><div class="lab-grid">${button('桌面端', 'switch-mode', 'desktop', 'btn-outline', 'monitor')}${button('手机端', 'switch-mode', 'mobile', 'btn-outline', 'phone')}${button('登录页', 'show-login', '', 'btn-outline', 'lock')}${button('演示说明', 'about', '', 'btn-outline', 'info')}</div></div><div class="dialog-actions">${button('恢复正常演示', 'reset-demo', '', 'btn-primary', 'refresh')}</div>`, 'drawer');
  556. }
  557. function reconnect() {
  558. state.connection = 'connecting';
  559. render();
  560. setTimeout(() => {
  561. state.connection = 'connected';
  562. render();
  563. toast('模拟连接已恢复,未发送的消息可以重试。');
  564. }, 1200);
  565. }
  566. function sendMessage() {
  567. const input = $('#message-input');
  568. const text = input?.value.trim();
  569. if (!text) { input?.focus(); return; }
  570. const agentId = state.agentId;
  571. const failed = state.failNext || state.connection !== 'connected';
  572. const m = { id: `local-${Date.now()}`, sender: 'me', time: '刚刚', text, failed, pending: !failed };
  573. (state.messages[agentId] ||= []).push(m);
  574. state.drafts[agentId] = '';
  575. state.failNext = false;
  576. render(); scrollMessages();
  577. if (failed) { toast('消息未发送,内容已保留。可点击消息下方重试。'); return; }
  578. simulatedReply(agentId, m);
  579. }
  580. function simulatedReply(agentId, message) {
  581. setTimeout(() => {
  582. message.pending = false;
  583. state.messages[agentId].push({ id: `reply-${Date.now()}`, sender: 'agent', time: '刚刚', text: '收到你的补充。我会结合当前项目继续整理,已有内容可以在交付成果里查看。\n\n这是演示回复,未调用真实数字生命。' });
  584. if (state.page === 'chat' && state.agentId === agentId) { render(); scrollMessages(); }
  585. }, 900);
  586. }
  587. function simulateUpload(file) {
  588. if (state.uploading) { toast('请等待当前文件处理完成。'); return; }
  589. if (file.size > 25 * 1024 * 1024) { toast('演示文件最大支持 25 MB,请选择较小的文件。'); return; }
  590. const agentId = state.agentId;
  591. state.uploading = true;
  592. let value = 0;
  593. const timer = setInterval(() => {
  594. value += 20;
  595. const status = $('#upload-status');
  596. if (status) status.innerHTML = `<label class="upload-label">${esc(file.name)} · 模拟上传 ${value}%<progress class="upload-progress" value="${value}" max="100" aria-label="模拟上传进度">${value}%</progress></label>`;
  597. if (value >= 100) {
  598. clearInterval(timer);
  599. state.uploading = false;
  600. state.messages[agentId].push({ id: `file-${Date.now()}`, sender: 'me', time: '刚刚', file: { name: file.name, size: `${Math.max(.1, file.size / 1024).toFixed(1)} KB` }, failed: state.connection !== 'connected' });
  601. render(); scrollMessages();
  602. toast('文件卡片已添加,仅模拟上传,文件未离开浏览器。');
  603. }
  604. }, 240);
  605. }
  606. function loginSubmit() {
  607. if (state.loginBusy) return;
  608. let error = '';
  609. if (state.loginMethod === 'phone') {
  610. const phone = $('#phone')?.value || '';
  611. const code = $('#code')?.value || '';
  612. if (!/^1[3-9]\d{9}$/.test(phone)) error = '请输入有效的 11 位手机号。';
  613. else if (code === '000000') error = '验证码已过期,请重新获取。';
  614. else if (code !== '123456') error = '验证码错误。演示验证码为 123456。';
  615. } else if ($('#token')?.value !== 'demo-fmode') error = 'Token 无效或已失效,请检查后重试。';
  616. if (error) { $('#login-error').textContent = error; return; }
  617. state.loginBusy = true;
  618. const submit = $('#login-form [type="submit"]');
  619. submit.disabled = true; submit.textContent = '正在登录…';
  620. setTimeout(() => {
  621. state.loginBusy = false;
  622. storage.set('signedOut', 'false');
  623. const redirect = new URLSearchParams(location.search).get('redirect');
  624. navigate(redirect && /^\/(desktop|mobile)\//.test(redirect) ? redirect : `/${state.mode}/${state.mode === 'mobile' ? 'home' : 'chat'}`, '', true);
  625. toast('已进入演示空间,未建立真实登录会话。');
  626. }, 700);
  627. }
  628. document.addEventListener('click', event => {
  629. const route = event.target.closest('[data-route]');
  630. if (route && !event.ctrlKey && !event.metaKey && !event.shiftKey) { event.preventDefault(); navigate(route.getAttribute('href')); return; }
  631. if (event.target.matches('[data-backdrop]')) { closeOverlay(); return; }
  632. const target = event.target.closest('[data-action]');
  633. if (!target || target.disabled) return;
  634. const { action, value } = target.dataset;
  635. const actions = {
  636. navigate: () => navigate(value),
  637. 'agent-open': () => openAgent(value),
  638. 'agent-detail': () => navigate('agent', value),
  639. 'agent-tasks': () => { state.agentId = value || state.agentId; state.taskFilter = 'all'; state.taskId = tasks.find(t => t.agentId === state.agentId)?.id || ''; navigate('tasks'); },
  640. project: () => { state.projectId = value; navigate('projects', value); },
  641. task: () => { const t = taskById(value); if (t) { state.taskId = t.id; state.agentId = t.agentId; if (state.taskFilter !== 'all' && state.taskFilter !== t.status) state.taskFilter = 'all'; navigate('tasks', value); } },
  642. 'contacts-tab': () => { state.contactsTab = value; render(); },
  643. colleague: () => { state.colleague = state.colleague === value ? '' : value; render(); },
  644. 'chat-filter': () => { state.chatFilter = value; render(); },
  645. 'task-filter': () => { state.taskFilter = value; if (state.id) { history.replaceState({}, '', href('tasks')); state.id = ''; } render(); },
  646. 'event-filter': () => { state.eventFilter = value; render(); },
  647. 'result-view': () => { state.resultView = value; render(); },
  648. preview: () => preview(value),
  649. download: () => download(value),
  650. 'open-file': () => download(value, true),
  651. 'results-drawer': () => showOverlay('交付成果', railContent(value || state.agentId), 'drawer'),
  652. 'retry-state': () => { if (state.homeStates[value]) state.homeStates[value] = 'normal'; else state.scenario = 'normal'; render(); toast('演示内容已重新加载。'); },
  653. refresh: () => { state.scenario = 'loading'; render(); setTimeout(() => { state.scenario = 'normal'; render(); toast('演示数据已刷新。'); }, 650); },
  654. channel: () => { const a = agentById(value); showOverlay('通道详情', `<div class="row">${avatar(a)}<div><h3>${a.name}</h3><p class="muted">${a.channel}</p></div></div><div class="divider"></div><div class="setting-row"><span>连接状态</span>${statusBadge(a.connected ? '已连接(模拟)' : '未接入', a.connected ? 'accent' : '')}</div><div class="setting-row"><span>通道类型</span><span class="mono">${a.connected ? 'Matrix' : '—'}</span></div><div class="setting-row"><span>最近检查</span><span class="secondary">今天 10:42:08</span></div><p style="margin-top:20px">此处仅演示连接信息,不建立 Matrix 连接,也不展示凭证。</p>`, 'drawer'); },
  655. balance: () => showOverlay('余额与账单', `<p>此处将打开平台的余额管理页面。需求未提供真实账单地址,本原型不跳转到猜测的外部站点。</p><div class="setting-row"><span>演示余额</span><strong class="mono">¥ ${agentById(value).balance}</strong></div><div class="dialog-actions">${button('知道了', 'close', '', 'btn-primary')}</div>`),
  656. close: () => closeOverlay(),
  657. lab: () => lab(),
  658. scenario: () => { state.scenario = value; state.homeStates = {}; closeOverlay(); render(); toast(`已切换到${{ normal: '正常内容', loading: '加载中', empty: '空状态', error: '加载失败', forbidden: '权限不足' }[value]}场景。`); },
  659. disconnect: () => { state.connection = 'disconnected'; closeOverlay(); if (state.page !== 'chat') navigate('chat', state.agentId); else render(); setTimeout(() => { if (state.connection === 'disconnected') reconnect(); }, 4500); },
  660. reconnect,
  661. 'fail-next': () => { state.failNext = !state.failNext; closeOverlay(); if (state.page !== 'chat') navigate('chat', state.agentId); toast('下一条消息将模拟发送失败,之后可重试。'); },
  662. 'retry-message': () => { const m = state.messages[state.agentId]?.find(m => m.id === value); if (!m) return; if (state.connection !== 'connected') { toast('请等待连接恢复后重试。'); return; } m.failed = false; m.pending = true; render(); simulatedReply(state.agentId, m); },
  663. attach: () => { if (!state.uploading) $('#file-input')?.click(); else toast('文件正在处理中,请稍候。'); },
  664. theme: () => { state.theme = value; storage.set('theme', value); render(); },
  665. palette: () => { if (palettes.some(palette => palette.id === value)) { state.palette = value; storage.set('palette-v2', value); render(); } },
  666. 'switch-mode': () => { state.mode = value; storage.set('mode', value); navigate(`/${value}/${value === 'mobile' ? 'home' : 'chat'}`); },
  667. 'auto-mode': () => { storage.set('mode', ''); const mode = initialMode(); state.mode = mode; navigate(`/${mode}/${mode === 'mobile' ? 'home' : 'chat'}`); toast('已恢复按设备自动识别。'); },
  668. 'repair-token': () => showOverlay('修复登录凭证', '<p>当前为演示会话,没有真实 Token 可修复。正式版本会重新校验登录凭证,租户归属以服务端返回为准。</p>' + `<div class="dialog-actions">${button('取消', 'close', '', 'btn-outline')}${button('模拟修复', 'repair-done', '', 'btn-primary', 'refresh')}</div>`),
  669. 'repair-done': () => { closeOverlay(); toast('模拟凭证校验完成,未请求真实服务。'); },
  670. logout: () => showOverlay('退出当前账号?', `<p>退出后将回到登录页。此操作仅结束演示会话,不影响任何真实账号。</p><div class="dialog-actions">${button('取消', 'close', '', 'btn-outline')}${button('确认退出', 'confirm-logout', '', 'btn-danger', 'logout')}</div>`),
  671. 'confirm-logout': () => { storage.set('signedOut', 'true'); navigate('/login'); },
  672. 'show-login': () => navigate('/login'),
  673. 'login-method': () => { state.loginMethod = value; state.loginError = ''; render(); },
  674. 'send-code': () => { if (!/^1[3-9]\d{9}$/.test($('#phone')?.value || '')) { $('#login-error').textContent = '请先输入有效的 11 位手机号。'; return; } state.codeSent = true; target.disabled = true; target.textContent = '已发送(模拟)'; $('#login-error').textContent = ''; toast('未发送真实短信。演示验证码:123456。'); setTimeout(() => { state.codeSent = false; if (target.isConnected) { target.disabled = false; target.textContent = '重新获取'; } }, 30000); },
  675. 'reset-demo': () => { state.scenario = 'normal'; state.homeStates = {}; state.connection = 'connected'; state.failNext = false; closeOverlay(); render(); toast('页面与会话状态已恢复正常。'); },
  676. about: () => showOverlay('关于这个 Demo', `<p>依据指定的 PRD、SPEC 与用户画像独立设计,覆盖 7 个业务模块与 5 项通用能力的可见界面。</p><p>人物、任务、消息、用量和成果均为演示数据。任务看板只读;验证码、Token、上传与回复均为模拟。</p><p>真实 Matrix、预签名上传、云函数、租户权限、历史虚拟滚动及生产 ui-kit 验收不包含在本原型中。</p><p class="mono">${VERSION} · 本地设计评审版</p><div class="dialog-actions">${button('继续体验', 'close', '', 'btn-primary')}</div>`),
  677. };
  678. actionTrigger = target;
  679. try { actions[action]?.(); } finally { actionTrigger = null; }
  680. });
  681. document.addEventListener('input', event => {
  682. if (event.target.dataset.search) {
  683. state.queries[event.target.dataset.search] = event.target.value;
  684. render();
  685. }
  686. if (event.target.id === 'message-input') {
  687. state.drafts[state.agentId] = event.target.value;
  688. autoHeight();
  689. }
  690. });
  691. document.addEventListener('change', event => {
  692. if (event.target.id === 'task-agent') {
  693. state.agentId = event.target.value;
  694. state.taskId = tasks.find(t => t.agentId === state.agentId)?.id || '';
  695. state.taskFilter = 'all';
  696. navigate('tasks');
  697. }
  698. if (event.target.id === 'file-input' && event.target.files[0]) simulateUpload(event.target.files[0]);
  699. if (event.target.dataset.homeState) {
  700. state.homeStates[event.target.dataset.homeState] = event.target.value;
  701. render();
  702. toast('首页分区状态已更新。');
  703. }
  704. });
  705. document.addEventListener('submit', event => {
  706. if (event.target.id === 'composer-form') { event.preventDefault(); sendMessage(); }
  707. if (event.target.id === 'login-form') { event.preventDefault(); loginSubmit(); }
  708. });
  709. document.addEventListener('keydown', event => {
  710. if (event.target.id === 'message-input' && event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
  711. event.preventDefault(); sendMessage();
  712. }
  713. if (state.overlay && event.key === 'Escape') { event.preventDefault(); closeOverlay(); }
  714. if (state.overlay && event.key === 'Tab') {
  715. const focusable = [...document.querySelectorAll('.dialog button:not(:disabled), .dialog a, .dialog input, .dialog select, .dialog [tabindex="0"]')];
  716. const first = focusable[0], last = focusable.at(-1);
  717. if (!first) { event.preventDefault(); return; }
  718. if (event.shiftKey && (document.activeElement === first || document.activeElement === $('.dialog'))) { event.preventDefault(); last.focus(); }
  719. else if (!event.shiftKey && (document.activeElement === last || document.activeElement === $('.dialog'))) { event.preventDefault(); first.focus(); }
  720. }
  721. if (event.target.matches('[role="tab"]') && ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
  722. const tabs = [...event.target.closest('[role="tablist"]').querySelectorAll('[role="tab"]')];
  723. const index = tabs.indexOf(event.target);
  724. const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
  725. const { action, value } = tabs[next].dataset;
  726. event.preventDefault();
  727. tabs[next].click();
  728. document.querySelector(`[role="tab"][data-action="${action}"][data-value="${value}"]`)?.focus();
  729. }
  730. });
  731. window.addEventListener('popstate', () => {
  732. const previous = renderedRoute;
  733. if (previous) state.scrolls.set(previous.path, $('#main')?.scrollTop || 0);
  734. closeOverlay(false);
  735. readRoute();
  736. render(false);
  737. requestAnimationFrame(() => {
  738. if ($('#main')) $('#main').scrollTop = state.scrolls.get(location.pathname) || 0;
  739. if (state.page === 'chat') scrollMessages();
  740. animateRoute(previous, true);
  741. });
  742. });
  743. // Tokens are consumed before first render and are never persisted in the demo.
  744. const tokenParams = new URLSearchParams(location.hash.replace(/^#/, ''));
  745. const queryParams = new URLSearchParams(location.search);
  746. const token = tokenParams.get('token') ?? queryParams.get('token');
  747. if (token !== null) {
  748. const params = new URLSearchParams(location.search);
  749. params.delete('token');
  750. history.replaceState({}, '', location.pathname + (params.size ? `?${params}` : ''));
  751. if (token === 'demo-fmode') { storage.set('signedOut', 'false'); history.replaceState({}, '', '/'); }
  752. else { state.loginError = 'Token 无效或已失效,请重新登录。'; history.replaceState({}, '', '/login'); }
  753. }
  754. readRoute();
  755. render(false);
  756. requestAnimationFrame(scrollMessages);