@airalogy/airalogy-bd 1.2.0 → 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 +7 -0
- package/dashboard/app/AccountSettings.tsx +10 -8
- 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/RemoteAccess.tsx +4 -5
- package/dashboard/app/SettingsUI.tsx +22 -0
- package/dashboard/app/TeamManagement.tsx +75 -48
- package/dashboard/app/WorkspaceAdmin.tsx +1 -1
- package/dashboard/app/admin.css +5 -0
- package/dashboard/app/globals.css +2 -60
- package/dashboard/app/settings.css +280 -0
- package/docs/accounts-and-branding.md +3 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.2.1] - 2026-10-27
|
|
8
|
+
|
|
9
|
+
- 成员管理以列表为主,支持按姓名、用户名、邮箱和状态筛选;邀请及权限调整按需打开编辑面板,保留权限预览、固定操作区、未保存提醒和保存失败反馈。
|
|
10
|
+
- 统一管理中心导航、表单分组、状态提示和间距,优化 AI 设置、用量费用、远程接入、备份、账号及登录页面。
|
|
11
|
+
- 优化手机和中等屏幕布局,主工作台的推进阶段与关键进展上下排列,资料详情操作按用途分组。
|
|
12
|
+
- 完善键盘焦点返回、错误提示定位和邀请流程说明。
|
|
13
|
+
|
|
7
14
|
## [1.2.0] - 2026-10-26
|
|
8
15
|
|
|
9
16
|
- 邀请成员时邮箱改为选填,由成员激活时自行补充;保留内部身份、原有权限及历史,拒绝占用其他成员的邮箱。
|
|
@@ -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
|
|
|
@@ -23,14 +24,15 @@ export default function AccountSettings({ user }: { user: TeamUser }) {
|
|
|
23
24
|
finally { setBusy(false); }
|
|
24
25
|
}
|
|
25
26
|
return <><AccountBar user={user} management /><main className="page-shell account-settings">
|
|
26
|
-
<BrandIdentity
|
|
27
|
-
<section className="panel"><
|
|
28
|
-
<
|
|
29
|
-
{error
|
|
30
|
-
<form className="team-form" onSubmit={save}><label
|
|
31
|
-
<p id="account-username-help" className=
|
|
32
|
-
{!saved
|
|
33
|
-
</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>
|
|
34
36
|
</section>
|
|
35
37
|
</main></>;
|
|
36
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>;
|
|
@@ -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
|
+
}
|
|
@@ -1,70 +1,97 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import
|
|
2
|
+
import * as Dialog from "@radix-ui/react-dialog";
|
|
3
|
+
import {useCallback,useEffect,useRef,useState} from "react";
|
|
3
4
|
import {useRouter} from "next/navigation";
|
|
4
5
|
import {teamRequest,type TeamUser} from "./lib/team-client";
|
|
5
6
|
import type {Project} from "./lib/types";
|
|
6
7
|
import {workspaceRequest} from "./lib/workspace-client";
|
|
7
8
|
import type {WorkspaceResponse} from "./lib/workspace-types";
|
|
9
|
+
import {SettingsIcon,SettingsPanelHeader,SettingsStatus} from "./SettingsUI";
|
|
10
|
+
|
|
8
11
|
type Audit={id:number;at:number;actor_name:string;action:string;target:string;outcome:string};
|
|
9
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:"导出完整备份"};
|
|
10
13
|
const roleNames={admin:"管理员",editor:"编辑成员",viewer:"只读成员"};
|
|
11
14
|
const roleHelp={admin:"管理全部资料、成员权限和系统设置。",editor:"修改获授权事项和附件、补充进展。",viewer:"查看和导出获授权资料,不能修改。"};
|
|
12
15
|
const statusNames={active:"已启用",email:"待验证邮箱",setup:"待绑定验证器",invited:"待接受邀请",expired:"邀请已过期",disabled:"已停用"};
|
|
16
|
+
const isInvitation=(member:TeamUser)=>member.status==="invited"||member.status==="expired";
|
|
17
|
+
|
|
13
18
|
export default function TeamManagement({user,projects,timezone}:{user:TeamUser;projects:Pick<Project,"id"|"name">[];timezone:string}) {
|
|
14
19
|
const router=useRouter();
|
|
15
|
-
const [members,setMembers]=useState<TeamUser[]>([]);
|
|
16
|
-
const [
|
|
17
|
-
const [
|
|
18
|
-
const [
|
|
19
|
-
const [
|
|
20
|
-
const [all,setAll]=useState(false)
|
|
21
|
-
const [
|
|
22
|
-
const [
|
|
23
|
-
const [
|
|
24
|
-
const [audit,setAudit]=useState<Audit[]>([]);
|
|
25
|
-
const
|
|
20
|
+
const [members,setMembers]=useState<TeamUser[]>([]),[loading,setLoading]=useState(true);
|
|
21
|
+
const [memberSearch,setMemberSearch]=useState(""),[statusFilter,setStatusFilter]=useState("all");
|
|
22
|
+
const [projectChoices,setProjectChoices]=useState(projects),[projectSearch,setProjectSearch]=useState("");
|
|
23
|
+
const [editorOpen,setEditorOpen]=useState(false),[selected,setSelected]=useState<TeamUser|null>(null);
|
|
24
|
+
const [email,setEmail]=useState(""),[name,setName]=useState("");
|
|
25
|
+
const [role,setRole]=useState<TeamUser["role"]>("viewer"),[all,setAll]=useState(false),[ids,setIds]=useState<string[]>([]);
|
|
26
|
+
const [active,setActive]=useState(true),[busy,setBusy]=useState(false);
|
|
27
|
+
const [error,setError]=useState(""),[formError,setFormError]=useState(""),[notice,setNotice]=useState("");
|
|
28
|
+
const [link,setLink]=useState(""),[linkExpires,setLinkExpires]=useState<number|null>(null);
|
|
29
|
+
const [audit,setAudit]=useState<Audit[]>([]),[auditLoaded,setAuditLoaded]=useState(false),[auditBusy,setAuditBusy]=useState(false);
|
|
30
|
+
const opener=useRef<HTMLButtonElement|null>(null),inviteButton=useRef<HTMLButtonElement|null>(null);
|
|
31
|
+
const formAlert=useRef<HTMLParagraphElement|null>(null);
|
|
26
32
|
const pendingInvitations=members.some(member=>member.status==="invited");
|
|
27
33
|
const expiryText=(seconds:number)=>new Intl.DateTimeFormat("zh-CN",{timeZone:timezone,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",hourCycle:"h23"}).format(new Date(seconds*1000));
|
|
28
34
|
const visibleProjects=projectChoices.filter(project=>project.name.toLocaleLowerCase().includes(projectSearch.trim().toLocaleLowerCase()));
|
|
35
|
+
const visibleMembers=members.filter(member=>{
|
|
36
|
+
const search=memberSearch.trim().toLocaleLowerCase();
|
|
37
|
+
const matches=[member.name,member.username,member.email].some(value=>value?.toLocaleLowerCase().includes(search));
|
|
38
|
+
return matches&&(statusFilter==="all"||(statusFilter==="pending"?["invited","expired","email","setup"].includes(member.status||""):member.status===statusFilter));
|
|
39
|
+
});
|
|
29
40
|
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;
|
|
30
42
|
const refresh=useCallback(async()=>{const result=await teamRequest<{users:TeamUser[]}>("team_list",{},user.csrf);setMembers(result.users);},[user.csrf]);
|
|
31
|
-
useEffect(()=>{let cancelled=false;teamRequest<{users:TeamUser[]}>("team_list",{},user.csrf).then(result=>{if(!cancelled)setMembers(result.users);}).catch(err=>{if(!cancelled)setError(err.message);});return()=>{cancelled=true;};},[user.csrf]);
|
|
43
|
+
useEffect(()=>{let cancelled=false;teamRequest<{users:TeamUser[]}>("team_list",{},user.csrf).then(result=>{if(!cancelled)setMembers(result.users);}).catch(err=>{if(!cancelled)setError(err.message);}).finally(()=>{if(!cancelled)setLoading(false);});return()=>{cancelled=true;};},[user.csrf]);
|
|
32
44
|
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();},[]);
|
|
33
45
|
useEffect(()=>{if(!pendingInvitations)return;const timer=window.setInterval(()=>{void refresh().catch(err=>setError(err.message));},60000);return()=>window.clearInterval(timer);},[pendingInvitations,refresh]);
|
|
34
|
-
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
{
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
46
|
+
useEffect(()=>{if(formError&&editorOpen){formAlert.current?.focus();formAlert.current?.scrollIntoView({block:"nearest"});}},[formError,editorOpen]);
|
|
47
|
+
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);
|
|
49
|
+
}
|
|
50
|
+
function closeEditor(){if(!busy&&(!dirty||window.confirm("放弃尚未保存的成员设置?")))setEditorOpen(false);}
|
|
51
|
+
function showLink(result:{token:string;expires:number}){setLinkExpires(result.expires);setLink(`${window.location.origin}/join#${result.token}`);setNotice("邀请链接已生成,请单独交给该成员。");}
|
|
52
|
+
async function save(event:React.FormEvent){
|
|
53
|
+
event.preventDefault();if(busy)return;setBusy(true);setFormError("");setNotice("");
|
|
54
|
+
try{
|
|
55
|
+
const payload={email,name,role,all_projects:all,project_ids:ids,active,id:selected?.id,revision:selected?.revision};
|
|
56
|
+
if(selected){
|
|
57
|
+
await teamRequest("team_update",payload,user.csrf);setNotice(active?"权限已更新,该成员需要重新登录。":"账号已停用,已有记录和历史已保留。");
|
|
58
|
+
if(selected.id===user.id){router.replace("/login");router.refresh();return;}
|
|
59
|
+
}else showLink(await teamRequest("team_invite",payload,user.csrf));
|
|
60
|
+
try{await refresh();}catch(err){setError(`已保存,但成员列表未能刷新:${(err as Error).message}`);}
|
|
61
|
+
setEditorOpen(false);
|
|
62
|
+
}catch(err){setFormError((err as Error).message);}finally{setBusy(false);}
|
|
63
|
+
}
|
|
64
|
+
async function reset(member:TeamUser){
|
|
65
|
+
if(!window.confirm(isInvitation(member)?`重新邀请 ${member.name}?旧链接将立即失效,新链接 48 小时内有效,原有身份和权限保留。`:`重新签发 ${member.name} 的登录邀请?现有登录会立即失效,原密码和验证器将需要重新设置。`))return;
|
|
66
|
+
setBusy(true);setError("");try{showLink(await teamRequest("team_reset",{id:member.id},user.csrf));await refresh();}catch(err){setError((err as Error).message);}finally{setBusy(false);}
|
|
67
|
+
}
|
|
68
|
+
async function copyLink(){try{await navigator.clipboard.writeText(link);setNotice("邀请链接已复制,请通过微信或邮件等渠道单独交给本人。");}catch{setNotice("无法自动复制,请选中邀请链接后手动复制。");}}
|
|
69
|
+
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
|
+
return <section className="team-management">
|
|
71
|
+
<section className="panel admin-center-panel member-panel">
|
|
72
|
+
<SettingsPanelHeader title="成员与权限" description="管理成员身份、角色和事项访问范围。" actions={<button ref={inviteButton} type="button" className="admin-button admin-primary" disabled={busy} onClick={event=>select(null,event.currentTarget)}><SettingsIcon name="plus"/>邀请新成员</button>}/>
|
|
73
|
+
{error&&<p className="admin-alert" role="alert">{error}</p>}{notice&&<p className="admin-notice" role="status">{notice}</p>}
|
|
74
|
+
{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
|
+
<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
|
+
{loading?<p className="settings-empty" role="status">正在读取成员…</p>:<div className="member-list">{visibleMembers.map(member=><article className="member-row" key={member.id}>
|
|
77
|
+
<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>
|
|
79
|
+
<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
|
+
</article>)}{!visibleMembers.length&&<p className="settings-empty">{members.length?"没有符合条件的成员,试试其他关键词或状态。":"尚无成员,可通过邀请链接加入团队。"}</p>}</div>}
|
|
81
|
+
<p className="member-policy-note">停用账号会撤销登录访问,保留已有业务记录和操作历史。人员总库维护、事项创建、全局历史和备份由管理员管理。</p>
|
|
82
|
+
</section>
|
|
83
|
+
<details className="settings-disclosure member-audit" onToggle={event=>{if(event.currentTarget.open&&!auditLoaded&&!auditBusy)void loadAudit();}}><summary><span>操作记录<small>查看成员与权限的变更</small></span></summary><div className="settings-disclosure-body"><div className="settings-row-heading"><p className="admin-help">显示实际操作账号。“开始”后没有完成记录的操作,需要检查执行结果。</p><button type="button" className="admin-button" disabled={auditBusy} onClick={()=>void loadAudit()}>{auditBusy?"读取中…":"刷新记录"}</button></div><div className="team-audit">{audit.map(item=><p key={item.id}><time>{expiryText(item.at)}</time><span>{item.actor_name} · {actionNames[item.action]||"账号操作"} · {{started:"开始",success:"完成",failed:"失败"}[item.outcome]||item.outcome}</span><small>{item.target}</small></p>)}{auditLoaded&&!audit.length&&<p className="settings-empty">暂无操作记录。</p>}</div>{audit.length>=100&&<button type="button" disabled={auditBusy} className="admin-button" onClick={()=>void loadAudit(true)}>加载更早记录</button>}</div></details>
|
|
84
|
+
<Dialog.Root open={editorOpen} onOpenChange={open=>{if(!open)closeEditor();}}><Dialog.Portal><Dialog.Overlay className="admin-dialog-overlay"/><Dialog.Content className="admin-dialog member-dialog" onInteractOutside={event=>event.preventDefault()} onEscapeKeyDown={event=>{event.preventDefault();closeEditor();}} onCloseAutoFocus={event=>{event.preventDefault();(opener.current?.isConnected?opener.current:inviteButton.current)?.focus();}}>
|
|
85
|
+
<header className="admin-dialog-head"><div><Dialog.Title>{selected?`调整 ${selected.name} 的权限`:"邀请新成员"}</Dialog.Title><Dialog.Description>{selected?"调整角色和访问范围,保存后生效。":"填写基本资料并分配权限,再把邀请链接交给本人。"}</Dialog.Description></div><button type="button" className="close-button" aria-label="关闭成员设置" disabled={busy} onClick={closeEditor}>×</button></header>
|
|
86
|
+
<form className="team-form" onSubmit={save}><div className="admin-dialog-scroll">{formError&&<p ref={formAlert} className="admin-alert" role="alert" tabIndex={-1}>{formError}</p>}<fieldset disabled={busy} className="member-form-fields">
|
|
87
|
+
{!selected&&<section className="settings-form-section"><h3>基本资料</h3><div className="edit-form-grid"><label>姓名<input required maxLength={100} autoComplete="name" value={name} onChange={e=>setName(e.target.value)}/></label><label><span>邮箱 <span className="field-optional">选填</span></span><input type="email" autoComplete="email" maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/></label></div><p className="admin-help">邮箱留空时由本人激活时填写;已填写的邮箱会绑定到本次邀请。</p></section>}
|
|
88
|
+
<section className="settings-form-section"><h3>角色与访问范围</h3><label>成员角色<select aria-describedby="team-role-help" value={role} onChange={e=>setRole(e.target.value as TeamUser["role"])}>{Object.entries(roleNames).map(([value,label])=><option key={value} value={value}>{label}</option>)}</select><span id="team-role-help" className="admin-help">{roleHelp[role]}</span></label>
|
|
89
|
+
{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
|
+
{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
|
+
</section>
|
|
92
|
+
{selected&&<label className="team-check member-active"><input type="checkbox" checked={active} onChange={e=>setActive(e.target.checked)}/>启用账号</label>}
|
|
93
|
+
<section className="team-permission-summary" aria-labelledby="team-permission-summary-title"><h3 id="team-permission-summary-title">权限预览</h3>{selected&&!active?<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></dl>{!fullAccess&&!ids.length&&<p className="admin-help">可以激活账号,但暂时不能查看业务事项。</p>}</>}</section>
|
|
94
|
+
</fieldset></div><footer className="admin-dialog-footer"><span>{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
|
+
</Dialog.Content></Dialog.Portal></Dialog.Root>
|
|
69
96
|
</section>;
|
|
70
97
|
}
|
|
@@ -19,7 +19,7 @@ export default function WorkspaceAdmin({data, onEdit, onHistory, onFiles, onNdaF
|
|
|
19
19
|
<div className="admin-detail">{!selected ? <div className="panel-empty-state">选择一条{labels[kind]}资料,查看或编辑。</div> : <>
|
|
20
20
|
<h2>{options.find(item => item.id === selected)?.name}</h2><p className="admin-help">{options.find(item => item.id === selected)?.description}</p>
|
|
21
21
|
<div className="admin-actions"><button type="button" className="admin-button" onClick={() => onEdit({kind,id:selected})}>编辑{labels[kind]}</button><button type="button" className="admin-button" onClick={() => onHistory({kind,id:selected})}>查看修改历史</button>{kind === "project" && <button type="button" className="admin-button admin-primary" onClick={() => onEdit({kind:"progress",id:"",project_id:selected})}>新增沟通进展</button>}</div>
|
|
22
|
-
{kind === "project" && project && <section className="admin-progress-list"><button type="button" className="admin-button" onClick={() => onEdit({kind:"counterparty",id:project.primary_counterparty_id})}>对接方档案与共用联系人</button><button type="button" className="admin-button" onClick={() => onHistory({kind:"counterparty",id:project.primary_counterparty_id})}>对接方修改历史</button><button type="button" className="admin-button" onClick={() => onFiles(selected)}>查看事项资料</button><h3>沟通进展</h3>{[...project.events].sort((a,b) => b.date.localeCompare(a.date)).map(event => <article key={event.id}><div><time>{event.date}</time><h4>{event.title}</h4><p>{event.summary}</p></div><button type="button" className="admin-button" onClick={() => onEdit({kind:"progress",id:event.id,project_id:selected})} aria-label={`编辑进展:${event.title}`}>编辑</button><button type="button" className="admin-button" onClick={() => onFiles(selected,event.id)} aria-label={`关联资料:${event.title}`}>资料</button></article>)}</section>}
|
|
22
|
+
{kind === "project" && project && <section className="admin-progress-list"><div className="admin-actions admin-related-actions"><button type="button" className="admin-button" onClick={() => onEdit({kind:"counterparty",id:project.primary_counterparty_id})}>对接方档案与共用联系人</button><button type="button" className="admin-button" onClick={() => onHistory({kind:"counterparty",id:project.primary_counterparty_id})}>对接方修改历史</button><button type="button" className="admin-button" onClick={() => onFiles(selected)}>查看事项资料</button></div><h3>沟通进展</h3>{[...project.events].sort((a,b) => b.date.localeCompare(a.date)).map(event => <article key={event.id}><div><time>{event.date}</time><h4>{event.title}</h4><p>{event.summary}</p></div><div className="admin-actions"><button type="button" className="admin-button" onClick={() => onEdit({kind:"progress",id:event.id,project_id:selected})} aria-label={`编辑进展:${event.title}`}>编辑</button><button type="button" className="admin-button" onClick={() => onFiles(selected,event.id)} aria-label={`关联资料:${event.title}`}>资料</button></div></article>)}</section>}
|
|
23
23
|
{kind === "person" && (() => {const person = data.people.find(person => person.id === selected); return person ? <dl className="admin-person-details"><dt>单位及职位</dt><dd>{person.affiliation}</dd><dt>电话</dt><dd>{person.phone || "未填写"}</dd><dt>微信</dt><dd>{person.wechat || "未填写"}</dd><dt>邮箱</dt><dd>{person.email || "未填写"}</dd><dt>关联事项</dt><dd>{person.projects.map(project => project.project_name).join("、") || "暂无关联事项"}</dd></dl> : null;})()}
|
|
24
24
|
{kind === "company" && <p>{data.company_events.find(event => event.id === selected)?.summary}</p>}
|
|
25
25
|
</>}</div>
|
package/dashboard/app/admin.css
CHANGED
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
.admin-list-items small { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
|
25
25
|
.admin-detail,.version-preview { border-left:1px solid var(--line); padding-left:24px; min-width:0; }
|
|
26
26
|
.admin-detail > p { font-size:13px; line-height:1.8; }
|
|
27
|
+
.admin-related-actions { margin-top:12px; }
|
|
27
28
|
.admin-progress-list article { display:flex; gap:12px; justify-content:space-between; align-items:flex-start; border-bottom:1px solid var(--line); padding:16px 0; }
|
|
29
|
+
.admin-progress-list article > div:first-child { flex:1; min-width:0; overflow-wrap:anywhere; }
|
|
30
|
+
.admin-progress-list article > .admin-actions { flex:none; }
|
|
28
31
|
.admin-progress-list time { font-size:11px; color:var(--muted); }
|
|
29
32
|
.admin-progress-list h4 { font-size:14px; margin:6px 0; }
|
|
30
33
|
.admin-progress-list p { font-size:13px; line-height:1.8; margin:0; }
|
|
@@ -113,6 +116,8 @@
|
|
|
113
116
|
.topbar-actions { flex-wrap:wrap; }
|
|
114
117
|
}
|
|
115
118
|
@media(max-width:650px) {
|
|
119
|
+
.workspace-admin > .section-heading,.version-history > .section-heading { flex-direction:column; gap:16px; }
|
|
120
|
+
.admin-progress-list article { flex-direction:column; }
|
|
116
121
|
.workspace-nav { flex-wrap:wrap; }
|
|
117
122
|
.workspace-nav button { padding:8px 11px; font-size:12px; }
|
|
118
123
|
.admin-layout,.history-layout,.edit-form-grid,.version-change-values,.history-filters { grid-template-columns:1fr; }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
@import "./admin.css";
|
|
3
3
|
@import "./files.css";
|
|
4
|
+
@import "./settings.css";
|
|
4
5
|
|
|
5
6
|
.weekly-trigger { white-space: nowrap; }
|
|
6
7
|
.weekly-navigation { display: grid; grid-template-columns: 1fr auto 1fr; gap: 4px; margin-top: 8px; padding: 4px; border: 1px solid var(--line); border-radius: 11px; background: #f3f7fa; }
|
|
@@ -2825,14 +2826,7 @@ select,
|
|
|
2825
2826
|
}
|
|
2826
2827
|
|
|
2827
2828
|
.dashboard-grid {
|
|
2828
|
-
|
|
2829
|
-
overflow-x: auto;
|
|
2830
|
-
scroll-snap-type: x mandatory;
|
|
2831
|
-
}
|
|
2832
|
-
|
|
2833
|
-
.dashboard-grid > .panel {
|
|
2834
|
-
flex: 0 0 calc(100vw - 42px);
|
|
2835
|
-
scroll-snap-align: start;
|
|
2829
|
+
grid-template-columns: minmax(0, 1fr);
|
|
2836
2830
|
}
|
|
2837
2831
|
|
|
2838
2832
|
.pipeline {
|
|
@@ -3040,55 +3034,3 @@ select,
|
|
|
3040
3034
|
transition: none !important;
|
|
3041
3035
|
}
|
|
3042
3036
|
}
|
|
3043
|
-
|
|
3044
|
-
/* Team deployment: authentication and membership management. */
|
|
3045
|
-
.auth-page{min-height:calc(100dvh - 80px);display:grid;place-items:center;padding:24px;background:#f5f7fa}
|
|
3046
|
-
.auth-card{width:min(100%,460px);padding:32px;border:1px solid #dfe5ed;border-radius:16px;background:white;box-shadow:0 12px 40px #152a4610}
|
|
3047
|
-
.auth-card h1{margin:0 0 12px}.auth-card form,.team-form fieldset{display:grid;gap:18px}
|
|
3048
|
-
.auth-card label,.team-form label,.team-management>label{display:grid;gap:8px;font-weight:600}
|
|
3049
|
-
.auth-card input,.team-form input:not([type=checkbox]):not([type=radio]),.team-form select,.team-management>label input{width:100%;padding:10px 12px;border:1px solid #cbd5e1;border-radius:8px;background:white;color:#152a46}
|
|
3050
|
-
.auth-setup{padding:14px;background:#f1f5f9;border-radius:8px}.auth-setup h2{font-size:16px}.auth-setup code{overflow-wrap:anywhere;user-select:all}
|
|
3051
|
-
.team-bar{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:10px max(20px,calc((100vw - 1440px)/2));border-bottom:1px solid #e2e8f0;background:white}.team-bar>div{display:flex;gap:8px}
|
|
3052
|
-
.team-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin:24px 0}.team-management .panel{padding:24px}.team-member{display:grid;gap:8px;padding:16px 0;border-bottom:1px solid #e2e8f0}.team-member>div{display:flex;gap:8px;flex-wrap:wrap}.team-form fieldset{border:0;padding:0}.team-form label.team-check{display:flex;align-items:center;font-weight:400}.team-form input[type=checkbox]{width:18px;height:18px}.team-audit{overflow-wrap:anywhere;font-size:13px}
|
|
3053
|
-
@media(max-width:760px){.team-grid{grid-template-columns:1fr}.team-bar{flex-wrap:wrap}.auth-card{padding:22px}}
|
|
3054
|
-
|
|
3055
|
-
.site-brand-identity { display: inline-flex; align-items: center; gap: 12px; font-weight: 700; }
|
|
3056
|
-
.site-brand-logo { display: block; width: auto; height: 36px; max-width: 144px; object-fit: contain; flex-shrink: 0; }
|
|
3057
|
-
.auth-card h1 .site-brand-logo { height: 44px; max-width: 176px; }
|
|
3058
|
-
.auth-card label .admin-help { display: block; font-weight: 400; }
|
|
3059
|
-
.account-settings { max-width: 800px; }
|
|
3060
|
-
.team-grid > .panel { min-width: 0; align-self: start; }
|
|
3061
|
-
.team-invitation-expired { color: #b45309; font-weight: 600; }
|
|
3062
|
-
.team-member-scope { font-size: 13px; color: #475569; }
|
|
3063
|
-
.team-form .admin-help { font-weight: 400; margin: 0; }
|
|
3064
|
-
.team-form legend { padding: 0; margin-bottom: 10px; font-weight: 600; }
|
|
3065
|
-
.team-form .team-scope-options { gap: 10px; }
|
|
3066
|
-
.team-form label.team-scope-option { display: flex; align-items: flex-start; gap: 10px; padding: 14px; border: 1px solid #cbd5e1; border-radius: 10px; cursor: pointer; }
|
|
3067
|
-
.team-form label.team-scope-option.is-selected { background: #eff6ff; border-color: #2563eb; }
|
|
3068
|
-
.team-scope-option input[type=radio] { flex: 0 0 auto; width: 18px; height: 18px; margin-top: 2px; accent-color: #2563eb; }
|
|
3069
|
-
.team-scope-option > span { display: grid; gap: 4px; }
|
|
3070
|
-
.team-scope-option > span > span { font-size: 13px; line-height: 1.5; color: #475569; font-weight: 400; }
|
|
3071
|
-
.team-scope-option:focus-within { outline: 2px solid #2563eb; outline-offset: 2px; }
|
|
3072
|
-
.team-form .team-project-picker { gap: 12px; }
|
|
3073
|
-
.team-selection-toolbar, .team-selection-toolbar > div { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
|
3074
|
-
.team-selection-toolbar { justify-content: space-between; font-size: 13px; }
|
|
3075
|
-
.team-project-list { display: grid; gap: 10px; max-height: 260px; overflow-y: auto; padding: 12px; border: 1px solid #e2e8f0; border-radius: 8px; }
|
|
3076
|
-
.team-project-list label { overflow-wrap: anywhere; }
|
|
3077
|
-
.team-project-list input[type=checkbox] { flex: 0 0 auto; }
|
|
3078
|
-
.team-permission-summary { padding: 16px; border: 1px solid #dbe4ee; border-radius: 10px; background: #f8fafc; }
|
|
3079
|
-
.team-permission-summary h3 { margin: 0 0 12px; font-size: 15px; }
|
|
3080
|
-
.team-permission-summary dl { display: grid; gap: 10px; margin: 0; }
|
|
3081
|
-
.team-permission-summary dl > div { display: grid; grid-template-columns: 7em minmax(0, 1fr); gap: 12px; font-size: 13px; }
|
|
3082
|
-
.team-permission-summary dt { color: #64748b; }
|
|
3083
|
-
.team-permission-summary dd { margin: 0; }
|
|
3084
|
-
.team-permission-summary .admin-help { margin-top: 12px; }
|
|
3085
|
-
.account-settings .team-form input[readonly] {
|
|
3086
|
-
background: #f1f5f9;
|
|
3087
|
-
color: #475569;
|
|
3088
|
-
border-color: #cbd5e1;
|
|
3089
|
-
cursor: default;
|
|
3090
|
-
caret-color: transparent;
|
|
3091
|
-
}
|
|
3092
|
-
.invitation-result { padding: 16px; margin: 16px 0; border: 1px solid var(--line); border-radius: 12px; }
|
|
3093
|
-
.invitation-result label { display: grid; gap: 8px; margin-bottom: 12px; }
|
|
3094
|
-
.invitation-result input { width: 100%; padding: 10px; }
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/* Shared account and administration surfaces. */
|
|
2
|
+
.site-brand-identity { display:inline-flex; align-items:center; gap:10px; font-weight:700; }
|
|
3
|
+
.site-brand-logo { display:block; width:auto; height:32px; max-width:144px; object-fit:contain; flex-shrink:0; }
|
|
4
|
+
.settings-icon { width:20px; height:20px; flex:none; }
|
|
5
|
+
.admin-button { display:inline-flex; align-items:center; justify-content:center; gap:7px; min-height:38px; }
|
|
6
|
+
.admin-button .settings-icon { width:17px; height:17px; }
|
|
7
|
+
.admin-button:focus-visible,.settings-text-button:focus-visible { outline:2px solid var(--blue); outline-offset:3px; }
|
|
8
|
+
.settings-text-button { border:0; padding:6px 2px; background:transparent; color:var(--blue); font-size:12px; cursor:pointer; line-height:1.5; }
|
|
9
|
+
.settings-text-button:hover { text-decoration:underline; text-underline-offset:3px; }
|
|
10
|
+
.settings-text-button:disabled { opacity:.45; cursor:default; text-decoration:none; }
|
|
11
|
+
.settings-status { display:inline-flex; align-items:center; gap:6px; width:fit-content; flex:none; border-radius:20px; background:#f1f4f7; padding:4px 9px; color:var(--muted); font-size:11px; line-height:1.4; white-space:nowrap; }
|
|
12
|
+
.settings-status > span { width:5px; height:5px; border-radius:50%; background:currentColor; }
|
|
13
|
+
.settings-status.is-success { background:#edf7f1; color:#287756; }
|
|
14
|
+
.settings-status.is-warning { background:#fff5e7; color:#97601f; }
|
|
15
|
+
.team-bar { display:flex; justify-content:space-between; align-items:center; gap:16px; padding:13px max(24px,calc((100vw - 1280px)/2)); border-bottom:1px solid var(--line); background:var(--paper); font-size:12px; color:var(--muted); }
|
|
16
|
+
.team-bar > span { overflow-wrap:anywhere; }
|
|
17
|
+
.team-bar > div { display:flex; align-items:center; flex-wrap:wrap; gap:4px; }
|
|
18
|
+
.team-bar .admin-button { background:transparent; border-color:transparent; color:var(--muted); min-height:34px; padding:6px 10px; font-size:12px; }
|
|
19
|
+
.team-bar .admin-button:hover { color:var(--navy); background:var(--canvas); }
|
|
20
|
+
.page-shell.admin-center { max-width:1280px; padding-top:34px; }
|
|
21
|
+
.settings-page-head { display:flex; justify-content:space-between; align-items:flex-start; gap:20px; margin-bottom:30px; }
|
|
22
|
+
.settings-page-head .site-brand-identity { color:var(--muted); font-size:13px; margin-bottom:12px; }
|
|
23
|
+
.settings-page-head .site-brand-logo { height:24px; }
|
|
24
|
+
.admin-center .settings-page-head h1,.account-settings .settings-page-head h1 { font-size:28px; font-weight:650; line-height:1.3; margin:0; letter-spacing:-.03em; }
|
|
25
|
+
.admin-center .settings-page-head p,.account-settings .settings-page-head p { margin:8px 0 0; font-size:13px; line-height:1.7; color:var(--muted); }
|
|
26
|
+
.settings-access-label { display:flex; align-items:center; gap:6px; margin-top:7px; font-size:11px; color:var(--muted); }
|
|
27
|
+
.settings-access-label .settings-icon { width:14px; height:14px; }
|
|
28
|
+
.settings-layout { display:grid; grid-template-columns:196px minmax(0,1fr); gap:28px; align-items:start; }
|
|
29
|
+
.settings-layout .admin-center-nav { display:flex; flex-direction:column; gap:5px; margin:0; position:sticky; top:24px; }
|
|
30
|
+
.settings-layout .admin-center-nav a { display:flex; align-items:center; gap:12px; padding:13px 12px; border:1px solid transparent; background:transparent; border-radius:10px; color:var(--muted); }
|
|
31
|
+
.settings-layout .admin-center-nav a:hover { background:#edf2f6; color:var(--navy); }
|
|
32
|
+
.settings-layout .admin-center-nav a[aria-current=page] { background:#e9f1f8; border-color:#dce8f2; color:var(--blue); }
|
|
33
|
+
.admin-center-nav a > span { display:grid; gap:5px; }
|
|
34
|
+
.admin-center-nav strong { font-size:13px; font-weight:600; }
|
|
35
|
+
.admin-center-nav small { font-size:10px; font-weight:400; opacity:.85; white-space:nowrap; }
|
|
36
|
+
.settings-content { min-width:0; }
|
|
37
|
+
.admin-center .panel,.account-settings .panel { border:1px solid var(--line); border-radius:14px; box-shadow:0 2px 6px #172b4d03; }
|
|
38
|
+
.admin-center .admin-center-panel { padding:28px; }
|
|
39
|
+
.settings-panel-head { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; margin-bottom:24px; }
|
|
40
|
+
.settings-panel-head h2 { margin:0; font-size:20px; line-height:1.4; font-weight:650; }
|
|
41
|
+
.settings-panel-head p { color:var(--muted); font-size:13px; line-height:1.7; margin:7px 0 0; }
|
|
42
|
+
.settings-panel-head > .admin-button { flex:none; }
|
|
43
|
+
.settings-empty { padding:28px 16px; text-align:center; color:var(--muted); font-size:13px; line-height:1.7; }
|
|
44
|
+
.settings-form-section { min-width:0; margin:0; padding:0; }
|
|
45
|
+
.settings-form-section + .settings-form-section { border-top:1px solid var(--line); margin-top:24px; padding-top:24px; }
|
|
46
|
+
.settings-form-section h3 { font-size:14px; font-weight:600; color:var(--ink); margin:0 0 16px; }
|
|
47
|
+
.settings-form-section > .admin-help { margin:10px 0 0; }
|
|
48
|
+
.settings-form-section .edit-field { margin:0; }
|
|
49
|
+
.settings-form-section .edit-field input,.settings-form-section .edit-field select { min-height:42px; }
|
|
50
|
+
.settings-form-actions { display:flex; align-items:center; justify-content:flex-end; gap:12px; border-top:1px solid var(--line); padding-top:20px; margin-top:24px; }
|
|
51
|
+
.settings-row-heading { display:flex; align-items:center; justify-content:space-between; gap:16px; }
|
|
52
|
+
.settings-row-heading p { margin:0; }
|
|
53
|
+
.settings-row-heading .admin-button { flex:none; }
|
|
54
|
+
.settings-search { display:flex; position:relative; align-items:center; min-width:0; }
|
|
55
|
+
.settings-search > .settings-icon { position:absolute; left:12px; width:17px; height:17px; pointer-events:none; color:var(--muted); }
|
|
56
|
+
.settings-search input { width:100%; min-width:0; min-height:40px; padding:9px 12px 9px 38px; border:1px solid var(--line); border-radius:8px; background:white; font-size:13px; color:var(--ink); }
|
|
57
|
+
.settings-search input:focus,.team-form input:focus,.team-form select:focus,.auth-card input:focus { outline:2px solid #2468a22b; outline-offset:1px; border-color:var(--blue); }
|
|
58
|
+
.settings-disclosure { margin-top:20px; border:1px solid var(--line); border-radius:12px; background:var(--paper); }
|
|
59
|
+
.settings-disclosure > summary { display:flex; justify-content:space-between; align-items:center; gap:16px; padding:17px 22px; cursor:pointer; list-style:none; font-size:13px; font-weight:600; }
|
|
60
|
+
.settings-disclosure > summary::-webkit-details-marker { display:none; }
|
|
61
|
+
.settings-disclosure > summary::after { content:"›"; font-size:22px; font-weight:400; color:var(--muted); transform:rotate(90deg); }
|
|
62
|
+
.settings-disclosure[open] > summary::after { transform:rotate(-90deg); }
|
|
63
|
+
.settings-disclosure summary small { margin-left:12px; font-size:12px; font-weight:400; color:var(--muted); }
|
|
64
|
+
.settings-disclosure-body { padding:0 22px 22px; border-top:1px solid var(--line); }
|
|
65
|
+
.settings-disclosure-body > .settings-row-heading { margin-top:18px; }
|
|
66
|
+
.field-optional { font-size:11px; font-weight:400; color:var(--muted); margin-left:4px; }
|
|
67
|
+
|
|
68
|
+
/* Members are browsed first; long forms live in a focused, scrollable dialog. */
|
|
69
|
+
.member-toolbar { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
|
|
70
|
+
.member-toolbar .settings-search { flex:1; max-width:360px; }
|
|
71
|
+
.member-status-filter select { min-height:40px; padding:8px 28px 8px 12px; border:1px solid var(--line); border-radius:8px; background:white; font-size:12px; color:var(--ink); }
|
|
72
|
+
.member-count { margin-left:auto; color:var(--muted); font-size:12px; white-space:nowrap; }
|
|
73
|
+
.member-row { display:grid; grid-template-columns:minmax(0,1.4fr) minmax(150px,1fr) auto; align-items:center; gap:20px; padding:22px 0; border-bottom:1px solid var(--line); }
|
|
74
|
+
.member-row:last-child { border-bottom:0; }
|
|
75
|
+
.member-identity { display:flex; align-items:flex-start; gap:12px; min-width:0; }
|
|
76
|
+
.member-identity > div { min-width:0; }
|
|
77
|
+
.member-avatar { display:grid; place-items:center; flex:none; width:38px; height:38px; background:#edf2f7; border-radius:11px; color:#54718a; font-size:14px; font-weight:600; }
|
|
78
|
+
.member-name { display:flex; align-items:center; flex-wrap:wrap; gap:7px; margin:0 0 6px; }
|
|
79
|
+
.member-name > strong { font-size:14px; }
|
|
80
|
+
.member-self { font-size:10px; color:var(--muted); }
|
|
81
|
+
.member-identity p,.member-permissions p { font-size:12px; line-height:1.6; margin:3px 0 0; overflow-wrap:anywhere; }
|
|
82
|
+
.member-identity p,.member-permissions small { color:var(--muted); }
|
|
83
|
+
.member-permissions small { display:block; font-size:11px; line-height:1.5; margin-top:4px; }
|
|
84
|
+
.member-role { display:inline-block; font-size:12px; font-weight:600; color:var(--navy); margin-bottom:3px; }
|
|
85
|
+
.member-actions { display:grid; justify-items:center; gap:5px; }
|
|
86
|
+
.member-policy-note { font-size:12px; line-height:1.8; color:var(--muted); border-top:1px solid var(--line); padding-top:16px; margin:0; }
|
|
87
|
+
.member-identity .team-invitation-expired { color:#97601f; }
|
|
88
|
+
.invitation-result { margin:0 0 24px; padding:18px; border:1px solid #c9ded3; border-radius:10px; background:#f4faf6; }
|
|
89
|
+
.invitation-result-heading { display:flex; justify-content:space-between; gap:12px; align-items:center; font-size:13px; }
|
|
90
|
+
.invitation-copy { display:flex; gap:8px; margin:10px 0; }
|
|
91
|
+
.invitation-copy input { flex:1; min-width:0; padding:9px 11px; border:1px solid #c9ded3; border-radius:7px; background:white; color:var(--ink); font-size:12px; }
|
|
92
|
+
.invitation-result .admin-help { margin:0; }
|
|
93
|
+
.team-audit p { display:grid; grid-template-columns:140px minmax(0,1fr); gap:4px 12px; margin:0; padding:13px 0; border-bottom:1px solid var(--line); font-size:12px; line-height:1.7; overflow-wrap:anywhere; }
|
|
94
|
+
.team-audit time,.team-audit small { color:var(--muted); }
|
|
95
|
+
.team-audit small { grid-column:2; font-size:11px; }
|
|
96
|
+
.member-dialog { inset:50% auto auto 50%; transform:translate(-50%,-50%); width:min(720px,calc(100vw - 40px)); max-height:calc(100dvh - 48px); }
|
|
97
|
+
.member-dialog .admin-dialog-head { padding:23px 26px; gap:16px; }
|
|
98
|
+
.member-dialog .admin-dialog-head h2 { font-size:21px; }
|
|
99
|
+
.member-dialog .admin-dialog-scroll { padding:24px 26px; }
|
|
100
|
+
.member-dialog .admin-dialog-footer { padding:16px 26px; }
|
|
101
|
+
.team-form fieldset { border:0; margin:0; padding:0; min-width:0; }
|
|
102
|
+
.team-form label { display:grid; gap:8px; min-width:0; font-size:13px; font-weight:500; }
|
|
103
|
+
.team-form input:not([type=checkbox]):not([type=radio]),.team-form select { width:100%; min-width:0; min-height:42px; padding:9px 11px; border:1px solid var(--line); border-radius:8px; background:white; color:var(--ink); font-size:13px; }
|
|
104
|
+
.team-form .admin-help { margin:0; font-size:12px; font-weight:400; }
|
|
105
|
+
.team-form .settings-form-section > .admin-help { margin-top:10px; }
|
|
106
|
+
.team-form legend { padding:0; font-size:13px; font-weight:500; margin-bottom:10px; }
|
|
107
|
+
.team-form .team-scope-options { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin-top:22px; }
|
|
108
|
+
.team-form label.team-scope-option { display:flex; align-items:flex-start; gap:10px; padding:14px; border:1px solid var(--line); border-radius:9px; cursor:pointer; }
|
|
109
|
+
.team-form label.team-scope-option.is-selected { background:#f1f6fb; border-color:#8aacc9; }
|
|
110
|
+
.team-scope-option input[type=radio] { flex:none; width:16px; height:16px; margin-top:2px; accent-color:var(--blue); }
|
|
111
|
+
.team-scope-option > span { display:grid; gap:5px; }
|
|
112
|
+
.team-scope-option strong { font-size:13px; font-weight:600; }
|
|
113
|
+
.team-scope-option > span > span { font-size:11px; line-height:1.6; color:var(--muted); font-weight:400; }
|
|
114
|
+
.team-scope-option:focus-within { outline:2px solid var(--blue); outline-offset:2px; }
|
|
115
|
+
.team-form .team-project-picker { margin-top:16px; }
|
|
116
|
+
.team-form .settings-search { display:flex; font-weight:400; }
|
|
117
|
+
.team-form .settings-search input[type=search] { padding-left:38px; }
|
|
118
|
+
.team-selection-toolbar,.team-selection-toolbar > div { display:flex; align-items:center; flex-wrap:wrap; gap:8px; }
|
|
119
|
+
.team-selection-toolbar { justify-content:space-between; font-size:12px; color:var(--muted); margin:8px 0; }
|
|
120
|
+
.team-project-list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:4px 12px; max-height:180px; overflow-y:auto; padding:12px; border:1px solid var(--line); border-radius:8px; margin-bottom:10px; }
|
|
121
|
+
.team-form label.team-check { display:flex; align-items:flex-start; gap:8px; font-weight:400; line-height:1.6; padding:5px 0; overflow-wrap:anywhere; }
|
|
122
|
+
.team-form input[type=checkbox] { flex:none; width:16px; height:16px; margin-top:2px; accent-color:var(--blue); }
|
|
123
|
+
.team-form .member-active { margin-top:20px; }
|
|
124
|
+
.team-permission-summary { padding:16px; border:1px solid var(--line); border-radius:9px; background:#f7f9fb; margin-top:22px; }
|
|
125
|
+
.member-dialog .team-permission-summary h3 { margin:0 0 12px; font-size:13px; font-weight:600; }
|
|
126
|
+
.team-permission-summary dl { display:grid; gap:8px; margin:0; }
|
|
127
|
+
.team-permission-summary dl > div { display:grid; grid-template-columns:7em minmax(0,1fr); gap:12px; font-size:12px; line-height:1.7; }
|
|
128
|
+
.team-permission-summary dt { color:var(--muted); }
|
|
129
|
+
.team-permission-summary dd { margin:0; }
|
|
130
|
+
.team-permission-summary > p,.team-permission-summary .admin-help { margin:10px 0 0; font-size:12px; line-height:1.7; }
|
|
131
|
+
|
|
132
|
+
/* Settings groups and reports share a readable width and action placement. */
|
|
133
|
+
.ai-settings { margin:0; }
|
|
134
|
+
.ai-settings .settings-form-section > .settings-row-heading { margin-bottom:20px; }
|
|
135
|
+
.settings-toggle-copy strong { display:block; font-size:14px; font-weight:600; }
|
|
136
|
+
.settings-toggle-copy p { margin:5px 0 0; font-size:12px; color:var(--muted); line-height:1.7; }
|
|
137
|
+
.settings-security-note { padding:12px 14px; background:#f5f7fa; border-radius:8px; color:var(--muted); font-size:12px; line-height:1.8; margin:18px 0 0; }
|
|
138
|
+
.settings-clear-key { display:flex; align-items:center; gap:8px; margin:12px 0 0; font-size:12px; color:var(--muted); }
|
|
139
|
+
.settings-clear-key input { accent-color:var(--blue); }
|
|
140
|
+
.usage-filters { display:flex; align-items:flex-end; flex-wrap:wrap; gap:14px; margin-bottom:24px; }
|
|
141
|
+
.usage-filters label { display:grid; gap:8px; font-size:12px; color:var(--muted); }
|
|
142
|
+
.usage-filters input { margin:0; min-height:40px; width:180px; max-width:100%; color:var(--ink); }
|
|
143
|
+
.usage-metrics { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:14px; margin:20px 0; }
|
|
144
|
+
.usage-metrics > div { padding:18px; border:1px solid var(--line); border-radius:10px; background:#f8fafc; min-width:0; }
|
|
145
|
+
.usage-metrics dt { font-size:12px; color:var(--muted); margin-bottom:8px; }
|
|
146
|
+
.usage-metrics dd { margin:0; font-size:24px; letter-spacing:-.03em; font-weight:600; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
|
|
147
|
+
.ai-usage-table { border:1px solid var(--line); border-radius:10px; margin:18px 0; }
|
|
148
|
+
.ai-usage-table table caption { padding:12px 14px; font-size:12px; }
|
|
149
|
+
.ai-usage-table th { background:#f5f7fa; color:var(--muted); font-size:11px; font-weight:500; }
|
|
150
|
+
.ai-usage-table td { font-size:12px; }
|
|
151
|
+
.ai-usage-table tr:last-child td { border-bottom:0; }
|
|
152
|
+
.usage-notes { border-top:1px solid var(--line); margin-top:24px; padding-top:16px; }
|
|
153
|
+
.usage-notes summary { color:var(--muted); font-size:12px; cursor:pointer; }
|
|
154
|
+
.usage-notes p { margin:10px 0 0; }
|
|
155
|
+
.remote-access .remote-state { display:flex; justify-content:space-between; gap:18px; align-items:center; padding:18px; border:1px solid var(--line); border-radius:10px; background:#f8fafc; margin:20px 0; }
|
|
156
|
+
.remote-state p { margin:8px 0 0; max-width:48em; }
|
|
157
|
+
.remote-items { padding:0; list-style:none; }
|
|
158
|
+
.remote-items > li > div,.remote-items > li > span { min-width:0; overflow-wrap:anywhere; }
|
|
159
|
+
.remote-items > li > button { flex:none; }
|
|
160
|
+
.remote-access .remote-form { display:grid; grid-template-columns:minmax(0,1fr) 160px; gap:14px; align-items:start; }
|
|
161
|
+
.remote-access .remote-form label { margin:0; font-size:13px; }
|
|
162
|
+
.remote-form label:has(input[type=checkbox]) { grid-column:1/-1; }
|
|
163
|
+
.remote-access .remote-form input:not([type=checkbox]) { width:100%; min-height:42px; margin-top:8px; }
|
|
164
|
+
.remote-access form > .admin-button { margin-top:16px; }
|
|
165
|
+
.remote-access .remote-guide { padding:16px; background:#f7f9fb; border:1px solid var(--line); border-radius:9px; }
|
|
166
|
+
.remote-access .remote-guide pre { padding:12px; background:white; border:1px solid var(--line); border-radius:7px; }
|
|
167
|
+
.admin-center .backup-settings { margin:0; padding:28px; max-width:none; }
|
|
168
|
+
.admin-center .backup-header h2 { font-size:20px; line-height:1.4; margin:0; font-weight:650; }
|
|
169
|
+
.admin-center .backup-header p { font-size:13px; }
|
|
170
|
+
.backup-settings .backup-extra-options { display:grid; gap:16px; margin-top:20px; padding-top:20px; border-top:1px solid var(--line); }
|
|
171
|
+
.backup-extra-options .team-check { display:flex; align-items:center; gap:8px; font-size:13px; }
|
|
172
|
+
.backup-extra-options .admin-help { margin:0; }
|
|
173
|
+
|
|
174
|
+
/* Account and authentication forms. */
|
|
175
|
+
.page-shell.account-settings { max-width:900px; padding-top:34px; }
|
|
176
|
+
.account-settings .panel { padding:28px; }
|
|
177
|
+
.account-profile { display:grid; grid-template-columns:1fr 2fr; gap:18px; margin:0 0 24px; padding-bottom:24px; border-bottom:1px solid var(--line); }
|
|
178
|
+
.account-profile dt { color:var(--muted); font-size:12px; margin-bottom:8px; }
|
|
179
|
+
.account-profile dd { margin:0; font-size:14px; line-height:1.7; overflow-wrap:anywhere; }
|
|
180
|
+
.account-profile .settings-status { margin-left:8px; }
|
|
181
|
+
.account-username { max-width:440px; }
|
|
182
|
+
.account-username > label { position:relative; }
|
|
183
|
+
.account-username .username-input { position:relative; }
|
|
184
|
+
.username-input > .settings-icon { position:absolute; right:12px; top:12px; width:17px; height:17px; color:var(--muted); }
|
|
185
|
+
.account-settings .team-form input[readonly] { background:#f3f5f8; color:#637082; border-color:#e0e5eb; padding-right:38px; cursor:default; }
|
|
186
|
+
.account-username .admin-help { margin:8px 0 18px; }
|
|
187
|
+
.account-recovery-note { border-top:1px solid var(--line); margin-top:24px; padding-top:20px; }
|
|
188
|
+
.account-recovery-note h3 { font-size:13px; font-weight:600; margin:0 0 8px; }
|
|
189
|
+
.account-recovery-note p { color:var(--muted); font-size:12px; line-height:1.8; margin:0; }
|
|
190
|
+
.auth-page { min-height:calc(100dvh - 80px); display:grid; place-items:center; padding:40px 24px; background:#f5f7fa; }
|
|
191
|
+
.auth-card { width:min(100%,460px); padding:36px; border:1px solid var(--line); border-radius:16px; background:white; box-shadow:0 12px 40px #152a4608; }
|
|
192
|
+
.auth-card h1 { margin:0 0 10px; font-size:21px; }
|
|
193
|
+
.auth-card h1 .site-brand-logo { height:40px; max-width:160px; }
|
|
194
|
+
.auth-card > p:first-of-type { font-size:14px; color:var(--muted); margin:0 0 28px; }
|
|
195
|
+
.auth-card form { display:grid; gap:20px; }
|
|
196
|
+
.auth-card label { display:grid; gap:8px; font-size:13px; font-weight:500; }
|
|
197
|
+
.auth-card input { width:100%; min-height:44px; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:white; color:var(--ink); }
|
|
198
|
+
.auth-card input[readonly] { background:#f3f5f8; color:var(--muted); }
|
|
199
|
+
.auth-card label .admin-help { font-size:11px; font-weight:400; margin:0; }
|
|
200
|
+
.auth-card form > .admin-primary { min-height:44px; margin-top:2px; }
|
|
201
|
+
.auth-card > .admin-help { padding-top:20px; margin:24px 0 0; border-top:1px solid var(--line); font-size:11px; line-height:1.9; }
|
|
202
|
+
.auth-card h2 { font-size:17px; margin:0; }
|
|
203
|
+
.auth-setup { padding:16px; background:#f3f6fa; border:1px solid var(--line); border-radius:9px; }
|
|
204
|
+
.auth-setup p { margin:10px 0; font-size:12px; line-height:1.8; }
|
|
205
|
+
.auth-setup code { display:block; overflow-wrap:anywhere; user-select:all; font-size:14px; padding:10px; background:white; border-radius:6px; }
|
|
206
|
+
.auth-card a { color:var(--blue); font-size:12px; }
|
|
207
|
+
|
|
208
|
+
@media(max-width:1050px) {
|
|
209
|
+
.settings-layout { grid-template-columns:164px minmax(0,1fr); gap:20px; }
|
|
210
|
+
.settings-layout .admin-center-nav a { gap:9px; padding:12px 10px; }
|
|
211
|
+
.admin-center-nav small { display:none; }
|
|
212
|
+
.member-row { grid-template-columns:minmax(0,1fr) auto; gap:12px 18px; }
|
|
213
|
+
.member-identity { grid-column:1; }
|
|
214
|
+
.member-permissions { grid-column:1; padding-left:50px; }
|
|
215
|
+
.member-actions { grid-column:2; grid-row:1 / 3; }
|
|
216
|
+
}
|
|
217
|
+
@media(max-width:760px) {
|
|
218
|
+
.team-bar { align-items:flex-start; flex-direction:column; gap:8px; padding:12px 20px; }
|
|
219
|
+
.team-bar > div { width:100%; gap:2px; }
|
|
220
|
+
.team-bar .admin-button { padding:6px 8px; font-size:11px; }
|
|
221
|
+
.settings-page-head { margin-bottom:22px; }
|
|
222
|
+
.settings-access-label { display:none; }
|
|
223
|
+
.settings-layout { grid-template-columns:minmax(0,1fr); gap:20px; }
|
|
224
|
+
.settings-layout .admin-center-nav { position:static; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:5px; }
|
|
225
|
+
.settings-layout .admin-center-nav a { justify-content:center; text-align:left; padding:10px 6px; gap:7px; }
|
|
226
|
+
.admin-center-nav .settings-icon { width:16px; height:16px; }
|
|
227
|
+
.admin-center-nav strong { font-size:12px; }
|
|
228
|
+
.admin-center .admin-center-panel,.admin-center .backup-settings,.account-settings .panel { padding:22px; }
|
|
229
|
+
.settings-panel-head { margin-bottom:20px; }
|
|
230
|
+
.usage-metrics { gap:9px; }
|
|
231
|
+
.usage-metrics > div { padding:13px; }
|
|
232
|
+
.usage-metrics dd { font-size:21px; }
|
|
233
|
+
}
|
|
234
|
+
@media(max-width:520px) {
|
|
235
|
+
.settings-layout .admin-center-nav { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
|
236
|
+
.page-shell.admin-center,.page-shell.account-settings { padding-top:24px; }
|
|
237
|
+
.settings-page-head .site-brand-identity { margin-bottom:10px; }
|
|
238
|
+
.admin-center .settings-page-head h1,.account-settings .settings-page-head h1 { font-size:25px; }
|
|
239
|
+
.admin-center .admin-center-panel,.admin-center .backup-settings,.account-settings .panel { padding:18px; }
|
|
240
|
+
.settings-panel-head { flex-wrap:wrap; gap:14px; }
|
|
241
|
+
.settings-panel-head > .admin-button { min-height:36px; }
|
|
242
|
+
.member-toolbar { flex-wrap:wrap; }
|
|
243
|
+
.member-toolbar .settings-search { flex:1 0 100%; max-width:none; }
|
|
244
|
+
.member-row { gap:12px; padding:20px 0; }
|
|
245
|
+
.member-avatar { width:32px; height:32px; border-radius:9px; }
|
|
246
|
+
.member-identity { gap:9px; }
|
|
247
|
+
.member-permissions { padding-left:41px; }
|
|
248
|
+
.member-name { gap:6px; }
|
|
249
|
+
.member-actions { grid-row:auto; grid-column:1 / -1; display:flex; justify-content:flex-start; padding-left:41px; gap:14px; }
|
|
250
|
+
.member-dialog { width:100%; max-height:100dvh; height:100dvh; border-radius:0; border:0; }
|
|
251
|
+
.member-dialog .admin-dialog-head { padding:20px; }
|
|
252
|
+
.member-dialog .admin-dialog-head h2 { font-size:19px; }
|
|
253
|
+
.member-dialog .admin-dialog-scroll { padding:20px; }
|
|
254
|
+
.member-dialog .admin-dialog-footer { padding:14px 20px max(14px,env(safe-area-inset-bottom)); }
|
|
255
|
+
.member-dialog .admin-dialog-footer > span { display:block; width:100%; }
|
|
256
|
+
.member-dialog .admin-dialog-footer > .admin-actions { width:100%; justify-content:flex-end; }
|
|
257
|
+
.team-form .team-scope-options,.team-project-list { grid-template-columns:minmax(0,1fr); }
|
|
258
|
+
.team-permission-summary dl > div { grid-template-columns:6em minmax(0,1fr); gap:8px; }
|
|
259
|
+
.settings-disclosure > summary { padding:16px 18px; }
|
|
260
|
+
.settings-disclosure summary small { display:block; margin:5px 0 0; }
|
|
261
|
+
.settings-disclosure-body { padding:0 18px 18px; }
|
|
262
|
+
.settings-row-heading { flex-wrap:wrap; }
|
|
263
|
+
.team-audit p { grid-template-columns:minmax(0,1fr); gap:4px; }
|
|
264
|
+
.team-audit small { grid-column:1; }
|
|
265
|
+
.invitation-copy { flex-wrap:wrap; }
|
|
266
|
+
.invitation-copy input { flex-basis:100%; }
|
|
267
|
+
.usage-filters { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
|
268
|
+
.usage-filters label,.usage-filters input { width:100%; min-width:0; }
|
|
269
|
+
.usage-filters > .admin-button { grid-column:1 / -1; }
|
|
270
|
+
.usage-metrics { grid-template-columns:minmax(0,1fr); gap:8px; }
|
|
271
|
+
.usage-metrics > div { display:flex; justify-content:space-between; align-items:center; gap:12px; }
|
|
272
|
+
.usage-metrics dt { margin:0; }
|
|
273
|
+
.usage-metrics dd { font-size:22px; }
|
|
274
|
+
.remote-access .remote-state { flex-direction:column; align-items:flex-start; }
|
|
275
|
+
.remote-items > li { align-items:flex-start; flex-wrap:wrap; }
|
|
276
|
+
.remote-access .remote-form { grid-template-columns:minmax(0,1fr); }
|
|
277
|
+
.account-profile { grid-template-columns:minmax(0,1fr); }
|
|
278
|
+
.auth-page { padding:24px 16px; }
|
|
279
|
+
.auth-card { padding:26px 22px; }
|
|
280
|
+
}
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
进入“管理中心 → 成员与权限 → 邀请新成员”,填写姓名、选择角色及事项权限,点击“生成邀请链接”。**邮箱选填**:不知道对方邮箱时可以留空,由受邀者激活时补充。系统创建待激活成员并显示一次性链接,不开放自行注册。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
点击“复制链接”,通过微信、邮件等可信渠道单独交给本人。链接有效期为 48 小时,只能使用一次;邀请链接不会自动通过邮件发送,也不生成需要转交的临时密码。成员打开链接后填写自己的邮箱、设置用户名和密码,再绑定身份验证器,即可进入获授权的事项。如管理员开启了邮箱验证,还须先输入账号邮箱收到的验证码。
|
|
8
|
+
|
|
9
|
+
成员页默认展示列表,可按姓名、用户名、邮箱搜索,或筛选启用、待激活和停用状态。点击“邀请新成员”或“调整权限”打开编辑面板,保存按钮保持在面板底部;有未保存修改时,关闭面板会提示确认。操作记录可按需展开查看。
|
|
8
10
|
|
|
9
11
|
管理员已填写邮箱的邀请会固定使用该邮箱;账号恢复也保留原邮箱和用户名。自行填写的邮箱不能占用其他成员的账号。待激活成员即使没有邮箱,也有独立的内部 ID 和已分配权限;同名成员不会合并,激活后仍沿用这个 ID、权限和历史。页面会显示“邮箱待本人填写”,无需编造占位邮箱。
|
|
10
12
|
|