@airalogy/airalogy-bd 1.1.2 → 1.2.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/THIRD_PARTY_NOTICES.md +1 -1
- package/bin/bd.mjs +3 -0
- package/dashboard/app/AccountSettings.tsx +13 -9
- package/dashboard/app/AdminCenter.tsx +23 -18
- package/dashboard/app/AiSettings.tsx +36 -40
- package/dashboard/app/AiUsage.tsx +6 -4
- package/dashboard/app/BackupSettings.tsx +4 -4
- package/dashboard/app/FileLibrary.tsx +1 -1
- package/dashboard/app/LoginForm.tsx +31 -9
- package/dashboard/app/RemoteAccess.tsx +4 -5
- package/dashboard/app/SettingsUI.tsx +22 -0
- package/dashboard/app/SoftwareVersion.tsx +0 -1
- package/dashboard/app/TeamManagement.tsx +83 -36
- package/dashboard/app/WorkspaceAdmin.tsx +1 -1
- package/dashboard/app/admin.css +5 -0
- package/dashboard/app/api/auth/route.ts +24 -5
- package/dashboard/app/globals.css +2 -28
- package/dashboard/app/lib/auth-provider.ts +73 -16
- package/dashboard/app/lib/email-delivery.ts +41 -0
- package/dashboard/app/lib/server-auth.ts +1 -1
- package/dashboard/app/settings.css +280 -0
- package/dashboard/next.config.ts +1 -1
- package/docs/accounts-and-branding.md +24 -4
- package/docs/dependency-licenses.json +15 -3
- package/docs/server-deployment.md +2 -2
- package/package.json +3 -1
- package/scripts/remote_access.py +1 -1
- package/scripts/team_auth.py +60 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.2.1] - 2026-10-27
|
|
8
|
+
|
|
9
|
+
- 成员管理以列表为主,支持按姓名、用户名、邮箱和状态筛选;邀请及权限调整按需打开编辑面板,保留权限预览、固定操作区、未保存提醒和保存失败反馈。
|
|
10
|
+
- 统一管理中心导航、表单分组、状态提示和间距,优化 AI 设置、用量费用、远程接入、备份、账号及登录页面。
|
|
11
|
+
- 优化手机和中等屏幕布局,主工作台的推进阶段与关键进展上下排列,资料详情操作按用途分组。
|
|
12
|
+
- 完善键盘焦点返回、错误提示定位和邀请流程说明。
|
|
13
|
+
|
|
14
|
+
## [1.2.0] - 2026-10-26
|
|
15
|
+
|
|
16
|
+
- 邀请成员时邮箱改为选填,由成员激活时自行补充;保留内部身份、原有权限及历史,拒绝占用其他成员的邮箱。
|
|
17
|
+
- 增加可选邮箱归属验证,默认关闭;通过 SMTP 发送短期一次性验证码,保留密码与验证器登录,提供重试、限速及服务端授权校验。
|
|
18
|
+
- 成员管理区分角色和事项访问范围,明确新增事项的授权规则,支持搜索、批量选择和保存前权限预览。
|
|
19
|
+
- 显示邀请到期时间和过期状态,支持重新邀请并废止旧链接;修复未填写邮箱的待激活成员重新邀请时的异常。
|
|
20
|
+
- 已设置的用户名显示为锁定状态;精简软件版本说明。
|
|
21
|
+
|
|
7
22
|
## [1.1.2] - 2026-10-26
|
|
8
23
|
|
|
9
24
|
- 源码开发直接使用 Next.js Fast Refresh,保留开发编译缓存;页面、样式和接口代码修改无需重启,安装工作区和生产构建继续使用独立运行目录。
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Airalogy BD 是一套支持本地使用与团队服务器部署的 BD 管理工
|
|
|
14
14
|
|
|
15
15
|
支持邀请制账号、密码与动态验证码登录、管理员/编辑/只读角色、事项授权、录入账号追踪、服务器容器部署和私有 OSS 自动备份。本地模式无需登录。部署方式、权限范围、备份恢复和版本更新步骤见 [团队部署说明](docs/server-deployment.md)。
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
管理员填写姓名并分配权限即可创建成员,邮箱选填;复制一次性邀请链接交给本人,由本人补充邮箱、设置用户名、密码和动态验证码。支持用户名或邮箱登录。邮箱归属验证可选、默认关闭,无需配置邮件服务即可使用;管理员配置 SMTP 后可为整站开启。默认使用 Airalogy Logo,自行部署可配置站点名称及自有 Logo,详见[账号邀请与站点品牌](docs/accounts-and-branding.md)。
|
|
18
18
|
|
|
19
19
|
## 可选 AI 助手
|
|
20
20
|
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -19,7 +19,7 @@ The declared dependency inventory is recorded in [docs/dependency-licenses.json]
|
|
|
19
19
|
|
|
20
20
|
## Inventory coverage
|
|
21
21
|
|
|
22
|
-
The inventory covers metadata for
|
|
22
|
+
The inventory covers metadata for 604 locked npm package/version pairs (551 installed manifests and 53 official registry manifests for optional/platform packages), plus 17 locked Python package/version pairs. Its inspection date and lockfile hashes are recorded in the inventory. Each entry includes a declared license. Most dependencies use permissive licenses; the exceptions above must not be treated as Apache-2.0.
|
|
23
23
|
|
|
24
24
|
The source repository does not vendor the installed dependencies. Package metadata does not exhaustively describe every library, font or dataset embedded inside a native binary or framework bundle. The inventory is therefore not a completed binary/image distribution compliance report. Before distributing an installer, browser bundle or container, inspect the exact artifact, include its required notices and source access, and account for Node.js, Python, uv, Git, system libraries and the base image's terms. Artifact publication requires a separate distribution review.
|
|
25
25
|
|
package/bin/bd.mjs
CHANGED
|
@@ -145,6 +145,9 @@ function runNext(command, contentDirectory, forwardedArguments) {
|
|
|
145
145
|
if (process.env.BD_MODE && !["local","server"].includes(process.env.BD_MODE)) fail("BD_MODE 只支持 local 或 server。");
|
|
146
146
|
if (serverMode && command !== "build") {
|
|
147
147
|
deploymentOrigin();
|
|
148
|
+
const emailVerification = process.env.BD_REQUIRE_EMAIL_VERIFICATION || "0";
|
|
149
|
+
if (!["0","1"].includes(emailVerification)) fail("BD_REQUIRE_EMAIL_VERIFICATION 只支持 0 或 1。");
|
|
150
|
+
if (emailVerification === "1" && ["BD_SMTP_HOST","BD_SMTP_USER","BD_SMTP_PASSWORD","BD_MAIL_FROM"].some(key => !process.env[key]?.trim())) fail("开启邮箱验证前,请先配置发信服务器、用户名、密码和发件邮箱;暂不启用时将 BD_REQUIRE_EMAIL_VERIFICATION 设为 0。");
|
|
148
151
|
if (!process.env.BETTER_AUTH_SECRET || process.env.BETTER_AUTH_SECRET.length < 32) fail("请配置至少 32 字符的 BETTER_AUTH_SECRET。");
|
|
149
152
|
if (command !== "start") fail("服务器模式请使用 bd build 构建后运行 bd start。");
|
|
150
153
|
if (!existsSync(path.join(path.dirname(contentDirectory), ".bd-auth", "accounts.sqlite3")) || !workspaceRequest(contentDirectory,{action:"account_status"}).initialized) fail("请先使用 bd account-init 初始化管理员。");
|
|
@@ -3,6 +3,7 @@ import { useState } from "react";
|
|
|
3
3
|
import { useRouter } from "next/navigation";
|
|
4
4
|
import AccountBar from "./AccountBar";
|
|
5
5
|
import { BrandIdentity } from "./Branding";
|
|
6
|
+
import {SettingsIcon,SettingsPanelHeader,SettingsStatus} from "./SettingsUI";
|
|
6
7
|
import { teamRequest, type TeamUser } from "./lib/team-client";
|
|
7
8
|
import { usernameHelp, usernamePattern } from "./lib/username-policy";
|
|
8
9
|
|
|
@@ -13,7 +14,9 @@ export default function AccountSettings({ user }: { user: TeamUser }) {
|
|
|
13
14
|
const [busy, setBusy] = useState(false);
|
|
14
15
|
const [error, setError] = useState("");
|
|
15
16
|
async function save(event: React.FormEvent) {
|
|
16
|
-
event.preventDefault();
|
|
17
|
+
event.preventDefault();
|
|
18
|
+
if (saved || busy) return;
|
|
19
|
+
setBusy(true); setError("");
|
|
17
20
|
try {
|
|
18
21
|
const result = await teamRequest<{ username: string }>("auth_username", { username }, user.csrf);
|
|
19
22
|
setUsername(result.username); setSaved(true); router.refresh();
|
|
@@ -21,14 +24,15 @@ export default function AccountSettings({ user }: { user: TeamUser }) {
|
|
|
21
24
|
finally { setBusy(false); }
|
|
22
25
|
}
|
|
23
26
|
return <><AccountBar user={user} management /><main className="page-shell account-settings">
|
|
24
|
-
<BrandIdentity
|
|
25
|
-
<section className="panel"><
|
|
26
|
-
<
|
|
27
|
-
{error
|
|
28
|
-
<form className="team-form" onSubmit={save}><label
|
|
29
|
-
<p className="admin-help">{usernameHelp}</p>
|
|
30
|
-
{saved
|
|
31
|
-
</form
|
|
27
|
+
<header className="settings-page-head"><div><BrandIdentity/><h1>我的账号</h1><p>查看账号资料和日常登录方式。</p></div></header>
|
|
28
|
+
<section className="panel"><SettingsPanelHeader title="账号资料" description="用户名和邮箱均可登录,姓名用于页面展示。"/>
|
|
29
|
+
<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
|
+
{error&&<p role="alert" className="admin-alert">{error}</p>}
|
|
31
|
+
<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>
|
|
32
|
+
<p id="account-username-help" className="admin-help" role={saved?"status":undefined}>{saved?"此用户名不能修改,可直接用于登录。":usernameHelp}</p>
|
|
33
|
+
{!saved&&<button className="admin-button admin-primary" disabled={busy}>{busy?"正在保存…":"设置用户名"}</button>}
|
|
34
|
+
</form>
|
|
35
|
+
<div className="account-recovery-note"><h3>账号恢复</h3><p>忘记密码或无法取得动态验证码时,请联系管理员重新签发登录邀请。账号恢复保留原有事项权限和历史记录。</p></div>
|
|
32
36
|
</section>
|
|
33
37
|
</main></>;
|
|
34
38
|
}
|
|
@@ -6,28 +6,33 @@ import AiUsage from "./AiUsage";
|
|
|
6
6
|
import BackupSettings from "./BackupSettings";
|
|
7
7
|
import TeamManagement from "./TeamManagement";
|
|
8
8
|
import RemoteAccess from "./RemoteAccess";
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
import type {
|
|
9
|
+
import {BrandIdentity} from "./Branding";
|
|
10
|
+
import {SettingsIcon,SettingsPanelHeader} from "./SettingsUI";
|
|
11
|
+
import type {TeamUser} from "./lib/team-client";
|
|
12
|
+
import type {Project} from "./lib/types";
|
|
12
13
|
|
|
13
|
-
const sections
|
|
14
|
-
type Section
|
|
15
|
-
export default function AdminCenter({
|
|
16
|
-
user:
|
|
14
|
+
const sections={members:{title:"成员与权限",description:"邀请成员 · 分配访问范围"},ai:{title:"AI 设置",description:"模型服务 · 密钥与限额"},usage:{title:"用量与费用",description:"请求统计 · 费用估算"},remote:{title:"远程接入",description:"成员授权 · 接入凭据"},backup:{title:"备份",description:"自动备份 · 数据保护"}};
|
|
15
|
+
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};
|
|
17
18
|
}) {
|
|
18
|
-
const section:
|
|
19
|
+
const section:Section=initialSection&&Object.hasOwn(sections,initialSection)?initialSection as Section:user?"members":"ai";
|
|
19
20
|
return <>
|
|
20
|
-
{user
|
|
21
|
+
{user?<AccountBar user={user} management/>:<div className="team-bar"><span>本机使用者 · 管理权限</span><Link className="admin-button" href="/">返回工作台</Link></div>}
|
|
21
22
|
<main className="page-shell admin-center">
|
|
22
|
-
<header
|
|
23
|
-
{!user
|
|
24
|
-
{unavailable
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
<header className="settings-page-head"><div><BrandIdentity/><h1>管理中心</h1><p>管理团队访问、AI 服务与数据备份。</p></div><span className="settings-access-label"><SettingsIcon name="lock"/>仅管理员可访问</span></header>
|
|
24
|
+
{!user&&<p className="admin-notice">当前为本机单人模式。成员邀请和权限管理在团队模式中提供。</p>}
|
|
25
|
+
{unavailable&&<p className="admin-alert" role="alert">工作区资料暂时不可用,事项列表未能加载,请先检查工作区。</p>}
|
|
26
|
+
<div className="settings-layout">
|
|
27
|
+
<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
|
+
<div className="settings-content" key={section}>
|
|
29
|
+
{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/>}
|
|
31
|
+
{section==="usage"&&<section className="panel admin-center-panel"><SettingsPanelHeader title="用量与费用" description="查看团队 AI 请求、Token 用量和费用估算。"/><AiUsage initialFrom={dates.from} initialTo={dates.to}/></section>}
|
|
32
|
+
{section==="backup"&&<BackupSettings timezone={timezone} onChanged={()=>{}}/>}
|
|
33
|
+
{section==="remote"&&(user?<RemoteAccess user={user} manage/>:<section className="panel admin-center-panel"><SettingsPanelHeader title="远程接入" description="在团队模式中按账号授权。"/><p>配置账号和 HTTPS 后,可开启远程查询与修改草稿。</p></section>)}
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
31
36
|
</main>
|
|
32
37
|
</>;
|
|
33
38
|
}
|
|
@@ -1,46 +1,42 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
4
|
-
import {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const [clearKey,
|
|
10
|
-
const [busy,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
<
|
|
19
|
-
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
<label className="edit-field"><span
|
|
24
|
-
<label className="edit-field"><span
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
<
|
|
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}
|
|
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><
|
|
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
|
-
|
|
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>
|
|
@@ -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
|
|
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>;
|
|
@@ -6,6 +6,7 @@ import {BrandIdentity} from "./Branding";
|
|
|
6
6
|
import {usernamePattern,usernameHelp} from "./lib/username-policy";
|
|
7
7
|
|
|
8
8
|
type Setup={secret:string;otp_uri:string};
|
|
9
|
+
type LoginStep={step:"email"|"setup"|"verify";email_sent?:boolean};
|
|
9
10
|
export default function LoginForm({join=false}:{join?:boolean}) {
|
|
10
11
|
const router=useRouter();
|
|
11
12
|
const token=useRef("");
|
|
@@ -13,9 +14,12 @@ export default function LoginForm({join=false}:{join?:boolean}) {
|
|
|
13
14
|
const [username,setUsername]=useState("");
|
|
14
15
|
const [password,setPassword]=useState("");
|
|
15
16
|
const [code,setCode]=useState("");
|
|
16
|
-
const [
|
|
17
|
+
const [emailCode,setEmailCode]=useState("");
|
|
18
|
+
const [accepted,setAccepted]=useState(false);
|
|
19
|
+
const [emailSent,setEmailSent]=useState(false);
|
|
20
|
+
const [invitation,setInvitation]=useState<{email:string|null;name:string;username?:string|null}|null>(null);
|
|
17
21
|
const [setup,setSetup]=useState<Setup|null>(null);
|
|
18
|
-
const [step,setStep]=useState<"password"|"setup"|"verify">("password");
|
|
22
|
+
const [step,setStep]=useState<"password"|"email"|"setup"|"verify">("password");
|
|
19
23
|
const [error,setError]=useState("");
|
|
20
24
|
const [busy,setBusy]=useState(false);
|
|
21
25
|
useEffect(()=>{
|
|
@@ -24,17 +28,34 @@ export default function LoginForm({join=false}:{join?:boolean}) {
|
|
|
24
28
|
// Keep invitations out of URLs, access logs and referrers after opening the page.
|
|
25
29
|
window.history.replaceState(null,"",window.location.pathname);
|
|
26
30
|
let active=true;
|
|
27
|
-
teamRequest<{email:string;name:string;username?:string|null}>("auth_invite_info",{token:token.current}).then(data=>{if(active){setInvitation(data);setEmail(data.email);setUsername(data.username||"");}}).catch(err=>{if(active)setError(err.message);});
|
|
31
|
+
teamRequest<{email:string|null;name:string;username?:string|null}>("auth_invite_info",{token:token.current}).then(data=>{if(active){setInvitation(data);setEmail(data.email||"");setUsername(data.username||"");}}).catch(err=>{if(active)setError(err.message);});
|
|
28
32
|
return()=>{active=false;};
|
|
29
33
|
},[join]);
|
|
34
|
+
async function passwordLogin() {
|
|
35
|
+
const response=await teamRequest<LoginStep>(join&&!accepted?"auth_accept":"auth_login",{identifier:email,email,username,password,token:token.current});
|
|
36
|
+
if(join)setAccepted(true);
|
|
37
|
+
setStep(response.step);setEmailSent(response.email_sent===true);
|
|
38
|
+
if(response.step==="email")return;
|
|
39
|
+
if(response.step==="verify"){setPassword("");return;}
|
|
40
|
+
const enrollment=await teamRequest<Setup>("auth_setup",{password});
|
|
41
|
+
setSetup(enrollment);setPassword("");setStep("verify");
|
|
42
|
+
}
|
|
43
|
+
async function resendEmail() {
|
|
44
|
+
setBusy(true);setError("");
|
|
45
|
+
try {await passwordLogin();setEmailCode("");}
|
|
46
|
+
catch(err){setError((err as Error).message);}
|
|
47
|
+
finally{setBusy(false);}
|
|
48
|
+
}
|
|
30
49
|
async function submit(event:React.FormEvent) {
|
|
31
50
|
event.preventDefault();setBusy(true);setError("");
|
|
32
51
|
try {
|
|
33
52
|
if(step==="verify") {await teamRequest("auth_verify",{code});router.replace("/");router.refresh();return;}
|
|
53
|
+
if(step==="email") {
|
|
54
|
+
await teamRequest("auth_email_verify",{identifier:email,code:emailCode});
|
|
55
|
+
setEmailCode("");await passwordLogin();return;
|
|
56
|
+
}
|
|
34
57
|
if(step==="password") {
|
|
35
|
-
|
|
36
|
-
setStep(response.step);
|
|
37
|
-
if(response.step==="verify"){setPassword("");return;}
|
|
58
|
+
await passwordLogin();return;
|
|
38
59
|
}
|
|
39
60
|
const enrollment=await teamRequest<Setup>("auth_setup",{password});
|
|
40
61
|
setSetup(enrollment);setPassword("");setStep("verify");
|
|
@@ -45,12 +66,13 @@ export default function LoginForm({join=false}:{join?:boolean}) {
|
|
|
45
66
|
{join && !invitation && !error && <p role="status">正在检查邀请…</p>}
|
|
46
67
|
{error && <p className="admin-alert" role="alert">{error}</p>}
|
|
47
68
|
{(!join || invitation) && <form onSubmit={submit}>
|
|
48
|
-
{step==="password"&&<>{join&&<p>受邀成员:{invitation?.name}</p>}<label>{join?"
|
|
69
|
+
{step==="password"&&<>{join&&<p>受邀成员:{invitation?.name}</p>}<label>{join?"账号邮箱":"用户名或邮箱"}<input required type={join?"email":"text"} autoComplete={join?"email":"username"} autoCapitalize="none" spellCheck={false} value={email} onChange={e=>setEmail(e.target.value)} readOnly={join&&!!invitation?.email}/>{join&&<span className="admin-help">{invitation?.email?"邀请指定的账号邮箱。":"请填写你自己的邮箱,用于账号资料,也可以用于登录。"}</span>}</label>
|
|
49
70
|
{join&&<label>用户名<input required autoComplete="username" autoCapitalize="none" spellCheck={false} pattern={usernamePattern} minLength={3} maxLength={30} value={username} onChange={e=>setUsername(e.target.value)} readOnly={!!invitation?.username}/><span className="admin-help">{invitation?.username?"账号恢复保留原用户名、事项权限和历史记录。":usernameHelp}</span></label>}</>}
|
|
50
|
-
{step
|
|
71
|
+
{(step==="password"||step==="setup")&&<label>{join&&!accepted?"设置密码(至少 12 个字符)":"密码"}<input required type="password" minLength={12} maxLength={256} autoComplete={join&&!accepted?"new-password":"current-password"} value={password} onChange={e=>setPassword(e.target.value)}/></label>}
|
|
72
|
+
{step==="email"&&<><h2>验证账号邮箱</h2><p role="status">{emailSent?"验证码已发送至账号邮箱,10 分钟内有效。这是邮箱收到的验证码,身份验证器的动态验证码将在下一步输入。":"账号已保留,但验证邮件暂未发送成功。请稍后重新发送,或联系管理员检查发信配置。"}</p><label>邮箱验证码<input required inputMode="numeric" pattern="[0-9]{6}" maxLength={6} autoComplete="one-time-code" value={emailCode} onChange={e=>setEmailCode(e.target.value)}/></label><button className="admin-button" type="button" onClick={resendEmail} disabled={busy}>重新发送验证码</button><p className="admin-help">每分钟最多发送一次;重新发送后请使用最新验证码。也可以返回登录页稍后继续。</p><a href="/login">返回登录</a></>}
|
|
51
73
|
{setup && <div className="auth-setup"><h2>绑定身份验证器</h2><p>在你的身份验证器中添加账号,选择“输入设置密钥”,填入以下密钥。请勿分享此密钥。</p><code>{setup.secret}</code><p><a href={setup.otp_uri}>在支持的身份验证器中打开</a></p></div>}
|
|
52
74
|
{step==="verify"&&<label>六位动态验证码<input required inputMode="numeric" pattern="[0-9]{6}" maxLength={6} autoComplete="one-time-code" value={code} onChange={e=>setCode(e.target.value)}/></label>}
|
|
53
|
-
<button className="admin-button admin-primary" type="submit" disabled={busy}>{busy?"正在验证…":step==="verify"?(setup?"完成绑定":"登录"):step==="setup"?"继续绑定":join?"设置密码并继续":"下一步"}</button>
|
|
75
|
+
<button className="admin-button admin-primary" type="submit" disabled={busy}>{busy?"正在验证…":step==="email"?"验证邮箱并继续":step==="verify"?(setup?"完成绑定":"登录"):step==="setup"?"继续绑定":join&&!accepted?"设置密码并继续":"下一步"}</button>
|
|
54
76
|
</form>}
|
|
55
77
|
<p className="admin-help">仅限受邀成员。忘记密码或无法取得验证码时,请联系管理员重新签发邀请。设置中断后,可从登录页面继续绑定。</p>
|
|
56
78
|
{join&&<p><a href="/login">返回登录</a></p>}
|
|
@@ -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
|
|
|
@@ -52,15 +53,13 @@ export default function RemoteAccess({ user, manage = false, endpoint = "" }: {
|
|
|
52
53
|
finally { setBusy(false); }
|
|
53
54
|
}
|
|
54
55
|
return <section className="panel admin-center-panel remote-access">
|
|
55
|
-
<
|
|
56
|
-
<p>远程助手可查询获授权事项和提交修改草稿。草稿由本人在本页面核对并确认后保存。</p>
|
|
56
|
+
<SettingsPanelHeader title={manage?"远程接入":"我的远程接入"} description="远程助手可查询获授权事项并提交草稿,核对确认后才会正式保存。"/>
|
|
57
57
|
{error && <p className="admin-alert" role="alert">{error}</p>}
|
|
58
58
|
{notice && <p className="admin-notice" role="status">{notice}</p>}
|
|
59
59
|
{!status ? <p>正在读取接入状态…</p> : <>
|
|
60
|
-
<
|
|
60
|
+
<div className="remote-state"><div><SettingsStatus tone={status.enabled?"success":"neutral"}>{status.enabled?"已开启":"已关闭"}</SettingsStatus><p className="admin-help">{manage?"按账号授权,成员使用自己生成的接入凭据。":"接入权限与账号的事项访问范围保持一致。"}</p></div>{manage&&<button className="admin-button" disabled={busy} onClick={()=>void act("configure",{enabled:!status.enabled,revision:status.revision},status.enabled?"已关闭,旧凭据和草稿已失效。":"已开启。请为需要使用的成员授权。")}>{status.enabled?"关闭远程接入并撤销凭据":"开启远程接入"}</button>}</div>
|
|
61
61
|
{manage ? <>
|
|
62
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
63
|
<h3>成员授权</h3>
|
|
65
64
|
<ul className="remote-items">{status.members?.map(member => <li key={member.id}><span>{member.name} · {{ admin: "管理员", editor: "编辑成员", viewer: "只读成员" }[member.role]}{!member.active && " · 未启用"}</span>
|
|
66
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>
|
|
@@ -85,7 +84,7 @@ export default function RemoteAccess({ user, manage = false, endpoint = "" }: {
|
|
|
85
84
|
</>}
|
|
86
85
|
</>}
|
|
87
86
|
<h3>{manage ? "全部个人凭据" : "我的凭据"}</h3>
|
|
88
|
-
{!status.tokens.length && <p>暂无凭据。</p>}
|
|
87
|
+
{!status.tokens.length && <p className="settings-empty">暂无凭据。</p>}
|
|
89
88
|
<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
89
|
<button className="admin-button" disabled={busy} onClick={() => void act("revoke", { tokenId: token.id }, "凭据及其待确认草稿已撤销。")}>撤销</button></li>)}</ul>
|
|
91
90
|
{!manage && <>
|
|
@@ -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
|
+
}
|
|
@@ -18,7 +18,6 @@ export default function SoftwareVersion({ locale, timezone }: { locale: string;
|
|
|
18
18
|
{local && <div><dt>代码版本</dt><dd>{buildInfo.revision || "尚未提交"}</dd></div>}
|
|
19
19
|
</dl>
|
|
20
20
|
{local && <p>本地修改以构建标识区分;更新后可在这里核对。</p>}
|
|
21
|
-
<p>显示当前运行的软件版本,未检查远程更新。</p>
|
|
22
21
|
</div>
|
|
23
22
|
</details>;
|
|
24
23
|
}
|