| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922 |
- /* ==========================================================================
- fmode 移动端 UI Demo · 路由 / 渲染 / 交互
- 形态:全屏移动端 App(无手机壳);参照 WorkBuddy 的版式语言,配色走项目令牌
- 交互范围:浏览态全部可点;写操作(发送/新建/删除/上传)只弹提示 —— 静态数据演示
- ========================================================================== */
- (function () {
- 'use strict';
- const D = window.DEMO;
- const $ = (sel, root) => (root || document).querySelector(sel);
- const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
- const icon = (name, cls) => `<svg class="icon ${cls || ''}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
- const initial = name => (name || '?').trim().charAt(0).toUpperCase();
- /* ---------------- 全局状态 ---------------- */
- const store = {
- get(key, fallback) {
- try { const v = localStorage.getItem(key); return v == null ? fallback : v; } catch (e) { return fallback; }
- },
- // 每次读取都以存储为准:登录态可能在别的标签页/脚本里被改过(演示版无跨标签同步)
- getFresh(key, fallback) { return this.get(key, fallback); },
- set(key, value) { try { localStorage.setItem(key, value); } catch (e) { /* 隐私模式忽略 */ } },
- };
- const state = {
- palette: store.get('fmode_palette', 'platinum'),
- dark: store.get('fmode_theme', 'light') === 'dark',
- fsBody: Number(store.get('fmode_fs', 15)),
- filters: { messages: 'all', contacts: 'all', tasks: 'all', deliverables: 'project' },
- expanded: { people: {}, tree: { workspace: true, daily: true, archived: false } },
- search: { messages: '' },
- authed: store.get('fmode_demo_auth', '') === '1',
- login: { stage: 'phone', phone: '', code: '', agreed: false, token: '' },
- };
- const VALID_PALETTES = ['platinum', 'ice', 'clay', 'slate'];
- if (VALID_PALETTES.indexOf(state.palette) < 0) state.palette = 'platinum';
- /* ---------------- 主题 ---------------- */
- const FS_STEPS = [11, 12, 13, 14, 15, 16, 20, 26]; // 字样板(滑块档位)
- function applyTheme() {
- const html = document.documentElement;
- html.dataset.palette = state.palette;
- html.classList.toggle('dark', state.dark);
- html.style.setProperty('--fs-body', state.fsBody + 'px');
- const canvas = getComputedStyle(html).getPropertyValue('--canvas').trim();
- const meta = $('meta[name="theme-color"]');
- if (meta && canvas) meta.setAttribute('content', canvas);
- }
- function setPalette(p) {
- state.palette = p; store.set('fmode_palette', p); applyTheme(); render();
- }
- function setDark(on) {
- state.dark = !!on; store.set('fmode_theme', on ? 'dark' : 'light'); applyTheme(); render();
- }
- function setFs(v) {
- const n = Number(v);
- state.fsBody = FS_STEPS.reduce((a, b) => (Math.abs(b - n) < Math.abs(a - n) ? b : a), FS_STEPS[0]);
- store.set('fmode_fs', state.fsBody); applyTheme(); render();
- }
- /* ---------------- 登录(演示版:只校验格式,不发起请求) ---------------- */
- function loginNext(step) {
- // 登录分两步:手机号 → 验证码;格式不合法就停在当前步
- state.login.stage = step;
- render();
- }
- function doLogin() {
- const { agreed } = state.login;
- if (!agreed) return toast('请先阅读并同意服务协议和隐私政策');
- store.set('fmode_demo_auth', '1');
- state.authed = true;
- toast('演示版:已进入体验账号');
- go('/messages');
- }
- function logout() {
- store.set('fmode_demo_auth', '');
- state.authed = false;
- closeAllLayers();
- go('/login');
- }
- /* ---------------- 路由 ---------------- */
- // 路由模式:#/messages、#/chat/s-1、#/agent/a-nova、#/tasks/t-1 ……
- function parseHash() {
- const raw = (location.hash || '#/messages').replace(/^#\/?/, '');
- const [path, query] = raw.split('?');
- const parts = path.split('/').filter(Boolean);
- return { page: parts[0] || 'messages', id: parts[1] || null, query: query || '' };
- }
- function go(path) {
- if (location.hash === '#' + path) { render(); return; }
- location.hash = path;
- }
- const TAB_PAGES = ['messages', 'contacts', 'workbench', 'mine'];
- /* ---------------- 通用片段 ---------------- */
- const agentAvatar = (a, cls) => `<span class="avatar ${cls || ''}">${esc(initial(a.name))}</span>`;
- const personAvatar = (p, cls) => `<span class="avatar alt ${cls || ''}">${esc(initial(p.name))}</span>`;
- const statusBadge = a => `<span class="badge ${D.AGENT_TONE[a.status] || ''}"><span class="dot ${D.AGENT_TONE[a.status] || ''}"></span>${D.STATUS_TEXT[a.status] || a.status}</span>`;
- const taskBadge = t => `<span class="badge ${D.TASK_TONE[t.status] || ''}">${D.TASK_STATUS_TEXT[t.status] || t.status}</span>`;
- function navbar({ title, sub, back, right, onTab }) {
- return `<header class="navbar">
- ${back ? `<button class="fab" data-act="back" aria-label="返回">${icon('chevron-left')}</button>` : (onTab ? `<button class="fab" data-act="drawer" aria-label="打开导航">${icon('menu')}</button>` : '<span class="spacer"></span>')}
- <div class="title">${esc(title || '')}${sub ? `<small>${esc(sub)}</small>` : ''}</div>
- ${right || '<span class="spacer"></span>'}
- </header>`;
- }
- function empty(text) { return `<div class="empty">${esc(text)}</div>`; }
- /* ---------------- 页面渲染 ---------------- */
- const PAGES = {
- /* 03 新建对话引导(落地态) */
- home() {
- const recent = D.sessions.filter(s => s.group !== 'archived').slice(0, 3);
- return `<div class="page">
- ${navbar({ title: '', right: `<button class="fab" data-act="go" data-to="/messages" aria-label="全部会话">${icon('message')}</button>`, onTab: true })}
- <div class="page-body has-tabbar" style="display:flex;flex-direction:column">
- <div class="hero" style="flex:1">
- <p class="wordmark">fmode</p>
- <h1>人机共生中枢</h1>
- <p class="sub">和数字生命一起干活 · 每条会话都可追踪</p>
- </div>
- <div class="section" style="margin-bottom:var(--sp-4)">
- <div class="section-title"><span>最近会话</span><button class="btn ghost" data-act="go" data-to="/messages" style="height:auto;padding:0">全部</button></div>
- <div class="list">
- ${recent.map(s => {
- const a = D.agentById(s.agentId);
- return `<button class="row" data-act="go" data-to="/chat/${s.id}">
- ${agentAvatar(a, 'sm')}
- <span class="grow"><span class="t1">${esc(s.title)}</span><span class="t2">${esc(s.lastPreview)}</span></span>
- <span class="time">${esc(s.lastAt)}</span>
- </button>`;
- }).join('')}
- </div>
- </div>
- </div>
- <div class="bottom with-tabbar"><div class="dock" id="home-dock">
- <textarea rows="1" placeholder="发消息..." data-act="dock-input" aria-label="输入消息"></textarea>
- <div class="tools">
- ${icon('paperclip', 'icon-sm')}
- <button class="btn ghost" style="padding:0;height:auto;gap:var(--sp-1);color:var(--muted)" data-act="toast-static">选择数字生命 ${icon('chevron-down', 'icon-sm')}</button>
- <button class="send" data-act="send-static" aria-label="发送">${icon('send', 'icon-sm')}</button>
- </div></div>
- </div>
- </div>`;
- },
- /* 05 会话列表 */
- messages() {
- const q = state.search.messages.trim().toLowerCase();
- const f = state.filters.messages;
- const list = D.sessions.filter(s => {
- if (f === 'unread' && !s.unread) return false;
- if (f === 'workspace' && s.group !== 'workspace') return false;
- if (q && !(s.title.toLowerCase().includes(q) || D.agentById(s.agentId).name.toLowerCase().includes(q))) return false;
- return true;
- });
- return `<div class="page">
- ${navbar({ title: '会话', onTab: true, right: `<button class="fab" data-act="new-chat" aria-label="新建对话">${icon('edit')}</button>` })}
- <div class="page-body has-tabbar">
- <div class="search">${icon('search', 'icon-sm')}<input type="search" placeholder="搜索会话或数字生命" value="${esc(state.search.messages)}" data-act="search-messages"></div>
- <div class="chips" style="margin-top:var(--sp-3)">
- ${[['all', '全部'], ['unread', '未读'], ['workspace', '工作区']].map(([k, t]) => `<button class="chip ${f === k ? 'on' : ''}" data-act="filter" data-scope="messages" data-key="${k}">${t}</button>`).join('')}
- </div>
- <div class="section" style="margin-top:var(--sp-3)">
- ${list.length ? `<div class="list">${list.map(s => {
- const a = D.agentById(s.agentId);
- return `<button class="row" data-act="go" data-to="/chat/${s.id}">
- <span style="position:relative">${agentAvatar(a)}<span class="dot ${D.AGENT_TONE[a.status]}" style="position:absolute;right:0;bottom:0;width:10px;height:10px;border:2px solid var(--surface);box-sizing:content-box"></span></span>
- <span class="grow"><span class="t1">${esc(s.title)}</span><span class="t2">${esc(a.name)} · ${esc(s.lastPreview)}</span></span>
- <span style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--sp-1)">
- <span class="time">${esc(s.lastAt)}</span>
- ${s.unread ? `<span class="badge danger">${s.unread}</span>` : ''}
- </span>
- </button>`;
- }).join('')}</div>` : empty('没有匹配的会话')}
- </div>
- </div>
- </div>`;
- },
- /* 02 登录 */
- login() {
- const L = state.login;
- const phoneOk = /^1\d{10}$/.test(L.phone);
- return `<div class="page">
- <div class="login">
- <div class="brand">
- <p class="wordmark">fmode</p>
- <h1>欢迎使用</h1>
- <p>人机共生中枢 · 登录后继续</p>
- </div>
- ${L.stage === 'phone' ? `
- <label class="field"><span class="prefix">+86</span>
- <input type="tel" inputmode="numeric" maxlength="11" placeholder="请输入手机号" value="${esc(L.phone)}" data-act="login-phone" aria-label="手机号">
- </label>
- <button class="btn primary block" ${phoneOk ? '' : 'disabled'} data-act="login-next" data-step="code">获取验证码</button>
- <div class="more" data-act="login-next" data-step="token">使用 Token 直接登录</div>
- ` : L.stage === 'code' ? `
- <label class="field"><span class="prefix">+86 ${esc(L.phone.slice(0, 3))} ${esc(L.phone.slice(7))}</span>
- <input type="tel" inputmode="numeric" maxlength="6" placeholder="6 位验证码" value="${esc(L.code)}" data-act="login-code" aria-label="验证码">
- <button class="get" data-act="toast-static">重新发送</button>
- </label>
- <button class="btn primary block" ${L.code.length === 6 ? '' : 'disabled'} data-act="login-submit">登录</button>
- <div class="more" data-act="login-next" data-step="phone">返回修改手机号</div>
- ` : `
- <label class="field">
- <input type="text" placeholder="粘贴 r: 开头的访问 Token" value="${esc(L.token)}" data-act="login-token" aria-label="访问 Token">
- </label>
- <button class="btn primary block" ${L.token.trim() ? '' : 'disabled'} data-act="login-submit">登录</button>
- <div class="more" data-act="login-next" data-step="phone">返回手机号登录</div>
- `}
- <label class="agree">
- <button class="cb ${L.agreed ? 'on' : ''}" data-act="login-agree" role="checkbox" aria-checked="${L.agreed}" aria-label="同意协议">${L.agreed ? icon('check', 'icon-sm') : ''}</button>
- <span>已阅读并同意<a href="#" data-act="toast-static">服务协议</a>和<a href="#" data-act="toast-static">隐私政策</a></span>
- </label>
- <p style="margin-top:auto;padding-top:var(--sp-6);text-align:center;font-size:var(--fs-11);color:var(--muted)">演示版:不会真的发送短信或请求接口</p>
- </div>
- </div>`;
- },
- /* 06 会话详情 */
- chat(id) {
- const s = D.sessionById(id) || D.sessions[0];
- const a = D.agentById(s.agentId);
- const msgs = D.messages[s.id] || [];
- return `<div class="page">
- ${navbar({
- title: s.title, sub: `${a.name} · ${D.STATUS_TEXT[a.status]}`,
- back: true,
- right: `<button class="fab" data-act="sheet-chat" aria-label="成果与状态">${icon('folder')}</button>`,
- })}
- <div class="page-body">
- <div class="chat-body">
- ${msgs.length ? msgs.map(m => m.sender === 'me' ? msgMe(m) : msgAgent(m)).join('') : empty('这个会话还没有消息')}
- </div>
- </div>
- <div class="bottom"><div class="dock">
- <textarea rows="1" placeholder="发消息或按住说话" data-act="dock-input" aria-label="输入消息"></textarea>
- <div class="tools">
- ${icon('mic', 'icon-sm')}
- <button class="btn ghost" style="padding:0;height:auto;gap:var(--sp-1);color:var(--muted)" data-act="toast-static">${esc(a.name)} ${icon('chevron-down', 'icon-sm')}</button>
- <button class="send" data-act="send-static" aria-label="发送">${icon('send', 'icon-sm')}</button>
- </div></div>
- </div>
- </div>`;
- },
- /* 09 通讯录 */
- contacts() {
- const f = state.filters.contacts;
- const agents = f === 'people' ? [] : D.agents;
- const people = f === 'agents' ? [] : D.people;
- return `<div class="page">
- ${navbar({ title: '通讯录', onTab: true })}
- <div class="page-body has-tabbar">
- <div class="section" style="padding-top:var(--sp-2)">
- <div class="segmented">
- ${[['all', '全部'], ['agents', '数字生命'], ['people', '同事']].map(([k, t]) => `<button class="${f === k ? 'on' : ''}" data-act="filter" data-scope="contacts" data-key="${k}">${t}</button>`).join('')}
- </div>
- </div>
- ${agents.length ? `<div class="section"><div class="section-title"><span>数字生命 · ${agents.length}</span></div><div class="list">
- ${agents.map(a => `<button class="row" data-act="agent-route" data-id="${a.id}">
- ${agentAvatar(a)}<span class="grow"><span class="t1">${esc(a.name)}</span><span class="t2">${esc(a.specialty)}</span></span>${statusBadge(a)}
- </button>`).join('')}
- </div></div>` : ''}
- ${people.length ? `<div class="section"><div class="section-title"><span>同事 · ${people.length}</span></div><div class="list">
- ${people.map(p => {
- const open = state.expanded.people[p.id];
- return `<button class="row" data-act="expand-person" data-id="${p.id}">
- ${personAvatar(p)}<span class="grow"><span class="t1">${esc(p.name)}</span><span class="t2">${esc(p.mobile)} · ${p.agents.length} 个数字生命</span></span>
- ${icon(open ? 'chevron-down' : 'chevron-right', 'icon-sm chev')}
- </button>
- ${open ? p.agents.map(id => { const a = D.agentById(id); return `<button class="row" style="padding-left:var(--sp-8)" data-act="agent-route" data-id="${a.id}">${agentAvatar(a, 'sm')}<span class="grow"><span class="t1">${esc(a.name)}</span><span class="t2">${esc(a.specialty)}</span></span>${statusBadge(a)}</button>`; }).join('') : ''}`;
- }).join('')}
- </div></div>` : ''}
- </div>
- </div>`;
- },
- /* 10 数字生命详情 */
- agent(id) {
- const a = D.agentById(id) || D.agents[0];
- const hist = D.deliverables.filter(d => d.agentId === a.id);
- return `<div class="page">
- ${navbar({ title: '数字生命', back: true, right: `<button class="fab" data-act="agent-route" data-id="${a.id}" aria-label="进入会话">${icon('message')}</button>` })}
- <div class="page-body">
- <div class="section" style="padding-top:var(--sp-2)">
- <div class="card" style="padding:var(--sp-4);display:flex;gap:var(--sp-4);align-items:center">
- ${agentAvatar(a, 'lg')}
- <div style="min-width:0">
- <div style="font-size:var(--fs-20);font-weight:600">${esc(a.name)}</div>
- <div style="font-size:var(--fs-13);color:var(--muted)">${esc(a.specialty)}</div>
- <div style="margin-top:var(--sp-2);display:flex;gap:var(--sp-2);flex-wrap:wrap">${statusBadge(a)}${a.sessionStatus !== 'ok' ? `<span class="badge warning">状态未上报</span>` : ''}</div>
- </div>
- </div>
- </div>
- <div class="section">
- <div class="section-title"><span>运行指标</span><span>心跳 ${esc(a.heartbeatAt)}</span></div>
- <div class="metric">
- <div class="m"><span>CPU</span><b>${a.host.cpuPercent}%</b><div class="bar"><i style="width:${a.host.cpuPercent}%"></i></div></div>
- <div class="m"><span>内存</span><b>${a.host.memPercent}%</b><div class="bar"><i style="width:${a.host.memPercent}%"></i></div></div>
- <div class="m"><span>本会话 Token</span><b>${a.usage}<small style="font-size:var(--fs-11);color:var(--muted)"> 万</small></b></div>
- <div class="m"><span>余额</span><b>¥${a.balance.remainCny}</b></div>
- </div>
- </div>
- <div class="section">
- <div class="section-title"><span>基本信息</span></div>
- <div class="card" style="padding:var(--sp-4)">
- <dl class="kv">
- <dt>当前任务</dt><dd>${esc(a.currentTask)}</dd>
- <dt>会话数</dt><dd>${a.usageSessions}</dd>
- <dt>通知通道</dt><dd>${a.channels.length ? a.channels.map(c => `<span class="badge">${esc(c)}</span>`).join(' ') : '未配置'}</dd>
- <dt>Matrix</dt><dd>${a.matrixReady ? '已连接' : '未连接'}</dd>
- </dl>
- </div>
- </div>
- <div class="section">
- <div class="section-title"><span>权限设置</span></div>
- <div class="list">
- <div class="row"><span class="grow"><span class="t1">允许群内任何人指挥</span><span class="t2">关闭后仅创建者可下达指令</span></span><span class="switch" role="switch" aria-checked="false" data-act="switch-static"></span></div>
- <div class="row"><span class="grow"><span class="t1">对外共享交付成果</span><span class="t2">开启后同项目成员可下载</span></span><span class="switch" role="switch" aria-checked="true" data-act="switch-static"></span></div>
- </div>
- </div>
- <div class="section">
- <div class="section-title"><span>最近交付</span></div>
- ${hist.length ? `<div class="list">${hist.map(d => `<button class="row" data-act="preview" data-id="${d.id}">${icon('file', 'icon chev')}<span class="grow"><span class="t1">${esc(d.title)}</span><span class="t2">${esc(d.version)} · ${esc(d.deliveredAt)}</span></span>${icon('chevron-right', 'icon-sm chev')}</button>`).join('')}</div>` : empty('暂无交付')}
- </div>
- </div>
- </div>`;
- },
- /* 11 项目(列表 / 详情同页) */
- projects(id) {
- if (!id) {
- return `<div class="page">
- ${navbar({ title: '项目', onTab: false, back: true })}
- <div class="page-body">
- <div class="section" style="padding-top:var(--sp-2)">
- ${D.projects.map(p => `<button class="task-card ${p.hasBlocked ? 'blocked' : ''}" data-act="go" data-to="/projects/${p.id}" style="margin-top:var(--sp-3)">
- <div class="top"><b>${esc(p.name)}</b>${p.hasBlocked ? '<span class="badge danger">有阻塞</span>' : `<span class="badge ${p.status === '进行中' ? 'primary' : ''}">${esc(p.status)}</span>`}</div>
- <div style="font-size:var(--fs-13);color:var(--ink-2);margin-top:var(--sp-2)">${esc(p.description)}</div>
- <div class="meta"><span>${esc(p.code)}</span><span>·</span><span>负责人 ${esc(p.owner)}</span><span>·</span><span>截止 ${esc(p.due)}</span></div>
- </button>`).join('')}
- </div>
- </div>
- </div>`;
- }
- const p = D.projectById(id) || D.projects[0];
- const sess = p.sessionIds.map(D.sessionById);
- const dels = p.deliverableIds.map(D.deliverableById);
- const tasks = D.tasks.filter(t => t.projectId === p.id);
- return `<div class="page">
- ${navbar({ title: p.name, back: true, right: '<span class="spacer"></span>' })}
- <div class="page-body">
- <div class="section" style="padding-top:var(--sp-2)">
- <div class="card" style="padding:var(--sp-4)">
- <div style="display:flex;align-items:center;gap:var(--sp-2)"><b style="flex:1;font-size:var(--fs-17)">${esc(p.name)}</b>${p.hasBlocked ? '<span class="badge danger">有阻塞</span>' : `<span class="badge primary">${esc(p.status)}</span>`}</div>
- <p style="color:var(--ink-2);font-size:var(--fs-13);margin:var(--sp-2) 0">${esc(p.description)}</p>
- <dl class="kv"><dt>编号</dt><dd>${esc(p.code)}</dd><dt>负责人</dt><dd>${esc(p.owner)}</dd><dt>截止</dt><dd>${esc(p.due)}</dd><dt>数字生命</dt><dd>${p.agentIds.map(i => esc(D.agentById(i).name)).join('、')}</dd></dl>
- </div>
- </div>
- <div class="section"><div class="section-title"><span>关联任务 · ${tasks.length}</span></div>
- ${tasks.map(t => taskCard(t)).join('') || empty('暂无任务')}</div>
- <div class="section"><div class="section-title"><span>关联会话</span></div>
- <div class="list">${sess.map(s => `<button class="row" data-act="go" data-to="/chat/${s.id}">${agentAvatar(D.agentById(s.agentId), 'sm')}<span class="grow"><span class="t1">${esc(s.title)}</span><span class="t2">${esc(s.lastPreview)}</span></span><span class="time">${esc(s.lastAt)}</span></button>`).join('')}</div></div>
- <div class="section"><div class="section-title"><span>交付成果 · ${dels.length}</span></div>
- ${dels.length ? `<div class="list">${dels.map(d => `<button class="row" data-act="preview" data-id="${d.id}">${icon('file', 'icon chev')}<span class="grow"><span class="t1">${esc(d.title)}</span><span class="t2">${esc(d.version)} · ${esc(d.deliveredAt)}</span></span>${icon('chevron-right', 'icon-sm chev')}</button>`).join('')}</div>` : empty('暂无交付')}</div>
- </div>
- </div>`;
- },
- /* 12 任务看板 / 任务详情 */
- tasks(id) {
- if (!id) {
- const f = state.filters.tasks;
- const order = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'done'];
- const counts = order.map(k => D.tasks.filter(t => t.status === k).length);
- const list = f === 'all' ? D.tasks : D.tasks.filter(t => t.status === f);
- return `<div class="page">
- ${navbar({ title: '任务', back: true })}
- <div class="page-body">
- <div class="chips" style="padding-top:var(--sp-2)">
- <button class="chip ${f === 'all' ? 'on' : ''}" data-act="filter" data-scope="tasks" data-key="all">全部 <span class="n">${D.tasks.length}</span></button>
- ${order.map((k, i) => `<button class="chip ${f === k ? 'on' : ''}" data-act="filter" data-scope="tasks" data-key="${k}">${D.TASK_STATUS_TEXT[k]} <span class="n">${counts[i]}</span></button>`).join('')}
- </div>
- <div class="section" style="margin-top:var(--sp-3)">
- ${list.map(t => taskCard(t)).join('') || empty('该状态下暂无任务')}
- </div>
- </div>
- </div>`;
- }
- const t = D.taskById(id) || D.tasks[0];
- const a = D.agentById(t.agentId);
- return `<div class="page">
- ${navbar({ title: '任务详情', back: true })}
- <div class="page-body">
- <div class="section" style="padding-top:var(--sp-2)">
- <div class="card ${t.status === 'blocked' ? 'blocked' : ''}" style="padding:var(--sp-4);${t.status === 'blocked' ? 'border-color:var(--danger)' : ''}">
- <div style="display:flex;gap:var(--sp-2);align-items:flex-start"><b style="flex:1;font-size:var(--fs-17)">${esc(t.title)}</b>${taskBadge(t)}</div>
- <div class="meta" style="display:flex;gap:var(--sp-2);margin-top:var(--sp-2);font-size:var(--fs-12);color:var(--muted);flex-wrap:wrap">
- <span>${esc(a.name)}</span><span>·</span><span>${esc(D.projectById(t.projectId).name)}</span><span>·</span><span>更新于 ${esc(t.updatedAt)}</span>
- ${t.status === 'blocked' ? `<span class="badge danger">已阻塞 ${t.blockedHours} 小时</span>` : ''}
- </div>
- ${t.status === 'blocked' ? `<div style="margin-top:var(--sp-3);padding:var(--sp-3);border-radius:var(--r-md);background:var(--danger-subtle);color:var(--danger);font-size:var(--fs-13);display:flex;gap:var(--sp-2)">${icon('alert', 'icon-sm')}<span>阻塞原因:${esc(t.blockKind)}</span></div>` : ''}
- <p style="margin:var(--sp-3) 0 0;color:var(--ink-2);font-size:var(--fs-13)">${esc(t.body)}</p>
- </div>
- </div>
- <div class="section"><div class="section-title"><span>运行流水</span></div>
- ${t.runs.length ? `<div class="card" style="padding:var(--sp-4)"><div class="timeline">${t.runs.map(r => `<div class="tl ${r.state}"><b>${esc(r.text)}</b><time>${esc(r.at)}</time></div>`).join('')}</div></div>` : empty('暂无运行记录')}</div>
- <div class="section"><div class="section-title"><span>附件 · ${t.attachments.length}</span></div>
- ${t.attachments.length ? `<div class="list">${t.attachments.map(f => `<button class="row" data-act="toast-static">${icon('file', 'icon chev')}<span class="grow"><span class="t1">${esc(f.name)}</span><span class="t2">${esc(f.meta)}</span></span>${icon('chevron-right', 'icon-sm chev')}</button>`).join('')}</div>` : empty('暂无附件')}</div>
- <div class="section"><div class="section-title"><span>评论 · ${t.comments.length}</span></div>
- ${t.comments.length ? `<div class="list">${t.comments.map(c => `<div class="row" style="align-items:flex-start">${personAvatar({ name: c.who }, 'sm')}<span class="grow"><span class="t1">${esc(c.who)} <small style="color:var(--muted);font-weight:400">${esc(c.at)}</small></span><span class="t2" style="white-space:normal">${esc(c.text)}</span></span></div>`).join('')}</div>` : empty('暂无评论')}</div>
- </div>
- </div>`;
- },
- /* 13 成果 */
- deliverables() {
- const f = state.filters.deliverables;
- const groups = f === 'project'
- ? D.projects.map(p => ({ title: p.name, items: D.deliverables.filter(d => d.projectId === p.id) })).filter(g => g.items.length)
- : [{ title: '按时间线', items: D.deliverables.slice().sort((x, y) => x.deliveredAt < y.deliveredAt ? 1 : -1) }];
- return `<div class="page">
- ${navbar({ title: '成果', back: true })}
- <div class="page-body">
- <div class="section" style="padding-top:var(--sp-2)">
- <div class="segmented">
- ${[['project', '按项目'], ['timeline', '按时间线']].map(([k, t]) => `<button class="${f === k ? 'on' : ''}" data-act="filter" data-scope="deliverables" data-key="${k}">${t}</button>`).join('')}
- </div>
- </div>
- ${groups.map(g => `<div class="section"><div class="section-title"><span>${esc(g.title)} · ${g.items.length}</span></div><div class="list">
- ${g.items.map(d => `<button class="row" data-act="preview" data-id="${d.id}">
- <span class="ft" style="width:36px;height:36px;border-radius:var(--r-sm);display:grid;place-items:center;font-size:var(--fs-11);font-weight:700;background:var(--primary-subtle);color:var(--primary);flex:none">${esc(d.fileType.toUpperCase())}</span>
- <span class="grow"><span class="t1">${esc(d.title)}</span><span class="t2">${esc(d.version)} · ${esc(d.size)} · ${esc(d.deliveredAt)}</span></span>
- <span class="badge ${d.state === 'delivered' ? 'success' : 'danger'}">${d.state === 'delivered' ? '已交付' : '失败'}</span>
- </button>`).join('')}
- </div></div>`).join('')}
- </div>
- </div>`;
- },
- /* 14 事件 */
- events() {
- const days = [];
- D.events.forEach(e => { const g = days.find(d => d.day === e.day); (g ? g.items : (days.push({ day: e.day, items: [] }), days[days.length - 1].items)).push(e); });
- const tone = { delivery: 'success', execution: 'info', alert: 'danger' };
- const label = { delivery: '交付', execution: '执行', alert: '告警' };
- return `<div class="page">
- ${navbar({ title: '事件', back: true })}
- <div class="page-body">
- ${days.map(d => `<div class="section" style="margin-top:var(--sp-4)"><div class="section-title"><span>${esc(d.day)}</span><span>${d.items.length} 条</span></div><div class="list">
- ${d.items.map(e => `<button class="row" style="align-items:flex-start" data-act="${e.deliverableId ? 'preview' : (e.taskId ? 'go' : 'toast-static')}" ${e.deliverableId ? `data-id="${e.deliverableId}"` : (e.taskId ? `data-to="/tasks/${e.taskId}"` : '')}>
- ${agentAvatar(D.agentById(e.agentId), 'sm')}
- <span class="grow"><span class="t1" style="white-space:normal">${esc(e.title)}</span>${e.description ? `<span class="t2" style="white-space:normal">${esc(e.description)}</span>` : ''}</span>
- <span style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--sp-1);flex:none"><span class="badge ${tone[e.category]}">${label[e.category]}</span><span class="time">${esc(e.time)}</span></span>
- </button>`).join('')}
- </div></div>`).join('')}
- </div>
- </div>`;
- },
- /* 15 工作台 */
- workbench() {
- return `<div class="page">
- ${navbar({ title: '工作台', onTab: true, right: `<button class="fab" data-act="toast-static" aria-label="刷新">${icon('refresh')}</button>` })}
- <div class="page-body has-tabbar">
- <div class="section" style="padding-top:var(--sp-2)">
- ${D.agents.map(a => `<div class="life-card">
- <div class="head">
- ${agentAvatar(a)}
- <div style="flex:1;min-width:0"><div style="font-weight:600">${esc(a.name)}</div><div style="font-size:var(--fs-12);color:var(--muted)">${esc(a.specialty)}</div></div>
- ${statusBadge(a)}
- </div>
- <div class="cur">${esc(a.currentTask)}</div>
- <div class="metric">
- <div class="m"><span>CPU</span><b style="font-size:var(--fs-17)">${a.host.cpuPercent}%</b><div class="bar"><i style="width:${a.host.cpuPercent}%"></i></div></div>
- <div class="m"><span>内存</span><b style="font-size:var(--fs-17)">${a.host.memPercent}%</b><div class="bar"><i style="width:${a.host.memPercent}%"></i></div></div>
- </div>
- <div style="display:flex;gap:var(--sp-2);margin-top:var(--sp-3)">
- <button class="btn" style="flex:1;height:36px;font-size:var(--fs-13)" data-act="agent-route" data-id="${a.id}">进入</button>
- <button class="btn" style="flex:1;height:36px;font-size:var(--fs-13)" data-act="sheet-channels" data-id="${a.id}">通道</button>
- <button class="btn" style="flex:1;height:36px;font-size:var(--fs-13)" data-act="toast-static">充值</button>
- </div>
- </div>`).join('')}
- </div>
- </div>
- </div>`;
- },
- /* 16 我的 */
- mine() {
- const palettes = [
- ['platinum', '铂金曜黑'], ['ice', '冰白靛蓝'], ['clay', '暖瓷陶橘'], ['slate', '岩灰鸢紫'],
- ];
- return `<div class="page">
- ${navbar({ title: '我的', onTab: true })}
- <div class="page-body has-tabbar">
- <div class="section" style="padding-top:var(--sp-2)">
- <div class="card" style="padding:var(--sp-4);display:flex;align-items:center;gap:var(--sp-3)">
- <span class="avatar lg">${esc(initial(D.me.name))}</span>
- <div style="flex:1;min-width:0"><div style="font-size:var(--fs-17);font-weight:600">${esc(D.me.name)}</div><div style="font-size:var(--fs-13);color:var(--muted)">${esc(D.me.mobile)} · ${esc(D.me.companyName)}</div></div>
- </div>
- </div>
- <div class="section"><div class="section-title"><span>账号</span></div>
- <div class="list">
- <button class="row" data-act="toast-static"><span class="grow"><span class="t1">访问 Token</span><span class="t2">r:••••••••••••8f2a</span></span>${icon('chevron-right', 'icon-sm chev')}</button>
- <button class="row" data-act="logout"><span class="grow"><span class="t1" style="color:var(--danger)">退出登录</span></span>${icon('chevron-right', 'icon-sm chev')}</button>
- </div>
- </div>
- <div class="section"><div class="section-title"><span>外观</span></div>
- <div class="card" style="padding:var(--sp-4)">
- <div style="font-size:var(--fs-13);color:var(--ink-2);margin-bottom:var(--sp-3)">整体配色</div>
- <div class="palette-grid">
- ${palettes.map(([k, t]) => `<button class="${state.palette === k ? 'on' : ''}" data-act="palette" data-key="${k}" aria-pressed="${state.palette === k}">
- <span class="swatch" data-preview-palette="${k}"><i></i><i></i><i></i></span>
- ${t}
- </button>`).join('')}
- </div>
- <div class="row" style="padding:var(--sp-3) 0;min-height:auto;margin-top:var(--sp-3);border-top:1px solid var(--edge)">
- <span class="grow"><span class="t1">深色模式</span><span class="t2">跟随手动切换</span></span>
- <span class="switch" role="switch" aria-checked="${state.dark}" data-act="toggle-dark"></span>
- </div>
- <div style="margin-top:var(--sp-3)">
- <div style="display:flex;justify-content:space-between;font-size:var(--fs-13);color:var(--ink-2)"><span>正文字号</span><span>${state.fsBody}px</span></div>
- <input class="slider" type="range" min="11" max="26" step="1" value="${state.fsBody}" data-act="fs" aria-label="正文字号">
- </div>
- </div>
- </div>
- </div>
- </div>`;
- },
- };
- function msgMe(m) {
- return `<div class="msg-me">${esc(m.text)}</div>`;
- }
- function msgAgent(m) {
- const procs = m.processes || [];
- return `<div class="msg-agent md" style="max-width:100%">
- <div class="process-line">
- <span class="dot success"></span>${procs.every(p => p.state === 'done') ? '已完成' : '执行中'}
- </div>
- <div class="process-line" style="margin-top:var(--sp-2)">
- <span class="dot ${procs.every(p => p.state === 'done') ? 'success' : 'info'}"></span>共 ${procs.length} 段过程
- <span class="pill" data-act="toggle-proc">${procs.length ? '收起' : ''}</span>
- </div>
- ${procs.map(p => `<div class="step"><span class="l">${esc(p.label)}</span><span class="r ${p.state === 'done' ? '' : 'badge info'}">${p.state === 'done' ? `已完成 ${icon('chevron-right', 'icon-sm')}` : '执行中'}</span></div>`).join('')}
- <div style="margin-top:var(--sp-3)">
- ${(m.markdown || []).map(b => block(b)).join('')}
- </div>
- ${(m.files || []).map(f => `<button class="file-card" style="margin-top:var(--sp-2)" data-act="preview-dummy" data-name="${esc(f.name)}" data-meta="${esc(f.meta)}">
- <span class="ft">${esc(f.name.split('.').pop().toUpperCase())}</span>
- <span class="grow" style="flex:1;min-width:0"><span class="t1">${esc(f.name)}</span><span class="t2">${esc(f.meta)}</span></span>
- ${icon('chevron-right', 'icon-sm chev')}
- </button>`).join('')}
- <div class="ai-foot"><span>内容由 AI 生成</span><span style="margin-left:auto;display:flex;gap:var(--sp-4)">${icon('volume', 'icon-sm')}${icon('copy', 'icon-sm')}${icon('share', 'icon-sm')}</span></div>
- </div>`;
- }
- function block(b) {
- if (b.type === 'p') return `<p>${esc(b.text)}</p>`;
- if (b.type === 'table') return `<table><tbody>${b.rows.map(r => `<tr>${r.map((c, i) => i === 0 ? `<th>${esc(c)}</th>` : `<td>${esc(c)}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
- return '';
- }
- function taskCard(t) {
- const a = D.agentById(t.agentId);
- return `<button class="task-card ${t.status === 'blocked' ? 'blocked' : ''}" data-act="go" data-to="/tasks/${t.id}">
- <div class="top"><b>${esc(t.title)}</b>${taskBadge(t)}</div>
- <div class="meta">
- ${agentAvatar(a, 'sm')}<span>${esc(a.name)}</span><span>·</span><span>${esc(D.projectById(t.projectId).name)}</span>
- ${t.status === 'blocked' ? `<span class="badge danger">阻塞 ${t.blockedHours}h</span>` : ''}
- <span style="margin-left:auto">${esc(t.updatedAt)}</span>
- </div>
- </button>`;
- }
- /* ---------------- 渲染入口 ---------------- */
- function render() {
- applyTheme();
- state.authed = store.get('fmode_demo_auth', '') === '1';
- let r = parseHash();
- // 权限守卫:未登录一律渲染登录页;已登录时访问登录页则直接渲染会话
- // 说明:守卫只决定「渲染什么」,不修改 hash —— 改 hash 会触发新一轮 hashchange,
- // 在 file:// 下(history 顺序不同步)与本次渲染互相覆盖,导致停在错误页面
- if (!state.authed && r.page !== 'login') r = { page: 'login', id: null };
- else if (state.authed && r.page === 'login') r = { page: 'messages', id: null };
- const fn = PAGES[r.page] || PAGES.home;
- $('#views').innerHTML = `<div class="view active">${fn(r.id)}</div>`;
- renderTabbar(r.page);
- if (window.scrollY) window.scrollTo(0, 0);
- }
- function renderTabbar(current) {
- const tabs = [
- ['messages', 'message', '会话'], ['contacts', 'users', '通讯录'],
- ['workbench', 'grid', '工作台'], ['mine', 'user', '我的'],
- ];
- $('#tabbar').style.display = TAB_PAGES.includes(current) ? 'flex' : 'none';
- const unread = D.sessions.reduce((n, s) => n + (s.group === 'archived' ? 0 : s.unread), 0);
- $('#tabbar').innerHTML = tabs.map(([k, ic, label]) => `<button class="${current === k ? 'on' : ''}" data-act="tab" data-to="${k}" aria-label="${label}">
- ${icon(ic)}<span>${label}</span>${k === 'messages' && unread ? `<span class="badge-dot">${unread}</span>` : ''}
- </button>`).join('');
- }
- /* ---------------- 浮层:抽屉 / 底部弹层 / 预览 / toast ---------------- */
- const layerRoot = $('#layers');
- function layerShell(inner, cls, onClose) {
- const wrap = document.createElement('div');
- wrap.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:var(--z-modal)';
- wrap.innerHTML = `<div class="sheet-mask" style="pointer-events:auto"></div>${inner}`;
- const mask = wrap.querySelector('.sheet-mask');
- mask.onclick = () => { closeLayer(wrap); onClose && onClose(); };
- wrap.querySelectorAll('.drawer,.sheet,.sheet-body-wrap').forEach(n => (n.style.pointerEvents = 'auto'));
- layerRoot.appendChild(wrap);
- requestAnimationFrame(() => { mask.classList.add('show'); wrap.querySelector('.drawer,.sheet')?.classList.add('show'); });
- return wrap;
- }
- function closeLayer(wrap) {
- const mask = wrap.querySelector('.sheet-mask');
- const panel = wrap.querySelector('.drawer,.sheet');
- mask.classList.remove('show');
- if (panel) panel.classList.remove('show');
- setTimeout(() => wrap.remove(), 260);
- }
- function toast(msg) {
- const t = document.createElement('div');
- t.className = 'toast';
- t.textContent = msg;
- $('#app').appendChild(t);
- setTimeout(() => t.remove(), 1800);
- }
- /* 04 抽屉:新建对话 + 一级入口 + 会话树(工作区 / 日常对话 / 已归档) */
- function treeHtml(sessions) {
- return sessions.map(s => `<button class="tree-item" data-act="go" data-to="/chat/${s.id}">
- ${agentAvatar(D.agentById(s.agentId), 'sm')}<span class="t">${esc(s.title)}</span>${s.unread ? `<span class="badge danger">${s.unread}</span>` : ''}
- </button>`).join('') || `<p style="margin:var(--sp-1) var(--sp-2);font-size:var(--fs-12);color:var(--muted)">暂无会话</p>`;
- }
- function openDrawer() {
- const groups = [
- ['工作区', 'workspace'],
- ['日常对话', 'daily'],
- ['已归档', 'archived'],
- ];
- const tree = groups.map(([label, key]) => {
- const items = D.sessions.filter(s => s.group === key);
- const open = !!state.expanded.tree[key];
- return `<section>
- <button class="tree-toggle" data-act="tree-toggle" data-key="${key}" aria-expanded="${open}" aria-controls="tree-${key}">
- <span class="arrow" style="display:inline-flex">${icon('chevron-down', 'icon-sm')}</span>
- ${label}<span class="n">${items.length}</span>
- </button>
- <div id="tree-${key}" ${open ? '' : 'hidden'}>${treeHtml(items)}</div>
- </section>`;
- }).join('');
- return layerShell(`
- <aside class="drawer" role="dialog" aria-label="导航" aria-modal="true">
- <div class="drawer-head">
- <button class="entry primary" data-act="new-chat">
- <span class="lead">${icon('edit', 'icon-sm')}</span><b>新建对话</b><span class="kbd">选择数字生命</span>
- </button>
- </div>
- <nav class="drawer-body" aria-label="功能">
- ${[['messages', '会话', 'message'], ['projects', '项目', 'folder'], ['tasks', '任务', 'check-square'],
- ['events', '事件', 'activity'], ['deliverables', '成果', 'package'], ['workbench', '工作台', 'grid'],
- ['contacts', '通讯录', 'users'], ['mine', '我的', 'user']].map(([k, t, ic]) => `
- <button class="entry" data-act="go" data-to="/${k}">
- <span class="lead">${icon(ic, 'icon-sm')}</span><b>${t}</b>${icon('chevron-right', 'icon-sm chev')}
- </button>`).join('')}
- <div style="border-top:1px solid var(--edge);margin:var(--sp-3) 0 var(--sp-1)"></div>
- <div style="font-size:var(--fs-12);color:var(--muted);padding:var(--sp-1) var(--sp-2)">对话</div>
- ${tree}
- </nav>
- </aside>`);
- }
- /* 分组折叠:只切换 aria-expanded + hidden,不重建抽屉(避免整块重绘导致的跳动) */
- function toggleTree(btn) {
- const key = btn.dataset.key;
- const open = !state.expanded.tree[key];
- state.expanded.tree[key] = open;
- btn.setAttribute('aria-expanded', String(open));
- const panel = document.getElementById('tree-' + key);
- if (panel) panel.hidden = !open;
- }
- /* 新建对话:底部弹层选择数字生命(对标桌面「选择容器新建对话」) */
- function openNewChat() {
- closeAllLayers();
- const usable = D.agents.filter(a => a.status === 'running' || a.status === 'idle');
- const draft = D.sessions.filter(s => s.group === 'daily').slice(0, 3);
- return layerShell(`
- <section class="sheet" role="dialog" aria-label="新建对话">
- <div class="grabber"></div>
- <div class="shead"><h2>新建对话</h2><button class="fab ghost" data-act="close" aria-label="关闭">${icon('x')}</button></div>
- <div class="sbody">
- <div class="section-title" style="padding:0"><span>选择数字生命 · ${usable.length} 个可用</span></div>
- <div class="list">${D.agents.map(a => {
- const on = a.status === 'running' || a.status === 'idle';
- return `<button class="row" ${on ? 'data-act="toast-static"' : 'disabled style="opacity:.55;cursor:not-allowed"'}>
- ${agentAvatar(a, 'sm')}<span class="grow"><span class="t1">${esc(a.name)}</span><span class="t2">${esc(a.specialty)}</span></span>${statusBadge(a)}
- </button>`;
- }).join('')}</div>
- <div class="section-title" style="margin-top:var(--sp-5);padding:0"><span>或继续已有会话</span></div>
- <div class="list">${draft.map(s => `<button class="row" data-act="go" data-to="/chat/${s.id}">
- ${agentAvatar(D.agentById(s.agentId), 'sm')}<span class="grow"><span class="t1">${esc(s.title)}</span><span class="t2">${esc(s.lastPreview)}</span></span><span class="time">${esc(s.lastAt)}</span>
- </button>`).join('')}</div>
- <p style="margin:var(--sp-4) 0 0;font-size:var(--fs-11);color:var(--muted);text-align:center">离线 / 异常的数字生命暂不可新建对话</p>
- </div>
- </section>`);
- }
- /* 07 会话内成果抽屉:任务 / 文件 / 成果 / 状态 */
- function openChatSheet(sessionId) {
- const s = D.sessionById(sessionId) || D.sessions[0];
- const a = D.agentById(s.agentId);
- const tasks = D.tasks.filter(t => t.agentId === a.id);
- const dels = D.deliverables.filter(d => d.agentId === a.id);
- const tab = (k, t) => `<button class="chip" data-tab="${k}">${t}</button>`;
- const wrap = layerShell(`
- <section class="sheet" role="dialog" aria-label="会话面板">
- <div class="grabber"></div>
- <div class="shead"><h2>${esc(s.title)}</h2><button class="fab ghost" data-act="close" aria-label="关闭">${icon('x')}</button></div>
- <div class="chips" style="padding:0 var(--mob-gutter) var(--sp-3)">${tab('task', '任务')}${tab('file', '文件')}${tab('deliver', '成果')}${tab('state', '状态')}</div>
- <div class="sbody" data-sheet-body></div>
- </section>`);
- const body = wrap.querySelector('[data-sheet-body]');
- const panels = {
- task: () => tasks.length ? `<div class="list">${tasks.map(t => `<button class="row" data-act="go" data-to="/tasks/${t.id}"><span class="grow"><span class="t1">${esc(t.title)}</span><span class="t2">${esc(t.updatedAt)}</span></span>${taskBadge(t)}</button>`).join('')}</div>` : empty('暂无任务'),
- file: () => (D.messages[s.id] || []).some(m => m.files) ? `<div class="list">${(D.messages[s.id] || []).flatMap(m => m.files || []).map(f => `<button class="row" data-act="preview-dummy" data-name="${esc(f.name)}" data-meta="${esc(f.meta)}">${icon('file', 'icon chev')}<span class="grow"><span class="t1">${esc(f.name)}</span><span class="t2">${esc(f.meta)}</span></span>${icon('chevron-right', 'icon-sm chev')}</button>`).join('')}</div>` : empty('暂无文件'),
- deliver: () => dels.length ? `<div class="list">${dels.map(d => `<button class="row" data-act="preview" data-id="${d.id}">${icon('file', 'icon chev')}<span class="grow"><span class="t1">${esc(d.title)}</span><span class="t2">${esc(d.version)} · ${esc(d.deliveredAt)}</span></span></button>`).join('')}</div>` : empty('暂无成果'),
- state: () => `<div class="card" style="padding:var(--sp-4)"><dl class="kv"><dt>状态</dt><dd>${D.STATUS_TEXT[a.status]}</dd><dt>当前任务</dt><dd>${esc(a.currentTask)}</dd><dt>心跳</dt><dd>${esc(a.heartbeatAt)}</dd><dt>Matrix</dt><dd>${a.matrixReady ? '已连接' : '未连接'}</dd><dt>Token</dt><dd>${a.usage} 万</dd></dl></div>`,
- };
- const setTab = k => {
- body.innerHTML = panels[k]();
- wrap.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('on', b.dataset.tab === k));
- };
- wrap.querySelectorAll('[data-tab]').forEach(b => (b.onclick = () => setTab(b.dataset.tab)));
- setTab('task');
- return wrap;
- }
- /* 通道 bottom-sheet */
- function openChannels(agentId) {
- const a = D.agentById(agentId) || D.agents[0];
- return layerShell(`
- <section class="sheet" role="dialog" aria-label="通知通道">
- <div class="grabber"></div>
- <div class="shead"><h2>${esc(a.name)} 的通知通道</h2><button class="fab ghost" data-act="close" aria-label="关闭">${icon('x')}</button></div>
- <div class="sbody">
- ${['钉钉', '飞书', '企业微信', '邮件'].map(c => `<div class="row"><span class="grow"><span class="t1">${c}</span></span><span class="switch" role="switch" aria-checked="${a.channels.includes(c)}" data-act="switch-static"></span></div>`).join('')}
- </div>
- </section>`);
- }
- /* 08 文件预览层 */
- function openPreview(deliverableId) {
- const d = D.deliverableById(deliverableId) || D.deliverables[0];
- const a = d.agentId ? D.agentById(d.agentId) : null;
- // 兼容消息里的临时文件对象(只有文件名/元信息)
- const filename = d.filename || d.name;
- const type = (d.fileType || filename.split('.').pop() || '').toLowerCase();
- const sub = [d.title || d.name, d.version, d.size, a ? a.name : ''].filter(Boolean).map(esc).join(' · ');
- const wrap = document.createElement('div');
- wrap.innerHTML = `<div class="preview" role="dialog" aria-label="文件预览">
- <div class="phead">
- <button class="fab" data-act="close" aria-label="返回">${icon('chevron-left')}</button>
- <div class="name"><b>${esc(filename)}</b><span>${sub || '示例文件'}</span></div>
- <button class="fab" data-act="toast-static" aria-label="分享">${icon('share')}</button>
- </div>
- <div class="pbody">
- ${type === 'xlsx' ? `<table class="table-mock"><thead><tr><th>事业部</th><th>Q2 营收</th><th>毛利率</th></tr></thead><tbody>
- <tr><td>智能制造</td><td>¥ 1,860 万</td><td>41.2%</td></tr><tr><td>云与数据</td><td>¥ 2,410 万</td><td>52.6%</td></tr><tr><td>消费终端</td><td>¥ 550 万</td><td>28.4%</td></tr></tbody></table>`
- : type === 'md' ? `<div class="doc-skeleton"><h3 style="font-size:var(--fs-20);margin:0 0 var(--sp-3)">${esc(d.title || d.name)}</h3><p style="color:var(--ink-2);font-size:var(--fs-13)">页面结构:导航 → 主视觉 → 最近会话 → 输入坞。令牌全部来自《设计令牌.md》。</p><div class="ln w80"></div><div class="ln"></div><div class="ln w60"></div></div>`
- : `<div class="doc-skeleton"><div class="bar"></div><div class="ln w60"></div><div class="ln"></div><div class="ln w80"></div><div class="ln"></div><div class="ln w40"></div><div class="bar" style="height:64px;margin-top:var(--sp-4)"></div><div class="ln"></div><div class="ln w80"></div></div>`}
- </div>
- </div>`;
- layerRoot.appendChild(wrap.firstElementChild);
- }
- /* ---------------- 事件委托 ---------------- */
- document.addEventListener('click', ev => {
- const el = ev.target.closest('[data-act]');
- if (!el) return;
- const act = el.dataset.act;
- switch (act) {
- case 'tab': go('/' + el.dataset.to); break;
- case 'go': go(el.dataset.to); closeAllLayers(); break;
- case 'back':
- if (history.length > 1) history.back();
- else go('/' + (TAB_PAGES.includes(parseHash().page) ? 'messages' : DEFAULT_TAB[parseHash().page] || 'messages'));
- break;
- case 'drawer': openDrawer(); break;
- case 'tree-toggle': toggleTree(el); break;
- case 'new-chat': openNewChat(); break;
- case 'expand-person': {
- const id = el.dataset.id;
- state.expanded.people[id] = !state.expanded.people[id];
- render();
- break;
- }
- // 与 core/agent-route.service 一致:可用(running/idle)且有活跃会话 → 进会话;否则进详情
- case 'agent-route': {
- const a = D.agentById(el.dataset.id);
- const s = D.sessions.find(x => x.agentId === a.id && x.group !== 'archived');
- const usable = a.status === 'running' || a.status === 'idle';
- closeAllLayers();
- go(usable && s ? `/chat/${s.id}` : `/agent/${a.id}`);
- break;
- }
- case 'filter': state.filters[el.dataset.scope] = el.dataset.key; render(); break;
- case 'sheet-chat': {
- const r = parseHash();
- closeAllLayers();
- openChatSheet(r.page === 'chat' ? r.id : null);
- break;
- }
- case 'sheet-channels': closeAllLayers(); openChannels(el.dataset.id); break;
- case 'preview': openPreview(el.dataset.id); break;
- case 'preview-dummy': openPreview({ filename: el.dataset.name, title: el.dataset.name, version: el.dataset.meta, size: '', agentId: 'a-orbit' }); break;
- case 'palette': setPalette(el.dataset.key); break;
- case 'toggle-dark': setDark(!state.dark); closeAllLayers(); break;
- case 'switch-static': toastStatic('演示版:设置项不保存'); break;
- case 'toast-static': toastStatic(); break;
- case 'login-next': loginNext(el.dataset.step); break;
- case 'login-submit': doLogin(); break;
- case 'login-agree': state.login.agreed = !state.login.agreed; render(); break;
- case 'send-static': { const dock = el.closest('.dock'); const ta = dock && dock.querySelector('textarea'); if (ta && ta.value.trim()) toast('演示版:数据为静态示例,不会真的发送'); else ta && ta.focus(); break; }
- case 'logout': confirmLogout(); break;
- case 'logout-confirm': logout(); break;
- case 'close': {
- const preview = el.closest('.preview');
- if (preview) { preview.remove(); break; }
- const wrap = el.closest('#layers > div');
- if (wrap) { closeLayer(wrap); break; }
- // 没有关闭按钮的浮层(退出确认):点取消直接移除
- if (el.closest('.sheet-body-wrap')) closeAllLayers();
- break;
- }
- }
- });
- // input 事件:搜索与输入坞
- document.addEventListener('input', ev => {
- const el = ev.target;
- if (el.dataset.act === 'search-messages') {
- state.search.messages = el.value;
- const pos = el.selectionStart;
- render();
- const next = $('[data-act="search-messages"]');
- if (next) { next.focus(); next.setSelectionRange(pos, pos); }
- }
- if (el.dataset.act === 'login-phone') {
- state.login.phone = el.value.replace(/\D/g, '').slice(0, 11);
- const pos = state.login.phone.length;
- render();
- const next = $('[data-act="login-phone"]');
- if (next) { next.focus(); next.setSelectionRange(pos, pos); }
- return;
- }
- if (el.dataset.act === 'login-code') {
- state.login.code = el.value.replace(/\D/g, '').slice(0, 6);
- const pos = state.login.code.length;
- render();
- const next = $('[data-act="login-code"]');
- if (next) { next.focus(); next.setSelectionRange(pos, pos); }
- return;
- }
- if (el.dataset.act === 'login-token') {
- state.login.token = el.value;
- const btn = $('[data-act="login-submit"]');
- if (btn) btn.disabled = !el.value.trim();
- return;
- }
- if (el.dataset.act === 'dock-input') {
- const send = el.closest('.dock').querySelector('.send');
- send.classList.toggle('ready', !!el.value.trim());
- el.style.height = 'auto';
- el.style.height = Math.min(el.scrollHeight, 120) + 'px';
- }
- });
- function confirmLogout() {
- const wrap = layerShell(`
- <div style="position:absolute;left:0;right:0;bottom:0;pointer-events:auto" class="sheet-body-wrap">
- <div class="sheet" style="position:relative;transform:none">
- <div class="shead" style="padding-top:var(--sp-4)"><h2>退出登录</h2></div>
- <p style="margin:0 var(--mob-gutter) var(--sp-4);color:var(--muted);font-size:var(--fs-13)">退出后需要重新登录才能继续使用。演示版不会真的退出。</p>
- <div style="padding:0 var(--mob-gutter) var(--sp-6);display:flex;flex-direction:column;gap:var(--sp-2)">
- <button class="btn block" style="color:var(--danger);border-color:var(--danger)" data-act="toast-static">确认退出</button>
- <button class="btn block" data-act="close">取消</button>
- </div>
- </div>
- </div>`);
- wrap.querySelectorAll('.sheet').forEach(s => s.classList.add('show'));
- }
- function toastStatic(msg) { toast(msg || '演示版:数据为静态示例'); }
- function closeAllLayers() {
- layerRoot.querySelectorAll(':scope > div').forEach(w => closeLayer(w));
- layerRoot.querySelectorAll(':scope > .preview').forEach(p => p.remove());
- }
- const DEFAULT_TAB = { chat: 'messages', agent: 'contacts', tasks: 'workbench', projects: 'messages', events: 'messages', deliverables: 'workbench' };
- /* ---------------- 启动 ---------------- */
- window.addEventListener('hashchange', render);
- // 入口:首次进入落到新建对话引导页
- if (!location.hash) location.replace(state.authed ? '#/messages' : '#/login');
- applyTheme();
- render();
- })();
|