@airalogy/airalogy-bd 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,70 +1,102 @@
1
1
  "use client";
2
- import {useCallback,useEffect,useState} from "react";
2
+ import * as Dialog from "@radix-ui/react-dialog";
3
+ import {useCallback,useEffect,useRef,useState} from "react";
3
4
  import {useRouter} from "next/navigation";
5
+ import Link from "next/link";
4
6
  import {teamRequest,type TeamUser} from "./lib/team-client";
5
7
  import type {Project} from "./lib/types";
6
8
  import {workspaceRequest} from "./lib/workspace-client";
7
9
  import type {WorkspaceResponse} from "./lib/workspace-types";
10
+ import {SettingsIcon,SettingsPanelHeader,SettingsStatus} from "./SettingsUI";
11
+
12
+ type MemberList={users:TeamUser[];remote_enabled:boolean};
8
13
  type Audit={id:number;at:number;actor_name:string;action:string;target:string;outcome:string};
9
- const actionNames:Record<string,string>={"account.invite":"邀请成员","account.activate":"激活账号","account.login":"登录","account.permissions":"调整权限","account.reset":"重置登录",save:"保存资料",file_save:"更新附件",restore:"恢复资料",backup_config:"调整备份",backup_now:"立即备份",file_backup:"导出完整备份"};
14
+ const actionNames:Record<string,string>={"account.invite":"邀请成员","account.activate":"激活账号","account.login":"登录","account.permissions":"调整权限","account.reset":"重置登录","remote.grant":"允许外部 AI 工具","remote.ungrant":"取消外部 AI 工具权限","remote.configure":"调整外部 AI 全局开关","remote.issue":"生成个人接入凭据","remote.revoke":"撤销接入凭据",save:"保存资料",file_save:"更新附件",restore:"恢复资料",backup_config:"调整备份",backup_now:"立即备份",file_backup:"导出完整备份"};
10
15
  const roleNames={admin:"管理员",editor:"编辑成员",viewer:"只读成员"};
11
16
  const roleHelp={admin:"管理全部资料、成员权限和系统设置。",editor:"修改获授权事项和附件、补充进展。",viewer:"查看和导出获授权资料,不能修改。"};
12
17
  const statusNames={active:"已启用",email:"待验证邮箱",setup:"待绑定验证器",invited:"待接受邀请",expired:"邀请已过期",disabled:"已停用"};
18
+ const isInvitation=(member:TeamUser)=>member.status==="invited"||member.status==="expired";
19
+
13
20
  export default function TeamManagement({user,projects,timezone}:{user:TeamUser;projects:Pick<Project,"id"|"name">[];timezone:string}) {
14
21
  const router=useRouter();
15
- const [members,setMembers]=useState<TeamUser[]>([]);
16
- const [projectChoices,setProjectChoices]=useState(projects);
17
- const [selected,setSelected]=useState<TeamUser|null>(null);
18
- const [email,setEmail]=useState("");const [name,setName]=useState("");
19
- const [role,setRole]=useState<TeamUser["role"]>("viewer");
20
- const [all,setAll]=useState(false);const [ids,setIds]=useState<string[]>([]);
21
- const [projectSearch,setProjectSearch]=useState("");
22
- const [active,setActive]=useState(true);const [busy,setBusy]=useState(false);
23
- const [error,setError]=useState("");const [notice,setNotice]=useState("");const [link,setLink]=useState("");
24
- const [audit,setAudit]=useState<Audit[]>([]);
25
- const [linkExpires,setLinkExpires]=useState<number|null>(null);
22
+ const [members,setMembers]=useState<TeamUser[]>([]),[loading,setLoading]=useState(true);
23
+ const [memberSearch,setMemberSearch]=useState(""),[statusFilter,setStatusFilter]=useState("all");
24
+ const [projectChoices,setProjectChoices]=useState(projects),[projectSearch,setProjectSearch]=useState("");
25
+ const [editorOpen,setEditorOpen]=useState(false),[selected,setSelected]=useState<TeamUser|null>(null);
26
+ const [email,setEmail]=useState(""),[name,setName]=useState("");
27
+ const [role,setRole]=useState<TeamUser["role"]>("viewer"),[all,setAll]=useState(false),[ids,setIds]=useState<string[]>([]);
28
+ const [remoteAllowed,setRemoteAllowed]=useState(false),[remoteEnabled,setRemoteEnabled]=useState(false);
29
+ const [active,setActive]=useState(true),[busy,setBusy]=useState(false);
30
+ const [error,setError]=useState(""),[formError,setFormError]=useState(""),[notice,setNotice]=useState("");
31
+ const [link,setLink]=useState(""),[linkExpires,setLinkExpires]=useState<number|null>(null);
32
+ const [audit,setAudit]=useState<Audit[]>([]),[auditLoaded,setAuditLoaded]=useState(false),[auditBusy,setAuditBusy]=useState(false);
33
+ const opener=useRef<HTMLButtonElement|null>(null),inviteButton=useRef<HTMLButtonElement|null>(null);
34
+ const formAlert=useRef<HTMLParagraphElement|null>(null);
26
35
  const pendingInvitations=members.some(member=>member.status==="invited");
27
36
  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
37
  const visibleProjects=projectChoices.filter(project=>project.name.toLocaleLowerCase().includes(projectSearch.trim().toLocaleLowerCase()));
38
+ const visibleMembers=members.filter(member=>{
39
+ const search=memberSearch.trim().toLocaleLowerCase();
40
+ const matches=[member.name,member.username,member.email].some(value=>value?.toLocaleLowerCase().includes(search));
41
+ return matches&&(statusFilter==="all"||(statusFilter==="pending"?["invited","expired","email","setup"].includes(member.status||""):member.status===statusFilter));
42
+ });
29
43
  const fullAccess=role==="admin"||all;
30
- 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]);
44
+ const dirty=selected?remoteAllowed!==!!selected.remote_allowed||role!==selected.role||all!==selected.all_projects||active!==!!selected.active||JSON.stringify([...ids].sort())!==JSON.stringify([...selected.project_ids].sort()):!!name||!!email||remoteAllowed||role!=="viewer"||all||ids.length>0;
45
+ const refresh=useCallback(async()=>{const result=await teamRequest<MemberList>("team_list",{},user.csrf);setMembers(result.users);setRemoteEnabled(result.remote_enabled);},[user.csrf]);
46
+ useEffect(()=>{let cancelled=false;teamRequest<MemberList>("team_list",{},user.csrf).then(result=>{if(!cancelled){setMembers(result.users);setRemoteEnabled(result.remote_enabled);}}).catch(err=>{if(!cancelled)setError(err.message);}).finally(()=>{if(!cancelled)setLoading(false);});return()=>{cancelled=true;};},[user.csrf]);
32
47
  useEffect(()=>{const controller=new AbortController();workspaceRequest<WorkspaceResponse>("workspace",{},false,controller.signal).then(result=>{if(result.data)setProjectChoices(result.data.projects);}).catch(err=>{if(!controller.signal.aborted)setError(err.message);});return()=>controller.abort();},[]);
33
48
  useEffect(()=>{if(!pendingInvitations)return;const timer=window.setInterval(()=>{void refresh().catch(err=>setError(err.message));},60000);return()=>window.clearInterval(timer);},[pendingInvitations,refresh]);
34
- function select(member:TeamUser|null){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);setLink("");setLinkExpires(null);setError("");setNotice("");}
35
- function showLink(result:{token:string;expires:number}){setLinkExpires(result.expires);setLink(`${window.location.origin}/join#${result.token}`);setNotice("请将邀请链接交给该成员。链接 48 小时内有效,仅可使用一次。");}
36
- async function save(event:React.FormEvent){event.preventDefault();setBusy(true);setError("");setNotice("");try{
37
- const payload={email,name,role,all_projects:all,project_ids:ids,active,id:selected?.id,revision:selected?.revision};
38
- if(selected){await teamRequest("team_update",payload,user.csrf);setNotice(active?"权限已更新,该成员需要重新登录。":"权限已更新,账号已停用。如需重新邀请,请生成新的登录邀请。");if(selected.id===user.id){router.replace("/login");router.refresh();return;}setSelected(null);}
39
- else showLink(await teamRequest("team_invite",payload,user.csrf));
40
- await refresh();
41
- }catch(err){setError((err as Error).message);}finally{setBusy(false);}}
42
- async function reset(member:TeamUser){const pending=member.status==="invited"||member.status==="expired";if(!window.confirm(pending?`重新邀请 ${member.name}?旧链接将立即失效,新链接 48 小时内有效,原有身份和权限保留。`:`重新签发 ${member.name} 的登录邀请?现有登录会立即失效,原密码和验证器将需要重新设置。`))return;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);}}
43
- async function copyLink(){try{await navigator.clipboard.writeText(link);setNotice("邀请链接已复制。请通过微信、邮件等渠道单独交给该成员,由本人填写邮箱、设置用户名、密码和验证器。");}catch{setNotice("无法自动复制,请选中邀请链接后手动复制。");}}
44
- async function loadAudit(more=false){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);}catch(err){setError((err as Error).message);}}
45
- return <section className="team-management"><h2>成员与权限</h2><p>角色决定能做什么,访问范围决定能看到哪些事项。人员总库的维护、事项创建、全局历史和备份由管理员管理。</p><p>移除成员时停用其账号,立即撤销登录访问,保留已有业务记录和操作历史。</p>
46
- {error&&<p className="admin-alert" role="alert">{error}</p>}{notice&&<p className="admin-notice" role="status">{notice}</p>}
47
- {link&&<div className="invitation-result"><label>一次性邀请链接<input readOnly value={link} onFocus={e=>e.target.select()}/></label><button type="button" className="admin-button" onClick={()=>void copyLink()}>复制邀请链接</button><p className="admin-help">{linkExpires&&<>到期时间:<time dateTime={new Date(linkExpires*1000).toISOString()}>{expiryText(linkExpires)}</time>({timezone})。</>}链接 48 小时内有效,仅可使用一次。邀请链接不会自动通过邮件发送;无需提供临时密码,请由成员本人完成设置。</p></div>}
48
- <div className="team-grid"><section className="panel"><h2>成员</h2><button className="admin-button" disabled={busy} onClick={()=>select(null)}>邀请新成员</button>{members.map(member=><article className="team-member" key={member.id}><strong>{member.name}</strong><span>{member.username?`用户名:${member.username} · `:""}{member.email||"邮箱待本人填写"} · {roleNames[member.role]} · <span className={member.status==="expired"?"team-invitation-expired":undefined}>{member.status?statusNames[member.status]:member.active?"已启用":"已停用"}</span></span>{member.invitation_expires&&<span className="team-member-scope">{member.status==="expired"?"已于":"邀请将于"} <time dateTime={new Date(member.invitation_expires*1000).toISOString()}>{expiryText(member.invitation_expires)}</time> 到期({timezone})</span>}<span className="team-member-scope">{member.role==="admin"||member.all_projects?"全部事项 · 自动包含新增事项":member.project_ids.length?`指定事项 · ${member.project_ids.length} 项 · 新增事项需另行授权`:"尚未分配事项"}</span><div><button className="admin-button" disabled={busy} onClick={()=>select(member)}>调整权限</button>{member.id!==user.id&&<button disabled={busy} className="admin-button" onClick={()=>void reset(member)}>{member.status==="invited"||member.status==="expired"?"重新邀请":"重新签发登录邀请"}</button>}</div></article>)}</section>
49
- <section className="panel"><h2>{selected?`调整 ${selected.name} 的权限`:"新建成员并邀请"}</h2>{!selected&&<p>填写姓名并分配权限,再把生成的链接交给本人。邮箱可以不填,由对方激活时补充。</p>}<form className="team-form" onSubmit={save}><fieldset disabled={busy}>
50
- {!selected&&<><label>姓名<input required maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></label><label>邮箱(选填)<input type="email" maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/><span className="admin-help">不知道对方邮箱可以留空;填写后,该邀请将使用指定邮箱。</span></label></>}
51
- <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>
52
- {role!=="admin"?<>
53
- <fieldset className="team-scope-options"><legend>事项访问范围</legend>
54
- <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>
55
- <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>
56
- </fieldset>
57
- {all?<p className="admin-help">同时可查看公司动态及共享的人员、对接方资料。</p>:<fieldset className="team-project-picker"><legend>选择可访问的事项</legend>
58
- <label className="team-search-label">搜索事项<input type="search" placeholder="按事项名称搜索" value={projectSearch} onChange={e=>setProjectSearch(e.target.value)}/></label>
59
- <div className="team-selection-toolbar"><span role="status">已选 {ids.length} 项</span><div><button type="button" className="admin-button" disabled={!visibleProjects.length} onClick={()=>setIds(current=>[...new Set([...current,...visibleProjects.map(project=>project.id)])])}>{projectSearch.trim()?"选中搜索结果":"选中当前全部"}</button><button type="button" className="admin-button" disabled={!ids.length} onClick={()=>setIds([])}>清空选择</button></div></div>
60
- <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>
61
- <p className="admin-help">“选中当前全部”只勾选现有事项,以后新增的事项仍需另行授权。跨事项附件需要具备其全部关联事项的权限。</p>
62
- </fieldset>}
63
- </>:<p className="admin-notice">管理员始终管理全部事项,自动包含以后新增的事项。</p>}
64
- {selected&&<label className="team-check"><input type="checkbox" checked={active} onChange={e=>setActive(e.target.checked)}/>启用账号</label>}
65
- <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>
66
- <button className="admin-button admin-primary" type="submit">{busy?"正在保存…":selected?"保存权限":"生成邀请链接"}</button>
67
- </fieldset></form></section></div>
68
- <section className="panel"><h2>操作记录</h2><button className="admin-button" onClick={()=>void loadAudit()}>查看 / 刷新</button><p>操作账号记录实际录入者,与业务记录中的会议参与人分别维护。“开始”后没有完成记录的操作,需要检查执行结果。</p><div className="team-audit">{audit.map(item=><p key={item.id}><time>{new Date(item.at*1000).toLocaleString("zh-CN")}</time> · {item.actor_name} · {actionNames[item.action]||"账号操作"} · {{started:"开始",success:"完成",failed:"失败"}[item.outcome]||item.outcome} {item.target}</p>)}</div>{audit.length>=100&&<button className="admin-button" onClick={()=>void loadAudit(true)}>更早记录</button>}</section>
49
+ useEffect(()=>{if(formError&&editorOpen){formAlert.current?.focus();formAlert.current?.scrollIntoView({block:"nearest"});}},[formError,editorOpen]);
50
+ function select(member:TeamUser|null,button:HTMLButtonElement){
51
+ opener.current=button;setSelected(member);setRemoteAllowed(!!member?.remote_allowed);setEmail(member?.email||"");setName(member?.name||"");setRole(member?.role||"viewer");setAll(member?.all_projects||false);setIds(member?.project_ids||[]);setProjectSearch("");setActive(member?!!member.active:true);setFormError("");setError("");setNotice("");setEditorOpen(true);
52
+ }
53
+ function closeEditor(){if(!busy&&(!dirty||window.confirm("放弃尚未保存的成员设置?")))setEditorOpen(false);}
54
+ function showLink(result:{token:string;expires:number}){setLinkExpires(result.expires);setLink(`${window.location.origin}/join#${result.token}`);setNotice("邀请链接已生成,请单独交给该成员。");}
55
+ async function save(event:React.FormEvent){
56
+ event.preventDefault();if(busy)return;setBusy(true);setFormError("");setNotice("");
57
+ try{
58
+ const payload={email,name,remote_allowed:remoteAllowed,...(selected?{remote_allowed_before:!!selected.remote_allowed}:{}),role,all_projects:all,project_ids:ids,active,id:selected?.id,revision:selected?.revision};
59
+ if(selected){
60
+ await teamRequest("team_update",payload,user.csrf);setNotice(isInvitation(selected)?"邀请权限已更新;原邀请链接和到期时间保持不变。":active?"权限已更新,该成员需要重新登录并按需生成新的工具凭据。":"账号已停用,已有记录和历史已保留。");
61
+ if(selected.id===user.id){router.replace("/login");router.refresh();return;}
62
+ }else showLink(await teamRequest("team_invite",payload,user.csrf));
63
+ try{await refresh();}catch(err){setError(`已保存,但成员列表未能刷新:${(err as Error).message}`);}
64
+ setEditorOpen(false);
65
+ }catch(err){setFormError((err as Error).message);}finally{setBusy(false);}
66
+ }
67
+ async function reset(member:TeamUser){
68
+ if(!window.confirm(isInvitation(member)?`重新邀请 ${member.name}?旧链接将立即失效,新链接 48 小时内有效,原有身份和权限保留。`:`重新签发 ${member.name} 的登录邀请?现有登录会立即失效,原密码和验证器将需要重新设置。`))return;
69
+ 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);}
70
+ }
71
+ async function copyLink(){try{await navigator.clipboard.writeText(link);setNotice("邀请链接已复制,请通过微信或邮件等渠道单独交给本人。");}catch{setNotice("无法自动复制,请选中邀请链接后手动复制。");}}
72
+ async function loadAudit(more=false){setAuditBusy(true);try{const result=await teamRequest<{entries:Audit[]}>("team_audit",more&&audit.length?{before:audit.at(-1)!.id}:{},user.csrf);setAudit(old=>more?[...old,...result.entries]:result.entries);setAuditLoaded(true);}catch(err){setError((err as Error).message);}finally{setAuditBusy(false);}}
73
+ return <section className="team-management">
74
+ <section className="panel admin-center-panel member-panel">
75
+ <SettingsPanelHeader title="成员与权限" description="一次设置成员角色、事项范围和外部 AI 工具权限。" actions={<button ref={inviteButton} type="button" className="admin-button admin-primary" disabled={busy} onClick={event=>select(null,event.currentTarget)}><SettingsIcon name="plus"/>邀请新成员</button>}/>
76
+ {error&&<p className="admin-alert" role="alert">{error}</p>}{notice&&<p className="admin-notice" role="status">{notice}</p>}
77
+ {link&&<section className="invitation-result" aria-label="生成的邀请链接"><div className="invitation-result-heading"><strong>一次性邀请链接</strong><button className="settings-text-button" type="button" onClick={()=>{setLink("");setLinkExpires(null);}}>收起</button></div><div className="invitation-copy"><label className="sr-only" htmlFor="member-invitation-link">邀请链接</label><input id="member-invitation-link" readOnly value={link} onFocus={e=>e.target.select()}/><button type="button" className="admin-button" onClick={()=>void copyLink()}>复制链接</button></div><p className="admin-help">{linkExpires&&<>有效至 <time dateTime={new Date(linkExpires*1000).toISOString()}>{expiryText(linkExpires)}</time>({timezone}),</>}仅可使用一次。由本人填写邮箱、设置密码和身份验证器。</p></section>}
78
+ <div className="member-toolbar"><label className="settings-search"><SettingsIcon name="search"/><span className="sr-only">搜索成员</span><input type="search" placeholder="搜索姓名、用户名或邮箱" value={memberSearch} onChange={event=>setMemberSearch(event.target.value)}/></label><label className="member-status-filter"><span className="sr-only">成员状态</span><select value={statusFilter} onChange={event=>setStatusFilter(event.target.value)}><option value="all">全部状态</option><option value="active">已启用</option><option value="pending">待激活</option><option value="disabled">已停用</option></select></label><span className="member-count">{members.length} 名成员</span></div>
79
+ {loading?<p className="settings-empty" role="status">正在读取成员…</p>:<div className="member-list">{visibleMembers.map(member=><article className="member-row" key={member.id}>
80
+ <div className="member-identity"><span className="member-avatar" aria-hidden="true">{Array.from(member.name)[0]}</span><div><div className="member-name"><strong>{member.name}</strong>{member.id===user.id&&<span className="member-self">你</span>}<SettingsStatus tone={member.status==="active"?"success":member.status==="expired"?"warning":"neutral"}>{member.status?statusNames[member.status]:member.active?"已启用":"已停用"}</SettingsStatus></div><p>{member.username||"尚未设置用户名"}</p><p className="member-email">{member.email||"邮箱待本人填写"}</p>{!!member.invitation_expires&&<p className={member.status==="expired"?"team-invitation-expired":""}>{member.status==="expired"?"已于":"邀请将于"} <time dateTime={new Date(member.invitation_expires*1000).toISOString()}>{expiryText(member.invitation_expires)}</time> 到期</p>}</div></div>
81
+ <div className="member-permissions"><span className="member-role">{roleNames[member.role]}</span><p>{member.role==="admin"||member.all_projects?"全部事项":member.project_ids.length?`指定事项 · ${member.project_ids.length} 项`:"尚未分配事项"}</p><small>{member.role==="admin"||member.all_projects?"自动包含新增事项":"新增事项需另行授权"}</small><small className="member-tool-access">外部 AI 工具 · {member.remote_allowed?(remoteEnabled?"已允许":"已允许,功能待开启"):"未允许"}</small></div>
82
+ <div className="member-actions"><button type="button" className="admin-button" disabled={busy} onClick={event=>select(member,event.currentTarget)}>调整权限</button>{member.id!==user.id&&<button type="button" disabled={busy} className="settings-text-button" onClick={()=>void reset(member)}>{isInvitation(member)?"重新邀请":"重置登录"}</button>}</div>
83
+ </article>)}{!visibleMembers.length&&<p className="settings-empty">{members.length?"没有符合条件的成员,试试其他关键词或状态。":"尚无成员,可通过邀请链接加入团队。"}</p>}</div>}
84
+ <p className="member-policy-note">停用账号会撤销登录访问,保留已有业务记录和操作历史。人员总库维护、事项创建、全局历史和备份由管理员管理。</p>
85
+ </section>
86
+ <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>
87
+ <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();}}>
88
+ <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>
89
+ <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">
90
+ {!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>}
91
+ <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>
92
+ {role!=="admin"?<><fieldset className="team-scope-options"><legend>可访问的事项</legend><label className={`team-scope-option${!all?" is-selected":""}`}><input type="radio" name="matter-scope" checked={!all} onChange={()=>setAll(false)}/><span><strong>指定事项</strong><span>仅限勾选项,新增事项需另行授权。</span></span></label><label className={`team-scope-option${all?" is-selected":""}`}><input type="radio" name="matter-scope" checked={all} onChange={()=>setAll(true)}/><span><strong>全部事项</strong><span>自动包含当前和以后新增的事项。</span></span></label></fieldset>
93
+ {all?<p className="admin-help">同时可查看公司动态及共享的人员、对接方资料。</p>:<div className="team-project-picker"><label className="settings-search"><SettingsIcon name="search"/><span className="sr-only">搜索事项</span><input type="search" placeholder="搜索事项名称" value={projectSearch} onChange={e=>setProjectSearch(e.target.value)}/></label><div className="team-selection-toolbar"><span role="status">已选 {ids.length} 项</span><div><button type="button" className="settings-text-button" disabled={!visibleProjects.length} onClick={()=>setIds(current=>[...new Set([...current,...visibleProjects.map(project=>project.id)])])}>{projectSearch.trim()?"选中搜索结果":"选中当前全部"}</button><button type="button" className="settings-text-button" disabled={!ids.length} onClick={()=>setIds([])}>清空</button></div></div><div className="team-project-list">{visibleProjects.map(project=><label className="team-check" key={project.id}><input type="checkbox" checked={ids.includes(project.id)} onChange={e=>setIds(e.target.checked?[...ids,project.id]:ids.filter(id=>id!==project.id))}/>{project.name}</label>)}{!visibleProjects.length&&<p className="admin-help">{projectChoices.length?"没有匹配的事项。":"暂无事项,可在新增后分配。"}</p>}</div><p className="admin-help">批量选择只包含现有事项。跨事项附件需具备其全部关联事项的权限。</p></div>}</>:<p className="admin-help">管理员始终管理全部事项,自动包含以后新增的事项。</p>}
94
+ </section>
95
+ <section className="settings-form-section member-external-ai"><h3>外部 AI 工具</h3><label className="team-scope-option"><input type="checkbox" checked={remoteAllowed} onChange={e=>setRemoteAllowed(e.target.checked)} aria-describedby="member-external-ai-help"/><span><strong>允许连接外部 AI 工具</strong><span>例如 Codex,通过 MCP 连接工作台。</span></span></label><p id="member-external-ai-help" className="admin-help">{role==="viewer"?"只读成员仅可查询获授权事项,不能提交修改草稿。":"仅可查询获授权事项、提交修改草稿;修改需本人在网站确认后保存。"}本人激活账号后,在“我的账号 → AI 工具连接”生成个人凭据。</p>{!remoteEnabled&&<p className="admin-notice">工作区暂未开启外部工具接入,可以先保存成员权限;管理员在 <Link href="/admin?section=ai#external-ai" target="_blank" rel="noopener noreferrer">AI 设置</Link> 开启后才能连接。</p>}</section>
96
+ {selected&&isInvitation(selected)&&<p className="admin-help">成员尚未接受邀请,可先调整权限;账号仍需本人完成激活。</p>}
97
+ {selected&&!isInvitation(selected)&&<label className="team-check member-active"><input type="checkbox" checked={active} onChange={e=>setActive(e.target.checked)}/>启用账号</label>}
98
+ <section className="team-permission-summary" aria-labelledby="team-permission-summary-title"><h3 id="team-permission-summary-title">权限预览</h3>{selected&&!active&&!isInvitation(selected)?<p>保存后账号将停用,不能访问工作台;已有记录和历史保留。</p>:<><dl><div><dt>可操作</dt><dd>{roleHelp[role]}</dd></div><div><dt>可访问</dt><dd>{fullAccess?"全部事项":ids.length?`已选的 ${ids.length} 个事项`:"尚未分配事项"}</dd></div><div><dt>以后新增事项</dt><dd>{fullAccess?"自动获得访问权限":"需要管理员另行授权"}</dd></div><div><dt>外部 AI 工具</dt><dd>{remoteAllowed?(role==="viewer"?"允许连接 · 仅查询":"允许连接 · 查询和提交草稿"):"未允许连接"}</dd></div></dl>{!fullAccess&&!ids.length&&<p className="admin-help">可以激活账号,但暂时不能查看业务事项。</p>}</>}</section>
99
+ </fieldset></div><footer className="admin-dialog-footer"><span>{selected?(isInvitation(selected)?"保留原邀请链接及其到期时间。":"保存后,现有登录、工具凭据与草稿将失效。"):"邀请链接有效期 48 小时,仅可使用一次。"}</span><div className="admin-actions"><button type="button" className="admin-button" disabled={busy} onClick={closeEditor}>取消</button><button className="admin-button admin-primary" type="submit" disabled={busy}>{busy?"正在保存…":selected?"保存权限":"生成邀请链接"}</button></div></footer></form>
100
+ </Dialog.Content></Dialog.Portal></Dialog.Root>
69
101
  </section>;
70
102
  }
@@ -4,5 +4,5 @@ import AccountBar from "./AccountBar";
4
4
  import type { TeamUser } from "./lib/team-client";
5
5
  import type {DashboardData} from "./lib/types";
6
6
  export default function TeamWorkspace({data,user}:{data:DashboardData;user:TeamUser}) {
7
- return <><AccountBar user={user} /><LiveDashboard initialData={data}/></>;
7
+ return <><AccountBar user={user} workspace={data.workspace} /><LiveDashboard initialData={data}/></>;
8
8
  }
@@ -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>
@@ -1,10 +1,12 @@
1
1
  import { redirect } from "next/navigation";
2
2
  import AccountSettings from "../AccountSettings";
3
- import { currentSession, serverMode } from "../lib/server-auth";
3
+ import { currentSession, publicOrigin, serverMode } from "../lib/server-auth";
4
+ import { loadDashboardData } from "../lib/server-data";
4
5
  export const dynamic = "force-dynamic";
5
6
  export default async function AccountPage() {
6
7
  if (!serverMode()) redirect("/");
7
8
  const session = await currentSession();
8
9
  if (!session) redirect("/login");
9
- return <AccountSettings user={session.user} />;
10
+ const data = await loadDashboardData().catch(() => null);
11
+ return <AccountSettings user={session.user} endpoint={`${publicOrigin()}/api/mcp`} workspace={data?.workspace} />;
10
12
  }
@@ -17,7 +17,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
17
17
  const data = await loadDashboardData().catch(() => null);
18
18
  if (team && data?.access && data.access.role !== "admin") redirect("/");
19
19
  const { section } = await searchParams;
20
+ if (section === "remote") redirect("/admin?section=ai#external-ai");
20
21
  const timezone = data?.workspace.timezone || "Asia/Shanghai";
21
22
  return <AdminCenter user={session?.user || null} projects={data?.projects.map(({ id, name }) => ({ id, name })) || []}
22
- timezone={timezone} dates={initialDates(timezone)} unavailable={!data} initialSection={section} />;
23
+ workspace={data?.workspace} timezone={timezone} dates={initialDates(timezone)} unavailable={!data} initialSection={section} />;
23
24
  }