app.js 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922
  1. /* ==========================================================================
  2. fmode 移动端 UI Demo · 路由 / 渲染 / 交互
  3. 形态:全屏移动端 App(无手机壳);参照 WorkBuddy 的版式语言,配色走项目令牌
  4. 交互范围:浏览态全部可点;写操作(发送/新建/删除/上传)只弹提示 —— 静态数据演示
  5. ========================================================================== */
  6. (function () {
  7. 'use strict';
  8. const D = window.DEMO;
  9. const $ = (sel, root) => (root || document).querySelector(sel);
  10. const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  11. const icon = (name, cls) => `<svg class="icon ${cls || ''}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
  12. const initial = name => (name || '?').trim().charAt(0).toUpperCase();
  13. /* ---------------- 全局状态 ---------------- */
  14. const store = {
  15. get(key, fallback) {
  16. try { const v = localStorage.getItem(key); return v == null ? fallback : v; } catch (e) { return fallback; }
  17. },
  18. // 每次读取都以存储为准:登录态可能在别的标签页/脚本里被改过(演示版无跨标签同步)
  19. getFresh(key, fallback) { return this.get(key, fallback); },
  20. set(key, value) { try { localStorage.setItem(key, value); } catch (e) { /* 隐私模式忽略 */ } },
  21. };
  22. const state = {
  23. palette: store.get('fmode_palette', 'platinum'),
  24. dark: store.get('fmode_theme', 'light') === 'dark',
  25. fsBody: Number(store.get('fmode_fs', 15)),
  26. filters: { messages: 'all', contacts: 'all', tasks: 'all', deliverables: 'project' },
  27. expanded: { people: {}, tree: { workspace: true, daily: true, archived: false } },
  28. search: { messages: '' },
  29. authed: store.get('fmode_demo_auth', '') === '1',
  30. login: { stage: 'phone', phone: '', code: '', agreed: false, token: '' },
  31. };
  32. const VALID_PALETTES = ['platinum', 'ice', 'clay', 'slate'];
  33. if (VALID_PALETTES.indexOf(state.palette) < 0) state.palette = 'platinum';
  34. /* ---------------- 主题 ---------------- */
  35. const FS_STEPS = [11, 12, 13, 14, 15, 16, 20, 26]; // 字样板(滑块档位)
  36. function applyTheme() {
  37. const html = document.documentElement;
  38. html.dataset.palette = state.palette;
  39. html.classList.toggle('dark', state.dark);
  40. html.style.setProperty('--fs-body', state.fsBody + 'px');
  41. const canvas = getComputedStyle(html).getPropertyValue('--canvas').trim();
  42. const meta = $('meta[name="theme-color"]');
  43. if (meta && canvas) meta.setAttribute('content', canvas);
  44. }
  45. function setPalette(p) {
  46. state.palette = p; store.set('fmode_palette', p); applyTheme(); render();
  47. }
  48. function setDark(on) {
  49. state.dark = !!on; store.set('fmode_theme', on ? 'dark' : 'light'); applyTheme(); render();
  50. }
  51. function setFs(v) {
  52. const n = Number(v);
  53. state.fsBody = FS_STEPS.reduce((a, b) => (Math.abs(b - n) < Math.abs(a - n) ? b : a), FS_STEPS[0]);
  54. store.set('fmode_fs', state.fsBody); applyTheme(); render();
  55. }
  56. /* ---------------- 登录(演示版:只校验格式,不发起请求) ---------------- */
  57. function loginNext(step) {
  58. // 登录分两步:手机号 → 验证码;格式不合法就停在当前步
  59. state.login.stage = step;
  60. render();
  61. }
  62. function doLogin() {
  63. const { agreed } = state.login;
  64. if (!agreed) return toast('请先阅读并同意服务协议和隐私政策');
  65. store.set('fmode_demo_auth', '1');
  66. state.authed = true;
  67. toast('演示版:已进入体验账号');
  68. go('/messages');
  69. }
  70. function logout() {
  71. store.set('fmode_demo_auth', '');
  72. state.authed = false;
  73. closeAllLayers();
  74. go('/login');
  75. }
  76. /* ---------------- 路由 ---------------- */
  77. // 路由模式:#/messages、#/chat/s-1、#/agent/a-nova、#/tasks/t-1 ……
  78. function parseHash() {
  79. const raw = (location.hash || '#/messages').replace(/^#\/?/, '');
  80. const [path, query] = raw.split('?');
  81. const parts = path.split('/').filter(Boolean);
  82. return { page: parts[0] || 'messages', id: parts[1] || null, query: query || '' };
  83. }
  84. function go(path) {
  85. if (location.hash === '#' + path) { render(); return; }
  86. location.hash = path;
  87. }
  88. const TAB_PAGES = ['messages', 'contacts', 'workbench', 'mine'];
  89. /* ---------------- 通用片段 ---------------- */
  90. const agentAvatar = (a, cls) => `<span class="avatar ${cls || ''}">${esc(initial(a.name))}</span>`;
  91. const personAvatar = (p, cls) => `<span class="avatar alt ${cls || ''}">${esc(initial(p.name))}</span>`;
  92. 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>`;
  93. const taskBadge = t => `<span class="badge ${D.TASK_TONE[t.status] || ''}">${D.TASK_STATUS_TEXT[t.status] || t.status}</span>`;
  94. function navbar({ title, sub, back, right, onTab }) {
  95. return `<header class="navbar">
  96. ${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>')}
  97. <div class="title">${esc(title || '')}${sub ? `<small>${esc(sub)}</small>` : ''}</div>
  98. ${right || '<span class="spacer"></span>'}
  99. </header>`;
  100. }
  101. function empty(text) { return `<div class="empty">${esc(text)}</div>`; }
  102. /* ---------------- 页面渲染 ---------------- */
  103. const PAGES = {
  104. /* 03 新建对话引导(落地态) */
  105. home() {
  106. const recent = D.sessions.filter(s => s.group !== 'archived').slice(0, 3);
  107. return `<div class="page">
  108. ${navbar({ title: '', right: `<button class="fab" data-act="go" data-to="/messages" aria-label="全部会话">${icon('message')}</button>`, onTab: true })}
  109. <div class="page-body has-tabbar" style="display:flex;flex-direction:column">
  110. <div class="hero" style="flex:1">
  111. <p class="wordmark">fmode</p>
  112. <h1>人机共生中枢</h1>
  113. <p class="sub">和数字生命一起干活 · 每条会话都可追踪</p>
  114. </div>
  115. <div class="section" style="margin-bottom:var(--sp-4)">
  116. <div class="section-title"><span>最近会话</span><button class="btn ghost" data-act="go" data-to="/messages" style="height:auto;padding:0">全部</button></div>
  117. <div class="list">
  118. ${recent.map(s => {
  119. const a = D.agentById(s.agentId);
  120. return `<button class="row" data-act="go" data-to="/chat/${s.id}">
  121. ${agentAvatar(a, 'sm')}
  122. <span class="grow"><span class="t1">${esc(s.title)}</span><span class="t2">${esc(s.lastPreview)}</span></span>
  123. <span class="time">${esc(s.lastAt)}</span>
  124. </button>`;
  125. }).join('')}
  126. </div>
  127. </div>
  128. </div>
  129. <div class="bottom with-tabbar"><div class="dock" id="home-dock">
  130. <textarea rows="1" placeholder="发消息..." data-act="dock-input" aria-label="输入消息"></textarea>
  131. <div class="tools">
  132. ${icon('paperclip', 'icon-sm')}
  133. <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>
  134. <button class="send" data-act="send-static" aria-label="发送">${icon('send', 'icon-sm')}</button>
  135. </div></div>
  136. </div>
  137. </div>`;
  138. },
  139. /* 05 会话列表 */
  140. messages() {
  141. const q = state.search.messages.trim().toLowerCase();
  142. const f = state.filters.messages;
  143. const list = D.sessions.filter(s => {
  144. if (f === 'unread' && !s.unread) return false;
  145. if (f === 'workspace' && s.group !== 'workspace') return false;
  146. if (q && !(s.title.toLowerCase().includes(q) || D.agentById(s.agentId).name.toLowerCase().includes(q))) return false;
  147. return true;
  148. });
  149. return `<div class="page">
  150. ${navbar({ title: '会话', onTab: true, right: `<button class="fab" data-act="new-chat" aria-label="新建对话">${icon('edit')}</button>` })}
  151. <div class="page-body has-tabbar">
  152. <div class="search">${icon('search', 'icon-sm')}<input type="search" placeholder="搜索会话或数字生命" value="${esc(state.search.messages)}" data-act="search-messages"></div>
  153. <div class="chips" style="margin-top:var(--sp-3)">
  154. ${[['all', '全部'], ['unread', '未读'], ['workspace', '工作区']].map(([k, t]) => `<button class="chip ${f === k ? 'on' : ''}" data-act="filter" data-scope="messages" data-key="${k}">${t}</button>`).join('')}
  155. </div>
  156. <div class="section" style="margin-top:var(--sp-3)">
  157. ${list.length ? `<div class="list">${list.map(s => {
  158. const a = D.agentById(s.agentId);
  159. return `<button class="row" data-act="go" data-to="/chat/${s.id}">
  160. <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>
  161. <span class="grow"><span class="t1">${esc(s.title)}</span><span class="t2">${esc(a.name)} · ${esc(s.lastPreview)}</span></span>
  162. <span style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--sp-1)">
  163. <span class="time">${esc(s.lastAt)}</span>
  164. ${s.unread ? `<span class="badge danger">${s.unread}</span>` : ''}
  165. </span>
  166. </button>`;
  167. }).join('')}</div>` : empty('没有匹配的会话')}
  168. </div>
  169. </div>
  170. </div>`;
  171. },
  172. /* 02 登录 */
  173. login() {
  174. const L = state.login;
  175. const phoneOk = /^1\d{10}$/.test(L.phone);
  176. return `<div class="page">
  177. <div class="login">
  178. <div class="brand">
  179. <p class="wordmark">fmode</p>
  180. <h1>欢迎使用</h1>
  181. <p>人机共生中枢 · 登录后继续</p>
  182. </div>
  183. ${L.stage === 'phone' ? `
  184. <label class="field"><span class="prefix">+86</span>
  185. <input type="tel" inputmode="numeric" maxlength="11" placeholder="请输入手机号" value="${esc(L.phone)}" data-act="login-phone" aria-label="手机号">
  186. </label>
  187. <button class="btn primary block" ${phoneOk ? '' : 'disabled'} data-act="login-next" data-step="code">获取验证码</button>
  188. <div class="more" data-act="login-next" data-step="token">使用 Token 直接登录</div>
  189. ` : L.stage === 'code' ? `
  190. <label class="field"><span class="prefix">+86 ${esc(L.phone.slice(0, 3))} ${esc(L.phone.slice(7))}</span>
  191. <input type="tel" inputmode="numeric" maxlength="6" placeholder="6 位验证码" value="${esc(L.code)}" data-act="login-code" aria-label="验证码">
  192. <button class="get" data-act="toast-static">重新发送</button>
  193. </label>
  194. <button class="btn primary block" ${L.code.length === 6 ? '' : 'disabled'} data-act="login-submit">登录</button>
  195. <div class="more" data-act="login-next" data-step="phone">返回修改手机号</div>
  196. ` : `
  197. <label class="field">
  198. <input type="text" placeholder="粘贴 r: 开头的访问 Token" value="${esc(L.token)}" data-act="login-token" aria-label="访问 Token">
  199. </label>
  200. <button class="btn primary block" ${L.token.trim() ? '' : 'disabled'} data-act="login-submit">登录</button>
  201. <div class="more" data-act="login-next" data-step="phone">返回手机号登录</div>
  202. `}
  203. <label class="agree">
  204. <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>
  205. <span>已阅读并同意<a href="#" data-act="toast-static">服务协议</a>和<a href="#" data-act="toast-static">隐私政策</a></span>
  206. </label>
  207. <p style="margin-top:auto;padding-top:var(--sp-6);text-align:center;font-size:var(--fs-11);color:var(--muted)">演示版:不会真的发送短信或请求接口</p>
  208. </div>
  209. </div>`;
  210. },
  211. /* 06 会话详情 */
  212. chat(id) {
  213. const s = D.sessionById(id) || D.sessions[0];
  214. const a = D.agentById(s.agentId);
  215. const msgs = D.messages[s.id] || [];
  216. return `<div class="page">
  217. ${navbar({
  218. title: s.title, sub: `${a.name} · ${D.STATUS_TEXT[a.status]}`,
  219. back: true,
  220. right: `<button class="fab" data-act="sheet-chat" aria-label="成果与状态">${icon('folder')}</button>`,
  221. })}
  222. <div class="page-body">
  223. <div class="chat-body">
  224. ${msgs.length ? msgs.map(m => m.sender === 'me' ? msgMe(m) : msgAgent(m)).join('') : empty('这个会话还没有消息')}
  225. </div>
  226. </div>
  227. <div class="bottom"><div class="dock">
  228. <textarea rows="1" placeholder="发消息或按住说话" data-act="dock-input" aria-label="输入消息"></textarea>
  229. <div class="tools">
  230. ${icon('mic', 'icon-sm')}
  231. <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>
  232. <button class="send" data-act="send-static" aria-label="发送">${icon('send', 'icon-sm')}</button>
  233. </div></div>
  234. </div>
  235. </div>`;
  236. },
  237. /* 09 通讯录 */
  238. contacts() {
  239. const f = state.filters.contacts;
  240. const agents = f === 'people' ? [] : D.agents;
  241. const people = f === 'agents' ? [] : D.people;
  242. return `<div class="page">
  243. ${navbar({ title: '通讯录', onTab: true })}
  244. <div class="page-body has-tabbar">
  245. <div class="section" style="padding-top:var(--sp-2)">
  246. <div class="segmented">
  247. ${[['all', '全部'], ['agents', '数字生命'], ['people', '同事']].map(([k, t]) => `<button class="${f === k ? 'on' : ''}" data-act="filter" data-scope="contacts" data-key="${k}">${t}</button>`).join('')}
  248. </div>
  249. </div>
  250. ${agents.length ? `<div class="section"><div class="section-title"><span>数字生命 · ${agents.length}</span></div><div class="list">
  251. ${agents.map(a => `<button class="row" data-act="agent-route" data-id="${a.id}">
  252. ${agentAvatar(a)}<span class="grow"><span class="t1">${esc(a.name)}</span><span class="t2">${esc(a.specialty)}</span></span>${statusBadge(a)}
  253. </button>`).join('')}
  254. </div></div>` : ''}
  255. ${people.length ? `<div class="section"><div class="section-title"><span>同事 · ${people.length}</span></div><div class="list">
  256. ${people.map(p => {
  257. const open = state.expanded.people[p.id];
  258. return `<button class="row" data-act="expand-person" data-id="${p.id}">
  259. ${personAvatar(p)}<span class="grow"><span class="t1">${esc(p.name)}</span><span class="t2">${esc(p.mobile)} · ${p.agents.length} 个数字生命</span></span>
  260. ${icon(open ? 'chevron-down' : 'chevron-right', 'icon-sm chev')}
  261. </button>
  262. ${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('') : ''}`;
  263. }).join('')}
  264. </div></div>` : ''}
  265. </div>
  266. </div>`;
  267. },
  268. /* 10 数字生命详情 */
  269. agent(id) {
  270. const a = D.agentById(id) || D.agents[0];
  271. const hist = D.deliverables.filter(d => d.agentId === a.id);
  272. return `<div class="page">
  273. ${navbar({ title: '数字生命', back: true, right: `<button class="fab" data-act="agent-route" data-id="${a.id}" aria-label="进入会话">${icon('message')}</button>` })}
  274. <div class="page-body">
  275. <div class="section" style="padding-top:var(--sp-2)">
  276. <div class="card" style="padding:var(--sp-4);display:flex;gap:var(--sp-4);align-items:center">
  277. ${agentAvatar(a, 'lg')}
  278. <div style="min-width:0">
  279. <div style="font-size:var(--fs-20);font-weight:600">${esc(a.name)}</div>
  280. <div style="font-size:var(--fs-13);color:var(--muted)">${esc(a.specialty)}</div>
  281. <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>
  282. </div>
  283. </div>
  284. </div>
  285. <div class="section">
  286. <div class="section-title"><span>运行指标</span><span>心跳 ${esc(a.heartbeatAt)}</span></div>
  287. <div class="metric">
  288. <div class="m"><span>CPU</span><b>${a.host.cpuPercent}%</b><div class="bar"><i style="width:${a.host.cpuPercent}%"></i></div></div>
  289. <div class="m"><span>内存</span><b>${a.host.memPercent}%</b><div class="bar"><i style="width:${a.host.memPercent}%"></i></div></div>
  290. <div class="m"><span>本会话 Token</span><b>${a.usage}<small style="font-size:var(--fs-11);color:var(--muted)"> 万</small></b></div>
  291. <div class="m"><span>余额</span><b>¥${a.balance.remainCny}</b></div>
  292. </div>
  293. </div>
  294. <div class="section">
  295. <div class="section-title"><span>基本信息</span></div>
  296. <div class="card" style="padding:var(--sp-4)">
  297. <dl class="kv">
  298. <dt>当前任务</dt><dd>${esc(a.currentTask)}</dd>
  299. <dt>会话数</dt><dd>${a.usageSessions}</dd>
  300. <dt>通知通道</dt><dd>${a.channels.length ? a.channels.map(c => `<span class="badge">${esc(c)}</span>`).join(' ') : '未配置'}</dd>
  301. <dt>Matrix</dt><dd>${a.matrixReady ? '已连接' : '未连接'}</dd>
  302. </dl>
  303. </div>
  304. </div>
  305. <div class="section">
  306. <div class="section-title"><span>权限设置</span></div>
  307. <div class="list">
  308. <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>
  309. <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>
  310. </div>
  311. </div>
  312. <div class="section">
  313. <div class="section-title"><span>最近交付</span></div>
  314. ${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('暂无交付')}
  315. </div>
  316. </div>
  317. </div>`;
  318. },
  319. /* 11 项目(列表 / 详情同页) */
  320. projects(id) {
  321. if (!id) {
  322. return `<div class="page">
  323. ${navbar({ title: '项目', onTab: false, back: true })}
  324. <div class="page-body">
  325. <div class="section" style="padding-top:var(--sp-2)">
  326. ${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)">
  327. <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>
  328. <div style="font-size:var(--fs-13);color:var(--ink-2);margin-top:var(--sp-2)">${esc(p.description)}</div>
  329. <div class="meta"><span>${esc(p.code)}</span><span>·</span><span>负责人 ${esc(p.owner)}</span><span>·</span><span>截止 ${esc(p.due)}</span></div>
  330. </button>`).join('')}
  331. </div>
  332. </div>
  333. </div>`;
  334. }
  335. const p = D.projectById(id) || D.projects[0];
  336. const sess = p.sessionIds.map(D.sessionById);
  337. const dels = p.deliverableIds.map(D.deliverableById);
  338. const tasks = D.tasks.filter(t => t.projectId === p.id);
  339. return `<div class="page">
  340. ${navbar({ title: p.name, back: true, right: '<span class="spacer"></span>' })}
  341. <div class="page-body">
  342. <div class="section" style="padding-top:var(--sp-2)">
  343. <div class="card" style="padding:var(--sp-4)">
  344. <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>
  345. <p style="color:var(--ink-2);font-size:var(--fs-13);margin:var(--sp-2) 0">${esc(p.description)}</p>
  346. <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>
  347. </div>
  348. </div>
  349. <div class="section"><div class="section-title"><span>关联任务 · ${tasks.length}</span></div>
  350. ${tasks.map(t => taskCard(t)).join('') || empty('暂无任务')}</div>
  351. <div class="section"><div class="section-title"><span>关联会话</span></div>
  352. <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>
  353. <div class="section"><div class="section-title"><span>交付成果 · ${dels.length}</span></div>
  354. ${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>
  355. </div>
  356. </div>`;
  357. },
  358. /* 12 任务看板 / 任务详情 */
  359. tasks(id) {
  360. if (!id) {
  361. const f = state.filters.tasks;
  362. const order = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'done'];
  363. const counts = order.map(k => D.tasks.filter(t => t.status === k).length);
  364. const list = f === 'all' ? D.tasks : D.tasks.filter(t => t.status === f);
  365. return `<div class="page">
  366. ${navbar({ title: '任务', back: true })}
  367. <div class="page-body">
  368. <div class="chips" style="padding-top:var(--sp-2)">
  369. <button class="chip ${f === 'all' ? 'on' : ''}" data-act="filter" data-scope="tasks" data-key="all">全部 <span class="n">${D.tasks.length}</span></button>
  370. ${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('')}
  371. </div>
  372. <div class="section" style="margin-top:var(--sp-3)">
  373. ${list.map(t => taskCard(t)).join('') || empty('该状态下暂无任务')}
  374. </div>
  375. </div>
  376. </div>`;
  377. }
  378. const t = D.taskById(id) || D.tasks[0];
  379. const a = D.agentById(t.agentId);
  380. return `<div class="page">
  381. ${navbar({ title: '任务详情', back: true })}
  382. <div class="page-body">
  383. <div class="section" style="padding-top:var(--sp-2)">
  384. <div class="card ${t.status === 'blocked' ? 'blocked' : ''}" style="padding:var(--sp-4);${t.status === 'blocked' ? 'border-color:var(--danger)' : ''}">
  385. <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>
  386. <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">
  387. <span>${esc(a.name)}</span><span>·</span><span>${esc(D.projectById(t.projectId).name)}</span><span>·</span><span>更新于 ${esc(t.updatedAt)}</span>
  388. ${t.status === 'blocked' ? `<span class="badge danger">已阻塞 ${t.blockedHours} 小时</span>` : ''}
  389. </div>
  390. ${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>` : ''}
  391. <p style="margin:var(--sp-3) 0 0;color:var(--ink-2);font-size:var(--fs-13)">${esc(t.body)}</p>
  392. </div>
  393. </div>
  394. <div class="section"><div class="section-title"><span>运行流水</span></div>
  395. ${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>
  396. <div class="section"><div class="section-title"><span>附件 · ${t.attachments.length}</span></div>
  397. ${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>
  398. <div class="section"><div class="section-title"><span>评论 · ${t.comments.length}</span></div>
  399. ${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>
  400. </div>
  401. </div>`;
  402. },
  403. /* 13 成果 */
  404. deliverables() {
  405. const f = state.filters.deliverables;
  406. const groups = f === 'project'
  407. ? D.projects.map(p => ({ title: p.name, items: D.deliverables.filter(d => d.projectId === p.id) })).filter(g => g.items.length)
  408. : [{ title: '按时间线', items: D.deliverables.slice().sort((x, y) => x.deliveredAt < y.deliveredAt ? 1 : -1) }];
  409. return `<div class="page">
  410. ${navbar({ title: '成果', back: true })}
  411. <div class="page-body">
  412. <div class="section" style="padding-top:var(--sp-2)">
  413. <div class="segmented">
  414. ${[['project', '按项目'], ['timeline', '按时间线']].map(([k, t]) => `<button class="${f === k ? 'on' : ''}" data-act="filter" data-scope="deliverables" data-key="${k}">${t}</button>`).join('')}
  415. </div>
  416. </div>
  417. ${groups.map(g => `<div class="section"><div class="section-title"><span>${esc(g.title)} · ${g.items.length}</span></div><div class="list">
  418. ${g.items.map(d => `<button class="row" data-act="preview" data-id="${d.id}">
  419. <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>
  420. <span class="grow"><span class="t1">${esc(d.title)}</span><span class="t2">${esc(d.version)} · ${esc(d.size)} · ${esc(d.deliveredAt)}</span></span>
  421. <span class="badge ${d.state === 'delivered' ? 'success' : 'danger'}">${d.state === 'delivered' ? '已交付' : '失败'}</span>
  422. </button>`).join('')}
  423. </div></div>`).join('')}
  424. </div>
  425. </div>`;
  426. },
  427. /* 14 事件 */
  428. events() {
  429. const days = [];
  430. 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); });
  431. const tone = { delivery: 'success', execution: 'info', alert: 'danger' };
  432. const label = { delivery: '交付', execution: '执行', alert: '告警' };
  433. return `<div class="page">
  434. ${navbar({ title: '事件', back: true })}
  435. <div class="page-body">
  436. ${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">
  437. ${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}"` : '')}>
  438. ${agentAvatar(D.agentById(e.agentId), 'sm')}
  439. <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>
  440. <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>
  441. </button>`).join('')}
  442. </div></div>`).join('')}
  443. </div>
  444. </div>`;
  445. },
  446. /* 15 工作台 */
  447. workbench() {
  448. return `<div class="page">
  449. ${navbar({ title: '工作台', onTab: true, right: `<button class="fab" data-act="toast-static" aria-label="刷新">${icon('refresh')}</button>` })}
  450. <div class="page-body has-tabbar">
  451. <div class="section" style="padding-top:var(--sp-2)">
  452. ${D.agents.map(a => `<div class="life-card">
  453. <div class="head">
  454. ${agentAvatar(a)}
  455. <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>
  456. ${statusBadge(a)}
  457. </div>
  458. <div class="cur">${esc(a.currentTask)}</div>
  459. <div class="metric">
  460. <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>
  461. <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>
  462. </div>
  463. <div style="display:flex;gap:var(--sp-2);margin-top:var(--sp-3)">
  464. <button class="btn" style="flex:1;height:36px;font-size:var(--fs-13)" data-act="agent-route" data-id="${a.id}">进入</button>
  465. <button class="btn" style="flex:1;height:36px;font-size:var(--fs-13)" data-act="sheet-channels" data-id="${a.id}">通道</button>
  466. <button class="btn" style="flex:1;height:36px;font-size:var(--fs-13)" data-act="toast-static">充值</button>
  467. </div>
  468. </div>`).join('')}
  469. </div>
  470. </div>
  471. </div>`;
  472. },
  473. /* 16 我的 */
  474. mine() {
  475. const palettes = [
  476. ['platinum', '铂金曜黑'], ['ice', '冰白靛蓝'], ['clay', '暖瓷陶橘'], ['slate', '岩灰鸢紫'],
  477. ];
  478. return `<div class="page">
  479. ${navbar({ title: '我的', onTab: true })}
  480. <div class="page-body has-tabbar">
  481. <div class="section" style="padding-top:var(--sp-2)">
  482. <div class="card" style="padding:var(--sp-4);display:flex;align-items:center;gap:var(--sp-3)">
  483. <span class="avatar lg">${esc(initial(D.me.name))}</span>
  484. <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>
  485. </div>
  486. </div>
  487. <div class="section"><div class="section-title"><span>账号</span></div>
  488. <div class="list">
  489. <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>
  490. <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>
  491. </div>
  492. </div>
  493. <div class="section"><div class="section-title"><span>外观</span></div>
  494. <div class="card" style="padding:var(--sp-4)">
  495. <div style="font-size:var(--fs-13);color:var(--ink-2);margin-bottom:var(--sp-3)">整体配色</div>
  496. <div class="palette-grid">
  497. ${palettes.map(([k, t]) => `<button class="${state.palette === k ? 'on' : ''}" data-act="palette" data-key="${k}" aria-pressed="${state.palette === k}">
  498. <span class="swatch" data-preview-palette="${k}"><i></i><i></i><i></i></span>
  499. ${t}
  500. </button>`).join('')}
  501. </div>
  502. <div class="row" style="padding:var(--sp-3) 0;min-height:auto;margin-top:var(--sp-3);border-top:1px solid var(--edge)">
  503. <span class="grow"><span class="t1">深色模式</span><span class="t2">跟随手动切换</span></span>
  504. <span class="switch" role="switch" aria-checked="${state.dark}" data-act="toggle-dark"></span>
  505. </div>
  506. <div style="margin-top:var(--sp-3)">
  507. <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>
  508. <input class="slider" type="range" min="11" max="26" step="1" value="${state.fsBody}" data-act="fs" aria-label="正文字号">
  509. </div>
  510. </div>
  511. </div>
  512. </div>
  513. </div>`;
  514. },
  515. };
  516. function msgMe(m) {
  517. return `<div class="msg-me">${esc(m.text)}</div>`;
  518. }
  519. function msgAgent(m) {
  520. const procs = m.processes || [];
  521. return `<div class="msg-agent md" style="max-width:100%">
  522. <div class="process-line">
  523. <span class="dot success"></span>${procs.every(p => p.state === 'done') ? '已完成' : '执行中'}
  524. </div>
  525. <div class="process-line" style="margin-top:var(--sp-2)">
  526. <span class="dot ${procs.every(p => p.state === 'done') ? 'success' : 'info'}"></span>共 ${procs.length} 段过程
  527. <span class="pill" data-act="toggle-proc">${procs.length ? '收起' : ''}</span>
  528. </div>
  529. ${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('')}
  530. <div style="margin-top:var(--sp-3)">
  531. ${(m.markdown || []).map(b => block(b)).join('')}
  532. </div>
  533. ${(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)}">
  534. <span class="ft">${esc(f.name.split('.').pop().toUpperCase())}</span>
  535. <span class="grow" style="flex:1;min-width:0"><span class="t1">${esc(f.name)}</span><span class="t2">${esc(f.meta)}</span></span>
  536. ${icon('chevron-right', 'icon-sm chev')}
  537. </button>`).join('')}
  538. <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>
  539. </div>`;
  540. }
  541. function block(b) {
  542. if (b.type === 'p') return `<p>${esc(b.text)}</p>`;
  543. 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>`;
  544. return '';
  545. }
  546. function taskCard(t) {
  547. const a = D.agentById(t.agentId);
  548. return `<button class="task-card ${t.status === 'blocked' ? 'blocked' : ''}" data-act="go" data-to="/tasks/${t.id}">
  549. <div class="top"><b>${esc(t.title)}</b>${taskBadge(t)}</div>
  550. <div class="meta">
  551. ${agentAvatar(a, 'sm')}<span>${esc(a.name)}</span><span>·</span><span>${esc(D.projectById(t.projectId).name)}</span>
  552. ${t.status === 'blocked' ? `<span class="badge danger">阻塞 ${t.blockedHours}h</span>` : ''}
  553. <span style="margin-left:auto">${esc(t.updatedAt)}</span>
  554. </div>
  555. </button>`;
  556. }
  557. /* ---------------- 渲染入口 ---------------- */
  558. function render() {
  559. applyTheme();
  560. state.authed = store.get('fmode_demo_auth', '') === '1';
  561. let r = parseHash();
  562. // 权限守卫:未登录一律渲染登录页;已登录时访问登录页则直接渲染会话
  563. // 说明:守卫只决定「渲染什么」,不修改 hash —— 改 hash 会触发新一轮 hashchange,
  564. // 在 file:// 下(history 顺序不同步)与本次渲染互相覆盖,导致停在错误页面
  565. if (!state.authed && r.page !== 'login') r = { page: 'login', id: null };
  566. else if (state.authed && r.page === 'login') r = { page: 'messages', id: null };
  567. const fn = PAGES[r.page] || PAGES.home;
  568. $('#views').innerHTML = `<div class="view active">${fn(r.id)}</div>`;
  569. renderTabbar(r.page);
  570. if (window.scrollY) window.scrollTo(0, 0);
  571. }
  572. function renderTabbar(current) {
  573. const tabs = [
  574. ['messages', 'message', '会话'], ['contacts', 'users', '通讯录'],
  575. ['workbench', 'grid', '工作台'], ['mine', 'user', '我的'],
  576. ];
  577. $('#tabbar').style.display = TAB_PAGES.includes(current) ? 'flex' : 'none';
  578. const unread = D.sessions.reduce((n, s) => n + (s.group === 'archived' ? 0 : s.unread), 0);
  579. $('#tabbar').innerHTML = tabs.map(([k, ic, label]) => `<button class="${current === k ? 'on' : ''}" data-act="tab" data-to="${k}" aria-label="${label}">
  580. ${icon(ic)}<span>${label}</span>${k === 'messages' && unread ? `<span class="badge-dot">${unread}</span>` : ''}
  581. </button>`).join('');
  582. }
  583. /* ---------------- 浮层:抽屉 / 底部弹层 / 预览 / toast ---------------- */
  584. const layerRoot = $('#layers');
  585. function layerShell(inner, cls, onClose) {
  586. const wrap = document.createElement('div');
  587. wrap.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:var(--z-modal)';
  588. wrap.innerHTML = `<div class="sheet-mask" style="pointer-events:auto"></div>${inner}`;
  589. const mask = wrap.querySelector('.sheet-mask');
  590. mask.onclick = () => { closeLayer(wrap); onClose && onClose(); };
  591. wrap.querySelectorAll('.drawer,.sheet,.sheet-body-wrap').forEach(n => (n.style.pointerEvents = 'auto'));
  592. layerRoot.appendChild(wrap);
  593. requestAnimationFrame(() => { mask.classList.add('show'); wrap.querySelector('.drawer,.sheet')?.classList.add('show'); });
  594. return wrap;
  595. }
  596. function closeLayer(wrap) {
  597. const mask = wrap.querySelector('.sheet-mask');
  598. const panel = wrap.querySelector('.drawer,.sheet');
  599. mask.classList.remove('show');
  600. if (panel) panel.classList.remove('show');
  601. setTimeout(() => wrap.remove(), 260);
  602. }
  603. function toast(msg) {
  604. const t = document.createElement('div');
  605. t.className = 'toast';
  606. t.textContent = msg;
  607. $('#app').appendChild(t);
  608. setTimeout(() => t.remove(), 1800);
  609. }
  610. /* 04 抽屉:新建对话 + 一级入口 + 会话树(工作区 / 日常对话 / 已归档) */
  611. function treeHtml(sessions) {
  612. return sessions.map(s => `<button class="tree-item" data-act="go" data-to="/chat/${s.id}">
  613. ${agentAvatar(D.agentById(s.agentId), 'sm')}<span class="t">${esc(s.title)}</span>${s.unread ? `<span class="badge danger">${s.unread}</span>` : ''}
  614. </button>`).join('') || `<p style="margin:var(--sp-1) var(--sp-2);font-size:var(--fs-12);color:var(--muted)">暂无会话</p>`;
  615. }
  616. function openDrawer() {
  617. const groups = [
  618. ['工作区', 'workspace'],
  619. ['日常对话', 'daily'],
  620. ['已归档', 'archived'],
  621. ];
  622. const tree = groups.map(([label, key]) => {
  623. const items = D.sessions.filter(s => s.group === key);
  624. const open = !!state.expanded.tree[key];
  625. return `<section>
  626. <button class="tree-toggle" data-act="tree-toggle" data-key="${key}" aria-expanded="${open}" aria-controls="tree-${key}">
  627. <span class="arrow" style="display:inline-flex">${icon('chevron-down', 'icon-sm')}</span>
  628. ${label}<span class="n">${items.length}</span>
  629. </button>
  630. <div id="tree-${key}" ${open ? '' : 'hidden'}>${treeHtml(items)}</div>
  631. </section>`;
  632. }).join('');
  633. return layerShell(`
  634. <aside class="drawer" role="dialog" aria-label="导航" aria-modal="true">
  635. <div class="drawer-head">
  636. <button class="entry primary" data-act="new-chat">
  637. <span class="lead">${icon('edit', 'icon-sm')}</span><b>新建对话</b><span class="kbd">选择数字生命</span>
  638. </button>
  639. </div>
  640. <nav class="drawer-body" aria-label="功能">
  641. ${[['messages', '会话', 'message'], ['projects', '项目', 'folder'], ['tasks', '任务', 'check-square'],
  642. ['events', '事件', 'activity'], ['deliverables', '成果', 'package'], ['workbench', '工作台', 'grid'],
  643. ['contacts', '通讯录', 'users'], ['mine', '我的', 'user']].map(([k, t, ic]) => `
  644. <button class="entry" data-act="go" data-to="/${k}">
  645. <span class="lead">${icon(ic, 'icon-sm')}</span><b>${t}</b>${icon('chevron-right', 'icon-sm chev')}
  646. </button>`).join('')}
  647. <div style="border-top:1px solid var(--edge);margin:var(--sp-3) 0 var(--sp-1)"></div>
  648. <div style="font-size:var(--fs-12);color:var(--muted);padding:var(--sp-1) var(--sp-2)">对话</div>
  649. ${tree}
  650. </nav>
  651. </aside>`);
  652. }
  653. /* 分组折叠:只切换 aria-expanded + hidden,不重建抽屉(避免整块重绘导致的跳动) */
  654. function toggleTree(btn) {
  655. const key = btn.dataset.key;
  656. const open = !state.expanded.tree[key];
  657. state.expanded.tree[key] = open;
  658. btn.setAttribute('aria-expanded', String(open));
  659. const panel = document.getElementById('tree-' + key);
  660. if (panel) panel.hidden = !open;
  661. }
  662. /* 新建对话:底部弹层选择数字生命(对标桌面「选择容器新建对话」) */
  663. function openNewChat() {
  664. closeAllLayers();
  665. const usable = D.agents.filter(a => a.status === 'running' || a.status === 'idle');
  666. const draft = D.sessions.filter(s => s.group === 'daily').slice(0, 3);
  667. return layerShell(`
  668. <section class="sheet" role="dialog" aria-label="新建对话">
  669. <div class="grabber"></div>
  670. <div class="shead"><h2>新建对话</h2><button class="fab ghost" data-act="close" aria-label="关闭">${icon('x')}</button></div>
  671. <div class="sbody">
  672. <div class="section-title" style="padding:0"><span>选择数字生命 · ${usable.length} 个可用</span></div>
  673. <div class="list">${D.agents.map(a => {
  674. const on = a.status === 'running' || a.status === 'idle';
  675. return `<button class="row" ${on ? 'data-act="toast-static"' : 'disabled style="opacity:.55;cursor:not-allowed"'}>
  676. ${agentAvatar(a, 'sm')}<span class="grow"><span class="t1">${esc(a.name)}</span><span class="t2">${esc(a.specialty)}</span></span>${statusBadge(a)}
  677. </button>`;
  678. }).join('')}</div>
  679. <div class="section-title" style="margin-top:var(--sp-5);padding:0"><span>或继续已有会话</span></div>
  680. <div class="list">${draft.map(s => `<button class="row" data-act="go" data-to="/chat/${s.id}">
  681. ${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>
  682. </button>`).join('')}</div>
  683. <p style="margin:var(--sp-4) 0 0;font-size:var(--fs-11);color:var(--muted);text-align:center">离线 / 异常的数字生命暂不可新建对话</p>
  684. </div>
  685. </section>`);
  686. }
  687. /* 07 会话内成果抽屉:任务 / 文件 / 成果 / 状态 */
  688. function openChatSheet(sessionId) {
  689. const s = D.sessionById(sessionId) || D.sessions[0];
  690. const a = D.agentById(s.agentId);
  691. const tasks = D.tasks.filter(t => t.agentId === a.id);
  692. const dels = D.deliverables.filter(d => d.agentId === a.id);
  693. const tab = (k, t) => `<button class="chip" data-tab="${k}">${t}</button>`;
  694. const wrap = layerShell(`
  695. <section class="sheet" role="dialog" aria-label="会话面板">
  696. <div class="grabber"></div>
  697. <div class="shead"><h2>${esc(s.title)}</h2><button class="fab ghost" data-act="close" aria-label="关闭">${icon('x')}</button></div>
  698. <div class="chips" style="padding:0 var(--mob-gutter) var(--sp-3)">${tab('task', '任务')}${tab('file', '文件')}${tab('deliver', '成果')}${tab('state', '状态')}</div>
  699. <div class="sbody" data-sheet-body></div>
  700. </section>`);
  701. const body = wrap.querySelector('[data-sheet-body]');
  702. const panels = {
  703. 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('暂无任务'),
  704. 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('暂无文件'),
  705. 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('暂无成果'),
  706. 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>`,
  707. };
  708. const setTab = k => {
  709. body.innerHTML = panels[k]();
  710. wrap.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('on', b.dataset.tab === k));
  711. };
  712. wrap.querySelectorAll('[data-tab]').forEach(b => (b.onclick = () => setTab(b.dataset.tab)));
  713. setTab('task');
  714. return wrap;
  715. }
  716. /* 通道 bottom-sheet */
  717. function openChannels(agentId) {
  718. const a = D.agentById(agentId) || D.agents[0];
  719. return layerShell(`
  720. <section class="sheet" role="dialog" aria-label="通知通道">
  721. <div class="grabber"></div>
  722. <div class="shead"><h2>${esc(a.name)} 的通知通道</h2><button class="fab ghost" data-act="close" aria-label="关闭">${icon('x')}</button></div>
  723. <div class="sbody">
  724. ${['钉钉', '飞书', '企业微信', '邮件'].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('')}
  725. </div>
  726. </section>`);
  727. }
  728. /* 08 文件预览层 */
  729. function openPreview(deliverableId) {
  730. const d = D.deliverableById(deliverableId) || D.deliverables[0];
  731. const a = d.agentId ? D.agentById(d.agentId) : null;
  732. // 兼容消息里的临时文件对象(只有文件名/元信息)
  733. const filename = d.filename || d.name;
  734. const type = (d.fileType || filename.split('.').pop() || '').toLowerCase();
  735. const sub = [d.title || d.name, d.version, d.size, a ? a.name : ''].filter(Boolean).map(esc).join(' · ');
  736. const wrap = document.createElement('div');
  737. wrap.innerHTML = `<div class="preview" role="dialog" aria-label="文件预览">
  738. <div class="phead">
  739. <button class="fab" data-act="close" aria-label="返回">${icon('chevron-left')}</button>
  740. <div class="name"><b>${esc(filename)}</b><span>${sub || '示例文件'}</span></div>
  741. <button class="fab" data-act="toast-static" aria-label="分享">${icon('share')}</button>
  742. </div>
  743. <div class="pbody">
  744. ${type === 'xlsx' ? `<table class="table-mock"><thead><tr><th>事业部</th><th>Q2 营收</th><th>毛利率</th></tr></thead><tbody>
  745. <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>`
  746. : 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>`
  747. : `<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>`}
  748. </div>
  749. </div>`;
  750. layerRoot.appendChild(wrap.firstElementChild);
  751. }
  752. /* ---------------- 事件委托 ---------------- */
  753. document.addEventListener('click', ev => {
  754. const el = ev.target.closest('[data-act]');
  755. if (!el) return;
  756. const act = el.dataset.act;
  757. switch (act) {
  758. case 'tab': go('/' + el.dataset.to); break;
  759. case 'go': go(el.dataset.to); closeAllLayers(); break;
  760. case 'back':
  761. if (history.length > 1) history.back();
  762. else go('/' + (TAB_PAGES.includes(parseHash().page) ? 'messages' : DEFAULT_TAB[parseHash().page] || 'messages'));
  763. break;
  764. case 'drawer': openDrawer(); break;
  765. case 'tree-toggle': toggleTree(el); break;
  766. case 'new-chat': openNewChat(); break;
  767. case 'expand-person': {
  768. const id = el.dataset.id;
  769. state.expanded.people[id] = !state.expanded.people[id];
  770. render();
  771. break;
  772. }
  773. // 与 core/agent-route.service 一致:可用(running/idle)且有活跃会话 → 进会话;否则进详情
  774. case 'agent-route': {
  775. const a = D.agentById(el.dataset.id);
  776. const s = D.sessions.find(x => x.agentId === a.id && x.group !== 'archived');
  777. const usable = a.status === 'running' || a.status === 'idle';
  778. closeAllLayers();
  779. go(usable && s ? `/chat/${s.id}` : `/agent/${a.id}`);
  780. break;
  781. }
  782. case 'filter': state.filters[el.dataset.scope] = el.dataset.key; render(); break;
  783. case 'sheet-chat': {
  784. const r = parseHash();
  785. closeAllLayers();
  786. openChatSheet(r.page === 'chat' ? r.id : null);
  787. break;
  788. }
  789. case 'sheet-channels': closeAllLayers(); openChannels(el.dataset.id); break;
  790. case 'preview': openPreview(el.dataset.id); break;
  791. case 'preview-dummy': openPreview({ filename: el.dataset.name, title: el.dataset.name, version: el.dataset.meta, size: '', agentId: 'a-orbit' }); break;
  792. case 'palette': setPalette(el.dataset.key); break;
  793. case 'toggle-dark': setDark(!state.dark); closeAllLayers(); break;
  794. case 'switch-static': toastStatic('演示版:设置项不保存'); break;
  795. case 'toast-static': toastStatic(); break;
  796. case 'login-next': loginNext(el.dataset.step); break;
  797. case 'login-submit': doLogin(); break;
  798. case 'login-agree': state.login.agreed = !state.login.agreed; render(); break;
  799. 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; }
  800. case 'logout': confirmLogout(); break;
  801. case 'logout-confirm': logout(); break;
  802. case 'close': {
  803. const preview = el.closest('.preview');
  804. if (preview) { preview.remove(); break; }
  805. const wrap = el.closest('#layers > div');
  806. if (wrap) { closeLayer(wrap); break; }
  807. // 没有关闭按钮的浮层(退出确认):点取消直接移除
  808. if (el.closest('.sheet-body-wrap')) closeAllLayers();
  809. break;
  810. }
  811. }
  812. });
  813. // input 事件:搜索与输入坞
  814. document.addEventListener('input', ev => {
  815. const el = ev.target;
  816. if (el.dataset.act === 'search-messages') {
  817. state.search.messages = el.value;
  818. const pos = el.selectionStart;
  819. render();
  820. const next = $('[data-act="search-messages"]');
  821. if (next) { next.focus(); next.setSelectionRange(pos, pos); }
  822. }
  823. if (el.dataset.act === 'login-phone') {
  824. state.login.phone = el.value.replace(/\D/g, '').slice(0, 11);
  825. const pos = state.login.phone.length;
  826. render();
  827. const next = $('[data-act="login-phone"]');
  828. if (next) { next.focus(); next.setSelectionRange(pos, pos); }
  829. return;
  830. }
  831. if (el.dataset.act === 'login-code') {
  832. state.login.code = el.value.replace(/\D/g, '').slice(0, 6);
  833. const pos = state.login.code.length;
  834. render();
  835. const next = $('[data-act="login-code"]');
  836. if (next) { next.focus(); next.setSelectionRange(pos, pos); }
  837. return;
  838. }
  839. if (el.dataset.act === 'login-token') {
  840. state.login.token = el.value;
  841. const btn = $('[data-act="login-submit"]');
  842. if (btn) btn.disabled = !el.value.trim();
  843. return;
  844. }
  845. if (el.dataset.act === 'dock-input') {
  846. const send = el.closest('.dock').querySelector('.send');
  847. send.classList.toggle('ready', !!el.value.trim());
  848. el.style.height = 'auto';
  849. el.style.height = Math.min(el.scrollHeight, 120) + 'px';
  850. }
  851. });
  852. function confirmLogout() {
  853. const wrap = layerShell(`
  854. <div style="position:absolute;left:0;right:0;bottom:0;pointer-events:auto" class="sheet-body-wrap">
  855. <div class="sheet" style="position:relative;transform:none">
  856. <div class="shead" style="padding-top:var(--sp-4)"><h2>退出登录</h2></div>
  857. <p style="margin:0 var(--mob-gutter) var(--sp-4);color:var(--muted);font-size:var(--fs-13)">退出后需要重新登录才能继续使用。演示版不会真的退出。</p>
  858. <div style="padding:0 var(--mob-gutter) var(--sp-6);display:flex;flex-direction:column;gap:var(--sp-2)">
  859. <button class="btn block" style="color:var(--danger);border-color:var(--danger)" data-act="toast-static">确认退出</button>
  860. <button class="btn block" data-act="close">取消</button>
  861. </div>
  862. </div>
  863. </div>`);
  864. wrap.querySelectorAll('.sheet').forEach(s => s.classList.add('show'));
  865. }
  866. function toastStatic(msg) { toast(msg || '演示版:数据为静态示例'); }
  867. function closeAllLayers() {
  868. layerRoot.querySelectorAll(':scope > div').forEach(w => closeLayer(w));
  869. layerRoot.querySelectorAll(':scope > .preview').forEach(p => p.remove());
  870. }
  871. const DEFAULT_TAB = { chat: 'messages', agent: 'contacts', tasks: 'workbench', projects: 'messages', events: 'messages', deliverables: 'workbench' };
  872. /* ---------------- 启动 ---------------- */
  873. window.addEventListener('hashchange', render);
  874. // 入口:首次进入落到新建对话引导页
  875. if (!location.hash) location.replace(state.authed ? '#/messages' : '#/login');
  876. applyTheme();
  877. render();
  878. })();