docs: add workspace collaboration design and client demos

Document shared workspaces, realtime messaging, member handling, and source permissions. Include local interaction demos and verification scripts, and update development startup for Electron Node mode. Production collaboration services remain unimplemented.
This commit is contained in:
lofyer
2026-09-28 17:04:34 +08:00
parent 440d878d76
commit 599a0a6645
19 changed files with 880 additions and 22 deletions
+40
View File
@@ -0,0 +1,40 @@
# GoodBuddy 客户端共享演示
- `workspace.html`:组织内多工作空间协作,支持创建空间、选择成员、`@` 定向消息、上下文预览、分身草稿和本人接手。建议从此页面开始体验。
- `index.html`:访客侧共享空间,包括知识库阅读、数字分身示例对话和授权来源说明。
- `settings.html`:客户端共享设置,包括知识库选择、分身身份、提示词、监督回顾授权及原文可见性。
- 设置页底部提供「组织工作空间 · 我的处理偏好」,按空间独立设置林知远的接收方式。空间偏好使用独立保存按钮,与个人访客分身开关分别生效。
- `demo.css`、`demo.js`:两页共用的样式、示例资料与交互逻辑,无外部资源依赖。
建议通过仓库开发服务访问 `/docs/demo/client/index.html` 和 `/docs/demo/client/settings.html`。也可直接打开 HTML;浏览器对 `file:` 页面的本地存储行为可能不同,两页联动以同源 HTTP 访问为准。
配置保存到当前浏览器的 `goodbuddy-sharing-demo-v1` 本地存储项。恢复示例后需点击保存;未保存的更改离开页面时会提示。真实 GoodBuddy 设置、服务端授权、身份验证、资料发布、模型调用均未接入。对话不执行自定义提示词,使用明确标注的固定示例回答。
## 演示流程
### 组织协作
1. 打开 `workspace.html`。当前模拟林知远已加入「明日工作室」,可切换「产品设计」和「AI 应用研究」,或创建多个新空间并选定成员。
2. 输入 `@` 或点击「选择成员」,明确接收人。展开上下文选择,预览讨论摘要、共享设置草稿后勾选发送。这里只模拟单接收人消息。
3. 在产品设计空间向张明发消息,默认由分身给出固定示例回复;向陈悦发消息,默认生成待本人确认的草稿。
4. 顶部切换模拟成员到陈悦,查看并编辑草稿,再确认发送。其他成员只能看到待确认状态和确认后的回复。
5. 切换到张明,可以接手已有分身回复的线程,补充本人回复,原分身回复保留。勾选「需要本人确认」发送的消息不自动发布分身回复。
6. 调整当前成员在当前空间的接收方式,或在设置页保存林知远各空间的偏好,后续消息使用新偏好。新空间默认本人处理。
空间消息和偏好保存在 `goodbuddy-workspace-demo-v1`。空间消息互相独立,上下文保存发送时的内容快照。角色切换、组织成员关系、加入空间均为本地模拟,不构成真实认证或授权;本地存储包含所有模拟角色的数据。线程对本空间成员可见,只有指定接收者显示处理操作。回复是固定演示逻辑,不会执行提示词或调用真实分身。当前只有一个示例组织,没有真实邀请、组织切换或后台任务派发。
### 个人共享
1. 浏览两个共享知识库,切换到数字分身并点击示例问题,查看对应资料来源。
2. 在设置中取消一个知识库并保存,访客侧阅读列表和分身可用来源同步减少。
3. 在数字分身设置中授权选定的监督回顾,保存后询问产品决策;单独开启原文查看,访客侧才出现回顾原文。
4. 暂停数字分身,知识库继续提供阅读;暂停知识共享,分身继续开放,但不能使用已撤销的知识来源。
5. 将访问范围改为仅团队成员,匿名访客页面显示访问限制。
本原型为知识库与分身采用同一共享空间和访问范围,没有模拟跨范围授权。监督回顾只包含一条明确展示的虚构记录。知识库数量及更新时间为示例元数据,正文只展示节选。桌面采用侧栏与双栏布局,窄屏改为单栏。
## 交互验证
启动 `npm run dev:web -- --host 127.0.0.1 --port 5178` 后,运行 `node docs/demo/client/verify.mjs`,验证资料撤销、监督回顾原文可见性、独立暂停和团队访问限制。脚本使用独立的 JSDOM 存储,不修改浏览器中的配置。Vite 注入样式可能产生 JSDOM CSS 解析提示;此脚本验证交互与内容,不替代浏览器视觉检查。
运行 `node docs/demo/client/verify-workspace.mjs` 验证定向消息、上下文快照、草稿可见性、本人确认优先、接手、空间隔离、创建及设置持久化。
File diff suppressed because one or more lines are too long
+76
View File
@@ -0,0 +1,76 @@
/* global document, window, localStorage, structuredClone, clearTimeout, setTimeout */
const key = 'goodbuddy-sharing-demo-v1';
const defaults = { knowledge: true, avatar: true, name: '林知远', scope: 'link', sources: ['practice', 'product'], useKnowledge: true, useReview: false, showOriginal: false, prompt: '以技术顾问的身份交流,先给出判断,再解释依据。资料不足时明确说明,不替本人承诺报价和交付时间。' };
const sources = [
{ id: 'practice', name: 'AI 应用实践', meta: '12 篇资料 · 今天更新', desc: '从原型到落地,记录真实项目中的设计取舍。', title: '让 AI 应用从一个小场景开始', body: '<p>一个值得先做的场景,通常有清晰的输入、可检查的输出,以及愿意持续使用它的人。</p><h3>先确定怎样算完成</h3><p>在接入模型之前,先写下验收条件。以资料问答为例,除了能回答问题,还应能指出依据,并在资料不足时说明不知道。</p><h3>把反馈留在工作流里</h3><p>先让小范围用户完成一次真实任务,记录哪里需要人工补充,再决定是否扩大自动化范围。</p>' },
{ id: 'product', name: '产品设计笔记', meta: '8 篇资料 · 昨天更新', desc: '围绕权限、知识共享和人机协作的产品思考。', title: '知识共享与数字分身的关系', body: '<p>知识库可以独立共享,也可以作为数字分身的回答来源。同一份资料只需维护一次。</p><h3>内容复用,授权明确</h3><p>关联知识库时,需要确认这些资料能否向分身的访问者提供。团队内资料不会因为关联一个公开分身就自动公开。</p><h3>阅读资料与交流分开</h3><p>访问者可以直接阅读共享知识,也可以向分身提问。监督回顾可用于帮助分身理解个人偏好,但原始记录是否可见需要单独设置。</p>' }
];
let state = load();
let tab = 'knowledge';
let selected = 'practice';
let dirty = false;
let toastTimer;
const settings = document.body.dataset.page === 'settings';
const app = document.querySelector('#app');
function load() { try { return { ...structuredClone(defaults), ...JSON.parse(localStorage.getItem(key) || 'null') }; } catch { return structuredClone(defaults); } }
function esc(value) { return String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]); }
function toast(text) { document.querySelector('.toast')?.remove(); clearTimeout(toastTimer); const node = document.createElement('div'); node.className = 'toast'; node.setAttribute('role', 'status'); node.textContent = text; document.body.append(node); toastTimer = setTimeout(() => node.remove(), 3500); }
function toggle(field, title, desc) { return `<label class="row"><span><strong>${title}</strong><p>${desc}</p></span><input class="switch" type="checkbox" data-field="${field}" ${state[field] ? 'checked' : ''} aria-label="${title}"></label>`; }
function render() {
app.innerHTML = `<div class="shell"><aside class="sidebar"><a class="brand" href="index.html"><span class="logo">g</span>GoodBuddy</a><div><div class="nav-label">${settings ? '偏好设置' : '共享空间'}</div><nav class="nav"><a href="index.html" class="${!settings ? 'active' : ''}">共享主页</a><a href="settings.html" class="${settings ? 'active' : ''}">共享设置</a></nav></div><div class="sidebar-bottom"><div class="account"><span class="mini-avatar">林</span><div>林知远<div class="muted">个人工作空间</div></div></div>交互原型 / CLIENT 01<br>所有资料与回答均为演示数据</div></aside><main><header class="topbar"><div><span class="mobile-brand">GoodBuddy · </span><span class="crumb">${settings ? '设置 / 共享与数字分身' : '个人空间 / 对外共享'}</span></div><a href="${settings ? 'index.html' : 'settings.html'}">${settings ? '查看访客页面 ↗' : '管理共享设置 ↗'}</a></header><div class="content">${settings ? settingsView() : visitorView()}</div></main></div>`;
bind();
const workspaceLink = document.createElement('a');
workspaceLink.href = 'workspace.html';
workspaceLink.textContent = '组织工作空间 ↗';
document.querySelector('.topbar').append(workspaceLink);
if (settings) window.mountWorkspacePreferences?.();
}
function settingsView() {
return `<div class="heading"><div><div class="eyebrow">SHARING & IDENTITY</div><h1>把知识分享出去,让分身参与交流</h1><p>选择愿意分享的内容,设置别人可以怎样与你的数字分身交流。</p></div><span class="badge gray">客户端设置演示</span></div><div class="tabs" role="tablist" aria-label="共享设置类别"><button class="tab ${tab === 'knowledge' ? 'active' : ''}" role="tab" aria-selected="${tab === 'knowledge'}" data-tab="knowledge">共享知识库</button><button class="tab ${tab === 'avatar' ? 'active' : ''}" role="tab" aria-selected="${tab === 'avatar'}" data-tab="avatar">数字分身</button></div><div class="columns"><div>${tab === 'knowledge' ? knowledgeSettings() : avatarSettings()}<section class="panel"><div class="panel-head"><div><h2>访问范围</h2><p>本演示中的知识库与分身使用同一个共享空间和访问范围。</p></div></div><label class="field"><span>谁可以访问</span><select data-field="scope"><option value="link" ${state.scope === 'link' ? 'selected' : ''}>持有链接的人</option><option value="team" ${state.scope === 'team' ? 'selected' : ''}>仅团队成员</option></select></label><p class="muted">团队模式下,匿名访客页面将显示访问限制。真实身份校验尚未接入。</p></section><div class="footer-actions"><span id="save-state" class="save-state" role="status">${dirty ? '有未保存的更改' : '当前为已保存的演示配置'}</span><div><button id="reset">恢复示例</button> <button class="primary" id="save">保存设置</button></div></div></div><aside class="aside"><section class="panel"><div class="eyebrow">共享关系</div><div class="flow"><div><strong>01 / 选择资料</strong><p>维护一份知识内容,选择对外共享的知识库。</p></div><div><strong>02 / 关联分身</strong><p>分身复用已授权的共享知识,再叠加个人设定。</p></div><div><strong>03 / 独立控制入口</strong><p>暂停分身后,知识库仍可共享。撤销知识共享后,分身也停止使用这些资料。</p></div></div></section><section class="panel"><h3>当前配置</h3><div class="summary-line"><span>共享知识库</span><strong>${state.knowledge ? state.sources.length + ' 个' : '已暂停'}</strong></div><div class="summary-line"><span>数字分身</span><strong>${state.avatar ? '已开启' : '已暂停'}</strong></div><div class="summary-line"><span>监督回顾</span><strong>${state.useReview ? '已选 1 条' : '未授权'}</strong></div><div class="notice">保存仅写入本浏览器,不会发布真实资料或修改 GoodBuddy 设置。</div></section></aside></div>`;
}
function knowledgeSettings() {
return `<section class="panel"><h2>共享知识库</h2>${toggle('knowledge', '开启知识共享', '允许访问者阅读选中的示例资料,供数字分身复用。')}</section><section class="panel"><div class="panel-head"><div><h2>选择共享内容</h2><p>仅选中的知识库对访问者开放。</p></div><span class="badge gray">本地知识库</span></div>${sources.map(s => `<label class="row check"><input type="checkbox" data-source="${s.id}" ${state.sources.includes(s.id) ? 'checked' : ''}><span><strong>${s.name}</strong><small>${s.meta}</small><small>${s.desc}</small></span></label>`).join('')}<div class="row"><span><strong>客户项目档案</strong><p>私人资料 · 未纳入本次共享</p></span><span class="badge gray">保持私有</span></div><div class="notice">取消选择或暂停知识共享后,分身将同步失去这些知识来源。数字分身开关不受影响。</div></section>`;
}
function avatarSettings() {
return `<section class="panel"><h2>数字分身</h2>${toggle('avatar', '开启我的数字分身', '为共享空间增加对话入口,使用你授权的资料与个人设定。')}</section><section class="panel"><h2>身份与表达</h2><p style="margin-bottom:18px">访问者会明确看到这是 AI 分身。</p><label class="field"><span>显示名称</span><input type="text" maxlength="30" data-field="name" value="${esc(state.name)}"></label><label class="field"><span>自定义提示词</span><textarea data-field="prompt" maxlength="2000">${esc(state.prompt)}</textarea></label></section><section class="panel"><h2>回答可以参考什么</h2>${toggle('useKnowledge', '关联共享知识库', `复用已授权的知识库。当前${state.knowledge ? '选中 ' + state.sources.length + ' 个' : '知识共享已暂停,暂不可用'}。`)}${toggle('useReview', '共享选定的监督回顾', '仅授权下方这一条示例回顾,不包含全部信息流或后续新记录。')}<details ${state.useReview ? 'open' : ''}><summary>查看待授权的回顾内容</summary><div class="record">示例回顾 · 09 月 24 日<br>讨论产品方案时,倾向于先确定一个可验证的小场景,再扩大范围;关注权限边界和用户能否理解操作结果。</div></details>${toggle('showOriginal', '允许访客查看回顾原文', '独立于回答参考权限;需先开启上方监督回顾授权。')}<div class="notice">默认不共享监督回顾。授权用于回答,并不自动允许访问者阅读原始记录。</div></section>`;
}
function visitorView() {
return `<div class="heading"><div class="profile"><div class="avatar">${esc(state.name.slice(0, 1) || '林')}</div><div><div class="eyebrow">PERSONAL KNOWLEDGE SPACE</div><h1>${esc(state.name || '我的')}的共享空间</h1><p>分享 AI 应用实践与产品思考,也欢迎和我的数字分身聊聊。</p></div></div><span class="badge gray">访客视角 · 演示</span></div>${state.scope === 'team' ? '<section class="panel empty"><h2>这个空间仅对团队成员开放</h2><p>当前模拟的是匿名访问者。请在共享设置中切换为「持有链接的人」,体验公开访问流程。</p><a href="settings.html">前往共享设置 →</a></section>' : `<div class="tabs" role="tablist" aria-label="共享内容"><button role="tab" aria-selected="${tab === 'knowledge'}" class="tab ${tab === 'knowledge' ? 'active' : ''}" data-tab="knowledge">知识库 <span class="muted">${state.knowledge ? state.sources.length : 0}</span></button><button role="tab" aria-selected="${tab === 'avatar'}" class="tab ${tab === 'avatar' ? 'active' : ''}" data-tab="avatar">与分身交流</button></div>${tab === 'knowledge' ? knowledgeVisitor() : avatarVisitor()}`}<p class="muted" style="margin-top:22px">交互演示:配置仅保存在当前浏览器;对话使用固定示例逻辑,未连接模型、知识检索或真实共享服务。</p>`;
}
function knowledgeVisitor() {
const available = sources.filter(s => state.sources.includes(s.id));
if (!state.knowledge || !available.length) return '<section class="panel empty"><h2>暂无开放的知识库</h2><p>主人已暂停知识共享,或尚未选择资料。数字分身可以独立开启。</p></section>';
const item = available.find(s => s.id === selected) || available[0];
selected = item.id;
return `<div class="split"><section><label class="field"><span>查找共享知识库</span><input id="search" type="search" placeholder="搜索名称或简介"></label><div class="source-list">${available.map(s => `<button class="source ${s.id === selected ? 'active' : ''}" data-select="${s.id}" aria-pressed="${s.id === selected}"><div class="source-icon">KNOWLEDGE / ${s.id === 'practice' ? '01' : '02'}</div><strong>${s.name}</strong><small>${s.desc}<br>${s.meta}</small></button>`).join('')}</div><p id="no-results" hidden>没有匹配的知识库。</p></section><section class="panel reader"><div class="panel-head"><span class="badge">已授权共享</span><span class="muted">资料节选 / 示例</span></div><h2>${item.title}</h2><p>来自 ${item.name} · 林知远</p><article>${item.body}</article><div class="row"><span class="muted">${state.avatar && state.useKnowledge ? '这份资料也用于数字分身回答' : '以共享资料形式提供阅读'}</span>${state.avatar ? '<button data-tab="avatar">向分身提问 ↗</button>' : ''}</div></section></div>`;
}
function avatarVisitor() {
if (!state.avatar) return '<section class="panel empty"><h2>数字分身已暂停</h2><p>暂时无法发起对话。已开放的共享知识库仍可阅读。</p><button data-tab="knowledge">浏览知识库</button></section>';
const available = state.knowledge && state.useKnowledge ? sources.filter(s => state.sources.includes(s.id)) : [];
return `<div class="columns"><section class="panel chat"><div class="panel-head"><div><h2>${esc(state.name || '我的')}的数字分身</h2><p>AI 分身 · 回答不代表本人承诺</p></div><span class="badge">可交流</span></div><div class="suggestions"><button data-ask="如何开始一个 AI 应用项目?">如何开始一个 AI 项目?</button><button data-ask="你通常怎样做产品决策?">你怎样做产品决策?</button></div><div class="messages" id="messages" role="log" aria-live="polite"><div class="message">你好,我可以根据主人授权的共享资料,与你交流 AI 应用和产品设计。你想从哪里开始?<small>固定示例回复,不是实时模型输出</small></div></div><form class="composer" id="chat-form"><input type="text" id="question" maxlength="500" required aria-label="向数字分身提问" placeholder="输入你的问题…"><button class="primary">发送</button></form></section><aside class="aside"><section class="panel"><h3>这次交流的知识来源</h3>${available.length ? available.map(s => `<div class="row"><span>${s.name}</span><span class="badge gray">已关联</span></div>`).join('') : '<p>当前未关联可用的共享知识库。</p>'}<div class="row"><span>监督回顾</span><span class="badge gray">${state.useReview ? '已授权 1 条' : '未共享'}</span></div>${state.useReview && state.showOriginal ? '<details><summary>查看获准公开的回顾原文</summary><div class="record">讨论产品方案时,倾向于先确定一个可验证的小场景,再扩大范围;关注权限边界和用户能否理解操作结果。</div></details>' : ''}<div class="notice">分身只能使用主人明确授权的内容。无法访问未共享的知识库和私人对话。</div></section><section class="panel"><h3>关于这个分身</h3><p>知识库提供资料依据,选定的回顾补充决策习惯,个人提示词设定表达方式。</p><p style="margin-top:12px">此原型仅演示来源开关,不执行自定义提示词。</p></section></aside></div>`;
}
function answer(question) {
const messages = document.querySelector('#messages');
const user = document.createElement('div'); user.className = 'message user'; user.textContent = question; messages.append(user);
const available = state.knowledge && state.useKnowledge ? state.sources : [];
let text = '目前授权的示例资料不足以回答这个问题。你可以询问 AI 项目起步或产品决策;具体承诺需要联系本人确认。';
let citation = '未使用资料 · 演示回复';
if (/决策|习惯/.test(question) && state.useReview) { text = '从这条已授权的回顾来看,主人倾向于先确定一个可验证的小场景,再扩大范围;同时关注权限边界和用户对操作结果的理解。'; citation = '参考:已授权监督回顾 · 09 月 24 日' + (state.showOriginal ? ' · 原文可在右侧查看' : ' · 原文未公开'); }
else if (/AI|项目|开始/i.test(question) && available.includes('practice')) { text = '可以先选一个输入清晰、输出可检查的小场景,写下验收条件,再邀请少量用户完成真实任务。对于资料问答,建议先验证回答是否有出处,以及资料不足时能否明确说明。'; citation = '来源:AI 应用实践 / 让 AI 应用从一个小场景开始'; }
else if (/产品|共享|知识库|分身/.test(question) && available.includes('product')) { text = '知识库和数字分身可以复用同一份资料,但要明确使用权限。知识库独立提供阅读,分身在授权范围内参考资料;监督回顾的回答权限与原文查看权限应分开控制。'; citation = '来源:产品设计笔记 / 知识共享与数字分身的关系'; }
const reply = document.createElement('div'); reply.className = 'message'; reply.textContent = text; const foot = document.createElement('small'); foot.textContent = citation + ' · 固定示例'; reply.append(foot); messages.append(reply); reply.scrollIntoView({ block: 'nearest' });
}
function bind() {
document.querySelectorAll('[data-tab]').forEach(el => el.addEventListener('click', () => { tab = el.dataset.tab; render(); document.querySelector(`.tab[data-tab="${tab}"]`)?.focus(); }));
document.querySelectorAll('[data-field]').forEach(el => el.addEventListener('input', () => { state[el.dataset.field] = el.type === 'checkbox' ? el.checked : el.value; dirty = true; if (el.type === 'checkbox' || el.tagName === 'SELECT') render(); else document.querySelector('#save-state').textContent = '有未保存的更改'; }));
document.querySelectorAll('[data-source]').forEach(el => el.addEventListener('change', () => { state.sources = el.checked ? [...state.sources, el.dataset.source] : state.sources.filter(id => id !== el.dataset.source); dirty = true; render(); }));
document.querySelector('#save')?.addEventListener('click', () => { if (!state.name.trim()) { toast('请填写分身显示名称'); return; } try { localStorage.setItem(key, JSON.stringify(state)); dirty = false; render(); toast('已保存演示配置,可打开访客页面查看'); } catch { toast('浏览器不允许本地存储,设置未保存'); } });
document.querySelector('#reset')?.addEventListener('click', () => { state = structuredClone(defaults); dirty = true; render(); toast('已恢复示例,保存后生效'); });
document.querySelectorAll('[data-select]').forEach(el => el.addEventListener('click', () => { selected = el.dataset.select; render(); }));
document.querySelector('#search')?.addEventListener('input', event => { const q = event.target.value.trim().toLowerCase(); let count = 0; document.querySelectorAll('[data-select]').forEach(el => { el.hidden = !el.textContent.toLowerCase().includes(q); if (!el.hidden) count++; }); document.querySelector('#no-results').hidden = count > 0; });
document.querySelector('#chat-form')?.addEventListener('submit', event => { event.preventDefault(); const input = document.querySelector('#question'); if (input.value.trim()) answer(input.value.trim()); input.value = ''; });
document.querySelectorAll('[data-ask]').forEach(el => el.addEventListener('click', () => answer(el.dataset.ask)));
}
window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
window.addEventListener('storage', event => { if (event.key === key && !dirty) { state = load(); render(); } });
render();
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>共享空间 · GoodBuddy</title><link rel="stylesheet" href="demo.css"></head>
<body data-page="visitor"><div id="app"></div><script src="demo.js"></script></body>
</html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>共享设置 · GoodBuddy</title><link rel="stylesheet" href="demo.css"></head>
<body data-page="settings"><div id="app"></div><link rel="stylesheet" href="workspace.css"><script src="workspace.js"></script><script src="demo.js"></script></body>
</html>
+67
View File
@@ -0,0 +1,67 @@
/* global console, structuredClone */
import { JSDOM } from 'jsdom';
import assert from 'node:assert/strict';
const key = 'goodbuddy-workspace-demo-v1';
const windows = [];
async function open(page, saved) {
const dom = await JSDOM.fromURL('http://127.0.0.1:5178/docs/demo/client/' + page, {
resources: 'usable', runScripts: 'dangerously',
beforeParse(w) { w.structuredClone = structuredClone; if (saved) w.localStorage.setItem(key, saved); },
});
windows.push(dom.window);
await new Promise(resolve => dom.window.addEventListener('load', resolve));
return dom.window;
}
try {
const w = await open('workspace.html');
const d = w.document;
const input = (selector, value, event = 'input') => { const el = d.querySelector(selector); el.value = value; el.dispatchEvent(new w.Event(event, { bubbles: true })); };
const submit = selector => d.querySelector(selector).dispatchEvent(new w.Event('submit', { bubbles: true, cancelable: true }));
const send = (to, human = false) => {
input('#message-text', '@');
assert.equal(d.querySelector('#mention-list').hidden, false);
d.querySelector(`[data-recipient=${to}]`).click();
input('#message-text', '请评估共享方案 <script>test</script>');
d.querySelector('[data-context=doc]').click();
if (human) d.querySelector('#require-human').click();
submit('#send-form');
};
send('zhang');
assert.match(d.querySelector('.thread').textContent, /分身已回复/);
assert.equal(d.querySelector('.thread script'), null);
assert.match(d.querySelector('.thread details').textContent, /原文查看权限/);
send('chen');
assert.equal(d.querySelector('[data-draft]'), null);
input('#actor', 'chen', 'change');
assert.ok(d.querySelector('[data-draft]'));
input('[data-draft]', '已由本人确认,先验证权限流程。');
d.querySelector('[data-confirm]').click();
assert.match(d.querySelectorAll('.thread')[1].textContent, /已由本人确认/);
input('#actor', 'lin', 'change');
send('zhang', true);
assert.match(d.querySelectorAll('.thread')[2].textContent, /待本人处理/);
assert.equal(d.querySelectorAll('.thread')[2].querySelector('.thread-reply'), null);
input('#actor', 'zhang', 'change');
d.querySelectorAll('[data-takeover]')[0].click();
input('[data-reply] textarea', '我已接手,补充本人意见。');
submit('[data-reply]');
assert.match(d.querySelector('.thread').textContent, /本人已回复/);
assert.match(d.querySelector('.thread').textContent, /AI 示例回复/);
d.querySelector('[data-space=research]').click();
assert.equal(d.querySelectorAll('.thread').length, 0);
input('#space-name', '新建交付空间');
submit('#create-form');
assert.match(d.querySelector('h1').textContent, /新建交付空间/);
assert.equal(d.querySelector('#mode').value, 'human');
const saved = w.localStorage.getItem(key);
const settings = await open('settings.html', saved);
const pref = settings.document.querySelector('[data-space-mode=product]');
assert.ok(pref);
pref.value = 'draft';
settings.document.querySelector('#save-preferences').click();
const restored = await open('workspace.html', settings.localStorage.getItem(key));
assert.equal(restored.document.querySelector('#mode').value, 'draft');
assert.equal(restored.document.querySelectorAll('.thread').length, 3);
console.log('PASS: mention routing, context snapshot, private draft, confirmation override, human takeover, workspace isolation, creation, settings persistence');
} finally { windows.forEach(w => w.close()); }
+60
View File
@@ -0,0 +1,60 @@
/* global structuredClone, console */
import { JSDOM } from 'jsdom';
import assert from 'node:assert/strict';
const base = 'http://127.0.0.1:5178/docs/demo/client/';
const key = 'goodbuddy-sharing-demo-v1';
const windows = [];
async function open(page, saved) {
const dom = await JSDOM.fromURL(base + page, {
resources: 'usable', runScripts: 'dangerously',
beforeParse(window) {
window.structuredClone = structuredClone;
window.HTMLElement.prototype.scrollIntoView = function () {};
if (saved) window.localStorage.setItem(key, saved);
},
});
windows.push(dom.window);
await new Promise(resolve => dom.window.addEventListener('load', resolve));
return dom.window;
}
try {
const owner = await open('settings.html');
const d = owner.document;
d.querySelector('[data-source=practice]').click();
d.querySelector('[data-tab=avatar]').click();
d.querySelector('[data-field=useReview]').click();
d.querySelector('#save').click();
const saved = owner.localStorage.getItem(key);
const guest = await open('index.html', saved);
const g = guest.document;
assert.equal(g.querySelectorAll('[data-select]').length, 1);
g.querySelector('[data-tab=avatar]').click();
g.querySelectorAll('[data-ask]')[1].click();
assert.match(g.querySelector('#messages').textContent, /原文未公开/);
assert.equal(g.querySelector('details'), null);
d.querySelector('[data-field=showOriginal]').click();
d.querySelector('#save').click();
const visible = await open('index.html', owner.localStorage.getItem(key));
visible.document.querySelector('[data-tab=avatar]').click();
assert.match(visible.document.querySelector('details').textContent, /回顾原文/);
d.querySelector('[data-field=avatar]').click();
d.querySelector('#save').click();
const paused = await open('index.html', owner.localStorage.getItem(key));
assert.equal(paused.document.querySelectorAll('[data-select]').length, 1);
paused.document.querySelector('[data-tab=avatar]').click();
assert.match(paused.document.body.textContent, /数字分身已暂停/);
const config = JSON.parse(saved);
config.knowledge = false;
const revoked = await open('index.html', JSON.stringify(config));
revoked.document.querySelector('[data-tab=avatar]').click();
revoked.document.querySelector('[data-ask]').click();
assert.match(revoked.document.querySelector('#messages').textContent, /资料不足/);
config.scope = 'team';
const team = await open('index.html', JSON.stringify(config));
assert.match(team.document.body.textContent, /仅对团队成员开放/);
assert.equal(team.document.querySelector('#chat-form'), null);
console.log('PASS: source revocation, review privacy, original visibility, independent pause, team gate');
} finally {
windows.forEach(window => window.close());
}
+1
View File
@@ -0,0 +1 @@
.workspace-shell{grid-template-columns:235px minmax(0,1fr)}.workspace-shell main{min-width:0}.space-nav{display:grid;gap:7px}.space-nav button{text-align:left;border:0;background:transparent}.space-nav button.active{background:#dfe9fc;color:#285fc7}.workspace-content{max-width:1250px}.workspace-layout{display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:22px}.workspace-feed{min-width:0}.thread{border:1px solid var(--line);background:white;border-radius:14px;margin-bottom:16px;padding:22px}.thread.selected{border-color:#9bb7ed}.thread-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.thread-body{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.8}.thread-context{font-size:12px;color:var(--muted);padding:12px 0}.thread-reply{border-left:2px solid #b6cbf3;padding:8px 0 8px 16px;margin:12px 0}.thread-reply p{white-space:pre-wrap;color:#40516a;overflow-wrap:anywhere}.thread-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px}.workspace-composer textarea{min-height:110px}.recipient{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:12px 0}.recipient .badge{font-size:12px}.mention-list{border:1px solid var(--line);padding:8px;border-radius:10px;background:#fff;display:grid;gap:4px;margin-bottom:12px}.mention-list button{text-align:left;border:0;display:flex;justify-content:space-between}.context-options{display:grid;gap:12px;margin:16px 0}.workspace-members{display:grid;gap:16px;margin-top:20px}.workspace-member{display:flex;gap:10px;align-items:center}.workspace-member strong{display:block;margin-bottom:4px}.workspace-modal{border:1px solid var(--line);border-radius:16px;padding:26px;width:min(480px,calc(100vw - 32px));color:inherit}.workspace-modal::backdrop{background:#14283d60}.workspace-modal h2{margin-bottom:18px}.workspace-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.workspace-toolbar select{width:auto}.context-preview{padding:12px;background:#f4f7fb;border-radius:8px;font-size:12px;white-space:pre-wrap;line-height:1.8}.workspace-notice{margin-bottom:20px}.workspace-status{min-height:20px;font-size:12px;color:#3569dc}.preference-row{display:grid;grid-template-columns:1fr 180px;gap:12px;align-items:center;padding:14px 0;border-top:1px solid var(--line)}.workspace-mobile-nav{display:none}.workspace-preferences{margin-top:24px}.thread details{margin-bottom:10px}@media(max-width:1050px){.workspace-layout{grid-template-columns:1fr}.workspace-layout>.aside{grid-template-columns:1fr 1fr}}@media(max-width:720px){.workspace-shell{display:block}.workspace-mobile-nav{display:flex;gap:8px;margin-bottom:18px}.workspace-mobile-nav select{min-width:0}.workspace-layout>.aside{grid-template-columns:1fr}.thread{padding:17px}.thread-head{align-items:flex-start}.preference-row{grid-template-columns:1fr}.workspace-toolbar{width:100%}.workspace-toolbar select{flex:1;min-width:0}}
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>组织工作空间 · GoodBuddy</title><link rel="stylesheet" href="demo.css"><link rel="stylesheet" href="workspace.css"></head>
<body data-page="workspace"><div id="app"></div><script src="workspace.js"></script></body>
</html>
+89
View File
@@ -0,0 +1,89 @@
/* global document, window, localStorage, crypto */
(() => {
const key = 'goodbuddy-workspace-demo-v1';
const people = [{ id: 'lin', name: '林知远', job: '产品负责人' }, { id: 'zhang', name: '张明', job: '技术顾问' }, { id: 'chen', name: '陈悦', job: '产品设计师' }];
const modes = { human: '本人处理', auto: '分身先处理', draft: '分身起草,本人确认' };
const initial = () => ({ spaces: [
{ id: 'product', name: '产品设计', members: ['lin', 'zhang', 'chen'], modes: { lin: 'human', zhang: 'auto', chen: 'draft' }, messages: [] },
{ id: 'research', name: 'AI 应用研究', members: ['lin', 'zhang'], modes: { lin: 'human', zhang: 'human' }, messages: [] }
] });
const read = () => { try { return JSON.parse(localStorage.getItem(key)) || initial(); } catch { return initial(); } };
const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const person = id => people.find(p => p.id === id);
let data = read();
let spaceId = data.spaces[0].id;
let actor = 'lin';
let recipient = '';
let status = '';
let draft = '';
let contexts = [];
let requireHuman = false;
const current = () => data.spaces.find(s => s.id === spaceId);
const options = (value) => Object.entries(modes).map(([id, label]) => `<option value="${id}" ${value === id ? 'selected' : ''}>${label}</option>`).join('');
function save(next) {
try { localStorage.setItem(key, JSON.stringify(next)); data = next; return true; }
catch { status = '浏览器存储不可用,更改未保存。'; return false; }
}
const contextItems = () => [
{ id: 'brief', title: `${current().name} · 空间讨论摘要`, text: `空间:${current().name}\n目标:验证共享知识与数字分身协作的最小可用流程。\n待讨论:如何区分分身回复和本人的确认。` },
{ id: 'doc', title: '共享设置草稿 · 空间文档', text: '知识库与分身复用已授权资料。监督回顾的回答使用权限与原文查看权限分开设置。' }
];
function render() {
const space = current();
document.querySelector('#app').innerHTML = `<div class="shell workspace-shell"><aside class="sidebar"><a class="brand" href="workspace.html"><span class="logo">g</span>GoodBuddy</a><div><div class="nav-label">当前组织</div><h3 style="margin-top:12px">明日工作室</h3><span class="badge">已加入 · 3 位成员</span></div><div><div class="panel-head"><span class="nav-label">共享工作空间</span><button data-create aria-label="新建工作空间">+</button></div><nav class="space-nav">${data.spaces.map(s => `<button data-space="${s.id}" class="${s.id === spaceId ? 'active' : ''}"># ${escape(s.name)}</button>`).join('')}</nav></div><nav class="nav"><a href="index.html">个人共享主页 ↗</a><a href="settings.html">共享与分身设置 ↗</a></nav><div class="sidebar-bottom">组织内协作演示<br>消息不会发送给真实成员</div></aside><main><header class="topbar"><span class="crumb">明日工作室 / 共享工作空间</span><div class="workspace-toolbar"><label for="actor">模拟当前成员</label><select id="actor">${space.members.map(id => `<option value="${id}" ${actor === id ? 'selected' : ''}>${person(id).name}</option>`).join('')}</select><a href="settings.html">设置 ↗</a></div></header><div class="content workspace-content"><div class="workspace-mobile-nav"><select id="space-select" aria-label="切换工作空间">${data.spaces.map(s => `<option value="${s.id}" ${s.id === spaceId ? 'selected' : ''}>${escape(s.name)}</option>`).join('')}</select><button data-create>新建空间</button></div><div class="heading"><div><div class="eyebrow">TEAM WORKSPACE</div><h1>${escape(space.name)}</h1><p>把问题交给具体的人,由本人或获准的数字分身接着处理。</p></div><button data-create>+ 新建工作空间</button></div><div class="notice workspace-notice">当前以 ${person(actor).name} 的身份体验。切换成员可演示接收、确认草稿和本人接手;这是本地角色模拟,不是真实登录。</div><div class="workspace-layout"><div class="workspace-feed"><section aria-label="空间消息">${space.messages.length ? space.messages.map(thread).join('') : '<section class="panel empty"><h2>从一个明确的问题开始</h2><p>在下方 @ 一位空间成员,选择要附带的资料。消息和处理结果会留在这里。</p></section>'}</section>${composer()}</div><aside class="aside"><section class="panel"><h3>空间成员 · ${space.members.length}</h3><p>@ 指定真实成员,处理方式由接收者设置。</p><div class="workspace-members">${space.members.map(id => `<div class="workspace-member"><span class="mini-avatar">${person(id).name[0]}</span><div><strong>${person(id).name}${id === actor ? ' · 当前' : ''}</strong><span class="muted">${modes[space.modes[id] || 'human']}</span></div></div>`).join('')}</div></section><section class="panel"><h3>我在此空间的处理方式</h3><label class="field"><span>${person(actor).name} · 接收 @消息时</span><select id="mode">${options(space.modes[actor] || 'human')}</select></label><button id="save-mode">保存处理偏好</button><div class="notice">只影响后续消息。「需要本人确认」会优先进入本人待处理。新空间默认由本人处理。</div></section><section class="panel"><h3>空间知识与上下文</h3><p>本示例包含讨论摘要和共享设置草稿。发送时可预览并选择附带内容。</p><div class="notice">不会自动附带个人对话、私有知识库或监督记录。个人共享入口的公开范围不改变组织空间成员权限。</div><a href="index.html">查看个人共享知识库 ↗</a></section></aside></div><div class="workspace-status" role="status">${escape(status)}</div></div></main></div><dialog class="workspace-modal" id="create-dialog"><form id="create-form"><h2>新建共享工作空间</h2><label class="field"><span>所属组织</span><input type="text" value="明日工作室" disabled></label><label class="field"><span>空间名称</span><input id="space-name" type="text" required maxlength="40" placeholder="例如:客户交付讨论"></label><h3>加入空间的成员</h3><div class="context-options">${people.map(p => `<label class="check"><input type="checkbox" name="member" value="${p.id}" ${p.id === actor ? 'checked disabled' : 'checked'}>${p.name}${p.id === actor ? '(创建者)' : ''}</label>`).join('')}</div><p class="muted">成员立即加入仅为演示;真实邀请和组织管理尚未接入。</p><div class="footer-actions"><button type="button" id="cancel-create">取消</button><button class="primary">创建空间</button></div></form></dialog>`;
bind();
}
function thread(message) {
const mine = actor === message.to;
const labels = { pending: '待本人处理', draft: '待本人确认草稿', replied: '分身已回复', handling: '本人已接手', done: '本人已回复' };
return `<article class="thread" data-message="${message.id}"><div class="thread-head"><strong>${person(message.from).name} <span class="muted">→</span> @${person(message.to).name}</strong><span class="badge ${message.status === 'pending' || message.status === 'draft' ? 'gray' : ''}">${labels[message.status]}</span></div><div class="thread-body">${escape(message.text)}</div><div class="thread-context">${message.requireHuman ? '需要本人确认 · ' : ''}接收方式:${modes[message.mode]} · 附带 ${message.contexts.length} 项上下文</div>${message.contexts.length ? `<details><summary>查看发送时的上下文</summary>${message.contexts.map(c => `<h3>${escape(c.title)}</h3><div class="context-preview">${escape(c.text)}</div>`).join('')}</details>` : ''}${message.replies.map(r => `<div class="thread-reply"><h3>${person(message.to).name}${r.ai ? '的数字分身 · AI 示例回复' : ' · 本人(模拟)'}</h3><p>${escape(r.text)}</p></div>`).join('')}${message.status === 'draft' && mine ? `<div class="thread-reply"><h3>分身草稿 · 仅接收者可见</h3><label class="field"><span>确认前可编辑</span><textarea data-draft="${message.id}" maxlength="2000">${escape(message.draft)}</textarea></label><button data-confirm="${message.id}" class="primary">确认并发送</button></div>` : ''}${mine && message.status !== 'done' ? `<div class="thread-actions">${message.status !== 'handling' ? `<button data-takeover="${message.id}">由我接手</button>` : ''}</div>${message.status === 'handling' ? `<form data-reply="${message.id}"><label class="field"><span>本人回复</span><textarea required maxlength="2000" placeholder="输入你的处理意见"></textarea></label><button class="primary">发送本人回复</button></form>` : ''}` : ''}</article>`;
}
function composer() {
return `<section class="panel workspace-composer"><h2>发起协作</h2><form id="send-form"><div class="recipient"><button type="button" id="mention-toggle">@ 选择成员</button><span id="recipient-label" class="badge gray">${recipient ? '@' + person(recipient).name : '尚未指定接收人'}</span></div><div class="mention-list" id="mention-list" hidden><input type="search" id="member-search" aria-label="搜索空间成员" placeholder="搜索成员姓名">${current().members.map(id => `<button type="button" data-recipient="${id}"><strong>@${person(id).name}</strong><span class="muted">${person(id).job}</span></button>`).join('')}<p id="no-members" hidden>没有匹配的空间成员</p></div><label class="field"><span>消息内容</span><textarea id="message-text" required maxlength="2000" placeholder="输入 @ 选择成员,例如:请评估这个方案的权限设计">${escape(draft)}</textarea></label><details><summary>选择附带上下文 · <span id="context-count">${contexts.length}</span> 项</summary><div class="context-options">${contextItems().map(c => `<label class="check"><input type="checkbox" data-context="${c.id}" ${contexts.includes(c.id) ? 'checked' : ''}><span>${escape(c.title)}<small>${escape(c.text)}</small></span></label>`).join('')}</div></details><label class="check" style="margin-top:18px"><input type="checkbox" id="require-human" ${requireHuman ? 'checked' : ''}><span>需要本人确认<small>此消息直接交给本人,不自动发布分身回复。</small></span></label><div class="footer-actions"><span class="muted">只发送给所选成员,线程对空间成员可见</span><button class="primary">发送消息</button></div></form></section>`;
}
function bind() {
function switchSpace(id) { spaceId = id; if (!current().members.includes(actor)) actor = current().members[0]; recipient = ''; draft = ''; contexts = []; requireHuman = false; status = ''; render(); }
document.querySelectorAll('[data-space]').forEach(el => el.onclick = () => switchSpace(el.dataset.space));
document.querySelector('#space-select').onchange = e => switchSpace(e.target.value);
document.querySelector('#actor').onchange = e => { actor = e.target.value; recipient = ''; draft = ''; contexts = []; requireHuman = false; render(); };
document.querySelectorAll('[data-create]').forEach(el => el.onclick = () => document.querySelector('#create-dialog').showModal());
document.querySelector('#cancel-create').onclick = () => document.querySelector('#create-dialog').close();
document.querySelector('#create-form').onsubmit = e => {
e.preventDefault(); const input = document.querySelector('#space-name'); const name = input.value.trim();
if (!name || data.spaces.some(s => s.name === name)) { input.setCustomValidity('请输入不重复的空间名称'); input.reportValidity(); return; }
const members = [...document.querySelectorAll('[name=member]:checked')].map(el => el.value);
const id = crypto.randomUUID(); const next = read(); next.spaces.push({ id, name, members, modes: Object.fromEntries(members.map(id => [id, 'human'])), messages: [] });
if (save(next)) switchSpace(id); else render();
};
document.querySelector('#space-name').oninput = e => e.target.setCustomValidity('');
document.querySelector('#mention-toggle').onclick = () => { const list = document.querySelector('#mention-list'); list.hidden = !list.hidden; if (!list.hidden) document.querySelector('#member-search').focus(); };
document.querySelector('#member-search').oninput = e => { let count = 0; document.querySelectorAll('[data-recipient]').forEach(el => { el.hidden = !el.textContent.includes(e.target.value.trim()); if (!el.hidden) count++; }); document.querySelector('#no-members').hidden = count > 0; };
document.querySelectorAll('[data-recipient]').forEach(el => el.onclick = () => { recipient = el.dataset.recipient; document.querySelector('#recipient-label').textContent = '@' + person(recipient).name; document.querySelector('#mention-list').hidden = true; const input = document.querySelector('#message-text'); input.value = input.value.replace(/@$/, ''); draft = input.value; input.focus(); });
document.querySelector('#message-text').oninput = e => { draft = e.target.value; if (draft.endsWith('@')) document.querySelector('#mention-list').hidden = false; };
document.querySelectorAll('[data-context]').forEach(el => el.onchange = () => { contexts = [...document.querySelectorAll('[data-context]:checked')].map(el => el.dataset.context); document.querySelector('#context-count').textContent = contexts.length; });
document.querySelector('#require-human').onchange = e => requireHuman = e.target.checked;
document.querySelector('#save-mode').onclick = () => { const next = read(); next.spaces.find(s => s.id === spaceId).modes[actor] = document.querySelector('#mode').value; if (save(next)) status = '处理偏好已保存,仅影响后续消息。'; render(); };
document.querySelector('#send-form').onsubmit = e => {
e.preventDefault(); if (!recipient) { document.querySelector('#mention-list').hidden = false; document.querySelector('#member-search').focus(); return; }
if (!draft.trim()) { document.querySelector('#message-text').focus(); return; }
const next = read(); const space = next.spaces.find(s => s.id === spaceId); const mode = space.modes[recipient] || 'human';
const attached = contextItems().filter(c => contexts.includes(c.id));
const reply = attached.length ? `已收到问题。当前附带 ${attached.length} 项上下文,建议先核对资料授权范围,并明确标注分身回复与本人确认。具体方案仍需本人审阅。` : '已收到问题,但未附带具体资料。请补充需要评估的方案和验收条件,再进行具体判断。';
const human = requireHuman || mode === 'human';
space.messages.push({ id: crypto.randomUUID(), from: actor, to: recipient, text: draft.trim(), contexts: attached, requireHuman, mode, status: human ? 'pending' : mode === 'draft' ? 'draft' : 'replied', draft: !human && mode === 'draft' ? reply : '', replies: !human && mode === 'auto' ? [{ ai: true, text: reply }] : [] });
if (save(next)) { draft = ''; contexts = []; recipient = ''; requireHuman = false; status = '消息已保存在当前演示空间。'; } render();
};
function update(id, change) { const next = read(); const message = next.spaces.find(s => s.id === spaceId).messages.find(m => m.id === id); if (message.to !== actor || message.status === 'done') return; change(message); save(next); render(); }
document.querySelectorAll('[data-takeover]').forEach(el => el.onclick = () => update(el.dataset.takeover, m => { m.status = 'handling'; m.draft = ''; }));
document.querySelectorAll('[data-confirm]').forEach(el => el.onclick = () => { const text = document.querySelector(`[data-draft="${el.dataset.confirm}"]`).value.trim(); if (text) update(el.dataset.confirm, m => { m.status = 'done'; m.replies.push({ ai: false, text }); m.draft = ''; }); });
document.querySelectorAll('[data-reply]').forEach(el => el.onsubmit = e => { e.preventDefault(); const text = el.querySelector('textarea').value.trim(); if (text) update(el.dataset.reply, m => { m.status = 'done'; m.replies.push({ ai: false, text }); }); });
}
window.mountWorkspacePreferences = () => {
data = read();
const section = document.createElement('section'); section.className = 'panel workspace-preferences';
section.innerHTML = `<div class="panel-head"><div><h2>组织工作空间 · 我的处理偏好</h2><p>明日工作室 / 林知远。与个人访客分身入口分别设置,以下偏好独立保存。</p></div><a href="workspace.html">进入协作空间 ↗</a></div>${data.spaces.filter(s => s.members.includes('lin')).map(s => `<label class="preference-row"><span>${escape(s.name)}</span><select data-space-mode="${s.id}">${options(s.modes.lin || 'human')}</select></label>`).join('')}<div class="notice">@ 指向你本人;可授权分身先回复或仅起草。对方要求本人确认时,优先交给你。更改仅影响后续消息。</div><div class="footer-actions"><span id="preferences-status" role="status"></span><button id="save-preferences">保存空间偏好</button></div>`;
document.querySelector('.content').append(section);
section.querySelector('#save-preferences').onclick = () => { const next = read(); section.querySelectorAll('[data-space-mode]').forEach(el => { const s = next.spaces.find(s => s.id === el.dataset.spaceMode); if (s?.members.includes('lin')) s.modes.lin = el.value; }); section.querySelector('#preferences-status').textContent = save(next) ? '空间偏好已保存' : status; };
};
if (document.body.dataset.page === 'workspace') render();
})();