@airalogy/airalogy-bd 1.2.1 → 1.3.1
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 +15 -0
- package/README.md +1 -1
- package/dashboard/app/AccountBar.tsx +30 -11
- package/dashboard/app/AccountSettings.tsx +8 -5
- package/dashboard/app/AdminCenter.tsx +8 -9
- package/dashboard/app/Dashboard.tsx +3 -10
- package/dashboard/app/PendingChangesLink.tsx +56 -0
- package/dashboard/app/RemoteAccess.tsx +25 -19
- package/dashboard/app/SettingsUI.tsx +2 -1
- package/dashboard/app/TeamManagement.tsx +17 -12
- package/dashboard/app/TeamWorkspace.tsx +1 -1
- package/dashboard/app/account/page.tsx +4 -2
- package/dashboard/app/admin/page.tsx +2 -1
- package/dashboard/app/admin.css +74 -74
- package/dashboard/app/api/remote/route.ts +1 -1
- package/dashboard/app/files.css +23 -23
- package/dashboard/app/globals.css +237 -193
- package/dashboard/app/lib/remote-events.ts +5 -0
- package/dashboard/app/lib/server-auth.ts +1 -1
- package/dashboard/app/page.tsx +3 -2
- package/dashboard/app/remote/page.tsx +7 -3
- package/dashboard/app/settings.css +151 -84
- package/dashboard/app/typography.css +28 -0
- package/docs/accounts-and-branding.md +8 -0
- package/docs/interface-design.md +23 -0
- package/docs/remote-access.md +6 -6
- package/package.json +1 -1
- package/scripts/remote_access.py +17 -11
- package/scripts/team_auth.py +44 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.3.1] - 2026-10-27
|
|
8
|
+
|
|
9
|
+
- 将「待确认」移到右上角头像旁,有草稿时显示数量,没有草稿时使用弱化样式,保持主导航聚焦于工作台和管理中心。
|
|
10
|
+
- 数量仅统计当前账号有权查看的有效待确认草稿,与草稿列表共用权限和有效期检查;处理草稿后立即刷新,页面可见时定期更新。
|
|
11
|
+
- 调整手机端顶部操作布局,并补充数量接口的账号隔离、权限撤销、过期及处理状态测试。
|
|
12
|
+
|
|
13
|
+
## [1.3.0] - 2026-10-27
|
|
14
|
+
|
|
15
|
+
- 统一界面字体、字号与行高,改善正文、表格和说明文字的可读性,手机端通过布局适配而非缩小正文。
|
|
16
|
+
|
|
17
|
+
- 将外部 AI 工具权限并入邀请和成员权限编辑,与角色、事项范围一起保存;默认关闭,支持在全局功能关闭时预先分配权限。
|
|
18
|
+
- 个人工具连接、凭据生成与撤销移到「我的账号」,外部工具全局开关移到「AI 设置」,草稿统一从「待确认修改」进入;保留原草稿链接及旧管理入口跳转。
|
|
19
|
+
- 统一工作台、管理中心、账号和草稿页的顶部导航:Logo 与工作区名称在最上方,账号操作收进个人菜单,下方承载业务操作和页内导航,并适配手机屏幕。
|
|
20
|
+
- 调整尚未接受邀请的成员权限时保留原邀请链接和有效期;增加授权事务、并发冲突、只读边界及撤销后凭据和草稿失效的回归测试。
|
|
21
|
+
|
|
7
22
|
## [1.2.1] - 2026-10-27
|
|
8
23
|
|
|
9
24
|
- 成员管理以列表为主,支持按姓名、用户名、邮箱和状态筛选;邀请及权限调整按需打开编辑面板,保留权限预览、固定操作区、未保存提醒和保存失败反馈。
|
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)
|
|
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
|
|
4
|
+
import PendingChangesLink from "./PendingChangesLink";
|
|
5
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
6
|
+
import { BrandIdentity, BrandLogo } from "./Branding";
|
|
5
7
|
import { teamRequest, type TeamUser } from "./lib/team-client";
|
|
8
|
+
import type { DashboardData } from "./lib/types";
|
|
6
9
|
|
|
7
|
-
export default function AccountBar({ user,
|
|
8
|
-
const router = useRouter();
|
|
10
|
+
export default function AccountBar({ user, workspace }: { user: TeamUser | null; workspace?: DashboardData["workspace"] }) {
|
|
11
|
+
const router = useRouter(), pathname = usePathname();
|
|
12
|
+
const menu = useRef<HTMLDetailsElement>(null);
|
|
9
13
|
const [error, setError] = useState("");
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const close = (event: PointerEvent) => { if (!menu.current?.contains(event.target as Node) && menu.current) menu.current.open = false; };
|
|
16
|
+
document.addEventListener("pointerdown", close);
|
|
17
|
+
return () => document.removeEventListener("pointerdown", close);
|
|
18
|
+
}, []);
|
|
10
19
|
async function logout() {
|
|
11
|
-
try { await teamRequest("auth_logout", {}, user
|
|
20
|
+
try { await teamRequest("auth_logout", {}, user?.csrf || ""); router.replace("/login"); router.refresh(); }
|
|
12
21
|
catch (err) { setError((err as Error).message); }
|
|
13
22
|
}
|
|
14
|
-
return
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
return <>
|
|
24
|
+
<header className="app-header">
|
|
25
|
+
<div className="app-header-inner">
|
|
26
|
+
<Link className="app-brand" href="/" aria-label="返回工作台">{workspace?<><BrandLogo/><span><strong>{workspace.short_name}</strong><small>{workspace.workspace_title}</small></span></>:<BrandIdentity/>}</Link>
|
|
27
|
+
<nav className="app-navigation" aria-label="全局导航">
|
|
28
|
+
<Link href="/" aria-current={pathname==="/"?"page":undefined}>工作台</Link>
|
|
29
|
+
{(!user||user.role==="admin")&&<Link href="/admin" aria-current={pathname==="/admin"?"page":undefined}>管理中心</Link>}
|
|
30
|
+
</nav>
|
|
31
|
+
{user?<div className="app-account-actions"><PendingChangesLink key={user.id}/><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></div>:<span className="app-local-label">本机工作区</span>}
|
|
35
|
+
</div>
|
|
36
|
+
</header>
|
|
37
|
+
{error&&<p className="admin-alert" role="alert">{error}</p>}
|
|
38
|
+
</>;
|
|
20
39
|
}
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { useRouter } from "next/navigation";
|
|
4
4
|
import AccountBar from "./AccountBar";
|
|
5
|
-
import
|
|
5
|
+
import RemoteAccess from "./RemoteAccess";
|
|
6
|
+
import type { DashboardData } from "./lib/types";
|
|
6
7
|
import {SettingsIcon,SettingsPanelHeader,SettingsStatus} from "./SettingsUI";
|
|
7
8
|
import { teamRequest, type TeamUser } from "./lib/team-client";
|
|
8
9
|
import { usernameHelp, usernamePattern } from "./lib/username-policy";
|
|
9
10
|
|
|
10
|
-
export default function AccountSettings({ user }: { user: TeamUser }) {
|
|
11
|
+
export default function AccountSettings({ user, endpoint, workspace }: { user: TeamUser; endpoint: string; workspace?: DashboardData["workspace"] }) {
|
|
11
12
|
const router = useRouter();
|
|
12
13
|
const [username, setUsername] = useState(user.username || "");
|
|
13
14
|
const [saved, setSaved] = useState(!!user.username);
|
|
@@ -23,9 +24,10 @@ export default function AccountSettings({ user }: { user: TeamUser }) {
|
|
|
23
24
|
} catch (err) { setError((err as Error).message); }
|
|
24
25
|
finally { setBusy(false); }
|
|
25
26
|
}
|
|
26
|
-
return <><AccountBar user={user}
|
|
27
|
-
<header className="settings-page-head"><div><
|
|
28
|
-
<
|
|
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="用户名和邮箱均可登录,姓名用于页面展示。"/>
|
|
29
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>
|
|
30
32
|
{error&&<p role="alert" className="admin-alert">{error}</p>}
|
|
31
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,5 +36,6 @@ export default function AccountSettings({ user }: { user: TeamUser }) {
|
|
|
34
36
|
</form>
|
|
35
37
|
<div className="account-recovery-note"><h3>账号恢复</h3><p>忘记密码或无法取得动态验证码时,请联系管理员重新签发登录邀请。账号恢复保留原有事项权限和历史记录。</p></div>
|
|
36
38
|
</section>
|
|
39
|
+
<RemoteAccess user={user} mode="connection" endpoint={endpoint}/>
|
|
37
40
|
</main></>;
|
|
38
41
|
}
|
|
@@ -6,31 +6,30 @@ 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
9
|
import {SettingsIcon,SettingsPanelHeader} from "./SettingsUI";
|
|
11
10
|
import type {TeamUser} from "./lib/team-client";
|
|
12
|
-
import type {Project} from "./lib/types";
|
|
11
|
+
import type {Project,DashboardData} from "./lib/types";
|
|
13
12
|
|
|
14
|
-
const sections={members:{title:"成员与权限",description:"邀请成员 · 分配访问范围"},ai:{title:"AI 设置",description:"
|
|
13
|
+
const sections={members:{title:"成员与权限",description:"邀请成员 · 分配访问范围"},ai:{title:"AI 设置",description:"内置 AI · 外部工具"},usage:{title:"用量与费用",description:"请求统计 · 费用估算"},backup:{title:"备份",description:"自动备份 · 数据保护"}};
|
|
15
14
|
type Section=keyof typeof sections;
|
|
16
|
-
export default function AdminCenter({user,projects,timezone,unavailable,initialSection,dates}:{
|
|
17
|
-
user:TeamUser|null;projects:Pick<Project,"id"|"name">[];timezone:string;unavailable:boolean;initialSection?:string;dates:{from:string;to:string};
|
|
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};
|
|
18
17
|
}) {
|
|
19
18
|
const section:Section=initialSection&&Object.hasOwn(sections,initialSection)?initialSection as Section:user?"members":"ai";
|
|
20
19
|
return <>
|
|
21
|
-
|
|
20
|
+
<AccountBar user={user} workspace={workspace}/>
|
|
22
21
|
<main className="page-shell admin-center">
|
|
23
|
-
<header className="settings-page-head"><div><
|
|
22
|
+
<header className="settings-page-head"><div><h1>管理中心</h1><p>管理团队访问、AI 服务与数据备份。</p></div><span className="settings-access-label"><SettingsIcon name="lock"/>仅管理员可访问</span></header>
|
|
24
23
|
{!user&&<p className="admin-notice">当前为本机单人模式。成员邀请和权限管理在团队模式中提供。</p>}
|
|
25
24
|
{unavailable&&<p className="admin-alert" role="alert">工作区资料暂时不可用,事项列表未能加载,请先检查工作区。</p>}
|
|
26
25
|
<div className="settings-layout">
|
|
27
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>
|
|
28
27
|
<div className="settings-content" key={section}>
|
|
29
28
|
{section==="members"&&(user?<TeamManagement user={user} projects={projects} timezone={timezone}/>:<section className="panel admin-center-panel"><SettingsPanelHeader title="成员与权限" description="本机单人模式无需创建账号。"/><p>部署为团队模式后,可在这里邀请成员、停用账号并分配事项权限。</p></section>)}
|
|
30
|
-
{section==="ai"&&<AiSettings/>}
|
|
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"/>}</>}
|
|
31
30
|
{section==="usage"&&<section className="panel admin-center-panel"><SettingsPanelHeader title="用量与费用" description="查看团队 AI 请求、Token 用量和费用估算。"/><AiUsage initialFrom={dates.from} initialTo={dates.to}/></section>}
|
|
32
31
|
{section==="backup"&&<BackupSettings timezone={timezone} onChanged={()=>{}}/>}
|
|
33
|
-
|
|
32
|
+
|
|
34
33
|
</div>
|
|
35
34
|
</div>
|
|
36
35
|
</main>
|
|
@@ -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="
|
|
1343
|
-
|
|
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>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { SettingsIcon } from "./SettingsUI";
|
|
6
|
+
import { REMOTE_DRAFTS_CHANGED } from "./lib/remote-events";
|
|
7
|
+
|
|
8
|
+
export default function PendingChangesLink() {
|
|
9
|
+
const pathname = usePathname();
|
|
10
|
+
const [count, setCount] = useState<number | null>(null);
|
|
11
|
+
const [unavailable, setUnavailable] = useState(false);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
let controller: AbortController | null = null;
|
|
14
|
+
let disposed = false;
|
|
15
|
+
async function refresh(force = false) {
|
|
16
|
+
if (document.visibilityState === "hidden" || (controller && !force)) return;
|
|
17
|
+
controller?.abort();
|
|
18
|
+
const current = new AbortController();
|
|
19
|
+
controller = current;
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch("/api/remote?action=pending", { cache: "no-store", signal: current.signal });
|
|
22
|
+
if (!response.ok) throw new Error("unavailable");
|
|
23
|
+
const body = await response.json();
|
|
24
|
+
if (!body.ok || !Number.isSafeInteger(body.result?.count) || body.result.count < 0) throw new Error("invalid count");
|
|
25
|
+
if (!disposed && !current.signal.aborted) { setCount(body.result.count); setUnavailable(false); }
|
|
26
|
+
} catch {
|
|
27
|
+
if (!disposed && !current.signal.aborted) { setCount(null); setUnavailable(true); }
|
|
28
|
+
} finally {
|
|
29
|
+
if (controller === current) controller = null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const onVisible = () => void refresh();
|
|
33
|
+
const onChanged = () => void refresh(true);
|
|
34
|
+
void refresh();
|
|
35
|
+
const timer = window.setInterval(onVisible, 60_000);
|
|
36
|
+
window.addEventListener("focus", onVisible);
|
|
37
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
38
|
+
window.addEventListener(REMOTE_DRAFTS_CHANGED, onChanged);
|
|
39
|
+
return () => {
|
|
40
|
+
disposed = true;
|
|
41
|
+
controller?.abort();
|
|
42
|
+
window.clearInterval(timer);
|
|
43
|
+
window.removeEventListener("focus", onVisible);
|
|
44
|
+
document.removeEventListener("visibilitychange", onVisible);
|
|
45
|
+
window.removeEventListener(REMOTE_DRAFTS_CHANGED, onChanged);
|
|
46
|
+
};
|
|
47
|
+
}, [pathname]);
|
|
48
|
+
const hasPending = count !== null && count > 0;
|
|
49
|
+
const label = hasPending ? `待确认修改,${count} 条草稿` : count === 0 ? "待确认修改,暂无草稿" : "待确认修改";
|
|
50
|
+
return <Link href="/remote" className={`app-pending${hasPending ? " has-pending" : " is-muted"}`}
|
|
51
|
+
aria-current={pathname === "/remote" ? "page" : undefined} aria-label={label}
|
|
52
|
+
title={unavailable ? "数量暂时无法获取,点击查看待确认修改" : label}>
|
|
53
|
+
<SettingsIcon name="review"/><span>待确认</span>
|
|
54
|
+
{hasPending && <span className="app-pending-count" aria-hidden="true">{count > 99 ? "99+" : count}</span>}
|
|
55
|
+
</Link>;
|
|
56
|
+
}
|
|
@@ -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 { notifyRemoteDraftsChanged } from "./lib/remote-events";
|
|
4
5
|
import {SettingsPanelHeader,SettingsStatus} from "./SettingsUI";
|
|
5
6
|
import type { TeamUser } from "./lib/team-client";
|
|
6
7
|
import { stageLabels, followUpStatusLabels } from "./lib/labels";
|
|
@@ -20,7 +21,8 @@ function display(field: string, value: unknown) {
|
|
|
20
21
|
}
|
|
21
22
|
const when = (value: number) => new Date(value * 1000).toLocaleString("zh-CN");
|
|
22
23
|
|
|
23
|
-
export default function RemoteAccess({ user,
|
|
24
|
+
export default function RemoteAccess({ user, mode, endpoint = "" }: { user: TeamUser; mode: "settings" | "connection" | "drafts"; endpoint?: string }) {
|
|
25
|
+
const manage = mode === "settings";
|
|
24
26
|
const [status, setStatus] = useState<Status | null>(null), [drafts, setDrafts] = useState<Draft[]>([]);
|
|
25
27
|
const [error, setError] = useState(""), [notice, setNotice] = useState(""), [busy, setBusy] = useState(false);
|
|
26
28
|
const [secret, setSecret] = useState(""), [label, setLabel] = useState("我的 Codex"), [days, setDays] = useState(7), [canDraft, setCanDraft] = useState(false);
|
|
@@ -35,42 +37,43 @@ export default function RemoteAccess({ user, manage = false, endpoint = "" }: {
|
|
|
35
37
|
}, [user.csrf]);
|
|
36
38
|
const load = useCallback(async () => {
|
|
37
39
|
const latest = await request<Status>(manage ? "settings" : "status");
|
|
38
|
-
const pending =
|
|
40
|
+
const pending = mode === "drafts" && latest.enabled && latest.granted ? (await request<{ drafts: Draft[] }>("drafts")).drafts : [];
|
|
39
41
|
return { latest, pending };
|
|
40
|
-
}, [manage, request]);
|
|
42
|
+
}, [manage, mode, request]);
|
|
41
43
|
const apply = useCallback(({ latest, pending }: { latest: Status; pending: Draft[] }) => {
|
|
42
44
|
setStatus(latest); setDrafts(pending); setChecked({});
|
|
43
45
|
}, []);
|
|
44
46
|
async function reload() { apply(await load()); }
|
|
45
47
|
useEffect(() => { void load().then(apply).catch(err => setError((err as Error).message)); }, [load, apply]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (mode !== "drafts" || !drafts.length) return;
|
|
50
|
+
const anchor = window.location.hash.slice(1);
|
|
51
|
+
if (drafts.some(draft => anchor === `draft-${draft.id}`)) document.getElementById(anchor)?.scrollIntoView({ block: "start" });
|
|
52
|
+
}, [mode, drafts]);
|
|
46
53
|
async function act(action: string, input: Record<string, unknown>, message: string) {
|
|
47
54
|
setBusy(true); setError(""); setNotice("");
|
|
48
55
|
try {
|
|
49
56
|
const result = await request<{ token?: string }>(action, input);
|
|
57
|
+
notifyRemoteDraftsChanged();
|
|
50
58
|
if (result.token) setSecret(result.token);
|
|
51
59
|
setNotice(message); await reload();
|
|
52
60
|
} catch (err) { setError((err as Error).message); }
|
|
53
61
|
finally { setBusy(false); }
|
|
54
62
|
}
|
|
55
|
-
return <section className="panel admin-center-panel remote-access">
|
|
56
|
-
<SettingsPanelHeader title={manage?"
|
|
63
|
+
return <section id={manage?"external-ai":mode==="connection"?"ai-tools":undefined} className="panel admin-center-panel remote-access">
|
|
64
|
+
<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
65
|
{error && <p className="admin-alert" role="alert">{error}</p>}
|
|
58
66
|
{notice && <p className="admin-notice" role="status">{notice}</p>}
|
|
59
67
|
{!status ? <p>正在读取接入状态…</p> : <>
|
|
60
|
-
<div className="remote-state"><div><SettingsStatus tone={status.enabled?"success":"neutral"}>{status.enabled?"
|
|
61
|
-
{manage ?
|
|
62
|
-
|
|
63
|
-
<
|
|
64
|
-
<
|
|
65
|
-
<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>
|
|
66
|
-
<p><Link className="admin-button" href="/remote">管理我自己的凭据和草稿</Link></p>
|
|
67
|
-
</> : <>
|
|
68
|
-
<p className="admin-help">返回给 Codex 的业务资料会进入你所使用的模型上下文。请按公司要求使用模型;这里不自动读取附件和联系方式。Codex 的模型费用由对应账号承担。</p>
|
|
69
|
-
{!status.enabled || !status.granted ? <p>请联系管理员开启功能并授权你的账号。</p> : <>
|
|
68
|
+
{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>}
|
|
69
|
+
{manage && <p className="admin-help">在 <Link href="/admin?section=members">成员与权限</Link> 邀请或编辑成员时分配权限。关闭全局开关会撤销全部凭据和草稿,保留成员授权;重新开启后,由成员生成新凭据。</p>}
|
|
70
|
+
{mode === "connection" && <>
|
|
71
|
+
<p className="admin-help">返回给外部工具的业务资料会进入你所使用的模型上下文,请按公司要求选择模型。这里不自动读取附件和联系方式,模型费用由对应工具账号承担。</p>
|
|
72
|
+
{!status.enabled || !status.granted ? <p className="settings-empty">{!status.enabled?"工作区尚未开启外部工具接入。":"请联系管理员允许你的账号连接外部 AI 工具。"}</p> : <>
|
|
70
73
|
<form onSubmit={event => { event.preventDefault(); void act("issue", { label, days, canDraft }, "凭据已生成,仅显示这一次。请保存在自己的电脑中。"); }}>
|
|
71
74
|
<div className="remote-form"><label>凭据名称<input required maxLength={80} value={label} onChange={e => setLabel(e.target.value)} /></label>
|
|
72
75
|
<label>有效天数<input type="number" min={1} max={30} required value={days} onChange={e => setDays(Number(e.target.value))} /></label>
|
|
73
|
-
{user.role !== "viewer" && <label><input type="checkbox" checked={canDraft} onChange={e => setCanDraft(e.target.checked)} />允许提交草稿(仍需本人确认)</label>}</div>
|
|
76
|
+
{user.role !== "viewer" && <label className="remote-draft-option"><input type="checkbox" checked={canDraft} onChange={e => setCanDraft(e.target.checked)} />允许提交草稿(仍需本人确认)</label>}</div>
|
|
74
77
|
<button className="admin-button" disabled={busy || !!secret}>生成个人凭据</button>
|
|
75
78
|
</form>
|
|
76
79
|
{secret && <div className="admin-notice"><p>仅本次显示,请勿发送到聊天、提交到代码仓库或与他人共用。</p>
|
|
@@ -83,14 +86,17 @@ export default function RemoteAccess({ user, manage = false, endpoint = "" }: {
|
|
|
83
86
|
</details>
|
|
84
87
|
</>}
|
|
85
88
|
</>}
|
|
89
|
+
{mode !== "drafts" && <>
|
|
86
90
|
<h3>{manage ? "全部个人凭据" : "我的凭据"}</h3>
|
|
87
91
|
{!status.tokens.length && <p className="settings-empty">暂无凭据。</p>}
|
|
88
92
|
<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>
|
|
89
93
|
<button className="admin-button" disabled={busy} onClick={() => void act("revoke", { tokenId: token.id }, "凭据及其待确认草稿已撤销。")}>撤销</button></li>)}</ul>
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
</>}
|
|
95
|
+
{mode === "drafts" && <>
|
|
96
|
+
<p className="admin-help">个人连接与凭据在 <Link href="/account#ai-tools">我的账号 → AI 工具连接</Link> 管理。</p>
|
|
97
|
+
{(!status.enabled || !status.granted) && <p className="admin-notice">{!status.enabled?"工作区尚未开启外部工具接入。":"你的账号尚未获授权使用外部工具。"}</p>}
|
|
92
98
|
<button className="admin-button" disabled={busy} onClick={() => { setError(""); void reload().catch(err => setError((err as Error).message)); }}>刷新草稿</button>
|
|
93
|
-
{!drafts.length && <p
|
|
99
|
+
{!drafts.length && <p>暂无可确认的修改。外部工具提交草稿后在这里核对;草稿最长保留 24 小时,权限变化、凭据失效或关闭功能会使草稿失效。</p>}
|
|
94
100
|
{drafts.map(draft => <article key={draft.id} id={`draft-${draft.id}`} className="ai-draft">
|
|
95
101
|
<h3>{draft.counterparty} · {draft.matterName} · {operations[draft.operation]}</h3>
|
|
96
102
|
{draft.progressTitle && <p>进展:{draft.progressDate} · {draft.progressTitle}</p>}<p>有效至 {when(draft.expires)}</p>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type {ReactNode} from "react";
|
|
2
2
|
|
|
3
|
-
export type SettingsIconName = "members" | "ai" | "usage" | "remote" | "backup" | "plus" | "search" | "lock";
|
|
3
|
+
export type SettingsIconName = "members" | "ai" | "usage" | "remote" | "backup" | "plus" | "search" | "lock" | "review";
|
|
4
4
|
const paths:Record<SettingsIconName,ReactNode> = {
|
|
5
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
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"/></>,
|
|
@@ -9,6 +9,7 @@ const paths:Record<SettingsIconName,ReactNode> = {
|
|
|
9
9
|
backup:<><path d="M12 3v12m-4-4 4 4 4-4M4 15v5h16v-5"/></>,
|
|
10
10
|
plus:<path d="M12 5v14M5 12h14"/>,
|
|
11
11
|
search:<><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></>,
|
|
12
|
+
review:<><path d="M9 4H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-4"/><rect x="9" y="2" width="6" height="4" rx="1"/><path d="m8 13 3 3 5-6"/></>,
|
|
12
13
|
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
|
};
|
|
14
15
|
export function SettingsIcon({name}:{name:SettingsIconName}) {
|
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
import * as Dialog from "@radix-ui/react-dialog";
|
|
3
3
|
import {useCallback,useEffect,useRef,useState} from "react";
|
|
4
4
|
import {useRouter} from "next/navigation";
|
|
5
|
+
import Link from "next/link";
|
|
5
6
|
import {teamRequest,type TeamUser} from "./lib/team-client";
|
|
6
7
|
import type {Project} from "./lib/types";
|
|
7
8
|
import {workspaceRequest} from "./lib/workspace-client";
|
|
8
9
|
import type {WorkspaceResponse} from "./lib/workspace-types";
|
|
9
10
|
import {SettingsIcon,SettingsPanelHeader,SettingsStatus} from "./SettingsUI";
|
|
10
11
|
|
|
12
|
+
type MemberList={users:TeamUser[];remote_enabled:boolean};
|
|
11
13
|
type Audit={id:number;at:number;actor_name:string;action:string;target:string;outcome:string};
|
|
12
|
-
const actionNames:Record<string,string>={"account.invite":"邀请成员","account.activate":"激活账号","account.login":"登录","account.permissions":"调整权限","account.reset":"重置登录",save:"保存资料",file_save:"更新附件",restore:"恢复资料",backup_config:"调整备份",backup_now:"立即备份",file_backup:"导出完整备份"};
|
|
14
|
+
const actionNames:Record<string,string>={"account.invite":"邀请成员","account.activate":"激活账号","account.login":"登录","account.permissions":"调整权限","account.reset":"重置登录","remote.grant":"允许外部 AI 工具","remote.ungrant":"取消外部 AI 工具权限","remote.configure":"调整外部 AI 全局开关","remote.issue":"生成个人接入凭据","remote.revoke":"撤销接入凭据",save:"保存资料",file_save:"更新附件",restore:"恢复资料",backup_config:"调整备份",backup_now:"立即备份",file_backup:"导出完整备份"};
|
|
13
15
|
const roleNames={admin:"管理员",editor:"编辑成员",viewer:"只读成员"};
|
|
14
16
|
const roleHelp={admin:"管理全部资料、成员权限和系统设置。",editor:"修改获授权事项和附件、补充进展。",viewer:"查看和导出获授权资料,不能修改。"};
|
|
15
17
|
const statusNames={active:"已启用",email:"待验证邮箱",setup:"待绑定验证器",invited:"待接受邀请",expired:"邀请已过期",disabled:"已停用"};
|
|
@@ -23,6 +25,7 @@ export default function TeamManagement({user,projects,timezone}:{user:TeamUser;p
|
|
|
23
25
|
const [editorOpen,setEditorOpen]=useState(false),[selected,setSelected]=useState<TeamUser|null>(null);
|
|
24
26
|
const [email,setEmail]=useState(""),[name,setName]=useState("");
|
|
25
27
|
const [role,setRole]=useState<TeamUser["role"]>("viewer"),[all,setAll]=useState(false),[ids,setIds]=useState<string[]>([]);
|
|
28
|
+
const [remoteAllowed,setRemoteAllowed]=useState(false),[remoteEnabled,setRemoteEnabled]=useState(false);
|
|
26
29
|
const [active,setActive]=useState(true),[busy,setBusy]=useState(false);
|
|
27
30
|
const [error,setError]=useState(""),[formError,setFormError]=useState(""),[notice,setNotice]=useState("");
|
|
28
31
|
const [link,setLink]=useState(""),[linkExpires,setLinkExpires]=useState<number|null>(null);
|
|
@@ -38,23 +41,23 @@ export default function TeamManagement({user,projects,timezone}:{user:TeamUser;p
|
|
|
38
41
|
return matches&&(statusFilter==="all"||(statusFilter==="pending"?["invited","expired","email","setup"].includes(member.status||""):member.status===statusFilter));
|
|
39
42
|
});
|
|
40
43
|
const fullAccess=role==="admin"||all;
|
|
41
|
-
const dirty=selected?role!==selected.role||all!==selected.all_projects||active!==!!selected.active||JSON.stringify([...ids].sort())!==JSON.stringify([...selected.project_ids].sort()):!!name||!!email||role!=="viewer"||all||ids.length>0;
|
|
42
|
-
const refresh=useCallback(async()=>{const result=await teamRequest<
|
|
43
|
-
useEffect(()=>{let cancelled=false;teamRequest<
|
|
44
|
+
const dirty=selected?remoteAllowed!==!!selected.remote_allowed||role!==selected.role||all!==selected.all_projects||active!==!!selected.active||JSON.stringify([...ids].sort())!==JSON.stringify([...selected.project_ids].sort()):!!name||!!email||remoteAllowed||role!=="viewer"||all||ids.length>0;
|
|
45
|
+
const refresh=useCallback(async()=>{const result=await teamRequest<MemberList>("team_list",{},user.csrf);setMembers(result.users);setRemoteEnabled(result.remote_enabled);},[user.csrf]);
|
|
46
|
+
useEffect(()=>{let cancelled=false;teamRequest<MemberList>("team_list",{},user.csrf).then(result=>{if(!cancelled){setMembers(result.users);setRemoteEnabled(result.remote_enabled);}}).catch(err=>{if(!cancelled)setError(err.message);}).finally(()=>{if(!cancelled)setLoading(false);});return()=>{cancelled=true;};},[user.csrf]);
|
|
44
47
|
useEffect(()=>{const controller=new AbortController();workspaceRequest<WorkspaceResponse>("workspace",{},false,controller.signal).then(result=>{if(result.data)setProjectChoices(result.data.projects);}).catch(err=>{if(!controller.signal.aborted)setError(err.message);});return()=>controller.abort();},[]);
|
|
45
48
|
useEffect(()=>{if(!pendingInvitations)return;const timer=window.setInterval(()=>{void refresh().catch(err=>setError(err.message));},60000);return()=>window.clearInterval(timer);},[pendingInvitations,refresh]);
|
|
46
49
|
useEffect(()=>{if(formError&&editorOpen){formAlert.current?.focus();formAlert.current?.scrollIntoView({block:"nearest"});}},[formError,editorOpen]);
|
|
47
50
|
function select(member:TeamUser|null,button:HTMLButtonElement){
|
|
48
|
-
opener.current=button;setSelected(member);setEmail(member?.email||"");setName(member?.name||"");setRole(member?.role||"viewer");setAll(member?.all_projects||false);setIds(member?.project_ids||[]);setProjectSearch("");setActive(member?!!member.active:true);setFormError("");setError("");setNotice("");setEditorOpen(true);
|
|
51
|
+
opener.current=button;setSelected(member);setRemoteAllowed(!!member?.remote_allowed);setEmail(member?.email||"");setName(member?.name||"");setRole(member?.role||"viewer");setAll(member?.all_projects||false);setIds(member?.project_ids||[]);setProjectSearch("");setActive(member?!!member.active:true);setFormError("");setError("");setNotice("");setEditorOpen(true);
|
|
49
52
|
}
|
|
50
53
|
function closeEditor(){if(!busy&&(!dirty||window.confirm("放弃尚未保存的成员设置?")))setEditorOpen(false);}
|
|
51
54
|
function showLink(result:{token:string;expires:number}){setLinkExpires(result.expires);setLink(`${window.location.origin}/join#${result.token}`);setNotice("邀请链接已生成,请单独交给该成员。");}
|
|
52
55
|
async function save(event:React.FormEvent){
|
|
53
56
|
event.preventDefault();if(busy)return;setBusy(true);setFormError("");setNotice("");
|
|
54
57
|
try{
|
|
55
|
-
const payload={email,name,role,all_projects:all,project_ids:ids,active,id:selected?.id,revision:selected?.revision};
|
|
58
|
+
const payload={email,name,remote_allowed:remoteAllowed,...(selected?{remote_allowed_before:!!selected.remote_allowed}:{}),role,all_projects:all,project_ids:ids,active,id:selected?.id,revision:selected?.revision};
|
|
56
59
|
if(selected){
|
|
57
|
-
await teamRequest("team_update",payload,user.csrf);setNotice(active?"
|
|
60
|
+
await teamRequest("team_update",payload,user.csrf);setNotice(isInvitation(selected)?"邀请权限已更新;原邀请链接和到期时间保持不变。":active?"权限已更新,该成员需要重新登录并按需生成新的工具凭据。":"账号已停用,已有记录和历史已保留。");
|
|
58
61
|
if(selected.id===user.id){router.replace("/login");router.refresh();return;}
|
|
59
62
|
}else showLink(await teamRequest("team_invite",payload,user.csrf));
|
|
60
63
|
try{await refresh();}catch(err){setError(`已保存,但成员列表未能刷新:${(err as Error).message}`);}
|
|
@@ -69,13 +72,13 @@ export default function TeamManagement({user,projects,timezone}:{user:TeamUser;p
|
|
|
69
72
|
async function loadAudit(more=false){setAuditBusy(true);try{const result=await teamRequest<{entries:Audit[]}>("team_audit",more&&audit.length?{before:audit.at(-1)!.id}:{},user.csrf);setAudit(old=>more?[...old,...result.entries]:result.entries);setAuditLoaded(true);}catch(err){setError((err as Error).message);}finally{setAuditBusy(false);}}
|
|
70
73
|
return <section className="team-management">
|
|
71
74
|
<section className="panel admin-center-panel member-panel">
|
|
72
|
-
<SettingsPanelHeader title="成员与权限" description="
|
|
75
|
+
<SettingsPanelHeader title="成员与权限" description="一次设置成员角色、事项范围和外部 AI 工具权限。" actions={<button ref={inviteButton} type="button" className="admin-button admin-primary" disabled={busy} onClick={event=>select(null,event.currentTarget)}><SettingsIcon name="plus"/>邀请新成员</button>}/>
|
|
73
76
|
{error&&<p className="admin-alert" role="alert">{error}</p>}{notice&&<p className="admin-notice" role="status">{notice}</p>}
|
|
74
77
|
{link&&<section className="invitation-result" aria-label="生成的邀请链接"><div className="invitation-result-heading"><strong>一次性邀请链接</strong><button className="settings-text-button" type="button" onClick={()=>{setLink("");setLinkExpires(null);}}>收起</button></div><div className="invitation-copy"><label className="sr-only" htmlFor="member-invitation-link">邀请链接</label><input id="member-invitation-link" readOnly value={link} onFocus={e=>e.target.select()}/><button type="button" className="admin-button" onClick={()=>void copyLink()}>复制链接</button></div><p className="admin-help">{linkExpires&&<>有效至 <time dateTime={new Date(linkExpires*1000).toISOString()}>{expiryText(linkExpires)}</time>({timezone}),</>}仅可使用一次。由本人填写邮箱、设置密码和身份验证器。</p></section>}
|
|
75
78
|
<div className="member-toolbar"><label className="settings-search"><SettingsIcon name="search"/><span className="sr-only">搜索成员</span><input type="search" placeholder="搜索姓名、用户名或邮箱" value={memberSearch} onChange={event=>setMemberSearch(event.target.value)}/></label><label className="member-status-filter"><span className="sr-only">成员状态</span><select value={statusFilter} onChange={event=>setStatusFilter(event.target.value)}><option value="all">全部状态</option><option value="active">已启用</option><option value="pending">待激活</option><option value="disabled">已停用</option></select></label><span className="member-count">{members.length} 名成员</span></div>
|
|
76
79
|
{loading?<p className="settings-empty" role="status">正在读取成员…</p>:<div className="member-list">{visibleMembers.map(member=><article className="member-row" key={member.id}>
|
|
77
80
|
<div className="member-identity"><span className="member-avatar" aria-hidden="true">{Array.from(member.name)[0]}</span><div><div className="member-name"><strong>{member.name}</strong>{member.id===user.id&&<span className="member-self">你</span>}<SettingsStatus tone={member.status==="active"?"success":member.status==="expired"?"warning":"neutral"}>{member.status?statusNames[member.status]:member.active?"已启用":"已停用"}</SettingsStatus></div><p>{member.username||"尚未设置用户名"}</p><p className="member-email">{member.email||"邮箱待本人填写"}</p>{!!member.invitation_expires&&<p className={member.status==="expired"?"team-invitation-expired":""}>{member.status==="expired"?"已于":"邀请将于"} <time dateTime={new Date(member.invitation_expires*1000).toISOString()}>{expiryText(member.invitation_expires)}</time> 到期</p>}</div></div>
|
|
78
|
-
<div className="member-permissions"><span className="member-role">{roleNames[member.role]}</span><p>{member.role==="admin"||member.all_projects?"全部事项":member.project_ids.length?`指定事项 · ${member.project_ids.length} 项`:"尚未分配事项"}</p><small>{member.role==="admin"||member.all_projects?"自动包含新增事项":"新增事项需另行授权"}</small></div>
|
|
81
|
+
<div className="member-permissions"><span className="member-role">{roleNames[member.role]}</span><p>{member.role==="admin"||member.all_projects?"全部事项":member.project_ids.length?`指定事项 · ${member.project_ids.length} 项`:"尚未分配事项"}</p><small>{member.role==="admin"||member.all_projects?"自动包含新增事项":"新增事项需另行授权"}</small><small className="member-tool-access">外部 AI 工具 · {member.remote_allowed?(remoteEnabled?"已允许":"已允许,功能待开启"):"未允许"}</small></div>
|
|
79
82
|
<div className="member-actions"><button type="button" className="admin-button" disabled={busy} onClick={event=>select(member,event.currentTarget)}>调整权限</button>{member.id!==user.id&&<button type="button" disabled={busy} className="settings-text-button" onClick={()=>void reset(member)}>{isInvitation(member)?"重新邀请":"重置登录"}</button>}</div>
|
|
80
83
|
</article>)}{!visibleMembers.length&&<p className="settings-empty">{members.length?"没有符合条件的成员,试试其他关键词或状态。":"尚无成员,可通过邀请链接加入团队。"}</p>}</div>}
|
|
81
84
|
<p className="member-policy-note">停用账号会撤销登录访问,保留已有业务记录和操作历史。人员总库维护、事项创建、全局历史和备份由管理员管理。</p>
|
|
@@ -89,9 +92,11 @@ export default function TeamManagement({user,projects,timezone}:{user:TeamUser;p
|
|
|
89
92
|
{role!=="admin"?<><fieldset className="team-scope-options"><legend>可访问的事项</legend><label className={`team-scope-option${!all?" is-selected":""}`}><input type="radio" name="matter-scope" checked={!all} onChange={()=>setAll(false)}/><span><strong>指定事项</strong><span>仅限勾选项,新增事项需另行授权。</span></span></label><label className={`team-scope-option${all?" is-selected":""}`}><input type="radio" name="matter-scope" checked={all} onChange={()=>setAll(true)}/><span><strong>全部事项</strong><span>自动包含当前和以后新增的事项。</span></span></label></fieldset>
|
|
90
93
|
{all?<p className="admin-help">同时可查看公司动态及共享的人员、对接方资料。</p>:<div className="team-project-picker"><label className="settings-search"><SettingsIcon name="search"/><span className="sr-only">搜索事项</span><input type="search" placeholder="搜索事项名称" value={projectSearch} onChange={e=>setProjectSearch(e.target.value)}/></label><div className="team-selection-toolbar"><span role="status">已选 {ids.length} 项</span><div><button type="button" className="settings-text-button" disabled={!visibleProjects.length} onClick={()=>setIds(current=>[...new Set([...current,...visibleProjects.map(project=>project.id)])])}>{projectSearch.trim()?"选中搜索结果":"选中当前全部"}</button><button type="button" className="settings-text-button" disabled={!ids.length} onClick={()=>setIds([])}>清空</button></div></div><div className="team-project-list">{visibleProjects.map(project=><label className="team-check" key={project.id}><input type="checkbox" checked={ids.includes(project.id)} onChange={e=>setIds(e.target.checked?[...ids,project.id]:ids.filter(id=>id!==project.id))}/>{project.name}</label>)}{!visibleProjects.length&&<p className="admin-help">{projectChoices.length?"没有匹配的事项。":"暂无事项,可在新增后分配。"}</p>}</div><p className="admin-help">批量选择只包含现有事项。跨事项附件需具备其全部关联事项的权限。</p></div>}</>:<p className="admin-help">管理员始终管理全部事项,自动包含以后新增的事项。</p>}
|
|
91
94
|
</section>
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
<section className="settings-form-section member-external-ai"><h3>外部 AI 工具</h3><label className="team-scope-option"><input type="checkbox" checked={remoteAllowed} onChange={e=>setRemoteAllowed(e.target.checked)} aria-describedby="member-external-ai-help"/><span><strong>允许连接外部 AI 工具</strong><span>例如 Codex,通过 MCP 连接工作台。</span></span></label><p id="member-external-ai-help" className="admin-help">{role==="viewer"?"只读成员仅可查询获授权事项,不能提交修改草稿。":"仅可查询获授权事项、提交修改草稿;修改需本人在网站确认后保存。"}本人激活账号后,在“我的账号 → AI 工具连接”生成个人凭据。</p>{!remoteEnabled&&<p className="admin-notice">工作区暂未开启外部工具接入,可以先保存成员权限;管理员在 <Link href="/admin?section=ai#external-ai" target="_blank" rel="noopener noreferrer">AI 设置</Link> 开启后才能连接。</p>}</section>
|
|
96
|
+
{selected&&isInvitation(selected)&&<p className="admin-help">成员尚未接受邀请,可先调整权限;账号仍需本人完成激活。</p>}
|
|
97
|
+
{selected&&!isInvitation(selected)&&<label className="team-check member-active"><input type="checkbox" checked={active} onChange={e=>setActive(e.target.checked)}/>启用账号</label>}
|
|
98
|
+
<section className="team-permission-summary" aria-labelledby="team-permission-summary-title"><h3 id="team-permission-summary-title">权限预览</h3>{selected&&!active&&!isInvitation(selected)?<p>保存后账号将停用,不能访问工作台;已有记录和历史保留。</p>:<><dl><div><dt>可操作</dt><dd>{roleHelp[role]}</dd></div><div><dt>可访问</dt><dd>{fullAccess?"全部事项":ids.length?`已选的 ${ids.length} 个事项`:"尚未分配事项"}</dd></div><div><dt>以后新增事项</dt><dd>{fullAccess?"自动获得访问权限":"需要管理员另行授权"}</dd></div><div><dt>外部 AI 工具</dt><dd>{remoteAllowed?(role==="viewer"?"允许连接 · 仅查询":"允许连接 · 查询和提交草稿"):"未允许连接"}</dd></div></dl>{!fullAccess&&!ids.length&&<p className="admin-help">可以激活账号,但暂时不能查看业务事项。</p>}</>}</section>
|
|
99
|
+
</fieldset></div><footer className="admin-dialog-footer"><span>{selected?(isInvitation(selected)?"保留原邀请链接及其到期时间。":"保存后,现有登录、工具凭据与草稿将失效。"):"邀请链接有效期 48 小时,仅可使用一次。"}</span><div className="admin-actions"><button type="button" className="admin-button" disabled={busy} onClick={closeEditor}>取消</button><button className="admin-button admin-primary" type="submit" disabled={busy}>{busy?"正在保存…":selected?"保存权限":"生成邀请链接"}</button></div></footer></form>
|
|
95
100
|
</Dialog.Content></Dialog.Portal></Dialog.Root>
|
|
96
101
|
</section>;
|
|
97
102
|
}
|
|
@@ -4,5 +4,5 @@ import AccountBar from "./AccountBar";
|
|
|
4
4
|
import type { TeamUser } from "./lib/team-client";
|
|
5
5
|
import type {DashboardData} from "./lib/types";
|
|
6
6
|
export default function TeamWorkspace({data,user}:{data:DashboardData;user:TeamUser}) {
|
|
7
|
-
return <><AccountBar user={user} /><LiveDashboard initialData={data}/></>;
|
|
7
|
+
return <><AccountBar user={user} workspace={data.workspace} /><LiveDashboard initialData={data}/></>;
|
|
8
8
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { redirect } from "next/navigation";
|
|
2
2
|
import AccountSettings from "../AccountSettings";
|
|
3
|
-
import { currentSession, serverMode } from "../lib/server-auth";
|
|
3
|
+
import { currentSession, publicOrigin, serverMode } from "../lib/server-auth";
|
|
4
|
+
import { loadDashboardData } from "../lib/server-data";
|
|
4
5
|
export const dynamic = "force-dynamic";
|
|
5
6
|
export default async function AccountPage() {
|
|
6
7
|
if (!serverMode()) redirect("/");
|
|
7
8
|
const session = await currentSession();
|
|
8
9
|
if (!session) redirect("/login");
|
|
9
|
-
|
|
10
|
+
const data = await loadDashboardData().catch(() => null);
|
|
11
|
+
return <AccountSettings user={session.user} endpoint={`${publicOrigin()}/api/mcp`} workspace={data?.workspace} />;
|
|
10
12
|
}
|
|
@@ -17,7 +17,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
|
|
17
17
|
const data = await loadDashboardData().catch(() => null);
|
|
18
18
|
if (team && data?.access && data.access.role !== "admin") redirect("/");
|
|
19
19
|
const { section } = await searchParams;
|
|
20
|
+
if (section === "remote") redirect("/admin?section=ai#external-ai");
|
|
20
21
|
const timezone = data?.workspace.timezone || "Asia/Shanghai";
|
|
21
22
|
return <AdminCenter user={session?.user || null} projects={data?.projects.map(({ id, name }) => ({ id, name })) || []}
|
|
22
|
-
timezone={timezone} dates={initialDates(timezone)} unavailable={!data} initialSection={section} />;
|
|
23
|
+
workspace={data?.workspace} timezone={timezone} dates={initialDates(timezone)} unavailable={!data} initialSection={section} />;
|
|
23
24
|
}
|