Redesign the shell on Zai tokens: panels, admin zone, artifact convergence, chat motion #24

Merged
weichen merged 6 commits from feature/ui-tokens-v3 into main 2026-10-01 02:00:47 +00:00
18 changed files with 798 additions and 493 deletions
+18
View File
@@ -17,6 +17,7 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^22.20.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^4.3.4",
@@ -1292,6 +1293,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.20.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz",
"integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
@@ -2350,6 +2361,13 @@
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
+1
View File
@@ -19,6 +19,7 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^22.20.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^4.3.4",
+3 -2
View File
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react'
import { auth } from './auth'
import { api } from './api'
import { LoginPage, ChangePasswordGate } from './components/LoginPage'
import { AdminUsersModal, ChangePasswordModal } from './components/AdminUsersModal'
import { ChangePasswordModal } from './components/AdminUsersModal'
import { AdminZone } from './components/AdminZone'
import { Icon } from '@ui'
import { store, useStore } from './store'
import { TaskSidebar } from './components/TaskSidebar'
@@ -148,7 +149,7 @@ export function App() {
{trackerRunId && <AgentTracker />}
{ui.settingsOpen && <SettingsModal />}
{ui.projectSettingsId && <ProjectSettingsModal />}
{ui.adminUsersOpen && <AdminUsersModal onClose={() => store.setUI({ adminUsersOpen: false })} />}
{ui.adminZoneOpen && <AdminZone />}
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
{toast && <div className="toast">{toast}</div>}
+4 -102
View File
@@ -1,13 +1,9 @@
// 管理员用户管理弹层 + 主动改密弹窗(R2 管理员发放制)。
// AdminUsersModal:建号(用户名/显示名/初始密码/管理员位)、重置密码、列表状态。
// ChangePasswordModal:登录者主动改密(与强制改密共用 API)。
import { useEffect, useState } from 'react'
// 管理员用户管理已迁「系统管理」分区(components/adminSections.tsx UsersPanel,
// 重设计方案 阶段②)。本文件只保留登录者主动改密弹窗(与强制改密共用 API)。
import { useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { Icon, Avatar } from '@ui'
import { randomPassword } from '../passwords'
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
import { Icon } from '@ui'
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
@@ -24,100 +20,6 @@ function Shell({ title, onClose, children }: { title: string; onClose: () => voi
)
}
export function AdminUsersModal({ onClose }: { onClose: () => void }) {
const me = useStore(s => s.me)
const [users, setUsers] = useState<AdminUserRow[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [owner, setOwner] = useState('')
const [name, setName] = useState('')
const [pw, setPw] = useState('')
const [asAdmin, setAsAdmin] = useState(false)
const [busy, setBusy] = useState(false)
const [resetFor, setResetFor] = useState<string | null>(null)
const [resetPw, setResetPw] = useState('')
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const create = async () => {
setErr(''); setNotice('')
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
setBusy(true)
try {
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
setOwner(''); setName(''); setPw('')
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const reset = async () => {
if (!resetFor) return
setErr('')
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.resetAdminUserPassword(resetFor, resetPw)
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
setResetFor(null); setResetPw('')
await refresh()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<Shell title={`用户管理${me?.isAdmin ? '' : '(只读)'}`} onClose={onClose}>
{me?.isAdmin && (
<div className="ad-create">
<div className="ad-row">
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
</div>
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码。</p>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{resetFor ? (
<div className="ad-reset">
<span>重置 <b>{resetFor}</b> 的密码:</span>
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
<button className="lg-submit ad-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
<button type="button" className="ad-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
</div>
) : (
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="ad-user-cell"><Avatar name={u.name || u.owner} size="sm" /><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="ad-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
)}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
</Shell>
)
}
/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */
export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
const me = useStore(s => s.me)
+66
View File
@@ -0,0 +1,66 @@
// 系统管理(重设计方案 阶段②):管理员专属的全屏独立视图(带返回,非弹窗)。
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
import { useState } from 'react'
import { store, useStore } from '../store'
import { Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
interface AdminSectionDef {
key: string
label: string
icon: IconName
desc: string
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
render: () => React.ReactNode
}
function ProfilesPanelWrapper() {
const profiles = useStore(s => s.profiles)
return <ProfilesSection profiles={profiles} />
}
const SECTIONS: AdminSectionDef[] = [
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
]
export function AdminZone() {
const me = useStore(s => s.me)
const [sectionKey, setSectionKey] = useState('users')
// 双保险:入口已在菜单层门控(me?.isAdmin),此处再拦一次(纵深防御)。
if (!me?.isAdmin) return null
const section = SECTIONS.find(s => s.key === sectionKey) ?? SECTIONS[0]
return (
<div className="az" role="dialog" aria-label="系统管理">
<div className="az-head">
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}>
<Icon name="chevron-down" size={14} /> 返回工作台
</button>
<span className="az-title">系统管理</span>
<span className="az-sub">仅管理员可见 · 变更即时生效</span>
<span style={{ flex: 1 }} />
<span className="az-tenant">{me.owner}</span>
</div>
<div className="az-body">
<nav className="az-side">
{SECTIONS.map(s => (
<button key={s.key} className={`az-nav-item ${s.key === sectionKey ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
<Icon name={s.icon} size={15} />
<span>{s.label}</span>
</button>
))}
</nav>
<main className="az-main">
<div className="az-section-head">
<h3>{section.label}</h3>
<p>{section.desc}</p>
</div>
{section.render()}
</main>
</div>
</div>
)
}
+41 -47
View File
@@ -25,56 +25,50 @@ export function AgentTracker() {
const focusMsgs = focus === runId ? messagesAll.filter(m => m.runId === runId) : subMessages[focus] ?? []
return (
// 阶段③ 收敛:只保留 OverlayHost 一层壳(此前在其内又嵌 .modal 双层窗框)。
<OverlayHost title="代理追踪" icon="🤖" onClose={() => store.setUI({ trackerRunId: null })}>
<div className="modal">
<div className="modal-header">
🤖 Sub-agent 追踪
<span className="spacer" />
<button className="btn ghost" onClick={() => store.setUI({ trackerRunId: null })}>关闭</button>
</div>
<div className="modal-body">
{!root && <div>加载中…</div>}
{root && (
<div className="subagent-tree">
<div className="subagent-node">
<div className="head">
<span className={`status-dot status-${root.status}`} />
<strong>{root.kind === 'subagent' ? root.agentName || '子代理' : '主任务'}</strong>
<span style={{ color: 'var(--text-faint)' }}>{root.status}{root.phase ? ` · ${root.phase}` : ''}</span>
<span className="spacer" style={{ flex: 1 }} />
<UsageBadge usage={root.usage} />
<div className="subagent-tree">
{!root && <div>加载中…</div>}
{root && (
<>
<div className="subagent-node">
<div className="head">
<span className={`status-dot status-${root.status}`} />
<strong>{root.kind === 'subagent' ? root.agentName || '子代理' : '主任务'}</strong>
<span style={{ color: 'var(--text-faint)' }}>{root.status}{root.phase ? ` · ${root.phase}` : ''}</span>
<span className="spacer" style={{ flex: 1 }} />
<UsageBadge usage={root.usage} />
</div>
{children.length > 0 && (
<div>
{children.map(c => (
<div key={c.id} className="agent-row" style={{ background: focus === c.id ? 'var(--accent-soft)' : undefined, borderRadius: 6 }}
onClick={() => setSelected(c.id)}>
<span className={`status-dot status-${c.status}`} />
<span style={{ flex: 1 }}>🤖 {c.agentName || c.id}</span>
<span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-xs)' }}>{c.status}</span>
</div>
))}
</div>
{children.length > 0 && (
<div>
{children.map(c => (
<div key={c.id} className="agent-row" style={{ background: focus === c.id ? 'var(--accent-soft)' : undefined, borderRadius: 6 }}
onClick={() => setSelected(c.id)}>
<span className={`status-dot status-${c.status}`} />
<span style={{ flex: 1 }}>🤖 {c.agentName || c.id}</span>
<span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{c.status}</span>
</div>
))}
</div>
)}
</div>
<h5 style={{ margin: '12px 0 6px' }}>中间过程({focus === runId ? '主任务' : '子代理'})</h5>
<div style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, padding: 12, maxHeight: '40vh', overflowY: 'auto' }}>
{focusMsgs.length === 0 && <div style={{ color: 'var(--text-faint)', fontSize: 12.5 }}>(无中间过程消息)</div>}
{focusMsgs.map(m => (
<div key={m.id} className="part">
{m.parts.map((p, i) => (
<div key={i} style={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7 }}>
{p.type === 'text' && p.text}
{p.type === 'thinking' && <span style={{ color: 'var(--text-faint)' }}>{p.text}</span>}
{p.type === 'tool_call' && <span className="tool-chip">🛠 {p.toolCall?.name}</span>}
</div>
))}
</div>
))}
</div>
)}
</div>
)}
</div>
<h5 style={{ margin: '12px 0 6px' }}>中间过程({focus === runId ? '主任务' : '子代理'})</h5>
<div style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, padding: 12, maxHeight: '40vh', overflowY: 'auto' }}>
{focusMsgs.length === 0 && <div style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-sm)' }}>(无中间过程消息)</div>}
{focusMsgs.map(m => (
<div key={m.id} className="part">
{m.parts.map((p, i) => (
<div key={i} style={{ whiteSpace: 'pre-wrap', fontSize: 'var(--fs-ui-caption)', lineHeight: 1.7 }}>
{p.type === 'text' && p.text}
{p.type === 'thinking' && <span style={{ color: 'var(--text-faint)' }}>{p.text}</span>}
{p.type === 'tool_call' && <span className="tool-chip">🛠 {p.toolCall?.name}</span>}
</div>
))}
</div>
))}
</div>
</>
)}
</div>
</OverlayHost>
)
+127 -55
View File
@@ -1,5 +1,7 @@
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
import { Icon, Avatar, BrandMark } from '@ui'
// 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(<details> 已禁);工具调用块走
// ToolBlock 契约(运行态扫光、完成边沿收起、展开态记忆);live 行扫光 + 已工作计时。
import { Icon, Avatar, BrandMark, Collapsible, ToolBlock } from '@ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
@@ -21,12 +23,8 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
dangerouslySetInnerHTML={{ __html: marked.parse(part.text ?? '') }} />
case 'thinking':
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
return (
<details className="part-thinking" open={live ? true : undefined}>
<summary>思考过程</summary>
<div className="part-thinking-body">{part.text}</div>
</details>
)
//(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。)
return <ThinkingBlock part={part} idKey={part.text?.slice(0, 24) ?? 't'} defaultOpen={!!live} />
case 'tool_call':
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
case 'tool_result': {
@@ -40,13 +38,14 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
const art = a ? findArtifact(a.artifactId) : undefined
const name = a?.name ?? '工件'
const icon = artifactIcon(name, a?.kind ?? art?.kind)
// 阶段③ 收敛:标题点击 = 右栏预览(唯一落位);动作只剩 全屏预览 + 下载。
return (
<span className="artifact-chip" data-artifact-chip={a?.artifactId}>
<button className="ac-main" title={`打开 ${name}`} onClick={() => { if (art) store.viewArtifact(art) }}>
<button className="ac-main" title={`预览 ${name}(右栏,边聊边看)`} onClick={() => { if (art) store.requestPreview(art) }}>
<Icon name={icon} size={14} /> {name}
</button>
<button className="ac-act" title={`右栏预览 ${name}(边聊边看)`}
onClick={() => { if (art) store.railPreview(art) }}><Icon name="panel-left" size={13} /></button>
<button className="ac-act" title={`全屏预览 ${name}`}
onClick={() => { if (art) store.requestPreview(art, 'full') }}><Icon name="eye" size={13} /></button>
<a className="ac-act" title={`下载 ${name}`} href={api.artifactDownloadUrl(a?.artifactId ?? '')}
download={name} onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
</span>
@@ -93,40 +92,108 @@ function findCard(id: string) {
return cardCache[id]
}
// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看)。
function ProcessCollapse({ parts }: { parts: Part[] }) {
// 思考块(Collapsible 契约):左线弱色,流式默认展开、终态默认收起;展开态记忆。
function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string; defaultOpen?: boolean }) {
return (
<div className="part-thinking">
<Collapsible id={`think:${idKey}`} defaultOpen={defaultOpen} triggerClassName="part-thinking-summary"
trigger={<><span className="part-thinking-chev">▸</span>思考过程</>}>
<div className="part-thinking-body">{part.text}</div>
</Collapsible>
</div>
)
}
// 过程段渲染(配对增强):tool_call 吸收紧随其后的同名 tool_result 组成 ToolBlock
//(运行态扫光、完成边沿收起、展开看结果摘要);其余部件原位 PartView。
function ProcessParts({ parts, live, idKey }: { parts: Part[]; live: boolean; idKey: string }) {
const out: React.ReactNode[] = []
for (let i = 0; i < parts.length; i++) {
const p = parts[i]
if (p.type === 'tool_call') {
const res = parts[i + 1]?.type === 'tool_result' ? parts[i + 1] : null
if (res) i++
const st = res?.toolResult?.status
out.push(
<ToolBlock key={i} toolId={`${idKey}:${i}`} name={`🛠 ${p.toolCall?.name ?? '工具'}`}
running={live && !res} status={st === 'ok' ? 'ok' : st ? 'error' : undefined}
statusDetail={res?.toolResult?.summary}
autoCollapseOnComplete>
{res && <div className="part-text">{res.toolResult?.summary}</div>}
</ToolBlock>)
continue
}
out.push(<div className="part" key={i}><PartView part={p} live={live && p.type === 'text'} /></div>)
}
return <>{out}</>
}
// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看;Collapsible 契约)。
function ProcessCollapse({ parts, idKey }: { parts: Part[]; idKey: string }) {
const toolCount = parts.filter(p => p.type === 'tool_call').length
const hasThinking = parts.some(p => p.type === 'thinking' && p.text)
return (
<details className="process-collapse">
<summary>
<span>过程输出</span>
<span>{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}(点击展开)</span>
</summary>
<div className="body">
{parts.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)}
</div>
</details>
<div className="process-collapse">
<Collapsible id={`process:${idKey}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>过程输出</span>
<span className="process-collapse-meta">
{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}
</span>
</>}>
<div className="body">
<ProcessParts parts={parts} live={false} idKey={idKey} />
</div>
</Collapsible>
</div>
)
}
// 长回复折叠阈值(CF0):终态后超长正文收成摘要行,点击展开全文;live 期间全文可见。
const LONG_REPLY_CHARS = 800
function LongReplyCollapse({ text }: { text: string }) {
function LongReplyCollapse({ text, idKey }: { text: string; idKey: string }) {
// 摘句取压缩空白后的前 100 字;字数按 Markdown 源码长度计。
const excerpt = text.replace(/\s+/g, ' ').trim().slice(0, 100)
return (
<details className="process-collapse long-reply">
<summary>
<span>📄 长回复</span>
<span>{text.length} 字(按 Markdown 源码计)</span>
{excerpt && <span style={{ color: 'var(--text-faint)' }}>{excerpt}…</span>}
</summary>
<div className="body">
<div className="part-text" dangerouslySetInnerHTML={{ __html: marked.parse(text) }} />
</div>
</details>
<div className="process-collapse long-reply">
<Collapsible id={`long:${idKey}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>📄 长回复</span>
<span className="process-collapse-meta">{text.length} 字(按 Markdown 源码计)</span>
{excerpt && <span className="process-collapse-excerpt">{excerpt}…</span>}
</>}>
<div className="body">
<div className="part-text" dangerouslySetInnerHTML={{ __html: marked.parse(text) }} />
</div>
</Collapsible>
</div>
)
}
// 已工作计时(ZCode「已工作 X 分 X 秒」摘要行;1s 步进,仅 live 期间挂载)。
function useElapsed(startedAt: number): string {
const [, tick] = useState(0)
useEffect(() => {
const t = window.setInterval(() => tick(n => n + 1), 1000)
return () => window.clearInterval(t)
}, [])
const s = Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
const m = Math.floor(s / 60)
return m > 0 ? `${m} 分 ${s % 60} 秒` : `${s} 秒`
}
// 流式状态行(独立组件:计时 hook 只在 live 行挂载期间运转)。
function LiveBar({ live }: { live: LiveState }) {
const elapsed = useElapsed(live.startedAt)
return (
<span className="live-bar">
<span className="live-dot" />
<span className="mk-sweep">{live.phase}</span>
<span className="live-elapsed">已工作 {elapsed}</span>
</span>
)
}
@@ -151,16 +218,16 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
<div className="msg-role">
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
<span>助手</span>
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
{live && <LiveBar live={live} />}
</div>
{blocks.map((b, i) => b.kind === 'process'
? (showProcess
? <div className="part-group" key={i}>{b.parts.map((p, j) => <div className="part" key={j}><PartView part={p} live={!!live} /></div>)}</div>
: <ProcessCollapse key={i} parts={b.parts} />)
? <div className="part-group" key={i}><ProcessParts parts={b.parts} live={!!live} idKey={`${msg.id}:${i}`} /></div>
: <ProcessCollapse key={i} parts={b.parts} idKey={`${msg.id}:${i}`} />)
: (() => {
// CF0:非 live 的超长文本部件折叠为摘要行;live 与短文本不受影响。
if (!live && b.part.type === 'text' && (b.part.text?.length ?? 0) > LONG_REPLY_CHARS)
return <LongReplyCollapse key={i} text={b.part.text ?? ''} />
return <LongReplyCollapse key={i} text={b.part.text ?? ''} idKey={`${msg.id}:${i}`} />
return <div className="part" key={i}>
<PartView part={b.part} live={!!(live && i === lastContentIdx && b.part.type === 'text')} />
</div>
@@ -174,22 +241,25 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
function ContextObsView({ obs }: { obs: ContextObs }) {
return (
<details className="process-collapse">
<summary>
<span>🧩 上下文构成</span>
<span>{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
</summary>
<div className="body">
<ul className="ui-list">
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
</ul>
{obs.truncations && obs.truncations.length > 0 && (
<div style={{ color: 'var(--warn)', fontSize: 12.5, marginTop: 6 }}>
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
</div>
)}
</div>
</details>
<div className="process-collapse">
<Collapsible id={`ctxobs:${obs.estChars}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>🧩 上下文构成</span>
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
</>}>
<div className="body">
<ul className="ui-list">
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
</ul>
{obs.truncations && obs.truncations.length > 0 && (
<div style={{ color: 'var(--warn)', fontSize: 'var(--fs-ui-sm)', marginTop: 6 }}>
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
</div>
)}
</div>
</Collapsible>
</div>
)
}
@@ -298,7 +368,7 @@ export function Conversation() {
<div className="chat-body"><div className="empty-state">
<div style={{ fontSize: 40 }}>⌘</div>
<div>选择或创建一个会话开始</div>
<div style={{ fontSize: 12 }}>mock 演示模式可在 设置 → runtime 开启,无需模型密钥</div>
<div style={{ fontSize: 'var(--fs-ui-sm)' }}>mock 演示模式可在 设置 → runtime 开启,无需模型密钥</div>
</div></div>
</div>
)
@@ -365,7 +435,7 @@ export function Conversation() {
{messages.length === 0 && <div className="empty-state">
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
<div>发送消息开始对话</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>
<div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>
</div>}
</div>
@@ -388,7 +458,9 @@ export function Conversation() {
e.target.value = ''
}} />
</label>
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡' : artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡'
: active ? '回合进行中——等当前回合结束(或点「停止」)后再发送'
: artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
{active ? (
+9 -107
View File
@@ -1,12 +1,14 @@
// 设置中心(4.9 → UI 能力平台迁移):@ui SettingsShell + SettingRow 统一行语义,
// 保存按钮进固定 actions 底栏(不随内容滚动);schema 驱动 + 生效时机标注 + 密钥掩码。
// 重设计方案 阶段②(IA):设置瘦身——只留个人偏好(常规);智能体画像/插件管理/
// 插件工作区迁「系统管理」(AdminZone,管理员专属全屏视图)。
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile, SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn, EmptyState } from '@ui'
import { store } from '../store'
import type { SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
type Section = 'settings' | 'profiles' | 'plugins'
type Section = 'settings'
// 分组显示名(项目设置面板共用)。
export const groupNames: Record<string, string> = {
@@ -15,8 +17,7 @@ export const groupNames: Record<string, string> = {
}
export function SettingsModal() {
const profiles = useStore(s => s.profiles)
const [section, setSection] = useState<Section>('settings')
const [section] = useState<Section>('settings')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [, setUser] = useState<Record<string, any>>({})
const [draft, setDraft] = useState<Record<string, any>>({})
@@ -49,17 +50,15 @@ export function SettingsModal() {
return (
<SettingsShell
title="设置" active={section} onSelect={k => setSection(k as Section)}
title="设置" active={section} onSelect={() => undefined}
onClose={() => store.openSettings(false)}
sections={[
{ key: 'settings', label: '常规', icon: 'settings' },
{ key: 'profiles', label: '智能体画像', icon: 'bot', badge: profiles.length || undefined },
{ key: 'plugins', label: '插件', icon: 'puzzle' },
]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
actions={section === 'settings' && schema.length > 0 ? (
<>
{saved && <span style={{ color: 'var(--ok)', fontSize: 12.5, marginRight: 8 }}>✓ 已保存</span>}
{saved && <span style={{ color: 'var(--ok)', fontSize: 'var(--fs-ui-sm)', marginRight: 8 }}>✓ 已保存</span>}
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
</>
) : undefined}
@@ -73,8 +72,6 @@ export function SettingsModal() {
))}
</SettingsGroup>
))}
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
{section === 'plugins' && <PluginsSection />}
</SettingsShell>
)
}
@@ -119,98 +116,3 @@ export function unflatten(flatMap: Record<string, any>): Record<string, any> {
return out
}
// ---- 画像管理(数据化:导入导出 + 编辑)----
function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
const [editing, setEditing] = useState<AgentProfile | null>(null)
const [text, setText] = useState('')
return (
<div>
<div className="doc-toolbar">
<Btn variant="primary" icon="plus" onClick={() => { setEditing(newProfile()); setText('') }}>新建画像</Btn>
<label className="btn ghost" style={{ cursor: 'pointer' }}>
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
const f = e.target.files?.[0]
if (!f) return
try {
const p = JSON.parse(await f.text())
p.id = p.id || 'prof_imported'
p.builtin = false
await api.saveProfile(p)
store.refreshProfiles()
store.toast('画像已导入')
} catch (err) { store.toast(`导入失败:${err}`) }
}} />
</label>
</div>
{profiles.map(p => (
<SettingRow key={p.id}
label={<>{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{p.id}{p.builtin ? ' · 内置' : ''}</span></>}
desc={p.description}>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<Btn variant="ghost" icon="download" onClick={() => {
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
}}>导出</Btn>
{!p.builtin && <Btn variant="ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</Btn>}
{!p.builtin && <Btn variant="ghost" icon="trash" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</Btn>}
</div>
</SettingRow>
))}
{editing && (
<SettingsGroup title={`编辑画像:${editing.name}`}>
<div className="ui-form">
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
<label>允许工具(逗号分隔,* 全部)<input value={editing.tools.allow.join(',')} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, allow: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
</div>
<Btn variant="primary" icon="check" onClick={async () => {
try {
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
await store.refreshProfiles()
setEditing(null)
store.toast('画像已保存')
} catch (e) { store.toast(`保存失败:${e}`) }
}}>保存画像</Btn>
</SettingsGroup>
)}
</div>
)
}
function newProfile(): AgentProfile {
return {
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
} as AgentProfile
}
// ---- 插件管理 ----
function PluginsSection() {
const [plugins, setPlugins] = useState<any[]>([])
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
useEffect(() => { refresh() }, [])
return (
<div>
<div className="doc-toolbar">
<Btn variant="ghost" icon="undo" onClick={async () => {
await api.reloadPlugins()
await refresh()
store.refreshPlugins()
store.toast('插件已热重载')
}}>热重载</Btn>
<span style={{ color: 'var(--text-faint)', fontSize: 12 }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
</div>
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(目录:常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
{plugins.map(p => (
<SettingRow key={p.manifest.id}
label={`${p.manifest.name} v${p.manifest.version}`}
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
<span className="effect-badge">api {p.manifest.apiVersion}</span>
</SettingRow>
))}
</div>
)
}
+7 -13
View File
@@ -1,13 +1,13 @@
// 左栏(方案 A「任务中心」,提案 2026-09-30 R1):单一主创建入口(P6)+
// 组织视图(P8:按项目/时间线)+ 置顶/归档 + 账户区(P7)。
// 工作区快捷图标条保留(D25:⌘ 与左栏共享同一清单)。
// 重设计方案 阶段②(IA 对话优先):工件/代理/插件快捷条撤出普通用户界面——
// 工件走右栏「工件」页签,插件工作区归「系统管理」(管理员专属全屏视图)。
import { useEffect, useRef, useState } from 'react'
import { store, useStore } from '../store'
import { auth } from '../auth'
import { api } from '../api'
import { Icon, BrandMark, workspaceIcon } from '@ui'
import { Icon, BrandMark } from '@ui'
import type { Conversation } from '../contracts'
import { workspaceEntries } from './Workspaces'
import { groupByProject, groupByTimeline, relativeTime, filterTasks, type TaskOrg } from '../tasklist'
const GROUPS_KEY = 'as.taskGroups'
@@ -194,15 +194,6 @@ export function TaskSidebar() {
))}
</div>
{/* 工作区快捷图标条(D25:与 ⌘1–9 同一清单) */}
<div className="tsb-wsstrip">
{workspaceEntries().map(e => (
<button key={e.key} title={`${e.title}(${e.shortcut})`} onClick={() => store.openWorkspace(e.key)}><Icon name={workspaceIcon(e.icon)} size={15} /></button>
))}
<span style={{ flex: 1 }} />
<button title={theme === 'dark' ? '切换浅色' : '切换深色'} onClick={() => store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' })}><Icon name={theme === 'dark' ? 'sun' : 'moon'} size={15} /></button>
</div>
{/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */}
<div className="tsb-footer">
<button className="tsb-user" onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'account' ? null : { kind: 'account', anchor: el }) }}>
@@ -251,7 +242,10 @@ export function TaskSidebar() {
<MenuItem icon="settings" onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
<MenuItem icon="inbox" onClick={() => { store.openInbox(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
{me?.isAdmin && <MenuItem icon="users" onClick={() => { store.setUI({ adminUsersOpen: true }); setMenu(null) }}>用户管理…</MenuItem>}
<MenuItem icon={theme === 'dark' ? 'sun' : 'moon'} onClick={() => { store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' }); setMenu(null) }}>
{theme === 'dark' ? '切换浅色主题' : '切换深色主题'}
</MenuItem>
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{me?.auth && <>
<div className="tsb-menu-div" />
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
+5 -4
View File
@@ -7,11 +7,12 @@ import { Icon } from '@ui'
import { api } from '../api'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(阶段③ 收敛:默认右栏轻预览)。
export const docBridge: DocBridge = {
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
onOpenDoc: id => {
const art = store.getState().artifacts.find(a => a.id === id)
if (art) store.viewArtifact(art)
if (art) store.requestPreview(art)
},
}
@@ -76,7 +77,7 @@ export function Workbench() {
<div className="rail-preview-head">
<span className="rp-name" title={railArtifact.name}>{railArtifact.name}</span>
<span className="spacer" style={{ flex: 1 }} />
<button className="icon-btn" title="深预览(弹窗)" onClick={() => store.viewArtifact(railArtifact)}><Icon name="panel-left" size={14} /></button>
<button className="icon-btn" title="全屏预览(唯一大窗落位)" onClick={() => store.requestPreview(railArtifact, 'full')}><Icon name="eye" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(railArtifact.id)} download={railArtifact.name}><Icon name="download" size={14} /></a>
<button className="icon-btn" title="关闭预览" onClick={() => store.railPreview(null)}><Icon name="x" size={14} /></button>
</div>
@@ -89,7 +90,7 @@ export function Workbench() {
downloadUrl: api.artifactDownloadUrl(railArtifact.id),
}} />
</>
) : <div className="empty-note" style={{ padding: 14 }}>在消息工件卡片或工件面板点「右栏预览」——文件会在这里边聊边看(md/图片/pdf/Office/代码均可)</div>}
) : <div className="empty-note" style={{ padding: 14 }}>点消息里的工件卡片或右侧工件面板——文件会在这里边聊边看(md/图片/pdf/Office/代码均可)</div>}
</div>
) : (
<div className="tab-body">
@@ -157,7 +158,7 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
<div className="agent-row" style={{ paddingLeft: 8 + depth * 18 }} onClick={() => store.openTracker(run.id)}>
<span className={`status-dot status-${run.status}`} />
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{run.status}</span>
<span style={{ fontSize: 'var(--fs-ui-xs)', color: 'var(--text-faint)' }}>{run.status}</span>
</div>
)
}
+14 -10
View File
@@ -1,5 +1,6 @@
// 工作区弹层(提案 T4/D25):工件/代理/插件面板/插件声明工作区统一经 OverlayHost 呈现;
// ⌘1–9 快捷键与左栏快捷区共享同一清单(workspaceEntries)。
// 工作区弹层(提案 T4/D25):工件/代理 经 OverlayHost 呈现。
// 重设计方案 阶段②(IA 对话优先):插件面板/服务端声明工作区属系统能力,
// 只进「系统管理」分区(AdminZone),不占普通用户的 ⌘1–9;清单按角色收窄。
import { useState } from 'react'
import { store, useStore } from '../store'
import type { Run } from '../contracts'
@@ -9,14 +10,17 @@ import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
import { artifactIcon, humanSize } from '../messageparts'
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区随后。 */
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区仅管理员可见。 */
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
const s = store.getState()
const admin = !!s.me?.isAdmin
const out = [
{ key: 'artifacts', title: '工件', icon: '📦', shortcut: '⌘1' },
{ key: 'agents', title: '代理', icon: '🤖', shortcut: '⌘2' },
...s.pluginPanels.map((p, i) => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', shortcut: `⌘${i + 3}` })),
...s.serverWorkspaces.map((w, i) => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', shortcut: w.shortcut ? `⌘${w.shortcut}` : `⌘${i + 3 + s.pluginPanels.length}` })),
...(admin ? [
...s.pluginPanels.map((p, i) => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', shortcut: `⌘${i + 3}` })),
...s.serverWorkspaces.map((w, i) => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', shortcut: w.shortcut ? `⌘${w.shortcut}` : `⌘${i + 3 + s.pluginPanels.length}` })),
] : []),
]
return out.slice(0, 9)
}
@@ -89,15 +93,15 @@ export function ArtifactsPanel() {
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: visible.map(a => (
<div key={a.id} className="artifact-row" data-artifact-row={a.id}>
<span className="ar-icon" onClick={() => store.viewArtifact(a)}><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main" onClick={() => store.viewArtifact(a)} title={a.name}>
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}(右栏,边聊边看)`}>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
<span className="ar-acts">
<button className="icon-btn" title="预览(弹窗)" onClick={() => store.viewArtifact(a)}><Icon name="eye" size={14} /></button>
<button className="icon-btn" title="右栏预览(边聊边看)" onClick={() => store.railPreview(a)}><Icon name="panel-left" size={14} /></button>
<button className="icon-btn" title="全屏预览" onClick={e => { e.stopPropagation(); store.requestPreview(a, 'full') }}><Icon name="eye" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</span>
+233
View File
@@ -0,0 +1,233 @@
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
import { useEffect, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { randomPassword } from '../passwords'
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制)----
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
export function UsersPanel() {
const me = useStore(s => s.me)
const [users, setUsers] = useState<AdminUserRow[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [owner, setOwner] = useState('')
const [name, setName] = useState('')
const [pw, setPw] = useState('')
const [asAdmin, setAsAdmin] = useState(false)
const [busy, setBusy] = useState(false)
const [resetFor, setResetFor] = useState<string | null>(null)
const [resetPw, setResetPw] = useState('')
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const create = async () => {
setErr(''); setNotice('')
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
setBusy(true)
try {
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
setOwner(''); setName(''); setPw('')
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const reset = async () => {
if (!resetFor) return
setErr('')
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.resetAdminUserPassword(resetFor, resetPw)
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
setResetFor(null); setResetPw('')
await refresh()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
{me?.isAdmin && (
<div className="ad-create">
<div className="ad-row">
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
</div>
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码。</p>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{resetFor ? (
<div className="ad-reset">
<span>重置 <b>{resetFor}</b> 的密码:</span>
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
<button className="lg-submit ad-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
<button type="button" className="ad-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
</div>
) : (
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="ad-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
)}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
</div>
)
}
// ---- 智能体画像(原 SettingsModal ProfilesSection:数据化导入导出 + 编辑)----
export function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
const [editing, setEditing] = useState<AgentProfile | null>(null)
const [text, setText] = useState('')
return (
<div>
<div className="doc-toolbar">
<Btn variant="primary" icon="plus" onClick={() => { setEditing(newProfile()); setText('') }}>新建画像</Btn>
<label className="btn ghost" style={{ cursor: 'pointer' }}>
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
const f = e.target.files?.[0]
if (!f) return
try {
const p = JSON.parse(await f.text())
p.id = p.id || 'prof_imported'
p.builtin = false
await api.saveProfile(p)
store.refreshProfiles()
store.toast('画像已导入')
} catch (err) { store.toast(`导入失败:${err}`) }
}} />
</label>
</div>
{profiles.map(p => (
<SettingRow key={p.id}
label={<>{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-xs)' }}>{p.id}{p.builtin ? ' · 内置' : ''}</span></>}
desc={p.description}>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<Btn variant="ghost" icon="download" onClick={() => {
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
}}>导出</Btn>
{!p.builtin && <Btn variant="ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</Btn>}
{!p.builtin && <Btn variant="ghost" icon="trash" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</Btn>}
</div>
</SettingRow>
))}
{editing && (
<SettingsGroup title={`编辑画像:${editing.name}`}>
<div className="ui-form">
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
<label>允许工具(逗号分隔,* 全部)<input value={editing.tools.allow.join(',')} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, allow: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
</div>
<Btn variant="primary" icon="check" onClick={async () => {
try {
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
await store.refreshProfiles()
setEditing(null)
store.toast('画像已保存')
} catch (e) { store.toast(`保存失败:${e}`) }
}}>保存画像</Btn>
</SettingsGroup>
)}
</div>
)
}
function newProfile(): AgentProfile {
return {
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
} as AgentProfile
}
// ---- 插件管理(原 SettingsModal PluginsSection:清单 + 热重载)----
export function PluginsSection() {
const [plugins, setPlugins] = useState<any[]>([])
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
useEffect(() => { refresh() }, [])
return (
<div>
<div className="doc-toolbar">
<Btn variant="ghost" icon="undo" onClick={async () => {
await api.reloadPlugins()
await refresh()
store.refreshPlugins()
store.toast('插件已热重载')
}}>热重载</Btn>
<span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-sm)' }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
</div>
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
{plugins.map(p => (
<SettingRow key={p.manifest.id}
label={`${p.manifest.name} v${p.manifest.version}`}
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
<span className="effect-badge">api {p.manifest.apiVersion}</span>
</SettingRow>
))}
</div>
)
}
// ---- 插件工作区(插件声明面板 + 服务端声明工作区;声明体经 UIRenderer 内联渲染)----
export function PluginWorkspacesPanel() {
const pluginPanels = useStore(s => s.pluginPanels)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
const [selected, setSelected] = useState<string | null>(null)
const entries = [
...pluginPanels.map(p => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', body: p.body ?? [] })),
...serverWorkspaces.map(w => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', body: (w.body ?? []) as any[] })),
]
const current = entries.find(e => e.key === selected) ?? entries[0]
if (entries.length === 0) {
return <EmptyState icon="grid">暂无插件声明工作区<br />(插件可在 manifest 里声明 panels / workspaces)</EmptyState>
}
return (
<div>
<div className="doc-toolbar" style={{ flexWrap: 'wrap' }}>
{entries.map(e => (
<button key={e.key} className={`kind-pill ${current?.key === e.key ? 'on' : ''}`} onClick={() => setSelected(e.key)}>
{e.icon} {e.title}
</button>
))}
<span style={{ flex: 1 }} />
<Btn variant="ghost" icon="undo" onClick={() => store.refreshPlugins()}>刷新</Btn>
</div>
{current && <UIRenderer nodes={current.body} docBridge={docBridge as DocBridge} />}
</div>
)
}
+1
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { App } from './App'
import { wirePreviewCapabilities } from './previewsetup'
import '@ui/tokens.css'
import '@ui/motion.css'
import '@ui/ui.css'
import './styles.css'
+12 -2
View File
@@ -41,7 +41,8 @@ export interface UIState {
taskOrg: 'project' | 'timeline'
showArchived: boolean
lastProjectId: string | null
adminUsersOpen: boolean
/** 系统管理全屏视图(阶段② IA:仅管理员;入口账户菜单,纵深防御在 AdminZone 再拦一次)。 */
adminZoneOpen: boolean
changePwOpen: boolean
projectSettingsId: string | null
}
@@ -91,7 +92,7 @@ const DEFAULT_UI: UIState = {
taskOrg: 'project',
showArchived: false,
lastProjectId: null,
adminUsersOpen: false,
adminZoneOpen: false,
changePwOpen: false,
projectSettingsId: null,
}
@@ -594,6 +595,15 @@ export const store = {
if (!a) { setUI({ railPreviewId: null, rightTab: 'inbox' }); return }
setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false })
},
/**
* 工件预览唯一入口(重设计方案 阶段③ 收敛,Command 语义):一切预览请求只经这里,
* 落位由本方法派生——默认右栏轻预览(不打断对话),hint 'full' 落位全屏深读
* (DocViewer,全项目唯一"大窗"形态)。下载语义同理由各落位只保留一个按钮。
*/
requestPreview(a: Artifact, hint?: 'full') {
if (hint === 'full') { setUI({ viewingArtifact: a }); return }
setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false })
},
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
toast(msg: string) {
+167 -149
View File
@@ -1,43 +1,44 @@
/* 设计令牌与 UI 组件样式已迁入 Agent-System-UI(@ui/tokens.css、@ui/ui.css,main.tsx 引入)。
R4 视觉收敛(2026-09-30):三级表面阶梯 + 发丝边框 + 圆角标尺 + 克制阴影 + 统一线性图标。 */
/* 宿主样式(R5/Zai 复刻,2026-09-30;权威规则见父仓 DESIGN.md):
- 颜色/圆角/字号只消费 @ui/tokens.css 令牌(--fs-ui-* 标尺);
- 动效只消费 @ui/motion.css 的 mk-* 类(本文件不得出现 @keyframes,守护测试拦截);
- 布局面板化:三栏为浮在 --bg-inset 底座上的圆角面板,间隙 4px; */
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
font-family: var(--font); background: var(--bg); color: var(--text);
font-size: 13.5px; line-height: 1.55;
font-family: var(--font); background: var(--bg-inset); color: var(--text);
font-size: var(--fs-ui-base); line-height: 1.6;
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
}
button { font-family: inherit; font-size: inherit; color: inherit; }
::selection { background: var(--accent-soft-2); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
.app { display: flex; height: 100vh; overflow: hidden; }
/* ---- 三栏布局:层次靠背景阶梯(bg-panel 侧栏 / bg 主区),边框只发丝勾边 ---- */
.left-nav { background: var(--bg-panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .15s var(--ease); }
/* ---- 面板化三栏:独立圆角面板 + 4px 间隙(DESIGN.md §6) ---- */
.app { display: flex; height: 100vh; overflow: hidden; gap: 4px; padding: 4px; background: var(--bg-inset); }
.left-nav { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .15s var(--ease); }
.left-nav.collapsed { width: 52px !important; }
.divider { width: 4px; cursor: col-resize; background: transparent; flex-shrink: 0; position: relative; }
.divider::before { content: ''; position: absolute; left: 1.5px; top: 0; bottom: 0; width: 1px; background: transparent; transition: background .12s; }
.divider:hover::before, .divider.dragging::before { background: var(--accent); }
.divider::before { content: ''; position: absolute; left: 1px; top: 12px; bottom: 12px; width: 2px; border-radius: 2px; background: transparent; transition: background .12s; }
.divider:hover::before, .divider.dragging::before { background: var(--border-strong); }
.divider::after { content: ''; position: absolute; inset: 0 -3px; } /* 拖拽热区加宽 */
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); }
.right-panel { background: var(--bg-panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
.right-panel { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
.right-panel.collapsed { width: 44px !important; }
/* ---- 通用控件 ---- */
.btn {
background: var(--accent); color: var(--text-on-accent); border: none;
border-radius: var(--radius); padding: 8px 16px; cursor: pointer;
font-size: 13px; font-weight: 550; white-space: nowrap;
box-shadow: inset 0 1px 0 rgba(255,255,255,.14), 0 1px 2px rgba(8,9,12,.3);
transition: filter .12s var(--ease), transform .12s var(--ease);
background: var(--primary); color: var(--primary-foreground); border: none;
border-radius: var(--radius-sm); padding: 8px 16px; cursor: pointer;
font-size: var(--fs-ui-caption); font-weight: 550; white-space: nowrap;
box-shadow: 0 1px 2px rgba(0, 0, 0, .25);
transition: background .12s var(--ease), transform .12s var(--ease);
}
.btn:hover:not(:disabled) { filter: brightness(1.09); }
.btn:hover:not(:disabled) { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: .42; cursor: not-allowed; box-shadow: none; }
.btn.ghost { background: var(--bg-raised); border: 1px solid var(--border-strong); color: var(--text-dim); box-shadow: none; }
.btn.ghost:hover:not(:disabled) { color: var(--text); border-color: var(--text-faint); filter: none; }
.btn.ghost:hover:not(:disabled) { color: var(--text); border-color: var(--text-faint); background: var(--bg-raised); }
.btn.danger { background: var(--err); }
.icon-btn {
background: none; border: none; color: var(--text-faint);
@@ -48,7 +49,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
.search-input, .cmd-menu input {
background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); padding: 7px 11px; font-family: inherit; font-size: 13px; width: 100%;
color: var(--text); padding: 7px 11px; font-family: inherit; font-size: var(--fs-ui-caption); width: 100%;
transition: border-color .12s, box-shadow .12s;
}
.search-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
@@ -57,24 +58,27 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* min-height:0 须随列向 flex 链下传(.main→.chat→.chat-body):flex 项的自动最小
高度是内容高度,缺失时消息会撑破 .chat——chat-body 永不滚动、输入栏被顶出视口。 */
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.chat-header { padding: 12px 22px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 10px; background: var(--bg); }
.chat-title { font-weight: 600; font-size: 14.5px; letter-spacing: -0.01em; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-header { padding: 12px 22px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 10px; background: var(--bg); border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
.chat-title { font-weight: 600; font-size: var(--fs-ui-base); letter-spacing: -0.01em; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-body { flex: 1; overflow-y: auto; min-height: 0; padding: 22px max(22px, 13%); }
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 10px; font-size: 13.5px; }
.empty-state .es-mark { width: 44px; height: 44px; border-radius: 12px; background: var(--bg-raised); border: 1px solid var(--border-soft); display: flex; align-items: center; justify-content: center; color: var(--text-faint); margin-bottom: 4px; }
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 10px; font-size: var(--fs-ui-caption); }
.empty-state .es-mark { width: 44px; height: 44px; border-radius: var(--radius-lg); background: var(--bg-raised); border: 1px solid var(--border-soft); display: flex; align-items: center; justify-content: center; color: var(--text-faint); margin-bottom: 4px; }
.empty-state svg { opacity: .5; }
.msg { margin-bottom: 20px; }
.msg-role { font-size: 12px; color: var(--text-faint); margin-bottom: 6px; display: flex; gap: 8px; align-items: center; }
.msg { margin-bottom: 20px; animation: mk-rise-in .18s var(--ease-out); }
.msg-role { font-size: var(--fs-ui-sm); color: var(--text-faint); margin-bottom: 6px; display: flex; gap: 8px; align-items: center; }
.msg-role .role-mark { width: 18px; height: 18px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
.msg.user .bubble {
background: var(--accent-soft); border: 1px solid transparent;
border-radius: var(--radius-lg); padding: 11px 16px; white-space: pre-wrap;
}
.part { margin: 4px 0; }
.part-thinking { color: var(--text-dim); font-size: 12.5px; border-left: 2px solid var(--border); padding-left: 10px; margin: 4px 0; }
.part-thinking summary { cursor: pointer; color: var(--text-faint); user-select: none; }
/* 思考块(Collapsible 契约:触发行 + mk-col 动画体) */
.part-thinking { color: var(--text-dim); font-size: var(--fs-ui-sm); border-left: 2px solid var(--border); padding-left: 10px; margin: 4px 0; }
.part-thinking .part-thinking-summary { color: var(--text-faint); user-select: none; }
.part-thinking .part-thinking-chev { display: inline-flex; color: var(--text-faint); font-size: var(--fs-ui-xs); transition: transform .12s; }
.part-thinking .mk-col-trigger[aria-expanded="true"] .part-thinking-chev { transform: rotate(90deg); }
.part-thinking .part-thinking-body { white-space: pre-wrap; margin-top: 4px; }
.part-tool { font-size: 12.5px; }
.part-tool { font-size: var(--fs-ui-sm); }
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 4px 9px; margin: 2px 4px 2px 0; font-family: var(--mono); }
.tool-chip .status-ok { color: var(--ok); }
.tool-chip .status-error { color: var(--err); }
@@ -91,8 +95,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.part-text :is(ul,ol) { margin: 0.4em 0; padding-left: 1.6em; }
.part-text li { margin: 0.15em 0; }
.part-text li > p { margin: 0.2em 0; }
.part-text pre { background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 11px 13px; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; }
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: var(--radius-xs); padding: 1px 5px; font-size: 12.5px; }
.part-text pre { background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 11px 13px; overflow-x: auto; font-family: var(--mono); font-size: var(--fs-ui-sm); }
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: var(--radius-xs); padding: 1px 5px; font-size: var(--fs-ui-sm); }
.part-text pre code { background: none; padding: 0; }
.part-text blockquote { margin: 0.5em 0; padding: 0.1em 0 0.1em 10px; border-left: 3px solid var(--border-strong); color: var(--text-dim); }
.part-text table { border-collapse: collapse; margin: 0.6em 0; font-size: 0.95em; display: block; max-width: 100%; overflow-x: auto; }
@@ -101,7 +105,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.part-text hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
.part-text a { color: var(--accent); }
.part-error { color: var(--err); background: var(--err-soft); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
.artifact-chip { display: inline-flex; align-items: stretch; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; font-size: 13px; margin: 2px 0; }
.artifact-chip { display: inline-flex; align-items: stretch; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; font-size: var(--fs-ui-caption); margin: 2px 0; }
.artifact-chip .ac-main { display: inline-flex; align-items: center; gap: 7px; color: var(--accent); background: none; border: none; padding: 5px 10px; cursor: pointer; font: inherit; max-width: 340px; }
.artifact-chip .ac-main span, .artifact-chip .ac-main { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.artifact-chip:hover { border-color: var(--accent); }
@@ -110,18 +114,21 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */
.part-group { margin: 2px 0; }
/* 过程折叠(run 终态后) */
/* 过程折叠(run 终态后;Collapsible 契约:触发行 + mk-col 动画体) */
.process-collapse { border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); margin: 6px 0; }
.process-collapse summary { list-style: none; cursor: pointer; padding: 7px 12px; color: var(--text-faint); font-size: 12.5px; display: flex; align-items: center; gap: 8px; user-select: none; }
.process-collapse summary::before { content: '▸'; transition: transform .12s; font-size: 10px; }
.process-collapse[open] summary::before { transform: rotate(90deg); }
.process-collapse .process-collapse-summary { padding: 7px 12px; color: var(--text-faint); font-size: var(--fs-ui-sm); user-select: none; }
.process-collapse .process-collapse-summary:hover { color: var(--text-dim); }
.process-collapse .process-collapse-chev { display: inline-flex; color: var(--text-faint); font-size: var(--fs-ui-xs); transition: transform .12s; }
.process-collapse .mk-col-trigger[aria-expanded="true"] .process-collapse-chev { transform: rotate(90deg); }
.process-collapse .process-collapse-meta { color: var(--text-faint); }
.process-collapse .process-collapse-excerpt { color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
.process-collapse .body { padding: 4px 12px 10px; }
/* 流式状态行 */
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: 12.5px; margin-bottom: 6px; }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s infinite; }
@keyframes pulse { 0%,100% { opacity: .4 } 50% { opacity: 1 } }
.cursor-blink::after { content: '▍'; animation: pulse 1s infinite; color: var(--accent); }
/* 流式状态行(呼吸/扫光走 mk-* 类;已工作计时弱色等宽) */
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: var(--fs-ui-sm); margin-bottom: 6px; }
.live-elapsed { color: var(--text-faint); font-variant-numeric: tabular-nums; }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: mk-breathe 1.2s infinite; }
.cursor-blink::after { content: '▍'; animation: mk-breathe 1s infinite; color: var(--accent); }
/* HITL 卡片(确认类交互统一绿色语言) */
.hitl-card { border: 1px solid var(--ok-border); background: var(--ok-soft); border-radius: var(--radius-lg); padding: 14px; margin: 10px 0; }
@@ -131,31 +138,31 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.hitl-choice { text-align: left; border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); border-radius: var(--radius); padding: 10px 12px; cursor: pointer; transition: border-color .12s, background .12s; }
.hitl-choice:hover { border-color: var(--accent); background: var(--accent-soft); }
.hitl-choice .label { font-weight: 600; display: flex; gap: 8px; align-items: center; }
.hitl-choice .desc { color: var(--text-dim); font-size: 12.5px; margin-top: 3px; }
.hitl-choice .impact { color: var(--warn); font-size: 12px; margin-top: 3px; }
.hitl-choice .rec { font-size: 10.5px; color: var(--accent); border: 1px solid var(--accent); padding: 0 5px; border-radius: var(--radius-full); }
.hitl-choice .desc { color: var(--text-dim); font-size: var(--fs-ui-sm); margin-top: 3px; }
.hitl-choice .impact { color: var(--warn); font-size: var(--fs-ui-sm); margin-top: 3px; }
.hitl-choice .rec { font-size: var(--fs-ui-xs); color: var(--accent); border: 1px solid var(--accent); padding: 0 5px; border-radius: var(--radius-full); }
.hitl-free { display: flex; gap: 8px; margin-top: 10px; }
/* 输入区:悬浮卡片式输入壳(外圆角大、聚焦发光) */
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); }
/* 输入区:悬浮卡片式输入壳(16px 大圆角 = DESIGN.md 特批例外) */
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
.attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 3px 8px; font-size: 12px; }
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 3px 8px; font-size: var(--fs-ui-sm); }
.input-row { display: flex; gap: 8px; align-items: flex-end; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 8px 8px 8px 14px; box-shadow: var(--shadow-md); transition: border-color .12s, box-shadow .12s; }
.input-row:focus-within { border-color: var(--accent); box-shadow: var(--shadow-md), var(--ring); }
.input-row textarea { flex: 1; resize: none; background: transparent; border: none; color: var(--text); padding: 6px 0; font-family: inherit; font-size: 13.5px; max-height: 160px; outline: none; }
.input-row textarea { flex: 1; resize: none; background: transparent; border: none; color: var(--text); padding: 6px 0; font-family: inherit; font-size: var(--fs-ui-caption); max-height: 160px; outline: none; }
.input-row textarea::placeholder { color: var(--text-faint); }
.cmd-menu { border: 1px solid var(--border); background: var(--bg-raised); border-radius: var(--radius); margin-bottom: 6px; max-height: 180px; overflow-y: auto; box-shadow: var(--shadow-md); }
.cmd-item { padding: 7px 12px; cursor: pointer; display: flex; gap: 10px; font-size: 13px; }
.cmd-item { padding: 7px 12px; cursor: pointer; display: flex; gap: 10px; font-size: var(--fs-ui-caption); }
.cmd-item:hover, .cmd-item.active { background: var(--bg-hover); }
.cmd-item .name { color: var(--accent); font-family: var(--mono); }
/* ---- 右栏 ---- */
.tabs { display: flex; border-bottom: 1px solid var(--border-soft); overflow-x: auto; padding: 0 8px; }
.tab { background: none; border: none; color: var(--text-faint); padding: 10px 10px; cursor: pointer; font-size: 12.5px; font-weight: 550; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; }
.tab { background: none; border: none; color: var(--text-faint); padding: 10px 10px; cursor: pointer; font-size: var(--fs-ui-sm); font-weight: 550; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; }
.tab:hover { color: var(--text-dim); }
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
.tab-body { flex: 1; overflow-y: auto; padding: 12px; }
.step { display: flex; gap: 8px; align-items: flex-start; padding: 6px 4px; font-size: 13px; }
.step { display: flex; gap: 8px; align-items: flex-start; padding: 6px 4px; font-size: var(--fs-ui-caption); }
.step-icon { width: 16px; text-align: center; flex-shrink: 0; }
.step.pending .step-icon { color: var(--text-faint); }
.step.in_progress .step-icon { color: var(--accent); }
@@ -164,8 +171,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* ---- 工件面板(工作区文件管理)---- */
.artifacts-panel { display: flex; flex-direction: column; gap: 4px; }
.ap-toolbar { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.ap-toolbar .search-input { flex: 1; min-width: 120px; height: 30px; font-size: 12.5px; }
.kind-pill { border: 1px solid var(--border); background: none; color: var(--text-dim); border-radius: var(--radius-full); padding: 3px 10px; font-size: 12px; cursor: pointer; }
.ap-toolbar .search-input { flex: 1; min-width: 120px; height: 30px; font-size: var(--fs-ui-sm); }
.kind-pill { border: 1px solid var(--border); background: none; color: var(--text-dim); border-radius: var(--radius-full); padding: 3px 10px; font-size: var(--fs-ui-sm); cursor: pointer; }
.kind-pill:hover { color: var(--text); background: var(--bg-hover); }
.kind-pill.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.ap-upload { cursor: pointer; }
@@ -173,30 +180,28 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.artifact-row:hover { background: var(--bg-hover); }
.artifact-row .ar-icon { display: flex; color: var(--text-dim); cursor: pointer; flex: none; }
.artifact-row .ar-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; cursor: pointer; }
.artifact-row .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.artifact-row .meta { font-size: 11px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.artifact-row .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--fs-ui-caption); }
.artifact-row .meta { font-size: var(--fs-ui-xs); color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.artifact-row .ar-acts { display: none; align-items: center; gap: 2px; flex: none; }
.artifact-row:hover .ar-acts { display: inline-flex; }
.artifact-row .ar-acts .icon-btn { width: 26px; height: 26px; }
.artifact-row .ar-acts .icon-btn.danger { color: var(--err); }
.empty-note { color: var(--text-faint); font-size: 12.5px; padding: 14px 6px; }
.agent-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; }
.empty-note { color: var(--text-faint); font-size: var(--fs-ui-sm); padding: 14px 6px; }
.agent-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--fs-ui-caption); }
.agent-row:hover { background: var(--bg-hover); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.status-running { background: var(--accent); animation: pulse 1.2s infinite; }
.status-running { background: var(--accent); animation: mk-breathe 1.2s infinite; }
.status-completed { background: var(--ok); }
.status-failed, .status-cancelled, .status-interrupted { background: var(--err); }
.status-pending, .status-waiting_hitl { background: var(--warn); }
/* ---- 弹窗 / 模态 ---- */
.overlay { position: fixed; inset: 0; background: rgba(8, 9, 12, .6); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 50; animation: overlay-in .14s var(--ease); }
@keyframes overlay-in { from { opacity: 0 } }
.modal { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xl); width: min(860px, 92vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: var(--shadow-lg); animation: modal-in .16s var(--ease); }
@keyframes modal-in { from { opacity: 0; transform: translateY(6px) scale(.99) } }
.modal-header { padding: 16px 20px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; font-weight: 600; font-size: 14.5px; letter-spacing: -0.01em; }
/* ---- 弹窗 / 模态(动画走 mk-* 类) ---- */
.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, .55); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 50; animation: mk-overlay-in .14s var(--ease); }
.modal { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xl); width: min(860px, 92vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: var(--shadow-lg); animation: mk-modal-in .16s var(--ease); }
.modal-header { padding: 16px 20px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; font-weight: 600; font-size: var(--fs-ui-base); letter-spacing: -0.01em; }
.modal-header .spacer { flex: 1; }
.modal-body { flex: 1; overflow-y: auto; padding: 18px 20px; }
.subagent-tree { font-size: 13px; }
.subagent-tree { font-size: var(--fs-ui-caption); }
.subagent-node { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; margin-bottom: 8px; background: var(--bg-panel); }
.subagent-node .head { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
@@ -205,7 +210,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.ui-setting-control input, .ui-setting-control select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; font-family: inherit; width: 100%; max-width: 340px; }
.ui-setting-control input:focus, .ui-setting-control select:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
.ui-setting-control input[type="checkbox"] { width: auto; }
.effect-badge { font-size: 10.5px; padding: 2px 7px; border-radius: var(--radius-full); border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
.effect-badge { font-size: var(--fs-ui-xs); padding: 2px 7px; border-radius: var(--radius-full); border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
.effect-hot { color: var(--ok); border-color: var(--ok); }
.effect-new-run { color: var(--warn); border-color: var(--warn); }
@@ -213,16 +218,16 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.docv { display: flex; flex-direction: column; min-height: 0; }
.docv-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 0 10px; border-bottom: 1px solid var(--border-soft); margin-bottom: 10px; }
.docv-toolbar .spacer { flex: 1; }
.docv-meta { font-size: 11.5px; color: var(--text-faint); text-transform: capitalize; }
.docv-dirty { color: var(--warn); font-size: 12px; }
.docv-meta { font-size: var(--fs-ui-xs); color: var(--text-faint); text-transform: capitalize; }
.docv-dirty { color: var(--warn); font-size: var(--fs-ui-sm); }
.docv-dl { text-decoration: none; }
.docv-body { flex: 1; min-height: 0; overflow: auto; }
.docv-pdf { width: 100%; height: 100%; min-height: 60vh; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg); }
.docv-editor { width: 100%; height: 56vh; font-family: var(--mono); font-size: 12.5px; resize: vertical; }
.docv-pre { font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-word; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 12px 14px; }
.docv-editor { width: 100%; height: 56vh; font-family: var(--mono); font-size: var(--fs-ui-sm); resize: vertical; }
.docv-pre { font-family: var(--mono); font-size: var(--fs-ui-sm); white-space: pre-wrap; word-break: break-word; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 12px 14px; }
/* ---- 杂项 ---- */
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: var(--radius-full); box-shadow: var(--shadow-lg); z-index: 90; font-size: 13px; }
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: var(--radius-full); box-shadow: var(--shadow-lg); z-index: 90; font-size: var(--fs-ui-caption); }
.conn-dot { width: 8px; height: 8px; border-radius: 50%; }
.conn-dot.on { background: var(--ok); }
.conn-dot.off { background: var(--err); }
@@ -232,49 +237,49 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); background-clip: content-box; border: 3px solid transparent; }
::-webkit-scrollbar-track { background: transparent; }
/* ---- 品牌标(侧栏 / 登录页 / 头像共用的星芒渐变底) ---- */
.brand-mark { background: linear-gradient(135deg, #6d8dff, #9d7bff); color: #fff; display: inline-flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.22); }
/* ---- 品牌标(侧栏 / 登录页 / 头像共用的星芒渐变底;辅色为品牌令牌 --brand-2) ---- */
.brand-mark { background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); display: inline-flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.22); }
/* ---- 左栏:任务中心(方案 A;R4 重造型) ---- */
/* ---- 左栏:任务中心(方案 A;Zai 复刻重造型) ---- */
.tsb-brand { display: flex; align-items: center; gap: 9px; padding: 14px 14px 8px; }
.tsb-logo { width: 24px; height: 24px; border-radius: 8px; background: linear-gradient(135deg, #6d8dff, #9d7bff); color: #fff; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
.tsb-name { font-weight: 650; font-size: 13.5px; letter-spacing: -0.01em; }
.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; position: relative; }
.tsb-logo { width: 24px; height: 24px; border-radius: var(--radius); background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
.tsb-name { font-weight: 650; font-size: var(--fs-ui-caption); letter-spacing: -0.01em; }
.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: var(--fs-ui-caption); display: inline-flex; align-items: center; justify-content: center; position: relative; }
.tsb-iconbtn:hover { background: var(--bg-hover); color: var(--text); }
.tsb-iconbtn.active { color: var(--accent); background: var(--accent-soft); }
.tsb-actions { padding: 6px 12px 8px; display: flex; flex-direction: column; gap: 7px; }
.tsb-newtask { display: flex; height: 36px; border-radius: var(--radius); background: var(--accent); color: var(--text-on-accent); overflow: hidden; box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 2px 6px rgba(8,9,12,.28); transition: filter .12s; }
.tsb-newtask:hover { filter: brightness(1.08); }
.tsb-newtask-main { flex: 1; display: flex; align-items: center; gap: 7px; padding: 0 11px; background: none; border: none; color: inherit; font-weight: 600; font-size: 13px; cursor: pointer; min-width: 0; }
.tsb-newtask { display: flex; height: 36px; border-radius: var(--radius); background: var(--primary); color: var(--primary-foreground); overflow: hidden; box-shadow: 0 1px 3px rgba(0, 0, 0, .3); transition: background .12s; }
.tsb-newtask:hover { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
.tsb-newtask-main { flex: 1; display: flex; align-items: center; gap: 7px; padding: 0 11px; background: none; border: none; color: inherit; font-weight: 600; font-size: var(--fs-ui-caption); cursor: pointer; min-width: 0; }
.tsb-newtask-main svg { flex: none; }
.tsb-newtask-proj { font-weight: 450; font-size: 11.5px; opacity: .75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-newtask-caret { width: 30px; border: none; border-left: 1px solid rgba(255,255,255,.18); background: none; color: inherit; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.tsb-newtask-caret:hover { background: rgba(255,255,255,.08); }
.tsb-newtask-proj { font-weight: 450; font-size: var(--fs-ui-xs); opacity: .75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-newtask-caret { width: 30px; border: none; border-left: 1px solid color-mix(in srgb, var(--primary-foreground) 18%, transparent); background: none; color: inherit; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.tsb-newtask-caret:hover { background: color-mix(in srgb, var(--primary-foreground) 8%, transparent); }
.tsb-search { display: flex; align-items: center; gap: 7px; height: 32px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-faint); padding: 0 10px; transition: border-color .12s, box-shadow .12s; }
.tsb-search:focus-within { border-color: var(--accent); box-shadow: var(--ring); }
.tsb-search svg { flex: none; }
.tsb-search input { flex: 1; border: none; background: none; color: var(--text); font-size: 12.5px; outline: none; min-width: 0; }
.tsb-search input { flex: 1; border: none; background: none; color: var(--text); font-size: var(--fs-ui-sm); outline: none; min-width: 0; }
.tsb-search input::placeholder { color: var(--text-faint); }
.tsb-orgrow { display: flex; align-items: center; gap: 6px; padding: 0 12px 8px; }
.tsb-pills { flex: 1; display: flex; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 2px; gap: 2px; }
.tsb-pill { flex: 1; height: 22px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: 12px; cursor: pointer; transition: all .12s; }
.tsb-pill { flex: 1; height: 22px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; transition: all .12s; }
.tsb-pill:hover { color: var(--text-dim); }
.tsb-pill.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(8,9,12,.25); }
.tsb-pill.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
.tsb-scroll { flex: 1; overflow-y: auto; padding: 0 10px 8px; min-height: 0; }
.tsb-seclabel { font-size: 11px; color: var(--text-faint); padding: 12px 8px 4px; letter-spacing: .05em; font-weight: 600; }
.tsb-empty { padding: 20px 14px; color: var(--text-faint); font-size: 12.5px; line-height: 1.9; text-align: center; }
.tsb-seclabel { font-size: var(--fs-ui-xs); color: var(--text-faint); padding: 12px 8px 4px; letter-spacing: .05em; font-weight: 600; }
.tsb-empty { padding: 20px 14px; color: var(--text-faint); font-size: var(--fs-ui-sm); line-height: 1.9; text-align: center; }
.tsb-newproject { padding: 0 2px 8px; }
.tsb-group { margin-bottom: 2px; }
.tsb-group-head { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 6px 0 4px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 550; font-size: 13px; cursor: pointer; user-select: none; }
.tsb-group-head { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 6px 0 4px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 550; font-size: var(--fs-ui-caption); cursor: pointer; user-select: none; }
.tsb-group-head:hover { background: var(--bg-hover); }
.tsb-group.closed .tsb-chev { transform: rotate(0deg); }
.tsb-chev { display: inline-flex; color: var(--text-faint); transform: rotate(90deg); transition: transform .12s; }
.tsb-group-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-group-cnt { font-size: 11px; color: var(--text-faint); font-weight: 400; font-variant-numeric: tabular-nums; }
.tsb-group-cnt { font-size: var(--fs-ui-xs); color: var(--text-faint); font-weight: 400; font-variant-numeric: tabular-nums; }
.tsb-group-add { visibility: hidden; width: 22px; height: 22px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
.tsb-group-head:hover .tsb-group-add { visibility: visible; }
.tsb-group-add:hover { background: var(--bg-active); color: var(--text); }
@@ -284,105 +289,118 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.tsb-task.active { background: var(--bg-active); color: var(--text); }
.tsb-task.active::before { content: ''; position: absolute; left: -2px; top: 6px; bottom: 6px; width: 2.5px; border-radius: 2px; background: var(--accent); }
.tsb-task.archived .tsb-task-t { text-decoration: line-through; opacity: .6; }
.tsb-task-t { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.tsb-task-tm { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
.tsb-pbadge { font-size: 10.5px; color: var(--text-faint); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 0 5px; line-height: 1.5; white-space: nowrap; }
.tsb-task-t { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--fs-ui-caption); }
.tsb-task-tm { font-size: var(--fs-ui-xs); color: var(--text-faint); font-variant-numeric: tabular-nums; }
.tsb-pbadge { font-size: var(--fs-ui-xs); color: var(--text-faint); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 0 5px; line-height: 1.5; white-space: nowrap; }
.tsb-row-acts { display: none; gap: 2px; }
.tsb-task:hover .tsb-row-acts { display: inline-flex; }
.tsb-task:hover .tsb-task-tm { display: none; }
.tsb-row-act { border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 3px; border-radius: var(--radius-xs); display: inline-flex; }
.tsb-row-act:hover { background: var(--bg-active); color: var(--text); }
.tsb-wsstrip { display: flex; align-items: center; gap: 2px; padding: 6px 12px; border-top: 1px solid var(--border-soft); }
.tsb-wsstrip button { border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 5px; border-radius: var(--radius-sm); display: inline-flex; }
.tsb-wsstrip button:hover { background: var(--bg-hover); color: var(--text); }
.tsb-footer { display: flex; align-items: center; gap: 4px; padding: 10px 12px; border-top: 1px solid var(--border-soft); }
.tsb-footer { display: flex; align-items: center; gap: 4px; padding: 10px 12px; border-top: 1px solid var(--border-soft); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
.tsb-user { flex: 1; display: flex; align-items: center; gap: 9px; min-width: 0; height: 40px; padding: 0 7px; border: none; border-radius: var(--radius); background: none; cursor: pointer; text-align: left; transition: background .12s; }
.tsb-user:hover { background: var(--bg-hover); }
.tsb-avatar { width: 27px; height: 27px; border-radius: 50%; background: linear-gradient(135deg, #6d8dff, #9d7bff); color: #fff; font-size: 12px; font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.2); }
.tsb-avatar { width: 27px; height: 27px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.2); }
.tsb-user-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.tsb-user-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-user-sub { font-size: 11px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-user-name { font-size: var(--fs-ui-caption); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-user-sub { font-size: var(--fs-ui-xs); color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-user-chev { color: var(--text-faint); display: inline-flex; }
.tsb-menu { position: fixed; z-index: 300; min-width: 216px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); padding: 5px; display: flex; flex-direction: column; animation: modal-in .12s var(--ease); }
.tsb-mi { display: flex; align-items: center; gap: 9px; min-height: 31px; padding: 4px 9px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); font-size: 13px; cursor: pointer; text-align: left; }
.tsb-menu { position: fixed; z-index: 300; min-width: 216px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius); box-shadow: var(--shadow-lg); padding: 5px; display: flex; flex-direction: column; animation: mk-modal-in .12s var(--ease); }
.tsb-mi { display: flex; align-items: center; gap: 9px; min-height: 31px; padding: 4px 9px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); font-size: var(--fs-ui-caption); cursor: pointer; text-align: left; }
.tsb-mi svg { color: var(--text-faint); flex: none; }
.tsb-mi:hover { background: var(--bg-hover); color: var(--text); }
.tsb-mi:hover svg { color: var(--text-dim); }
.tsb-mi.danger { color: var(--err); }
.tsb-mi.danger:hover { background: var(--err); color: #fff; }
.tsb-mi.danger:hover svg { color: #fff; }
.tsb-mi kbd { margin-left: auto; font-size: 10.5px; font-family: var(--mono); color: var(--text-faint); }
.tsb-mi.danger:hover { background: var(--err); color: var(--text-on-accent); }
.tsb-mi.danger:hover svg { color: var(--text-on-accent); }
.tsb-mi kbd { margin-left: auto; font-size: var(--fs-ui-xs); font-family: var(--mono); color: var(--text-faint); }
.tsb-menu-div { height: 1px; background: var(--border-soft); margin: 5px 4px; }
.tsb-acc-head { display: flex; gap: 10px; align-items: center; padding: 8px 9px 6px; }
.tsb-acc-name { font-size: 13.5px; font-weight: 600; }
.tsb-acc-mail { font-size: 11.5px; color: var(--text-faint); }
.tsb-acc-name { font-size: var(--fs-ui-caption); font-weight: 600; }
.tsb-acc-mail { font-size: var(--fs-ui-xs); color: var(--text-faint); }
/* ---- 登录页与改密门(R4 重造型:宽屏右侧品牌面板) ---- */
.lg-wrap { position: fixed; inset: 0; display: flex; align-items: stretch; background: var(--bg); z-index: 500; }
.lg-panel { flex: none; width: 46%; max-width: 620px; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; padding: 64px; color: #dfe4f2; background: linear-gradient(160deg, #171e38 0%, #1d2145 55%, #251d4d 100%); }
.lg-panel::before { content: ''; position: absolute; width: 560px; height: 560px; border-radius: 50%; background: radial-gradient(closest-side, rgba(109,141,255,.22), transparent 70%); top: -140px; right: -160px; }
.lg-panel::after { content: ''; position: absolute; width: 440px; height: 440px; border-radius: 50%; background: radial-gradient(closest-side, rgba(157,123,255,.18), transparent 70%); bottom: -150px; left: -120px; }
/* ---- 登录页与改密门(Zai 复刻:近单色深底 + 深海军品牌面;品牌面板固定深底浅字) ---- */
.lg-wrap { position: fixed; inset: 0; display: flex; align-items: stretch; background: var(--bg-inset); z-index: 500; }
.lg-panel { flex: none; width: 46%; max-width: 620px; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; padding: 64px; color: #dfe4f2; background: linear-gradient(160deg, #0e1116 0%, #101826 55%, #0b1f38 100%); }
.lg-panel::before { content: ''; position: absolute; width: 560px; height: 560px; border-radius: 50%; background: radial-gradient(closest-side, rgba(64,153,255,.16), transparent 70%); top: -140px; right: -160px; }
.lg-panel::after { content: ''; position: absolute; width: 440px; height: 440px; border-radius: 50%; background: radial-gradient(closest-side, rgba(123,92,229,.12), transparent 70%); bottom: -150px; left: -120px; }
.lg-panel h2 { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.35; margin: 0 0 12px; position: relative; }
.lg-panel p { font-size: 14px; opacity: .72; line-height: 1.75; margin: 0; position: relative; }
.lg-panel p { font-size: var(--fs-ui-base); opacity: .72; line-height: 1.75; margin: 0; position: relative; }
.lg-panel ul { margin: 28px 0 0; list-style: none; display: flex; flex-direction: column; gap: 13px; position: relative; }
.lg-panel li { display: flex; gap: 10px; align-items: flex-start; font-size: 13.5px; opacity: .88; }
.lg-panel li svg { margin-top: 2px; color: #8fa8ff; flex: none; }
.lg-panel .lg-foot { position: absolute; bottom: 28px; left: 64px; right: 64px; display: flex; justify-content: space-between; font-size: 11.5px; opacity: .45; }
.lg-panel li { display: flex; gap: 10px; align-items: flex-start; font-size: var(--fs-ui-caption); opacity: .88; }
.lg-panel li svg { margin-top: 2px; color: var(--accent-strong); flex: none; }
.lg-panel .lg-foot { position: absolute; bottom: 28px; left: 64px; right: 64px; display: flex; justify-content: space-between; font-size: var(--fs-ui-xs); opacity: .45; }
@media (max-width: 960px) { .lg-panel { display: none; } }
.lg-formside { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; min-width: 0; }
.lg-card { width: 392px; max-width: 100%; }
.lg-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 15.5px; letter-spacing: -0.01em; margin-bottom: 26px; }
.lg-title { margin: 0 0 4px; font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.lg-sub { margin: 0 0 18px; font-size: 12.5px; color: var(--text-faint); line-height: 1.6; }
.lg-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: var(--fs-ui-base); letter-spacing: -0.01em; margin-bottom: 26px; }
.lg-title { margin: 0 0 4px; font-size: var(--fs-ui-xl); font-weight: 700; letter-spacing: -0.02em; }
.lg-sub { margin: 0 0 18px; font-size: var(--fs-ui-sm); color: var(--text-faint); line-height: 1.6; }
.lg-tabs { display: flex; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 3px; gap: 3px; margin-bottom: 16px; }
.lg-tab { flex: 1; height: 30px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: 12.5px; cursor: pointer; transition: all .12s; }
.lg-tab { flex: 1; height: 30px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; transition: all .12s; }
.lg-tab:hover { color: var(--text-dim); }
.lg-tab.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(8,9,12,.25); }
.lg-label { display: block; font-size: 12px; font-weight: 550; color: var(--text-dim); margin: 12px 0 6px; }
.lg-input { width: 100%; height: 38px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); padding: 0 12px; font-size: 13.5px; transition: border-color .12s, box-shadow .12s; }
.lg-tab.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
.lg-label { display: block; font-size: var(--fs-ui-sm); font-weight: 550; color: var(--text-dim); margin: 12px 0 6px; }
.lg-input { width: 100%; height: 38px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); padding: 0 12px; font-size: var(--fs-ui-caption); transition: border-color .12s, box-shadow .12s; }
.lg-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
.lg-mono { font-family: var(--mono); font-size: 12.5px; }
.lg-err { margin-top: 12px; font-size: 12.5px; color: var(--err); background: var(--err-soft); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
.lg-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.lg-err { margin-top: 12px; font-size: var(--fs-ui-sm); color: var(--err); background: var(--err-soft); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
.lg-err svg { flex: none; margin-top: 1px; }
.lg-submit {
width: 100%; height: 38px; margin-top: 16px; border: none; border-radius: var(--radius);
background: var(--accent); color: var(--text-on-accent); font-weight: 600; font-size: 13.5px; cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 2px 8px var(--accent-glow);
transition: filter .12s, transform .12s;
background: var(--primary); color: var(--primary-foreground); font-weight: 600; font-size: var(--fs-ui-caption); cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, .3);
transition: background .12s, transform .12s;
}
.lg-submit:disabled { opacity: .45; cursor: default; box-shadow: none; }
.lg-submit:not(:disabled):hover { filter: brightness(1.09); }
.lg-submit:not(:disabled):hover { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
.lg-submit:not(:disabled):active { transform: translateY(1px); }
.lg-cancel { width: 100%; margin-top: 8px; border: none; background: none; color: var(--text-faint); font-size: 12.5px; cursor: pointer; }
.lg-cancel { width: 100%; margin-top: 8px; border: none; background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; }
.lg-cancel:hover { color: var(--text-dim); text-decoration: underline; }
.lg-hint { margin: 14px 0 0; font-size: 11.5px; color: var(--text-faint); line-height: 1.7; }
.lg-hint code { font-family: var(--mono); font-size: 10.5px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 1px 5px; }
.lg-hint { margin: 14px 0 0; font-size: var(--fs-ui-xs); color: var(--text-faint); line-height: 1.7; }
.lg-hint code { font-family: var(--mono); font-size: var(--fs-ui-xs); background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 1px 5px; }
/* ---- 系统管理(AdminZone,重设计方案 阶段②):管理员专属全屏视图 ---- */
.az { position: fixed; inset: 4px; z-index: 70; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; animation: mk-modal-in .16s var(--ease); }
.az-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border-soft); background: var(--bg-panel); }
.az-title { font-weight: 650; font-size: var(--fs-ui-base); letter-spacing: -0.01em; }
.az-sub { font-size: var(--fs-ui-xs); color: var(--text-faint); }
.az-tenant { font-size: var(--fs-ui-xs); color: var(--text-faint); font-family: var(--mono); border: 1px solid var(--border); border-radius: var(--radius-full); padding: 2px 10px; }
.az-body { flex: 1; min-height: 0; display: flex; }
.az-side { width: 220px; flex: none; border-right: 1px solid var(--border-soft); background: var(--bg-panel); padding: 10px 8px; display: flex; flex-direction: column; gap: 2px; }
.az-nav-item { display: flex; align-items: center; gap: 9px; padding: 9px 11px; border: none; background: none; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; text-align: left; }
.az-nav-item:hover { background: var(--bg-hover); color: var(--text); }
.az-nav-item.on { background: var(--bg-active); color: var(--text); font-weight: 550; }
.az-nav-item.on svg { color: var(--accent); }
.az-main { flex: 1; min-width: 0; overflow-y: auto; padding: 20px 24px; }
.az-section-head { margin-bottom: 14px; }
.az-section-head h3 { margin: 0 0 4px; font-size: var(--fs-ui-lg); font-weight: 600; letter-spacing: -0.01em; }
.az-section-head p { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-faint); }
/* ---- 用户管理 / 改密弹层 ---- */
.ad-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; }
.ad-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); }
.ad-row { display: flex; gap: 8px; align-items: center; }
.ad-row .lg-input { flex: 1; min-width: 0; height: 34px; }
.ad-check { display: flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--text-dim); white-space: nowrap; }
.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.14); }
.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: 12px; cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
.ad-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; }
.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; }
.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
.ad-mini:hover { color: var(--text); border-color: var(--accent); }
.ad-hint { margin: 0; font-size: 11.5px; color: var(--text-faint); }
.ad-notice { font-size: 12.5px; color: var(--ok); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
.ad-hint { margin: 0; font-size: var(--fs-ui-xs); color: var(--text-faint); }
.ad-notice { font-size: var(--fs-ui-sm); color: var(--ok); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
.ad-notice svg { flex: none; margin-top: 1px; }
.ad-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.ad-table th { text-align: left; font-weight: 600; color: var(--text-faint); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; padding: 8px; border-bottom: 1px solid var(--border); }
.ad-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
.ad-table th { text-align: left; font-weight: 600; color: var(--text-faint); font-size: var(--fs-ui-xs); text-transform: uppercase; letter-spacing: .06em; padding: 8px; border-bottom: 1px solid var(--border); }
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
.ad-table tr:last-child td { border-bottom: none; }
.ad-mono { font-family: var(--mono); font-size: 12px; }
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.ad-dim { color: var(--text-faint); }
.ad-chip { font-size: 10px; border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-faint); }
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-faint); }
.ad-chip-admin { background: var(--accent-soft); color: var(--accent); }
.ad-chip-warn { background: var(--warn-soft); color: var(--warn); }
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: 12.5px; }
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
@@ -390,7 +408,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }
.icon-only button { background: none; border: none; color: var(--text-faint); cursor: pointer; padding: 0; width: 34px; height: 34px; border-radius: var(--radius); display: inline-flex; align-items: center; justify-content: center; position: relative; }
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
.rail-badge { position: absolute; top: 3px; right: 3px; min-width: 14px; height: 14px; border-radius: 7px; background: var(--warn); color: #fff; font-size: 9.5px; font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; }
.rail-badge { position: absolute; top: 3px; right: 3px; min-width: 14px; height: 14px; border-radius: 7px; background: var(--warn); color: var(--text-on-accent); font-size: var(--fs-ui-xs); font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; }
/* ---- UI 能力平台消费(消息头像列 / 设置外壳 / 管理弹层头像) ---- */
.role-mark { width: 20px; height: 20px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
@@ -400,17 +418,17 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* ---- 右栏轻预览(裁定 D4:右栏 + 弹窗双落位)---- */
.rail-preview-head { display: flex; align-items: center; gap: 6px; padding: 0 2px 8px; border-bottom: 1px solid var(--border-soft); margin-bottom: 8px; }
.rail-preview-head .rp-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 55%; }
.rail-preview-head .rp-name { font-size: var(--fs-ui-caption); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 55%; }
.tab-body[data-rail-preview] { display: flex; flex-direction: column; }
.tab-body[data-rail-preview] .ui-fp { flex: 1; }
.artifact-chip .ac-act { border: none; background: none; }
/* 项目收件箱(CF3/D51):会话/项目范围分段控件 */
/* 项目收件箱(CF3/D51):会话/项目范围分段控件(令牌对齐 DESIGN.md,阶段④合入主线时归一) */
.inbox-scope {
display: flex; gap: 4px; padding: 6px 14px 2px;
}
.inbox-scope button {
border: 1px solid var(--border, #d8d8d8); background: transparent; color: inherit;
border-radius: 999px; padding: 2px 12px; font-size: 12px; cursor: pointer; opacity: .72;
border: 1px solid var(--border); background: transparent; color: inherit;
border-radius: var(--radius-full); padding: 2px 12px; font-size: var(--fs-ui-sm); cursor: pointer; opacity: .72;
}
.inbox-scope button.active { opacity: 1; font-weight: 600; background: var(--hover, rgba(0,0,0,.05)); }
.inbox-scope button.active { opacity: 1; font-weight: 600; background: var(--bg-hover); }
+88
View File
@@ -0,0 +1,88 @@
// 设计系统守护测试(父仓 DESIGN.md §11 禁止清单 · 宿主侧,2026-09-30)。
// 棘轮基线:现存豁免登记在下方;新增同类违规直接失败。收紧 = 修掉违规后删基线条目。
// 范围:src/styles.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url)) // test/
const FE_ROOT = join(HERE, '..')
const SRC = join(FE_ROOT, 'src')
const DEV_ROOT = join(FE_ROOT, '..', '..') // Dev 父仓根(DESIGN.md;单独检出本仓时不存在)
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p, out)
else out.push(p)
}
return out
}
const read = (p: string) => readFileSync(p, 'utf8')
const stripCssComments = (t: string) => t.replace(/\/\*[\s\S]*?\*\//g, '')
const collectHex = (t: string) => [...t.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].map(m => m[0].toLowerCase())
// ---- 1. 宪法在位(组合检出时) ----
describe('DESIGN.md 宪法在位', () => {
it('DESIGN.md 存在且 AGENTS.md 带强制阅读门禁', () => {
const designPath = join(DEV_ROOT, 'DESIGN.md')
if (!existsSync(designPath)) { console.warn('(单独检出 Frontend 仓:跳过宪法检查)'); return }
expect(read(designPath)).toContain('Agent-System-Dev 设计系统')
const agents = read(join(DEV_ROOT, 'AGENTS.md'))
expect(agents).toContain('DESIGN.md')
})
})
// ---- 2. 动效唯一所有者 ----
describe('动效唯一所有者', () => {
it('styles.css 无 @keyframes(全部走 @ui/motion.css 的 mk-* 类)', () => {
expect(stripCssComments(read(join(SRC, 'styles.css')))).not.toContain('@keyframes')
})
})
// ---- 3. 裸色值棘轮 ----
const HEX_BASELINE_STYLES = [
'#0e1116', '#101826', '#0b1f38', // 登录品牌面板固定深底渐变(面板自带深底,主题无关)
'#dfe4f2', // 同上面板的固定浅字
] as const
describe('裸色值棘轮', () => {
it('styles.css 不引入基线外裸色值', () => {
const found = collectHex(read(join(SRC, 'styles.css')))
const allow = new Set<string>(HEX_BASELINE_STYLES)
const extra = found.filter(h => !allow.has(h))
expect(extra, `新增裸色值 ${[...new Set(extra)].join(', ')}——改用 tokens 令牌;确需豁免更新 HEX_BASELINE_STYLES 并写明理由`).toEqual([])
})
it('组件 TSX 无裸色值', () => {
const extra: string[] = []
for (const p of walk(SRC).filter(p => /\.(tsx|ts)$/.test(p) && !p.endsWith('.d.ts'))) {
for (const h of collectHex(read(p))) extra.push(`${relative(SRC, p)}: ${h}`)
}
expect(extra, '组件里出现裸色值——走 tokens 令牌').toEqual([])
})
})
// ---- 4. 行内字号棘轮(--fs-ui-* 标尺) ----
const FONTSIZE_LITERAL_BASELINE = ['40'] as const // Conversation 空态装饰字形(⌘ 展示字号,非 UI 层级)
describe('行内字号棘轮', () => {
it('组件 TSX 无基线外 fontSize 数值字面量', () => {
const allow = new Set<string>(FONTSIZE_LITERAL_BASELINE)
const extra: string[] = []
for (const p of walk(SRC).filter(p => p.endsWith('.tsx'))) {
const lines = read(p).split('\n')
lines.forEach((l: string, i: number) => {
const m = l.match(/fontSize:\s*['"`]?([\d.]+)/)
if (m && !allow.has(m[1])) extra.push(`${relative(SRC, p)}:${i + 1} (${m[1]})`)
})
}
expect(extra, '行内 fontSize 数值——改用 var(--fs-ui-*);确需豁免更新基线并写明理由').toEqual([])
})
it('组件 TSX 无 <details> 折叠(走 @ui Collapsible,DESIGN.md §11.7)', () => {
const extra: string[] = []
for (const p of walk(SRC).filter(p => p.endsWith('.tsx'))) {
const code = read(p).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
if (/<details[\s>]/.test(code)) extra.push(relative(SRC, p))
}
expect(extra, '<details> 折叠——改用 @ui Collapsible').toEqual([])
})
})
+1 -1
View File
@@ -12,7 +12,7 @@
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"],
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": {
"@ui": ["../ui/src/index.ts"],
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/previewsetup.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/previewsetup.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AdminZone.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./src/components/adminSections.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/design-guard.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}