@airalogy/airalogy-bd 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.3.0] - 2026-10-27
8
+
9
+ - 统一界面字体、字号与行高,改善正文、表格和说明文字的可读性,手机端通过布局适配而非缩小正文。
10
+
11
+ - 将外部 AI 工具权限并入邀请和成员权限编辑,与角色、事项范围一起保存;默认关闭,支持在全局功能关闭时预先分配权限。
12
+ - 个人工具连接、凭据生成与撤销移到「我的账号」,外部工具全局开关移到「AI 设置」,草稿统一从「待确认修改」进入;保留原草稿链接及旧管理入口跳转。
13
+ - 统一工作台、管理中心、账号和草稿页的顶部导航:Logo 与工作区名称在最上方,账号操作收进个人菜单,下方承载业务操作和页内导航,并适配手机屏幕。
14
+ - 调整尚未接受邀请的成员权限时保留原邀请链接和有效期;增加授权事务、并发冲突、只读边界及撤销后凭据和草稿失效的回归测试。
15
+
16
+ ## [1.2.1] - 2026-10-27
17
+
18
+ - 成员管理以列表为主,支持按姓名、用户名、邮箱和状态筛选;邀请及权限调整按需打开编辑面板,保留权限预览、固定操作区、未保存提醒和保存失败反馈。
19
+ - 统一管理中心导航、表单分组、状态提示和间距,优化 AI 设置、用量费用、远程接入、备份、账号及登录页面。
20
+ - 优化手机和中等屏幕布局,主工作台的推进阶段与关键进展上下排列,资料详情操作按用途分组。
21
+ - 完善键盘焦点返回、错误提示定位和邀请流程说明。
22
+
7
23
  ## [1.2.0] - 2026-10-26
8
24
 
9
25
  - 邀请成员时邮箱改为选填,由成员激活时自行补充;保留内部身份、原有权限及历史,拒绝占用其他成员的邮箱。
package/README.md CHANGED
@@ -6,7 +6,7 @@ Airalogy BD 是一套支持本地使用与团队服务器部署的 BD 管理工
6
6
 
7
7
  首次发行基线为 **1.0.0**,版本统一使用 `x.y.z`。版本内容见 [更新记录](CHANGELOG.md),后续版本规则见 [版本管理说明](docs/versioning.md)。本地正式使用安装固定版本,源码安装用于开发;版本号不代表安装包、镜像或线上服务已经发布。
8
8
 
9
- 可选的 [Codex 远程接入](docs/remote-access.md) 默认关闭:管理员授权后,成员可通过 MCP 查询和提交草稿,由本人在 BD 网页确认后保存。与内置 AI 的开关、模型配置和计费相互独立。
9
+ 可选的 [Codex 远程接入](docs/remote-access.md) 默认关闭:管理员在邀请或调整成员时分配权限,成员在「我的账号」连接工具,通过 MCP 查询和提交草稿,由本人在「待确认修改」核对保存。与内置 AI 的开关、模型配置和计费相互独立。
10
10
 
11
11
  本仓库只保存通用实现、数据校验、内容模板和虚构测试数据,不保存客户业务、真实联系人、合同或融资资料;产品名称和开发公司的版权归属属于公开产品信息。
12
12
 
@@ -1,20 +1,39 @@
1
1
  "use client";
2
- import { useState } from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
3
  import Link from "next/link";
4
- import { useRouter } from "next/navigation";
4
+ import { usePathname, useRouter } from "next/navigation";
5
+ import { BrandIdentity, BrandLogo } from "./Branding";
5
6
  import { teamRequest, type TeamUser } from "./lib/team-client";
7
+ import type { DashboardData } from "./lib/types";
6
8
 
7
- export default function AccountBar({ user, management = false }: { user: TeamUser; management?: boolean }) {
8
- const router = useRouter();
9
+ export default function AccountBar({ user, workspace }: { user: TeamUser | null; workspace?: DashboardData["workspace"] }) {
10
+ const router = useRouter(), pathname = usePathname();
11
+ const menu = useRef<HTMLDetailsElement>(null);
9
12
  const [error, setError] = useState("");
13
+ useEffect(() => {
14
+ const close = (event: PointerEvent) => { if (!menu.current?.contains(event.target as Node) && menu.current) menu.current.open = false; };
15
+ document.addEventListener("pointerdown", close);
16
+ return () => document.removeEventListener("pointerdown", close);
17
+ }, []);
10
18
  async function logout() {
11
- try { await teamRequest("auth_logout", {}, user.csrf); router.replace("/login"); router.refresh(); }
19
+ try { await teamRequest("auth_logout", {}, user?.csrf || ""); router.replace("/login"); router.refresh(); }
12
20
  catch (err) { setError((err as Error).message); }
13
21
  }
14
- return <><div className="team-bar"><span>{user.name} · {{ admin: "管理员", editor: "编辑成员", viewer: "只读成员" }[user.role]}</span><div>
15
- {management ? <Link className="admin-button" href="/">返回工作台</Link> : user.role === "admin" && <Link className="admin-button" href="/admin">管理中心</Link>}
16
- <Link className="admin-button" href="/account">我的账号</Link>
17
- <Link className="admin-button" href="/remote">远程接入与草稿</Link>
18
- <button className="admin-button" onClick={() => void logout()}>退出登录</button>
19
- </div></div>{error && <p className="admin-alert" role="alert">{error}</p>}</>;
22
+ return <>
23
+ <header className="app-header">
24
+ <div className="app-header-inner">
25
+ <Link className="app-brand" href="/" aria-label="返回工作台">{workspace?<><BrandLogo/><span><strong>{workspace.short_name}</strong><small>{workspace.workspace_title}</small></span></>:<BrandIdentity/>}</Link>
26
+ <nav className="app-navigation" aria-label="全局导航">
27
+ <Link href="/" aria-current={pathname==="/"?"page":undefined}>工作台</Link>
28
+ {user&&<Link href="/remote" aria-current={pathname==="/remote"?"page":undefined}>待确认修改</Link>}
29
+ {(!user||user.role==="admin")&&<Link href="/admin" aria-current={pathname==="/admin"?"page":undefined}>管理中心</Link>}
30
+ </nav>
31
+ {user?<details className="app-account" ref={menu} onKeyDown={event=>{if(event.key==="Escape"&&menu.current){menu.current.open=false;menu.current.querySelector("summary")?.focus();}}}>
32
+ <summary aria-label={`${user.name},账号菜单`} className={pathname==="/account"?"is-current":undefined}><span className="app-avatar" aria-hidden="true">{Array.from(user.name)[0]}</span><span className="app-account-name">{user.name}<small>{{admin:"管理员",editor:"编辑成员",viewer:"只读成员"}[user.role]}</small></span><span className="app-account-chevron" aria-hidden="true">⌄</span></summary>
33
+ <div className="app-account-menu"><Link href="/account" onClick={()=>{if(menu.current)menu.current.open=false;}}>我的账号</Link><Link href="/account#ai-tools" onClick={()=>{if(menu.current)menu.current.open=false;}}>AI 工具连接</Link><button type="button" onClick={()=>void logout()}>退出登录</button></div>
34
+ </details>:<span className="app-local-label">本机工作区</span>}
35
+ </div>
36
+ </header>
37
+ {error&&<p className="admin-alert" role="alert">{error}</p>}
38
+ </>;
20
39
  }
@@ -2,11 +2,13 @@
2
2
  import { useState } from "react";
3
3
  import { useRouter } from "next/navigation";
4
4
  import AccountBar from "./AccountBar";
5
- import { BrandIdentity } from "./Branding";
5
+ import RemoteAccess from "./RemoteAccess";
6
+ import type { DashboardData } from "./lib/types";
7
+ import {SettingsIcon,SettingsPanelHeader,SettingsStatus} from "./SettingsUI";
6
8
  import { teamRequest, type TeamUser } from "./lib/team-client";
7
9
  import { usernameHelp, usernamePattern } from "./lib/username-policy";
8
10
 
9
- export default function AccountSettings({ user }: { user: TeamUser }) {
11
+ export default function AccountSettings({ user, endpoint, workspace }: { user: TeamUser; endpoint: string; workspace?: DashboardData["workspace"] }) {
10
12
  const router = useRouter();
11
13
  const [username, setUsername] = useState(user.username || "");
12
14
  const [saved, setSaved] = useState(!!user.username);
@@ -22,15 +24,18 @@ export default function AccountSettings({ user }: { user: TeamUser }) {
22
24
  } catch (err) { setError((err as Error).message); }
23
25
  finally { setBusy(false); }
24
26
  }
25
- return <><AccountBar user={user} management /><main className="page-shell account-settings">
26
- <BrandIdentity /><h1>我的账号</h1>
27
- <section className="panel"><p>姓名:{user.name}</p><p>账号邮箱:{user.email}({user.email_verified?"已验证":"未验证"})</p>
28
- <p>用户名和邮箱均可登录,姓名用于展示。设置用户名不会改变你的事项权限或历史记录。</p>
29
- {error && <p role="alert" className="admin-alert">{error}</p>}
30
- <form className="team-form" onSubmit={save}><label>用户名<input required autoComplete="username" autoCapitalize="none" spellCheck={false} pattern={usernamePattern} minLength={3} maxLength={30} readOnly={saved} aria-describedby="account-username-help" value={username} onChange={e => setUsername(e.target.value)} /></label>
31
- <p id="account-username-help" className={saved ? "admin-notice" : "admin-help"} role={saved ? "status" : undefined}>{saved ? "已锁定,不能修改。可使用此用户名登录。" : usernameHelp}</p>
32
- {!saved && <button className="admin-button admin-primary" disabled={busy}>{busy ? "正在保存…" : "设置用户名"}</button>}
33
- </form><p className="admin-help">忘记密码或无法取得动态验证码时,请联系管理员重新签发登录邀请。</p>
27
+ return <><AccountBar user={user} workspace={workspace} /><main className="page-shell account-settings">
28
+ <header className="settings-page-head"><div><h1>我的账号</h1><p>管理登录资料和个人 AI 工具连接。</p></div></header>
29
+ <nav className="account-section-nav" aria-label="账号设置内容"><a href="#profile">账号资料</a><a href="#ai-tools">AI 工具连接</a></nav>
30
+ <section id="profile" className="panel"><SettingsPanelHeader title="账号资料" description="用户名和邮箱均可登录,姓名用于页面展示。"/>
31
+ <dl className="account-profile"><div><dt>姓名</dt><dd>{user.name}</dd></div><div><dt>账号邮箱</dt><dd>{user.email}<SettingsStatus tone={user.email_verified?"success":"neutral"}>{user.email_verified?"已验证":"未验证"}</SettingsStatus></dd></div></dl>
32
+ {error&&<p role="alert" className="admin-alert">{error}</p>}
33
+ <form className="team-form account-username" onSubmit={save}><label><span>用户名 {saved&&<span className="field-optional">已锁定</span>}</span><span className="username-input"><input required autoComplete="username" autoCapitalize="none" spellCheck={false} pattern={usernamePattern} minLength={3} maxLength={30} readOnly={saved} aria-describedby="account-username-help" value={username} onChange={e=>setUsername(e.target.value)}/>{saved&&<SettingsIcon name="lock"/>}</span></label>
34
+ <p id="account-username-help" className="admin-help" role={saved?"status":undefined}>{saved?"此用户名不能修改,可直接用于登录。":usernameHelp}</p>
35
+ {!saved&&<button className="admin-button admin-primary" disabled={busy}>{busy?"正在保存…":"设置用户名"}</button>}
36
+ </form>
37
+ <div className="account-recovery-note"><h3>账号恢复</h3><p>忘记密码或无法取得动态验证码时,请联系管理员重新签发登录邀请。账号恢复保留原有事项权限和历史记录。</p></div>
34
38
  </section>
39
+ <RemoteAccess user={user} mode="connection" endpoint={endpoint}/>
35
40
  </main></>;
36
41
  }
@@ -6,28 +6,32 @@ import AiUsage from "./AiUsage";
6
6
  import BackupSettings from "./BackupSettings";
7
7
  import TeamManagement from "./TeamManagement";
8
8
  import RemoteAccess from "./RemoteAccess";
9
- import { BrandIdentity } from "./Branding";
10
- import type { TeamUser } from "./lib/team-client";
11
- import type { Project } from "./lib/types";
9
+ import {SettingsIcon,SettingsPanelHeader} from "./SettingsUI";
10
+ import type {TeamUser} from "./lib/team-client";
11
+ import type {Project,DashboardData} from "./lib/types";
12
12
 
13
- const sections = { members: "成员与权限", ai: "AI 设置", usage: "用量与费用", remote: "远程接入", backup: "备份" };
14
- type Section = keyof typeof sections;
15
- export default function AdminCenter({ user, projects, timezone, unavailable, initialSection, dates }: {
16
- user: TeamUser | null; projects: Pick<Project, "id" | "name">[]; timezone: string; unavailable: boolean; initialSection?: string; dates: { from: string; to: string };
13
+ const sections={members:{title:"成员与权限",description:"邀请成员 · 分配访问范围"},ai:{title:"AI 设置",description:"内置 AI · 外部工具"},usage:{title:"用量与费用",description:"请求统计 · 费用估算"},backup:{title:"备份",description:"自动备份 · 数据保护"}};
14
+ type Section=keyof typeof sections;
15
+ export default function AdminCenter({user,projects,timezone,unavailable,initialSection,dates,workspace}:{
16
+ workspace?:DashboardData["workspace"];user:TeamUser|null;projects:Pick<Project,"id"|"name">[];timezone:string;unavailable:boolean;initialSection?:string;dates:{from:string;to:string};
17
17
  }) {
18
- const section: Section = initialSection && Object.hasOwn(sections, initialSection) ? initialSection as Section : user ? "members" : "ai";
18
+ const section:Section=initialSection&&Object.hasOwn(sections,initialSection)?initialSection as Section:user?"members":"ai";
19
19
  return <>
20
- {user ? <AccountBar user={user} management /> : <div className="team-bar"><span>本机使用者 · 管理权限</span><Link className="admin-button" href="/">返回工作台</Link></div>}
20
+ <AccountBar user={user} workspace={workspace}/>
21
21
  <main className="page-shell admin-center">
22
- <header><BrandIdentity /><div className="eyebrow">仅管理员可访问</div><h1>管理中心</h1><p>管理成员访问权限、AI 服务、使用预算与备份。</p></header>
23
- {!user && <p className="admin-notice">当前为本机单人模式,由本机使用者管理设置。团队模式启用后,仅管理员账号可以进入本页面。</p>}
24
- {unavailable && <p className="admin-alert" role="alert">工作区资料暂时不可用,事项列表未能加载,请先检查工作区。</p>}
25
- <nav className="admin-center-nav" aria-label="管理中心功能">{Object.entries(sections).map(([key, label]) => <Link key={key} href={`/admin?section=${key}`} aria-current={section === key ? "page" : undefined}>{label}</Link>)}</nav>
26
- {section === "members" && (user ? <TeamManagement user={user} projects={projects} timezone={timezone} /> : <section className="panel admin-center-panel"><h2>成员与权限</h2><p>成员邀请、停用账号和事项授权在团队模式中提供。本机单人模式无需创建账号。</p></section>)}
27
- {section === "ai" && <AiSettings />}
28
- {section === "usage" && <section className="panel admin-center-panel"><h2>AI 用量与费用</h2><AiUsage initialFrom={dates.from} initialTo={dates.to} /></section>}
29
- {section === "backup" && <BackupSettings timezone={timezone} onChanged={() => {}} />}
30
- {section === "remote" && (user ? <RemoteAccess user={user} manage /> : <section className="panel admin-center-panel"><h2>Codex 远程接入</h2><p>远程接入仅在配置了账号和 HTTPS 的团队模式中提供。</p></section>)}
22
+ <header className="settings-page-head"><div><h1>管理中心</h1><p>管理团队访问、AI 服务与数据备份。</p></div><span className="settings-access-label"><SettingsIcon name="lock"/>仅管理员可访问</span></header>
23
+ {!user&&<p className="admin-notice">当前为本机单人模式。成员邀请和权限管理在团队模式中提供。</p>}
24
+ {unavailable&&<p className="admin-alert" role="alert">工作区资料暂时不可用,事项列表未能加载,请先检查工作区。</p>}
25
+ <div className="settings-layout">
26
+ <nav className="admin-center-nav" aria-label="管理中心功能">{Object.entries(sections).map(([key,item])=><Link key={key} href={`/admin?section=${key}`} aria-current={section===key?"page":undefined}><SettingsIcon name={key as Section}/><span><strong>{item.title}</strong><small>{item.description}</small></span></Link>)}</nav>
27
+ <div className="settings-content" key={section}>
28
+ {section==="members"&&(user?<TeamManagement user={user} projects={projects} timezone={timezone}/>:<section className="panel admin-center-panel"><SettingsPanelHeader title="成员与权限" description="本机单人模式无需创建账号。"/><p>部署为团队模式后,可在这里邀请成员、停用账号并分配事项权限。</p></section>)}
29
+ {section==="ai"&&<>{user&&<nav className="account-section-nav" aria-label="AI 设置内容"><a href="#builtin-ai">内置 AI 助手</a><a href="#external-ai">外部 AI 工具</a></nav>}<div id="builtin-ai"><AiSettings/></div>{user&&<RemoteAccess user={user} mode="settings"/>}</>}
30
+ {section==="usage"&&<section className="panel admin-center-panel"><SettingsPanelHeader title="用量与费用" description="查看团队 AI 请求、Token 用量和费用估算。"/><AiUsage initialFrom={dates.from} initialTo={dates.to}/></section>}
31
+ {section==="backup"&&<BackupSettings timezone={timezone} onChanged={()=>{}}/>}
32
+
33
+ </div>
34
+ </div>
31
35
  </main>
32
36
  </>;
33
37
  }
@@ -1,46 +1,42 @@
1
1
  "use client";
2
- import { useEffect, useState } from "react";
3
- import type { AiSettings, AiStatus } from "./lib/ai-types";
4
- import { aiRequest } from "./lib/ai-client";
2
+ import {useEffect,useState} from "react";
3
+ import type {AiSettings,AiStatus} from "./lib/ai-types";
4
+ import {aiRequest} from "./lib/ai-client";
5
+ import {SettingsPanelHeader,SettingsStatus} from "./SettingsUI";
5
6
 
6
- function Settings({ settings, onSaved }: { settings: AiSettings; onSaved: (status: AiStatus) => void }) {
7
- const [value, setValue] = useState(settings);
8
- const [key, setKey] = useState("");
9
- const [clearKey, setClearKey] = useState(false);
10
- const [busy, setBusy] = useState(false);
11
- const [error, setError] = useState("");
12
- async function save(event: React.FormEvent) {
13
- event.preventDefault(); setBusy(true); setError("");
14
- try { onSaved(await aiRequest<AiStatus>({ action: "configure", ...value, apiKey: key, clearKey })); setKey(""); }
15
- catch (err) { setError((err as Error).message); } finally { setBusy(false); }
7
+ const limits=[{key:"dailyTokenLimit",label:"每日 Token 上限",step:"1",max:1000000000,hint:"留空不设此项限制,默认 100 万;调用前预留额度。"},{key:"dailyBudgetCny",label:"每日费用上限(人民币元)",step:"0.01",max:1000000,hint:"选填,需先填写下方的输入与输出单价。"}] as const;
8
+ const prices=[{key:"inputPriceCny",label:"输入单价",hint:"未填写时,费用标为未计价。"},{key:"outputPriceCny",label:"输出单价",hint:"美元报价需先换算成人民币。"},{key:"cachedInputPriceCny",label:"缓存输入单价",hint:"选填;留空时按普通输入单价估算。"}] as const;
9
+ function Settings({settings,onSaved}:{settings:AiSettings;onSaved:(status:AiStatus)=>void}) {
10
+ const [value,setValue]=useState(settings),[key,setKey]=useState(""),[clearKey,setClearKey]=useState(false);
11
+ const [busy,setBusy]=useState(false),[error,setError]=useState("");
12
+ async function save(event:React.FormEvent){
13
+ event.preventDefault();setBusy(true);setError("");
14
+ try{onSaved(await aiRequest<AiStatus>({action:"configure",...value,apiKey:key,clearKey}));setKey("");}
15
+ catch(err){setError((err as Error).message);}finally{setBusy(false);}
16
16
  }
17
- return <form className="ai-settings" onSubmit={save}>
18
- <p className="admin-help">开启后,成员发起请求时会将输入文字和本次所需的授权事项资料发送到所选模型服务。系统不自动读取附件原文、电话或微信等联系方式。</p>
19
- <div className="edit-form-grid">
20
- <label className="edit-field"><span>模型服务</span><select value={value.provider} onChange={e => { const provider = e.target.value as AiSettings["provider"]; setValue({ ...value, provider, baseUrl: provider === "openai" ? "https://api.openai.com/v1" : "https://dashscope.aliyuncs.com/compatible-mode/v1", model: provider === "openai" ? "" : "qwen-plus", inputPriceCny: null, cachedInputPriceCny: null, outputPriceCny: null, dailyBudgetCny: null, keyConfigured: false, enabled: false }); setKey(""); }}><option value="qwen-cn">Qwen · 阿里云百炼(北京)</option><option value="openai">OpenAI</option></select></label>
21
- <label className="edit-field"><span>模型名称</span><input required value={value.model} maxLength={120} onChange={e => setValue({ ...value, model: e.target.value, inputPriceCny: null, cachedInputPriceCny: null, outputPriceCny: null, dailyBudgetCny: null })} placeholder="填写服务控制台中已开通的文本模型" /></label>
22
- <label className="edit-field edit-field-wide"><span>服务地址</span><input required type="url" value={value.baseUrl} onChange={e => setValue({ ...value, baseUrl: e.target.value })} /><small>可使用百炼北京地域的通用地址或业务空间专属地址。</small></label>
23
- <label className="edit-field"><span>API 密钥</span><input type="password" autoComplete="new-password" value={key} maxLength={500} onChange={e => { setKey(e.target.value); setClearKey(false); }} placeholder={value.keyConfigured ? "已保存,留空保持现有密钥" : "填写对应服务的 API 密钥"} /></label>
24
- <label className="edit-field"><span>公司每日请求上限</span><input type="number" required min={1} max={1000} value={value.dailyLimit} onChange={e => setValue({ ...value, dailyLimit: Number(e.target.value) })} /><small>按工作区时区计数;失败请求也计入,另受模型服务自身额度限制。</small></label>
25
- {([{ key: "dailyTokenLimit", label: "公司每日 Token 上限", step: "1", max: 1000000000, hint: "留空不设此项限制,默认 100 万;调用前预留额度。" }, { key: "dailyBudgetCny", label: "公司每日费用上限(人民币元)", step: "0.01", max: 1000000, hint: "选填,需要先填写输入与输出单价。" }, { key: "inputPriceCny", label: "输入单价(元 / 百万 Token)", step: "0.000001", max: 1000000, hint: "按账户实际单价填写;未填写时费用标为未计价。" }, { key: "outputPriceCny", label: "输出单价(元 / 百万 Token)", step: "0.000001", max: 1000000, hint: "美元报价需换算成人民币后填写。" }, { key: "cachedInputPriceCny", label: "缓存输入单价(元 / 百万 Token)", step: "0.000001", max: 1000000, hint: "选填;留空时按普通输入单价估算。" }] as const).map(field => <label className="edit-field" key={field.key}><span>{field.label}</span><input type="number" min={0} max={field.max} step={field.step} value={value[field.key] ?? ""} onChange={e => setValue({ ...value, [field.key]: e.target.value === "" ? null : Number(e.target.value) })} /><small>{field.hint}</small></label>)}
26
- </div>
27
- <div className="admin-actions"><label><input type="checkbox" checked={value.enabled} onChange={e => setValue({ ...value, enabled: e.target.checked })} /> 开启 AI 助手</label><label><input type="checkbox" checked={clearKey} onChange={e => { setClearKey(e.target.checked); if (e.target.checked) { setKey(""); setValue({ ...value, enabled: false }); } }} /> 清除已保存密钥</label><button className="admin-button admin-primary" disabled={busy}>{busy ? "保存中…" : "保存 AI 设置"}</button></div>
28
- {error && <p role="alert" className="admin-alert">{error}</p>}
29
- </form>;
17
+ return <form className="ai-settings" onSubmit={save}><fieldset className="edit-fields" disabled={busy}>
18
+ <section className="settings-form-section">
19
+ <div className="settings-row-heading"><div className="settings-toggle-copy"><strong id="ai-enabled-title">AI 助手</strong><p id="ai-enabled-help">开启后,成员可使用对话录入和进展总结。</p></div><label className="backup-switch"><input type="checkbox" role="switch" aria-labelledby="ai-enabled-title" aria-describedby="ai-enabled-help" checked={value.enabled} onChange={e=>setValue({...value,enabled:e.target.checked})}/><span aria-hidden="true"/></label></div>
20
+ <p className="settings-security-note">成员发起请求时,输入文字和本次所需的授权事项资料会发送到所选模型服务。系统不自动读取附件原文、电话或微信等联系方式。</p>
21
+ </section>
22
+ <section className="settings-form-section"><h3>模型与连接</h3><div className="edit-form-grid">
23
+ <label className="edit-field"><span>模型服务</span><select value={value.provider} onChange={e=>{const provider=e.target.value as AiSettings["provider"];setValue({...value,provider,baseUrl:provider==="openai"?"https://api.openai.com/v1":"https://dashscope.aliyuncs.com/compatible-mode/v1",model:provider==="openai"?"":"qwen-plus",inputPriceCny:null,cachedInputPriceCny:null,outputPriceCny:null,dailyBudgetCny:null,keyConfigured:false,enabled:false});setKey("");}}><option value="qwen-cn">Qwen · 阿里云百炼(北京)</option><option value="openai">OpenAI</option></select></label>
24
+ <label className="edit-field"><span>模型名称</span><input required value={value.model} maxLength={120} onChange={e=>setValue({...value,model:e.target.value,inputPriceCny:null,cachedInputPriceCny:null,outputPriceCny:null,dailyBudgetCny:null})} placeholder="填写已开通的文本模型"/></label>
25
+ <label className="edit-field edit-field-wide"><span>服务地址</span><input required type="url" value={value.baseUrl} onChange={e=>setValue({...value,baseUrl:e.target.value})}/><small>百炼可使用北京地域通用地址或业务空间专属地址。</small></label>
26
+ <label className="edit-field edit-field-wide"><span>API 密钥</span><input type="password" autoComplete="new-password" value={key} maxLength={500} onChange={e=>{setKey(e.target.value);setClearKey(false);}} placeholder={value.keyConfigured?"已保存,留空保持现有密钥":"填写对应服务的 API 密钥"}/><small>已保存的密钥不会回显,仅管理员可以替换或清除。</small></label>
27
+ </div>{settings.keyConfigured&&<label className="settings-clear-key"><input type="checkbox" checked={clearKey} onChange={e=>{setClearKey(e.target.checked);if(e.target.checked){setKey("");setValue({...value,enabled:false});}}}/>清除已保存密钥,同时关闭 AI 助手</label>}</section>
28
+ <section className="settings-form-section"><h3>团队每日限额</h3><div className="edit-form-grid"><label className="edit-field"><span>每日请求上限</span><input type="number" required min={1} max={1000} value={value.dailyLimit} onChange={e=>setValue({...value,dailyLimit:Number(e.target.value)})}/><small>按工作区时区计数,失败请求也计入。</small></label>{limits.map(field=><label className="edit-field" key={field.key}><span>{field.label}</span><input type="number" min={0} max={field.max} step={field.step} value={value[field.key]??""} onChange={e=>setValue({...value,[field.key]:e.target.value===""?null:Number(e.target.value)})}/><small>{field.hint}</small></label>)}</div></section>
29
+ <section className="settings-form-section"><h3>费用单价 <span className="field-optional">人民币元 / 百万 Token</span></h3><div className="edit-form-grid">{prices.map(field=><label className="edit-field" key={field.key}><span>{field.label}</span><input type="number" min={0} max={1000000} step="0.000001" value={value[field.key]??""} onChange={e=>setValue({...value,[field.key]:e.target.value===""?null:Number(e.target.value)})}/><small>{field.hint}</small></label>)}</div><p className="admin-help">按模型服务商的实际账户单价填写;费用统计为估算值。</p></section>
30
+ {error&&<p role="alert" className="admin-alert">{error}</p>}
31
+ <div className="settings-form-actions"><button className="admin-button admin-primary" disabled={busy}>{busy?"保存中…":"保存 AI 设置"}</button></div>
32
+ </fieldset></form>;
30
33
  }
31
- export default function AiSettingsPanel() {
32
- const [status, setStatus] = useState<AiStatus | null>(null);
33
- const [error, setError] = useState("");
34
- const [notice, setNotice] = useState("");
35
- useEffect(() => {
36
- let active = true;
37
- aiRequest<AiStatus>(undefined, "settings").then(value => { if (active) setStatus(value); }).catch(err => { if (active) setError(err.message); });
38
- return () => { active = false; };
39
- }, []);
40
- return <section className="panel admin-center-panel"><h2>AI 设置</h2>
41
- <p>仅管理员可开启 AI、更换密钥、配置模型和使用限额。已保存的密钥不会显示,可替换或清除。</p>
42
- {error && <p className="admin-alert" role="alert">{error}</p>}{notice && <p role="status">{notice}</p>}
43
- {!status && !error && <p>正在读取 AI 设置…</p>}
44
- {status?.canManage && status.settings && <><p>当前状态:{status.enabled ? "已开启" : "未开启"}</p><Settings key={status.settings.revision} settings={status.settings} onSaved={value => { setStatus(value); setNotice("AI 设置已保存。"); }} /></>}
34
+ export default function AiSettingsPanel(){
35
+ const [status,setStatus]=useState<AiStatus|null>(null),[error,setError]=useState(""),[notice,setNotice]=useState("");
36
+ useEffect(()=>{let active=true;aiRequest<AiStatus>(undefined,"settings").then(value=>{if(active)setStatus(value);}).catch(err=>{if(active)setError(err.message);});return()=>{active=false;};},[]);
37
+ return <section className="panel admin-center-panel"><SettingsPanelHeader title="AI 设置" description="配置模型服务、访问密钥和团队使用限额。" actions={status&&<SettingsStatus tone={status.enabled?"success":"neutral"}>{status.enabled?"已开启":"未开启"}</SettingsStatus>}/>
38
+ {error&&<p className="admin-alert" role="alert">{error}</p>}{notice&&<p className="admin-notice" role="status">{notice}</p>}
39
+ {!status&&!error&&<p className="settings-empty" role="status">正在读取 AI 设置…</p>}
40
+ {status?.canManage&&status.settings&&<Settings key={status.settings.revision} settings={status.settings} onSaved={value=>{setStatus(value);setNotice("AI 设置已保存。");}}/>}
45
41
  </section>;
46
42
  }
@@ -25,10 +25,12 @@ export default function AiUsage({ initialFrom, initialTo }: { initialFrom: strin
25
25
  }
26
26
  const total = report?.rows.reduce((all, row) => ({ requests: all.requests + row.requests, tokens: all.tokens + row.inputTokens + row.outputTokens, cost: all.cost + row.estimatedCny, unknown: all.unknown + row.unknownUsage, unpriced: all.unpriced + row.unpriced, held: all.held + row.heldTokens, heldCost: all.heldCost + row.heldCny }), { requests: 0, tokens: 0, cost: 0, unknown: 0, unpriced: 0, held: 0, heldCost: 0 });
27
27
  return <section className="ai-usage">
28
- <p className="admin-help">Token 数来自模型接口回传;人民币费用按调用时配置的单价估算,实际账单以服务商为准。缓存 Token 已包含在输入中,不重复计数。统计不记录对话内容。</p>
29
- <form className="admin-actions" onSubmit={refresh}><label>统计开始<input aria-label="用量统计开始日期" type="date" required value={from} onChange={e => setFrom(e.target.value)} /></label><label>统计结束<input aria-label="用量统计结束日期" type="date" required value={to} onChange={e => setTo(e.target.value)} /></label><button className="admin-button" disabled={busy}>{busy ? "查询中…" : "查询用量"}</button></form>
28
+ <form className="usage-filters" onSubmit={refresh}><label>统计开始<input aria-label="用量统计开始日期" type="date" required value={from} onChange={e => setFrom(e.target.value)} /></label><label>统计结束<input aria-label="用量统计结束日期" type="date" required value={to} onChange={e => setTo(e.target.value)} /></label><button className="admin-button" disabled={busy}>{busy ? "查询中…" : "查询用量"}</button></form>
30
29
  {error && <p role="alert" className="admin-alert">{error}</p>}
31
- {report && total && <><p>{report.from} 至 {report.to}:{number(total.requests)} 次请求,已回传 {number(total.tokens)} Token,已估算 {money(total.cost)}。</p>{(total.unknown > 0 || total.unpriced > 0) && <p className="admin-help">其中 {total.unknown} 次未回传用量,{total.unpriced} 次费用尚不能确定;仍占用限额的预留量为 {number(total.held)} Token / {money(total.heldCost)}。这些预留值不是实际消耗,请核对服务商账单。</p>}
32
- <div className="ai-usage-table"><table><caption>按日期、成员和模型统计</caption><thead><tr><th>日期</th><th>成员</th><th>模型服务 / 模型</th><th>请求</th><th>失败或中断</th><th>输入 Token</th><th>输出 Token</th><th>缓存命中</th><th>估算费用</th></tr></thead><tbody>{report.rows.map((row, i) => <tr key={i}><td>{row.day}</td><td>{row.member}</td><td>{row.provider === "qwen-cn" ? "百炼北京" : "OpenAI"}<br />{row.model}</td><td>{row.requests}{row.running ? `(${row.running} 进行中)` : ""}</td><td>{row.failures}</td><td>{number(row.inputTokens)}{row.unknownUsage > 0 && `(${row.unknownUsage} 次未知)`}</td><td>{number(row.outputTokens)}</td><td>{number(row.cachedTokens)}</td><td>{money(row.estimatedCny)}{row.unpriced > 0 && `(${row.unpriced} 次未计价)`}</td></tr>)}</tbody></table>{!report.rows.length && <p>所选日期内暂无 AI 请求。</p>}</div></>}
30
+ {report && total && <><p className="admin-help">{report.from} 至 {report.to}</p><dl className="usage-metrics"><div><dt>请求次数</dt><dd>{number(total.requests)}</dd></div><div><dt>已回传 Token</dt><dd>{number(total.tokens)}</dd></div><div><dt>估算费用</dt><dd>{money(total.cost)}</dd></div></dl>{(total.unknown > 0 || total.unpriced > 0) && <p className="admin-help">其中 {total.unknown} 次未回传用量,{total.unpriced} 次费用尚不能确定;仍占用限额的预留量为 {number(total.held)} Token / {money(total.heldCost)}。这些预留值不是实际消耗,请核对服务商账单。</p>}
31
+ <div className="ai-usage-table"><table><caption>按日期、成员和模型统计</caption><thead><tr><th>日期</th><th>成员</th><th>模型服务 / 模型</th><th>请求</th><th>失败或中断</th><th>输入 Token</th><th>输出 Token</th><th>缓存命中</th><th>估算费用</th></tr></thead><tbody>{report.rows.map((row, i) => <tr key={i}><td>{row.day}</td><td>{row.member}</td><td>{row.provider === "qwen-cn" ? "百炼北京" : "OpenAI"}<br />{row.model}</td><td>{row.requests}{row.running ? `(${row.running} 进行中)` : ""}</td><td>{row.failures}</td><td>{number(row.inputTokens)}{row.unknownUsage > 0 && `(${row.unknownUsage} 次未知)`}</td><td>{number(row.outputTokens)}</td><td>{number(row.cachedTokens)}</td><td>{money(row.estimatedCny)}{row.unpriced > 0 && `(${row.unpriced} 次未计价)`}</td></tr>)}</tbody></table>{!report.rows.length && <p className="settings-empty">所选日期内暂无 AI 请求。</p>}</div></>}
32
+ <details className="usage-notes"><summary>统计口径与费用说明</summary>
33
+ <p className="admin-help">Token 数来自模型接口回传;人民币费用按调用时配置的单价估算,实际账单以服务商为准。缓存 Token 已包含在输入中,不重复计数。统计不记录对话内容。</p>
34
+ </details>
33
35
  </section>;
34
36
  }
@@ -63,7 +63,7 @@ export default function BackupSettings({timezone,onChanged}: {timezone:string;on
63
63
 
64
64
  return <section className="panel backup-settings" aria-labelledby="backup-title">
65
65
  <header className="backup-header">
66
- <div><h1 id="backup-title">备份</h1><p>为业务资料、附件、账号权限和操作历史保留独立副本。</p></div>
66
+ <div><h2 id="backup-title">备份</h2><p>为业务资料、附件、账号权限和操作历史保留独立副本。</p></div>
67
67
  <button type="button" className="admin-button admin-primary backup-now" disabled={!status || changed || busy || status.in_progress} onClick={() => void backup()} title={changed ? "请先保存或取消设置修改" : undefined}>
68
68
  {pending === "backup" ? <span className="backup-spinner" aria-hidden="true"/> : <BackupIcon kind="archive"/>}
69
69
  {pending === "backup" ? "正在备份…" : "立即备份"}
@@ -101,10 +101,10 @@ export default function BackupSettings({timezone,onChanged}: {timezone:string;on
101
101
  <label className="edit-field"><span>备份文件夹</span><div className="backup-directory-input"><BackupIcon kind="folder"/><input required readOnly={status.server} value={directory} onChange={event => setDirectory(event.target.value)} placeholder="填写完整文件夹路径" autoComplete="off" spellCheck={false}/></div></label>
102
102
  <label className="edit-field"><span>备份频率</span><select value={interval} onChange={event => setIntervalMinutes(Number(event.target.value))}><option value={60}>每小时一次</option><option value={1440}>每 24 小时一次</option></select></label>
103
103
  </div>
104
- <label className="team-check"><input type="checkbox" checked={oss} disabled={!status.oss_configured&&!oss} onChange={e=>setOss(e.target.checked)}/>同步到私有 OSS</label>
105
- {!status.oss_configured&&<p>服务器维护人员配置 OSS 后即可启用。访问凭据不会在页面中保存或显示。</p>}
104
+ <div className="backup-extra-options"><label className="team-check"><input type="checkbox" checked={oss} disabled={!status.oss_configured&&!oss} onChange={e=>setOss(e.target.checked)}/>同步到私有 OSS</label>
105
+ {!status.oss_configured&&<p className="admin-help">服务器维护人员配置 OSS 后即可启用。访问凭据不会在页面中保存或显示。</p>}
106
106
  <label className="edit-field"><span>本地副本保留期</span><select value={retention} onChange={e=>setRetention(Number(e.target.value))}>{[0,7,30,90,365].map(days=><option key={days} value={days}>{days?`${days} 天`:"不自动清理"}</option>)}</select></label>
107
- <p className="backup-schedule-note">{status.server?"服务器持续运行期间自动检查;资料变化后按设置频率备份。服务器目录由部署配置管理。":"关闭工作台期间暂停,重新打开后检查补做。"} OSS 保留期由存储空间生命周期规则管理。</p>
107
+ </div><p className="backup-schedule-note">{status.server?"服务器持续运行期间自动检查;资料变化后按设置频率备份。服务器目录由部署配置管理。":"关闭工作台期间暂停,重新打开后检查补做。"} OSS 保留期由存储空间生命周期规则管理。</p>
108
108
  {changed && <div className="backup-save-bar"><span>设置尚未保存</span><div className="admin-actions"><button type="button" className="admin-button" onClick={cancel}>取消修改</button><button type="submit" className="admin-button admin-primary">{pending === "save" ? "正在保存…" : "保存设置"}</button></div></div>}
109
109
  </fieldset>
110
110
  </form>
@@ -1338,15 +1338,9 @@ export default function Dashboard({ data, workspaceStatus, workspaceError, onWor
1338
1338
  : scopeLabel;
1339
1339
 
1340
1340
  return (
1341
- <main className="page-shell">
1342
- <header className="topbar">
1343
- <a className="brand" href="#top" aria-label="返回看板顶部">
1344
- <BrandLogo />
1345
- <span>
1346
- <strong>{data.workspace.short_name}</strong>
1347
- <small>{data.workspace.workspace_title}</small>
1348
- </span>
1349
- </a>
1341
+ <main className="page-shell workspace-page">
1342
+ <header className="workspace-toolbar">
1343
+ {readOnly?<a className="brand" href="#top" aria-label="返回历史看板顶部"><BrandLogo/><span><strong>{data.workspace.short_name}</strong><small>历史资料</small></span></a>:<div className="workspace-context"><span>业务工作台</span><p>{data.workspace.description}</p></div>}
1350
1344
  <div className="topbar-actions">
1351
1345
  <div className="data-freshness">
1352
1346
  <span className="live-dot" aria-hidden="true" />
@@ -1383,7 +1377,6 @@ export default function Dashboard({ data, workspaceStatus, workspaceError, onWor
1383
1377
  </button>
1384
1378
  {!readOnly && canAdmin && <button type="button" aria-pressed={page === "admin"} onClick={() => setPage("admin")}>资料管理</button>}
1385
1379
  {!readOnly && canAdmin && <button type="button" aria-pressed={page === "history"} onClick={() => {setHistoryTarget(null);setPage("history");}}>修改历史</button>}
1386
- {!readOnly && !data.access && <Link href="/admin" className="admin-button">管理中心</Link>}
1387
1380
  {!readOnly && <button type="button" onClick={()=>openFiles()}>事项资料</button>}{readOnly && <button type="button" onClick={()=>openFiles()}>历史资料</button>}
1388
1381
  {!readOnly && <button type="button" aria-pressed={page === "ai"} onClick={() => {setAiScope("");setPage("ai");}}>AI 助手</button>}
1389
1382
  </nav>
@@ -166,7 +166,7 @@ export default function FileLibraryDialog({ data, target, onClose, onChanged, on
166
166
  {file.notes && <p className="files-notes">{file.notes}</p>}<p className="admin-help">归档对接方:{data.counterparties.find(item => item.id === file.home_counterparty_id)?.name || file.home_counterparty_id}</p><p className="admin-help">关联事项:{file.project_ids.map(id => data.projects.find(item => item.id === id)?.name || id).join("、")}</p>
167
167
  <section className="files-section"><h4>文件版本</h4>{[...file.versions].reverse().map(version => <article className="file-version" key={version.id}><div><strong>v{file.versions.indexOf(version)+1} · {fileStatuses[version.status]} {version.id === file.current_version && <span className="file-current">当前版本</span>}</strong><p>{version.filename}</p><small>{new Date(version.created_at).toLocaleString(data.workspace.locale,{timeZone:data.workspace.timezone})} · {sizeLabel(version.size)}</small>{version.note && <p>{version.note}</p>}</div><div className="admin-actions"><a className="admin-button" href={fileUrl(file,version)} download>下载</a>{local && <button type="button" className="admin-button" disabled={busy} onClick={() => void reveal({version_id:version.id})}>打开所在文件夹</button>}{/\.(pdf|png|jpe?g)$/i.test(version.filename) && <a className="admin-button" href={fileUrl(file,version,true)} target="_blank" rel="noreferrer">预览</a>}{canEdit && !file.archived && version.id !== file.current_version && <button className="admin-button" disabled={busy} onClick={() => setConfirmation({title:`恢复为 v${file.versions.indexOf(version)+1}?`,detail:"其他文件版本继续保留,已有发送记录仍指向当时的版本。",action:() => {void save("select_version",{version_id:version.id});}})}>设为当前版本</button>}</div></article>)}</section>
168
168
  <section className="files-section"><h4>使用与发送记录</h4>{!file.records.length && <p className="admin-help">暂无使用记录。导入文件不会自动标记为已发送。</p>}{[...file.records].sort((a,b) => b.date.localeCompare(a.date)).map(record => {const relatedProject=data.projects.find(item => item.id === record.project_id);const relatedEvent=relatedProject?.events.find(item => item.id === record.event_id);const version=file.versions.find(item => item.id === record.version_id)!;return <article className="file-usage" key={record.id}><div><strong>{record.date} · {fileUses[record.kind]} · v{file.versions.indexOf(version)+1}</strong><p>{record.people.map(id => data.people.find(person => person.id === id)?.name || id).join("、") || "未指定人员"}{record.channel ? ` · ${record.channel}` : ""}</p><small>{relatedProject?.name}{relatedEvent ? ` · ${relatedEvent.title}` : ""}</small>{record.note && <p>{record.note}</p>}</div><div className="admin-actions"><a className="admin-button" href={fileUrl(file,version)} download>下载当时版本</a>{local && <button type="button" className="admin-button" disabled={busy} onClick={() => void reveal({record_id:record.id})}>打开所在文件夹</button>}{canEdit && !file.archived && <><button className="admin-button" disabled={busy} onClick={() => {setUsageId(record.id);setMode("usage");}}>更正记录</button><button className="admin-button" disabled={busy} onClick={() => setConfirmation({title:"移除这条使用记录?",detail:"仅移除这条记录,文件原件继续保留;修改历史中可回溯。",action:() => {void save("usage_remove",{record_id:record.id});}})}>移除记录</button></>}</div></article>;})}</section>
169
- </> : <div className="files-empty"><h3>把交流资料与进展放在一起</h3><p>选择左侧资料查看版本和发送情况,或导入第一份资料。</p><p>同一份资料可关联多个事项,发送记录始终对应具体版本。</p></div>}
169
+ </> : <div className="files-empty"><h3>把交流资料与进展放在一起</h3><p>选择一份资料查看版本和发送情况,或导入第一份资料。</p><p>同一份资料可关联多个事项,发送记录始终对应具体版本。</p></div>}
170
170
  </div></div>
171
171
  {listing && <footer className="files-footer"><details><summary>归档与备份说明</summary>{local?<p>工作区根目录:{listing.workspace_directory}</p>:<p>资料保存在公司服务器,可下载到自己的电脑。完整备份由管理员管理。</p>}<p>附件随对接方保存:{listing.directory_pattern}</p><p>业务记录、附件和自动修改历史统一保存在这个工作区内。备份仅包含工作台管理的资料。</p><p>每个对接方的档案、事项和附件放在同一个文件夹内。同一资料关联多个事项时,只在归档对接方内保存文件。</p><p>同一份资料的各版本放在同一个文件夹内,默认保留原文件名;同名且内容不同时追加版本后缀。NDA 的签署状态单独记录,不会改变关联事项的推进阶段。</p><p>目录中的文件是归档副本。更新内容请导入新版本;原有主版本可以继续在原目录编辑。完整备份包含附件原件、业务记录和工作台自动修改历史,原仓库 Git 历史由原仓库单独备份。</p></details></footer>}
172
172
  </Dialog.Content></Dialog.Portal></Dialog.Root>;
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { useCallback, useEffect, useState } from "react";
3
3
  import Link from "next/link";
4
+ import {SettingsPanelHeader,SettingsStatus} from "./SettingsUI";
4
5
  import type { TeamUser } from "./lib/team-client";
5
6
  import { stageLabels, followUpStatusLabels } from "./lib/labels";
6
7
 
@@ -19,7 +20,8 @@ function display(field: string, value: unknown) {
19
20
  }
20
21
  const when = (value: number) => new Date(value * 1000).toLocaleString("zh-CN");
21
22
 
22
- export default function RemoteAccess({ user, manage = false, endpoint = "" }: { user: TeamUser; manage?: boolean; endpoint?: string }) {
23
+ export default function RemoteAccess({ user, mode, endpoint = "" }: { user: TeamUser; mode: "settings" | "connection" | "drafts"; endpoint?: string }) {
24
+ const manage = mode === "settings";
23
25
  const [status, setStatus] = useState<Status | null>(null), [drafts, setDrafts] = useState<Draft[]>([]);
24
26
  const [error, setError] = useState(""), [notice, setNotice] = useState(""), [busy, setBusy] = useState(false);
25
27
  const [secret, setSecret] = useState(""), [label, setLabel] = useState("我的 Codex"), [days, setDays] = useState(7), [canDraft, setCanDraft] = useState(false);
@@ -34,14 +36,19 @@ export default function RemoteAccess({ user, manage = false, endpoint = "" }: {
34
36
  }, [user.csrf]);
35
37
  const load = useCallback(async () => {
36
38
  const latest = await request<Status>(manage ? "settings" : "status");
37
- const pending = !manage && latest.enabled && latest.granted ? (await request<{ drafts: Draft[] }>("drafts")).drafts : [];
39
+ const pending = mode === "drafts" && latest.enabled && latest.granted ? (await request<{ drafts: Draft[] }>("drafts")).drafts : [];
38
40
  return { latest, pending };
39
- }, [manage, request]);
41
+ }, [manage, mode, request]);
40
42
  const apply = useCallback(({ latest, pending }: { latest: Status; pending: Draft[] }) => {
41
43
  setStatus(latest); setDrafts(pending); setChecked({});
42
44
  }, []);
43
45
  async function reload() { apply(await load()); }
44
46
  useEffect(() => { void load().then(apply).catch(err => setError((err as Error).message)); }, [load, apply]);
47
+ useEffect(() => {
48
+ if (mode !== "drafts" || !drafts.length) return;
49
+ const anchor = window.location.hash.slice(1);
50
+ if (drafts.some(draft => anchor === `draft-${draft.id}`)) document.getElementById(anchor)?.scrollIntoView({ block: "start" });
51
+ }, [mode, drafts]);
45
52
  async function act(action: string, input: Record<string, unknown>, message: string) {
46
53
  setBusy(true); setError(""); setNotice("");
47
54
  try {
@@ -51,27 +58,20 @@ export default function RemoteAccess({ user, manage = false, endpoint = "" }: {
51
58
  } catch (err) { setError((err as Error).message); }
52
59
  finally { setBusy(false); }
53
60
  }
54
- return <section className="panel admin-center-panel remote-access">
55
- <h2>{manage ? "Codex 远程接入" : "我的远程接入"}</h2>
56
- <p>远程助手可查询获授权事项和提交修改草稿。草稿由本人在本页面核对并确认后保存。</p>
61
+ return <section id={manage?"external-ai":mode==="connection"?"ai-tools":undefined} className="panel admin-center-panel remote-access">
62
+ <SettingsPanelHeader title={manage?"外部 AI 工具":mode==="connection"?"AI 工具连接":"修改草稿"} description={manage?"允许成员通过 Codex 等外部工具访问工作台,与内置 AI 的模型配置和费用独立。":mode==="connection"?"连接自己的 AI 工具,查询获授权事项或提交修改草稿。":"核对外部 AI 工具提交的内容,由你确认后正式保存。"} actions={mode==="connection"?<Link className="admin-button" href="/remote">查看待确认修改</Link>:undefined}/>
57
63
  {error && <p className="admin-alert" role="alert">{error}</p>}
58
64
  {notice && <p className="admin-notice" role="status">{notice}</p>}
59
65
  {!status ? <p>正在读取接入状态…</p> : <>
60
- <p>当前状态:<strong>{status.enabled ? "已开启" : "已关闭"}</strong></p>
61
- {manage ? <>
62
- <p className="admin-help">按账号授权。关闭接入将撤销全部凭据和待确认草稿;重新开启后需重新生成凭据。</p>
63
- <button className="admin-button" disabled={busy} onClick={() => void act("configure", { enabled: !status.enabled, revision: status.revision }, status.enabled ? "已关闭,旧凭据和草稿已失效。" : "已开启。请为需要使用的成员授权。")}>{status.enabled ? "关闭远程接入并撤销凭据" : "开启远程接入"}</button>
64
- <h3>成员授权</h3>
65
- <ul className="remote-items">{status.members?.map(member => <li key={member.id}><span>{member.name} · {{ admin: "管理员", editor: "编辑成员", viewer: "只读成员" }[member.role]}{!member.active && " · 未启用"}</span>
66
- <button className="admin-button" disabled={busy || (!member.active && !member.granted)} onClick={() => void act("grant", { memberId: member.id, allowed: !member.granted }, member.granted ? "已取消授权,该成员的凭据与草稿已失效。" : "成员可登录后自行生成凭据。")}>{member.granted ? "取消远程授权" : "允许远程接入"}</button></li>)}</ul>
67
- <p><Link className="admin-button" href="/remote">管理我自己的凭据和草稿</Link></p>
68
- </> : <>
69
- <p className="admin-help">返回给 Codex 的业务资料会进入你所使用的模型上下文。请按公司要求使用模型;这里不自动读取附件和联系方式。Codex 的模型费用由对应账号承担。</p>
70
- {!status.enabled || !status.granted ? <p>请联系管理员开启功能并授权你的账号。</p> : <>
66
+ {mode !== "drafts" && <div className="remote-state"><div><SettingsStatus tone={status.enabled?"success":"neutral"}>{status.enabled?"工作区已开启":"工作区未开启"}</SettingsStatus><p className="admin-help">{manage?"成员权限统一在“成员与权限”中分配。":status.granted?"你的账号已获授权,访问范围与事项权限一致。":"你的账号尚未获授权,请联系管理员调整成员权限。"}</p></div>{manage&&<button className="admin-button" disabled={busy} onClick={()=>{if(status.enabled&&!window.confirm("关闭外部 AI 工具接入?全部个人凭据和待确认草稿将失效,成员授权设置保留。"))return;void act("configure",{enabled:!status.enabled,revision:status.revision},status.enabled?"已关闭,旧凭据和草稿已失效。":"已开启,已获授权的成员可自行连接工具。");}}>{status.enabled?"关闭外部工具接入":"开启外部工具接入"}</button>}</div>}
67
+ {manage && <p className="admin-help">在 <Link href="/admin?section=members">成员与权限</Link> 邀请或编辑成员时分配权限。关闭全局开关会撤销全部凭据和草稿,保留成员授权;重新开启后,由成员生成新凭据。</p>}
68
+ {mode === "connection" && <>
69
+ <p className="admin-help">返回给外部工具的业务资料会进入你所使用的模型上下文,请按公司要求选择模型。这里不自动读取附件和联系方式,模型费用由对应工具账号承担。</p>
70
+ {!status.enabled || !status.granted ? <p className="settings-empty">{!status.enabled?"工作区尚未开启外部工具接入。":"请联系管理员允许你的账号连接外部 AI 工具。"}</p> : <>
71
71
  <form onSubmit={event => { event.preventDefault(); void act("issue", { label, days, canDraft }, "凭据已生成,仅显示这一次。请保存在自己的电脑中。"); }}>
72
72
  <div className="remote-form"><label>凭据名称<input required maxLength={80} value={label} onChange={e => setLabel(e.target.value)} /></label>
73
73
  <label>有效天数<input type="number" min={1} max={30} required value={days} onChange={e => setDays(Number(e.target.value))} /></label>
74
- {user.role !== "viewer" && <label><input type="checkbox" checked={canDraft} onChange={e => setCanDraft(e.target.checked)} />允许提交草稿(仍需本人确认)</label>}</div>
74
+ {user.role !== "viewer" && <label className="remote-draft-option"><input type="checkbox" checked={canDraft} onChange={e => setCanDraft(e.target.checked)} />允许提交草稿(仍需本人确认)</label>}</div>
75
75
  <button className="admin-button" disabled={busy || !!secret}>生成个人凭据</button>
76
76
  </form>
77
77
  {secret && <div className="admin-notice"><p>仅本次显示,请勿发送到聊天、提交到代码仓库或与他人共用。</p>
@@ -84,14 +84,17 @@ export default function RemoteAccess({ user, manage = false, endpoint = "" }: {
84
84
  </details>
85
85
  </>}
86
86
  </>}
87
+ {mode !== "drafts" && <>
87
88
  <h3>{manage ? "全部个人凭据" : "我的凭据"}</h3>
88
- {!status.tokens.length && <p>暂无凭据。</p>}
89
+ {!status.tokens.length && <p className="settings-empty">暂无凭据。</p>}
89
90
  <ul className="remote-items">{status.tokens.map(token => <li key={token.id}><div><strong>{token.label}</strong>{manage && <span> · {status.members?.find(member => member.id === token.owner)?.name || "成员"}</span>}<p>{token.can_draft ? "查询和提交草稿" : "仅查询"} · {when(token.expires)} 到期</p></div>
90
91
  <button className="admin-button" disabled={busy} onClick={() => void act("revoke", { tokenId: token.id }, "凭据及其待确认草稿已撤销。")}>撤销</button></li>)}</ul>
91
- {!manage && <>
92
- <h3>我的待确认草稿</h3>
92
+ </>}
93
+ {mode === "drafts" && <>
94
+ <p className="admin-help">个人连接与凭据在 <Link href="/account#ai-tools">我的账号 → AI 工具连接</Link> 管理。</p>
95
+ {(!status.enabled || !status.granted) && <p className="admin-notice">{!status.enabled?"工作区尚未开启外部工具接入。":"你的账号尚未获授权使用外部工具。"}</p>}
93
96
  <button className="admin-button" disabled={busy} onClick={() => { setError(""); void reload().catch(err => setError((err as Error).message)); }}>刷新草稿</button>
94
- {!drafts.length && <p>暂无可确认的草稿。草稿最长保留 24 小时;权限变化、凭据失效或关闭功能后不可确认。</p>}
97
+ {!drafts.length && <p>暂无可确认的修改。外部工具提交草稿后在这里核对;草稿最长保留 24 小时,权限变化、凭据失效或关闭功能会使草稿失效。</p>}
95
98
  {drafts.map(draft => <article key={draft.id} id={`draft-${draft.id}`} className="ai-draft">
96
99
  <h3>{draft.counterparty} · {draft.matterName} · {operations[draft.operation]}</h3>
97
100
  {draft.progressTitle && <p>进展:{draft.progressDate} · {draft.progressTitle}</p>}<p>有效至 {when(draft.expires)}</p>
@@ -0,0 +1,22 @@
1
+ import type {ReactNode} from "react";
2
+
3
+ export type SettingsIconName = "members" | "ai" | "usage" | "remote" | "backup" | "plus" | "search" | "lock";
4
+ const paths:Record<SettingsIconName,ReactNode> = {
5
+ members:<><circle cx="9" cy="8" r="3"/><path d="M3 20v-2a6 6 0 0 1 12 0v2M16 5a3 3 0 0 1 0 6m2 3a5 5 0 0 1 3 4v2"/></>,
6
+ ai:<><path d="m12 3 2.7 6.3L21 12l-6.3 2.7L12 21l-2.7-6.3L3 12l6.3-2.7L12 3Z"/></>,
7
+ usage:<><path d="M4 4v16h16M9 15v-4m5 4V7m5 8v-6"/></>,
8
+ remote:<><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4M8 9l-2 2 2 2m8-4 2 2-2 2"/></>,
9
+ backup:<><path d="M12 3v12m-4-4 4 4 4-4M4 15v5h16v-5"/></>,
10
+ plus:<path d="M12 5v14M5 12h14"/>,
11
+ search:<><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></>,
12
+ lock:<><rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V6a4 4 0 0 1 8 0v4m-4 5v2"/></>,
13
+ };
14
+ export function SettingsIcon({name}:{name:SettingsIconName}) {
15
+ return <svg className="settings-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>;
16
+ }
17
+ export function SettingsPanelHeader({title,description,actions}:{title:string;description?:string;actions?:ReactNode}) {
18
+ return <header className="settings-panel-head"><div><h2>{title}</h2>{description&&<p>{description}</p>}</div>{actions}</header>;
19
+ }
20
+ export function SettingsStatus({children,tone="neutral"}:{children:ReactNode;tone?:"neutral"|"success"|"warning"}) {
21
+ return <span className={`settings-status is-${tone}`}><span aria-hidden="true"/>{children}</span>;
22
+ }