| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761 |
- import { VERSION, agents, projects, deliverables, tasks, statuses, events, initialMessages } from './data.js';
- const $ = (s, root = document) => root.querySelector(s);
- const esc = (value = '') => String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
- const icons = {
- 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"/>',
- 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"/>',
- 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"/>',
- events: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>',
- 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"/>',
- box: '<path d="m12 3 9 5v9l-9 5-9-5V8l9-5ZM3 8l9 5 9-5M12 13v9M7.5 5.5l9 5"/>',
- user: '<circle cx="12" cy="8" r="4"/><path d="M4 22v-3a8 8 0 0 1 16 0v3"/>',
- search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
- chevron: '<path d="m9 5 7 7-7 7"/>',
- down: '<path d="m6 9 6 6 6-6"/>',
- back: '<path d="m15 5-7 7 7 7"/>',
- arrow: '<path d="M4 12h16m-6-6 6 6-6 6"/>',
- 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"/>',
- 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"/>',
- download: '<path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/>',
- clip: '<path d="m8 13 7-7a3 3 0 0 1 4 4L9 20a5 5 0 0 1-7-7L13 2M6 15l9-9"/>',
- send: '<path d="m22 2-7 20-4-9-9-4 20-7ZM22 2 11 13"/>',
- plus: '<path d="M12 4v16M4 12h16"/>',
- check: '<path d="m5 12 4 4L19 6"/>',
- circleCheck: '<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
- 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"/>',
- clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
- close: '<path d="m6 6 12 12M6 18 18 6"/>',
- 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"/>',
- 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"/>',
- home: '<path d="m3 10 9-8 9 8v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1V10Z"/>',
- info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7h.01"/>',
- bolt: '<path d="m13 2-9 12h7l-1 8 10-13h-8l1-7Z"/>',
- more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
- lock: '<rect x="4" y="10" width="16" height="12" rx="2"/><path d="M8 10V6a4 4 0 0 1 8 0v4M12 15v3"/>',
- monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 22h8M12 17v5"/>',
- phone: '<rect x="6" y="2" width="12" height="20" rx="2"/><path d="M11 18h2"/>',
- moon: '<path d="M21 13a9 9 0 0 1-10-10 9 9 0 1 0 10 10Z"/>',
- sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1 1M18 18l1 1M5 19l1-1M18 6l1-1"/>',
- logout: '<path d="M9 3H4v18h5M9 12h13m-5-5 5 5-5 5"/>',
- layers: '<path d="m12 2 10 6-10 6L2 8l10-6ZM2 12l10 6 10-6M2 16l10 6 10-6"/>',
- 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"/>',
- inbox: '<path d="M4 4h16l2 12v5H2v-5L4 4ZM2 16h6l2 3h4l2-3h6"/>',
- };
- const icon = name => `<svg class="icon" aria-hidden="true" viewBox="0 0 24 24">${icons[name] || icons.file}</svg>`;
- const mark = () => '<span class="brand-mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M5 20V4h16v4H9v3h9v4H9v5Z"/></svg></span>';
- const brand = () => `<div class="brand">${mark()}<span class="brand-word">fmode<span class="muted">.</span></span></div>`;
- 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>`;
- 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>`;
- const avatar = (a, size = '') => `<span class="avatar ${a.tone || 'person'} ${size}" aria-hidden="true">${esc(a.initials || a.name.slice(0, 1))}</span>`;
- const agentById = id => agents.find(a => a.id === id);
- const projectById = id => projects.find(p => p.id === id);
- const artifactById = id => deliverables.find(a => a.id === id);
- const taskById = id => tasks.find(t => t.id === id);
- const statusBadge = (text, style = '') => `<span class="badge ${style}"><span class="dot"></span>${esc(text)}</span>`;
- const agentStatus = a => statusBadge(a.statusText, a.status === 'blocked' ? 'danger' : a.status === 'working' ? 'accent' : '');
- const taskBadge = status => statusBadge(statuses[status], status === 'blocked' ? 'danger' : status === 'done' ? 'success' : status === 'running' ? 'accent' : '');
- const storage = {
- get(key, fallback) { try { return localStorage.getItem(`fmode-design-demo:${key}`) ?? fallback; } catch { return fallback; } },
- set(key, value) { try { localStorage.setItem(`fmode-design-demo:${key}`, value); } catch { toast('浏览器存储不可用,本次设置仅在当前页面生效。'); } },
- };
- const state = {
- mode: 'desktop', page: 'chat', id: '', agentId: 'zhixia',
- projectId: 'autumn', taskId: 'T-108', contactsTab: 'all', colleague: '',
- chatFilter: 'all', taskFilter: 'all', eventFilter: 'all', resultView: 'project',
- queries: {}, scenario: 'normal', homeStates: {}, connection: 'connected',
- failNext: false, messages: structuredClone(initialMessages), drafts: {},
- theme: storage.get('theme', 'light'), palette: storage.get('palette-v2', 'platinum'),
- loginMethod: 'phone', loginError: '', loginBusy: false, codeSent: false,
- scrolls: new Map(), overlay: null, focusReturn: null, uploading: false,
- };
- const pageLabels = { chat: '会话', messages: '会话', contacts: '通讯录', projects: '项目', events: '事件', workbench: '工作台', tasks: '任务看板', deliverables: '交付成果', mine: '我的', agent: '数字生命详情', home: '首页' };
- const navItems = [['chat', 'chat', '会话'], ['contacts', 'contacts', '通讯录'], ['projects', 'folder', '项目'], ['events', 'events', '事件'], ['workbench', 'grid', '工作台'], ['deliverables', 'box', '成果'], ['mine', 'user', '我的']];
- const mobileItems = [['home', 'home', '首页'], ['messages', 'chat', '会话'], ['contacts', 'contacts', '通讯录'], ['workbench', 'grid', '工作台'], ['mine', 'user', '我的']];
- const palettes = [
- { id: 'platinum', name: '铂金曜黑', description: '炭黑 × 铂金灰' },
- { id: 'ice', name: '冰白靛蓝', description: '冰白 × 靛青' },
- { id: 'clay', name: '暖瓷陶橘', description: '暖瓷 × 陶橘' },
- { id: 'slate', name: '岩灰鸢紫', description: '岩灰 × 鸢紫' },
- ];
- if (!palettes.some(palette => palette.id === state.palette)) state.palette = 'graphite';
- if (!['light', 'dark'].includes(state.theme)) state.theme = 'light';
- const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
- let renderedRoute = null;
- let routeAnimations = [];
- let actionTrigger = null;
- function motionOptions(token) {
- const style = getComputedStyle(document.documentElement);
- return { duration: reducedMotion.matches ? 0 : parseFloat(style.getPropertyValue(token)), easing: style.getPropertyValue('--ease-standard').trim(), fill: 'both' };
- }
- function animateRoute(previous, back = false) {
- routeAnimations.forEach(animation => animation.cancel());
- routeAnimations = [];
- if (!previous || reducedMotion.matches || previous.path === location.pathname) return;
- const samePage = previous.page === state.page && previous.mode === state.mode;
- let target = $('#main')?.firstElementChild;
- if (state.mode === 'desktop' && samePage) {
- target = $(state.page === 'chat' ? '.messages' : state.page === 'tasks' ? '.task-detail' : '.project-detail');
- }
- if (!target) return;
- const intoDetail = state.mode === 'mobile' && state.id && !previous.id;
- const fromDetail = state.mode === 'mobile' && previous.id && !state.id;
- const directional = state.mode === 'mobile' && (intoDetail || fromDetail);
- const offset = back || fromDetail ? -16 : 16;
- const frames = directional
- ? [{ opacity: .7, transform: `translateX(${offset}px)` }, { opacity: 1, transform: 'translateX(0)' }]
- : [{ opacity: .65 }, { opacity: 1 }];
- const animation = target.animate(frames, motionOptions(directional ? '--motion-detail' : '--motion-page'));
- routeAnimations.push(animation);
- animation.finished.then(() => animation.cancel(), () => {});
- }
- function initialMode() {
- const saved = storage.get('mode', '');
- if (['desktop', 'mobile'].includes(saved)) return saved;
- return /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent) || innerWidth < 768 ? 'mobile' : 'desktop';
- }
- function href(page, id = '') {
- return `/${state.mode}/${page}${id ? `/${encodeURIComponent(id)}` : ''}`;
- }
- function navigate(page, id = '', replace = false) {
- const previous = renderedRoute;
- const main = $('#main');
- state.scrolls.set(location.pathname, main?.scrollTop || 0);
- closeOverlay(false);
- const target = page.startsWith('/') ? page : href(page, id);
- if (target === location.pathname) { render(); return; }
- history[replace ? 'replaceState' : 'pushState']({}, '', target);
- readRoute();
- render(false);
- requestAnimationFrame(() => {
- const el = $('#main');
- if (el) el.scrollTop = state.scrolls.get(location.pathname) || 0;
- if (state.page === 'chat') scrollMessages();
- animateRoute(previous);
- });
- }
- function readRoute() {
- const parts = location.pathname.split('/').filter(Boolean).map(x => { try { return decodeURIComponent(x); } catch { return x; } });
- if (!parts.length || parts[0] === 'index.html') {
- const mode = initialMode();
- history.replaceState({}, '', `/${mode}/${mode === 'mobile' ? 'home' : 'chat'}`);
- return readRoute();
- }
- if (parts[0] === 'login') {
- state.mode = initialMode(); state.page = 'login'; state.id = '';
- return;
- }
- if (!['desktop', 'mobile'].includes(parts[0])) {
- state.mode = initialMode();
- const legacy = { messages: state.mode === 'mobile' ? 'messages' : 'chat', workbench: 'workbench', deliverables: 'deliverables', mine: 'mine', chat: 'chat', agent: 'agent', call: 'agent' };
- const mapped = legacy[parts[0]];
- if (mapped) {
- if (parts[0] === 'call') setTimeout(() => toast('语音通话功能暂未开放,已转至数字生命详情。'), 100);
- history.replaceState({}, '', href(mapped, parts[1] || ''));
- return readRoute();
- }
- state.page = 'not-found';
- return;
- }
- state.mode = parts[0];
- state.page = parts[1] || (state.mode === 'mobile' ? 'home' : 'chat');
- state.id = parts[2] || '';
- if (!parts[1]) history.replaceState({}, '', href(state.page));
- if (!pageLabels[state.page]) state.page = 'not-found';
- if (state.page === 'chat' && state.id && agentById(state.id)) state.agentId = state.id;
- if (state.page === 'tasks' && state.id && taskById(state.id)) {
- state.taskId = state.id;
- state.agentId = taskById(state.id).agentId;
- }
- if (state.page === 'projects' && projectById(state.id)) state.projectId = state.id;
- if (storage.get('signedOut', 'false') === 'true') {
- history.replaceState({}, '', `/login?redirect=${encodeURIComponent(location.pathname)}`);
- state.page = 'login';
- }
- }
- function applyTheme() {
- document.documentElement.dataset.theme = state.theme;
- document.documentElement.dataset.palette = state.palette;
- const colors = getComputedStyle(document.documentElement);
- $('meta[name="theme-color"]').content = colors.getPropertyValue('--bg').trim();
- 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>`;
- $('link[rel="icon"]').href = `data:image/svg+xml,${encodeURIComponent(favicon)}`;
- }
- function toast(message) {
- const el = $('#toast');
- el.textContent = message;
- el.classList.add('visible');
- clearTimeout(toast.timer);
- toast.timer = setTimeout(() => el.classList.remove('visible'), 3600);
- }
- function markdown(text) {
- let inList = false;
- return text.split('\n').map(line => {
- const safe = esc(line);
- let prefix = '';
- if (!line.startsWith('- ') && inList) { prefix = '</ul>'; inList = false; }
- if (line.startsWith('# ')) return `${prefix}<h1>${safe.slice(2)}</h1>`;
- if (line.startsWith('## ')) return `${prefix}<h2>${safe.slice(3)}</h2>`;
- if (line.startsWith('- ')) { const open = inList ? '' : '<ul>'; inList = true; return `${open}<li>${safe.slice(2)}</li>`; }
- if (line === '---') return `${prefix}<div class="divider"></div>`;
- return `${prefix}${line ? `<p>${safe}</p>` : ''}`;
- }).join('') + (inList ? '</ul>' : '');
- }
- function fileIcon(a) {
- return `<span class="file-icon ${a.type === 'CSV' ? 'csv' : ''}">${icon('file')}<span>${esc(a.type)}</span></span>`;
- }
- function searchField(key, placeholder) {
- 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>`;
- }
- function filterTabs(items, active, action, role = 'tablist') {
- 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>`;
- }
- function panelState(scope, content, empty = false) {
- const scenario = state.homeStates[scope] || state.scenario;
- if (scenario === 'normal' && !empty) return content;
- 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>`;
- const kind = scenario === 'normal' ? 'empty' : scenario;
- const texts = {
- empty: ['inbox', '这里暂时没有内容', empty ? '试试其他筛选条件,或等待新的内容。' : '有新的内容后,会显示在这里。'],
- error: ['alert', '加载失败', '连接暂时不可用,已有内容不会因此被删除。'],
- forbidden: ['lock', '无权访问此内容', '当前演示账号没有此资源的访问权限。请联系管理员。'],
- };
- const [i, title, description] = texts[kind] || texts.empty;
- 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>`;
- }
- function pageHeader(title, description, actions = '') {
- return `<div class="page-heading"><div><h1>${title}</h1>${description ? `<p>${description}</p>` : ''}</div>${actions ? `<div class="row">${actions}</div>` : ''}</div>`;
- }
- function backHeader(page, label) {
- return `<div class="breadcrumbs">${button(label, 'navigate', page, '', 'back')}<span>/</span><span>详情</span></div>`;
- }
- function sidebar() {
- const current = state.page === 'tasks' ? 'workbench' : state.page;
- 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>`;
- }
- function mobileNav() {
- const current = state.page === 'chat' ? 'messages' : state.page === 'tasks' ? 'workbench' : state.page;
- 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>`;
- }
- function demoStrip() {
- 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>`;
- }
- function chatList() {
- const query = (state.queries.chat || '').toLowerCase();
- const list = agents.filter(a => a.connected && (state.chatFilter !== 'unread' || a.unread) && a.name.toLowerCase().includes(query));
- 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>`;
- }
- function chatRow(a, home = false) {
- 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>`;
- }
- function messageFile(id) {
- const a = artifactById(id);
- 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>`;
- }
- function messageView(m, a) {
- 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>`;
- }
- function chatMain() {
- const a = agentById(state.agentId);
- if (!a || (state.id && !agentById(state.id))) return notFound('这段会话不存在');
- if (!a.connected) return agentPage(a.id);
- const p = projects.find(p => p.agentIds.includes(a.id));
- const list = state.messages[a.id] || [];
- 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>`;
- }
- function railContent(agentId) {
- const a = agentById(agentId);
- const p = projects.find(p => p.agentIds.includes(agentId));
- const results = deliverables.filter(d => d.agentId === agentId);
- 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>`;
- }
- function chatPage() {
- if (state.mode === 'mobile') return state.page === 'messages' || !state.id ? chatList() : chatMain();
- 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>`;
- }
- function artifactRow(d, extended = false) {
- const a = agentById(d.agentId);
- 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>`;
- }
- function homePage() {
- const recent = agents.filter(a => a.connected).slice(0, 6);
- 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>`;
- }
- function contactsPage() {
- const q = (state.queries.contacts || '').toLowerCase();
- const list = agents.filter(a => a.name.toLowerCase().includes(q));
- const colleagues = ['林悦', '陈序', '沈言'].filter(name => name.toLowerCase().includes(q));
- 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('');
- 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('');
- 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}` : ''}`;
- const empty = state.contactsTab === 'ai' ? !list.length : state.contactsTab === 'people' ? !colleagues.length : !list.length && !colleagues.length;
- 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>`;
- }
- function projectDetail(p) {
- 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>`;
- }
- function projectsPage() {
- const mobileDetail = state.mode === 'mobile' && state.id;
- const p = projectById(state.id || state.projectId);
- if (!p) return notFound('项目不存在');
- const list = projects.filter(p => p.name.includes(state.queries.projects || ''));
- 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>`;
- }
- function metric(label, value) {
- return `<div class="metric"><div class="spread"><span>${label}</span><strong>${value}%</strong></div><div class="bar"><span style="--value:${value}%"></span></div></div>`;
- }
- function agentCard(a) {
- 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>`;
- }
- function workbenchPage() {
- const q = state.queries.workbench || '';
- const list = agents.filter(a => a.name.includes(q));
- 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>`;
- }
- function taskDetail(t) {
- 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>`;
- }
- function tasksPage() {
- const t = taskById(state.id || state.taskId);
- if (state.id && !t) return notFound('任务不存在');
- const mobileDetail = state.mode === 'mobile' && state.id;
- const list = tasks.filter(t => t.agentId === state.agentId && (state.taskFilter === 'all' || t.status === state.taskFilter)).sort((a, b) => b.time.localeCompare(a.time));
- const selected = list.find(x => x.id === state.taskId) || list[0];
- const detail = mobileDetail ? t : selected;
- 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>`;
- }
- function eventsPage() {
- const list = events.filter(e => state.eventFilter === 'all' || e.category === state.eventFilter);
- 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 => {
- const dayEvents = list.filter(e => e.day === day);
- if (!dayEvents.length) return '';
- 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>`;
- }).join(''), !list.length)}<p class="muted" style="font-size:var(--fs-xs);margin-top:28px">事件由交付与运行状态生成,不作为独立审计记录。</p></div>`;
- }
- function resultsPage() {
- const q = state.queries.results || '';
- const list = deliverables.filter(d => d.title.includes(q));
- 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)]);
- 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>`;
- }
- function agentPage(id = state.id) {
- const a = agentById(id);
- if (!a) return notFound('数字生命不存在');
- 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>`;
- }
- function minePage() {
- const themeChoices = [['light', '浅色', 'sun'], ['dark', '深色', 'moon']].map(([id, name, i]) => `
- <button class="btn tab ${state.theme === id ? 'active' : ''}" data-action="theme" data-value="${id}" aria-pressed="${state.theme === id}">
- ${icon(i)}${name}
- </button>`).join('');
- const paletteChoices = palettes.map(palette => `
- <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}">
- <span class="palette-thumbnail" data-preview-palette="${palette.id}" data-preview-theme="${state.theme}" aria-hidden="true">
- <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>
- </span>
- <span class="palette-choice-label">${palette.name}<span class="theme-radio">${state.palette === palette.id ? icon('check') : ''}</span></span>
- <span class="palette-choice-description">${palette.description}</span>
- </button>`).join('');
- return `<div class="page settings-page">
- ${pageHeader('我的', '管理账号、界面外观与使用偏好。')}
- <div class="settings-layout">
- <aside class="account-panel" aria-label="账号信息">
- <div class="identity-block">${avatar({ name: '林悦' }, 'large')}<div><h2>林悦</h2><p>品牌与业务协作</p></div></div>
- <span class="badge">演示账号</span>
- <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>
- <div class="account-note">${icon('lock')}<p>当前为独立演示账号,<br>不会连接真实业务服务。</p></div>
- ${button('退出登录', 'logout', '', 'btn-outline account-logout', 'logout')}
- </aside>
- <div class="settings-content">
- <section class="settings-section">
- <div class="settings-section-heading">${icon('sun')}<h3>界面外观</h3></div>
- <div class="settings-appearance">
- <div class="setting-copy"><h4>显示模式</h4><p>选择适合当前环境的界面。</p></div>
- <div class="segmented theme-switch" role="group" aria-label="显示模式">${themeChoices}</div>
- </div>
- <div class="settings-palettes">
- <div class="setting-copy"><h4>整体配色</h4><p>导航、内容与控件统一切换。</p></div>
- <div class="palette-options" role="group" aria-label="整体配色方案">${paletteChoices}</div>
- </div>
- </section>
- <section class="settings-section">
- <div class="settings-section-heading">${icon('monitor')}<h3>使用端</h3></div>
- <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>
- <div class="setting-row"><div class="setting-copy"><h4>自动识别设备</h4><p>恢复默认的设备访问方式。</p></div>${button('恢复自动识别', 'auto-mode', '', '', 'refresh')}</div>
- </section>
- <section class="settings-section">
- <div class="settings-section-heading">${icon('lock')}<h3>账号与凭证</h3></div>
- <div class="setting-row"><div class="setting-copy"><h4>登录凭证</h4><p>原型不保存真实 Token。</p></div>${button('修复 Token', 'repair-token', '', 'btn-outline', 'refresh')}</div>
- </section>
- <div class="settings-about"><span>Fmode <span class="mono">${VERSION}</span></span>${button('演示说明', 'about', '', 'text-link', 'info')}</div>
- </div>
- </div>
- </div>`;
- }
- function loginPage() {
- 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>`;
- }
- function notFound(title = '这个页面不存在') {
- 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>`;
- }
- // Reconcile stable nodes so typing, selection, scroll, and focus survive updates.
- function nodeKey(node) {
- if (node.nodeType !== Node.ELEMENT_NODE) return node.nodeName;
- const id = node.id || node.getAttribute('data-message-id') || node.getAttribute('data-testid');
- const action = node.getAttribute('data-action');
- return `${node.nodeName}:${id || (action ? `${action}:${node.getAttribute('data-value') || ''}` : node.getAttribute('href') || node.getAttribute('class')?.split(' ')[0] || '')}`;
- }
- function reconcile(current, next) {
- if (current.nodeType !== Node.ELEMENT_NODE && current.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
- if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
- return;
- }
- if (current.nodeType === Node.ELEMENT_NODE) {
- for (const attribute of [...current.attributes]) {
- if (!next.hasAttribute(attribute.name)) current.removeAttribute(attribute.name);
- }
- for (const attribute of next.attributes) {
- if (current.getAttribute(attribute.name) !== attribute.value) current.setAttribute(attribute.name, attribute.value);
- }
- if (current instanceof HTMLTextAreaElement) {
- if (current.value !== next.value) current.value = next.value;
- return;
- }
- if (current instanceof HTMLInputElement) {
- if (next.hasAttribute('value') && current.type !== 'file' && current.value !== next.value) current.value = next.value;
- return;
- }
- }
- const unused = new Set(current.childNodes);
- let cursor = current.firstChild;
- for (const child of [...next.childNodes]) {
- const match = [...unused].find(node => nodeKey(node) === nodeKey(child));
- if (match) {
- unused.delete(match);
- if (match !== cursor) current.insertBefore(match, cursor);
- reconcile(match, child);
- cursor = match.nextSibling;
- } else {
- const added = child.cloneNode(true);
- current.insertBefore(added, cursor);
- cursor = added.nextSibling;
- }
- }
- for (const node of unused) node.remove();
- if (current instanceof HTMLSelectElement) current.value = next.value;
- }
- function render(preserve = true) {
- routeAnimations.forEach(animation => animation.cancel());
- routeAnimations = [];
- const active = document.activeElement;
- const focus = active?.id;
- const start = active?.selectionStart;
- const end = active?.selectionEnd;
- const scroll = preserve ? $('#main')?.scrollTop || 0 : 0;
- const msgScroll = preserve ? $('#messages')?.scrollTop : undefined;
- applyTheme();
- document.body.dataset.mode = state.mode;
- 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 };
- const isChat = state.mode === 'mobile' && state.page === 'chat' && state.id;
- document.title = `${pageLabels[state.page] || '登录'} · Fmode 演示`;
- const template = document.createElement('template');
- 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>`;
- reconcile($('#app'), template.content.firstElementChild);
- $('#app').inert = !!state.overlay;
- if ($('#main')) $('#main').scrollTop = scroll;
- if (msgScroll !== undefined && $('#messages')) $('#messages').scrollTop = msgScroll;
- if (!active?.isConnected && focus && document.getElementById(focus)) {
- const el = document.getElementById(focus);
- el.focus({ preventScroll: true });
- if (typeof el.setSelectionRange === 'function' && start !== null && start !== undefined) {
- try { el.setSelectionRange(start, end); } catch { /* Number-like inputs do not support text selection. */ }
- }
- }
- autoHeight();
- renderedRoute = { mode: state.mode, page: state.page, id: state.id, path: location.pathname };
- }
- function scrollMessages() {
- const el = $('#messages');
- if (el) el.scrollTop = el.scrollHeight;
- }
- function autoHeight() {
- const el = $('#message-input');
- if (!el) return;
- el.style.height = 'auto';
- el.style.height = `${Math.min(el.scrollHeight, state.mode === 'mobile' ? 100 : 160)}px`;
- }
- function openAgent(id) {
- const a = agentById(id);
- if (!a) return;
- state.agentId = id;
- navigate(a.connected ? 'chat' : 'agent', id);
- }
- function showOverlay(title, content, kind = '') {
- if (!state.overlay) state.focusReturn = actionTrigger || document.activeElement;
- state.overlay = { title, content, kind };
- $('#app').inert = true;
- $('#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>`;
- const overlay = state.overlay;
- const dialog = $('.dialog');
- const startTransform = kind === 'drawer' ? (state.mode === 'mobile' ? 'translateY(100%)' : 'translateX(100%)') : 'none';
- overlay.transform = startTransform;
- if (!reducedMotion.matches) {
- const backdropAnimation = $('.overlay-backdrop').animate([{ opacity: 0 }, { opacity: 1 }], motionOptions('--motion-page'));
- const dialogAnimation = dialog.animate([{ transform: startTransform }, { transform: 'none' }], motionOptions(kind === 'drawer' ? '--motion-panel' : '--motion-page'));
- overlay.animations = [backdropAnimation, dialogAnimation];
- overlay.animations.forEach(animation => animation.finished.then(() => animation.cancel(), () => {}));
- }
- requestAnimationFrame(() => { if (state.overlay === overlay) dialog.focus({ preventScroll: true }); });
- }
- function closeOverlay(restore = true) {
- if (!state.overlay) return;
- const closing = state.overlay;
- const finish = () => {
- if (state.overlay !== closing) return;
- $('#overlay-root').innerHTML = '';
- state.overlay = null;
- $('#app').inert = false;
- if (restore && state.focusReturn?.isConnected) state.focusReturn.focus({ preventScroll: true });
- };
- if (!restore || reducedMotion.matches) { finish(); return; }
- if (closing.closing) return;
- closing.closing = true;
- const backdrop = $('.overlay-backdrop');
- const dialog = $('.dialog');
- const startOpacity = getComputedStyle(backdrop).opacity;
- const startTransform = getComputedStyle(dialog).transform;
- closing.animations?.forEach(animation => animation.cancel());
- const animations = [
- backdrop.animate([{ opacity: startOpacity }, { opacity: 0 }], motionOptions('--motion-exit')),
- dialog.animate([{ transform: startTransform }, { transform: closing.transform }], motionOptions('--motion-exit')),
- ];
- Promise.allSettled(animations.map(animation => animation.finished)).then(finish);
- }
- function preview(id) {
- const d = artifactById(id);
- if (!d) return;
- showOverlay(d.title, '', 'preview');
- 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>`;
- $('.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>`;
- }
- function download(id, open = false) {
- const d = artifactById(id);
- if (!d) return;
- 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>`;
- 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' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- if (open) { a.target = '_blank'; a.rel = 'noopener'; } else a.download = d.filename;
- a.click();
- setTimeout(() => URL.revokeObjectURL(url), 60000);
- if (!open) toast('演示文件已开始下载。');
- }
- function lab() {
- const choices = [['normal', '正常内容'], ['loading', '加载中'], ['empty', '空状态'], ['error', '加载失败'], ['forbidden', '权限不足']];
- 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');
- }
- function reconnect() {
- state.connection = 'connecting';
- render();
- setTimeout(() => {
- state.connection = 'connected';
- render();
- toast('模拟连接已恢复,未发送的消息可以重试。');
- }, 1200);
- }
- function sendMessage() {
- const input = $('#message-input');
- const text = input?.value.trim();
- if (!text) { input?.focus(); return; }
- const agentId = state.agentId;
- const failed = state.failNext || state.connection !== 'connected';
- const m = { id: `local-${Date.now()}`, sender: 'me', time: '刚刚', text, failed, pending: !failed };
- (state.messages[agentId] ||= []).push(m);
- state.drafts[agentId] = '';
- state.failNext = false;
- render(); scrollMessages();
- if (failed) { toast('消息未发送,内容已保留。可点击消息下方重试。'); return; }
- simulatedReply(agentId, m);
- }
- function simulatedReply(agentId, message) {
- setTimeout(() => {
- message.pending = false;
- state.messages[agentId].push({ id: `reply-${Date.now()}`, sender: 'agent', time: '刚刚', text: '收到你的补充。我会结合当前项目继续整理,已有内容可以在交付成果里查看。\n\n这是演示回复,未调用真实数字生命。' });
- if (state.page === 'chat' && state.agentId === agentId) { render(); scrollMessages(); }
- }, 900);
- }
- function simulateUpload(file) {
- if (state.uploading) { toast('请等待当前文件处理完成。'); return; }
- if (file.size > 25 * 1024 * 1024) { toast('演示文件最大支持 25 MB,请选择较小的文件。'); return; }
- const agentId = state.agentId;
- state.uploading = true;
- let value = 0;
- const timer = setInterval(() => {
- value += 20;
- const status = $('#upload-status');
- 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>`;
- if (value >= 100) {
- clearInterval(timer);
- state.uploading = false;
- 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' });
- render(); scrollMessages();
- toast('文件卡片已添加,仅模拟上传,文件未离开浏览器。');
- }
- }, 240);
- }
- function loginSubmit() {
- if (state.loginBusy) return;
- let error = '';
- if (state.loginMethod === 'phone') {
- const phone = $('#phone')?.value || '';
- const code = $('#code')?.value || '';
- if (!/^1[3-9]\d{9}$/.test(phone)) error = '请输入有效的 11 位手机号。';
- else if (code === '000000') error = '验证码已过期,请重新获取。';
- else if (code !== '123456') error = '验证码错误。演示验证码为 123456。';
- } else if ($('#token')?.value !== 'demo-fmode') error = 'Token 无效或已失效,请检查后重试。';
- if (error) { $('#login-error').textContent = error; return; }
- state.loginBusy = true;
- const submit = $('#login-form [type="submit"]');
- submit.disabled = true; submit.textContent = '正在登录…';
- setTimeout(() => {
- state.loginBusy = false;
- storage.set('signedOut', 'false');
- const redirect = new URLSearchParams(location.search).get('redirect');
- navigate(redirect && /^\/(desktop|mobile)\//.test(redirect) ? redirect : `/${state.mode}/${state.mode === 'mobile' ? 'home' : 'chat'}`, '', true);
- toast('已进入演示空间,未建立真实登录会话。');
- }, 700);
- }
- document.addEventListener('click', event => {
- const route = event.target.closest('[data-route]');
- if (route && !event.ctrlKey && !event.metaKey && !event.shiftKey) { event.preventDefault(); navigate(route.getAttribute('href')); return; }
- if (event.target.matches('[data-backdrop]')) { closeOverlay(); return; }
- const target = event.target.closest('[data-action]');
- if (!target || target.disabled) return;
- const { action, value } = target.dataset;
- const actions = {
- navigate: () => navigate(value),
- 'agent-open': () => openAgent(value),
- 'agent-detail': () => navigate('agent', value),
- 'agent-tasks': () => { state.agentId = value || state.agentId; state.taskFilter = 'all'; state.taskId = tasks.find(t => t.agentId === state.agentId)?.id || ''; navigate('tasks'); },
- project: () => { state.projectId = value; navigate('projects', value); },
- 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); } },
- 'contacts-tab': () => { state.contactsTab = value; render(); },
- colleague: () => { state.colleague = state.colleague === value ? '' : value; render(); },
- 'chat-filter': () => { state.chatFilter = value; render(); },
- 'task-filter': () => { state.taskFilter = value; if (state.id) { history.replaceState({}, '', href('tasks')); state.id = ''; } render(); },
- 'event-filter': () => { state.eventFilter = value; render(); },
- 'result-view': () => { state.resultView = value; render(); },
- preview: () => preview(value),
- download: () => download(value),
- 'open-file': () => download(value, true),
- 'results-drawer': () => showOverlay('交付成果', railContent(value || state.agentId), 'drawer'),
- 'retry-state': () => { if (state.homeStates[value]) state.homeStates[value] = 'normal'; else state.scenario = 'normal'; render(); toast('演示内容已重新加载。'); },
- refresh: () => { state.scenario = 'loading'; render(); setTimeout(() => { state.scenario = 'normal'; render(); toast('演示数据已刷新。'); }, 650); },
- 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'); },
- 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>`),
- close: () => closeOverlay(),
- lab: () => lab(),
- scenario: () => { state.scenario = value; state.homeStates = {}; closeOverlay(); render(); toast(`已切换到${{ normal: '正常内容', loading: '加载中', empty: '空状态', error: '加载失败', forbidden: '权限不足' }[value]}场景。`); },
- disconnect: () => { state.connection = 'disconnected'; closeOverlay(); if (state.page !== 'chat') navigate('chat', state.agentId); else render(); setTimeout(() => { if (state.connection === 'disconnected') reconnect(); }, 4500); },
- reconnect,
- 'fail-next': () => { state.failNext = !state.failNext; closeOverlay(); if (state.page !== 'chat') navigate('chat', state.agentId); toast('下一条消息将模拟发送失败,之后可重试。'); },
- '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); },
- attach: () => { if (!state.uploading) $('#file-input')?.click(); else toast('文件正在处理中,请稍候。'); },
- theme: () => { state.theme = value; storage.set('theme', value); render(); },
- palette: () => { if (palettes.some(palette => palette.id === value)) { state.palette = value; storage.set('palette-v2', value); render(); } },
- 'switch-mode': () => { state.mode = value; storage.set('mode', value); navigate(`/${value}/${value === 'mobile' ? 'home' : 'chat'}`); },
- 'auto-mode': () => { storage.set('mode', ''); const mode = initialMode(); state.mode = mode; navigate(`/${mode}/${mode === 'mobile' ? 'home' : 'chat'}`); toast('已恢复按设备自动识别。'); },
- 'repair-token': () => showOverlay('修复登录凭证', '<p>当前为演示会话,没有真实 Token 可修复。正式版本会重新校验登录凭证,租户归属以服务端返回为准。</p>' + `<div class="dialog-actions">${button('取消', 'close', '', 'btn-outline')}${button('模拟修复', 'repair-done', '', 'btn-primary', 'refresh')}</div>`),
- 'repair-done': () => { closeOverlay(); toast('模拟凭证校验完成,未请求真实服务。'); },
- logout: () => showOverlay('退出当前账号?', `<p>退出后将回到登录页。此操作仅结束演示会话,不影响任何真实账号。</p><div class="dialog-actions">${button('取消', 'close', '', 'btn-outline')}${button('确认退出', 'confirm-logout', '', 'btn-danger', 'logout')}</div>`),
- 'confirm-logout': () => { storage.set('signedOut', 'true'); navigate('/login'); },
- 'show-login': () => navigate('/login'),
- 'login-method': () => { state.loginMethod = value; state.loginError = ''; render(); },
- '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); },
- 'reset-demo': () => { state.scenario = 'normal'; state.homeStates = {}; state.connection = 'connected'; state.failNext = false; closeOverlay(); render(); toast('页面与会话状态已恢复正常。'); },
- 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>`),
- };
- actionTrigger = target;
- try { actions[action]?.(); } finally { actionTrigger = null; }
- });
- document.addEventListener('input', event => {
- if (event.target.dataset.search) {
- state.queries[event.target.dataset.search] = event.target.value;
- render();
- }
- if (event.target.id === 'message-input') {
- state.drafts[state.agentId] = event.target.value;
- autoHeight();
- }
- });
- document.addEventListener('change', event => {
- if (event.target.id === 'task-agent') {
- state.agentId = event.target.value;
- state.taskId = tasks.find(t => t.agentId === state.agentId)?.id || '';
- state.taskFilter = 'all';
- navigate('tasks');
- }
- if (event.target.id === 'file-input' && event.target.files[0]) simulateUpload(event.target.files[0]);
- if (event.target.dataset.homeState) {
- state.homeStates[event.target.dataset.homeState] = event.target.value;
- render();
- toast('首页分区状态已更新。');
- }
- });
- document.addEventListener('submit', event => {
- if (event.target.id === 'composer-form') { event.preventDefault(); sendMessage(); }
- if (event.target.id === 'login-form') { event.preventDefault(); loginSubmit(); }
- });
- document.addEventListener('keydown', event => {
- if (event.target.id === 'message-input' && event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
- event.preventDefault(); sendMessage();
- }
- if (state.overlay && event.key === 'Escape') { event.preventDefault(); closeOverlay(); }
- if (state.overlay && event.key === 'Tab') {
- const focusable = [...document.querySelectorAll('.dialog button:not(:disabled), .dialog a, .dialog input, .dialog select, .dialog [tabindex="0"]')];
- const first = focusable[0], last = focusable.at(-1);
- if (!first) { event.preventDefault(); return; }
- if (event.shiftKey && (document.activeElement === first || document.activeElement === $('.dialog'))) { event.preventDefault(); last.focus(); }
- else if (!event.shiftKey && (document.activeElement === last || document.activeElement === $('.dialog'))) { event.preventDefault(); first.focus(); }
- }
- if (event.target.matches('[role="tab"]') && ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
- const tabs = [...event.target.closest('[role="tablist"]').querySelectorAll('[role="tab"]')];
- const index = tabs.indexOf(event.target);
- const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
- const { action, value } = tabs[next].dataset;
- event.preventDefault();
- tabs[next].click();
- document.querySelector(`[role="tab"][data-action="${action}"][data-value="${value}"]`)?.focus();
- }
- });
- window.addEventListener('popstate', () => {
- const previous = renderedRoute;
- if (previous) state.scrolls.set(previous.path, $('#main')?.scrollTop || 0);
- closeOverlay(false);
- readRoute();
- render(false);
- requestAnimationFrame(() => {
- if ($('#main')) $('#main').scrollTop = state.scrolls.get(location.pathname) || 0;
- if (state.page === 'chat') scrollMessages();
- animateRoute(previous, true);
- });
- });
- // Tokens are consumed before first render and are never persisted in the demo.
- const tokenParams = new URLSearchParams(location.hash.replace(/^#/, ''));
- const queryParams = new URLSearchParams(location.search);
- const token = tokenParams.get('token') ?? queryParams.get('token');
- if (token !== null) {
- const params = new URLSearchParams(location.search);
- params.delete('token');
- history.replaceState({}, '', location.pathname + (params.size ? `?${params}` : ''));
- if (token === 'demo-fmode') { storage.set('signedOut', 'false'); history.replaceState({}, '', '/'); }
- else { state.loginError = 'Token 无效或已失效,请重新登录。'; history.replaceState({}, '', '/login'); }
- }
- readRoute();
- render(false);
- requestAnimationFrame(scrollMessages);
|