feat: harden tenant access and production operations
This commit is contained in:
+209
-10
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Clapperboard,
|
||||
Coins,
|
||||
Copy,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileJson2,
|
||||
Film,
|
||||
@@ -83,7 +84,7 @@ import {
|
||||
ScriptProductionPage,
|
||||
SeriesBiblePage
|
||||
} from "./components/ProductionPages";
|
||||
import { addProjectMember, changeProjectLifecycle, completeMfaEnrollment, createJob, createOrganization, createProject, createWorkspace, fetchAuthSession, fetchPlatformContext, fetchProject, getAuthSession, getClientContext, inviteMember, login, loginWithMfa, logout, redeemSsoTicket, resendOrganizationInvitation, revokeOrganizationInvitation, startMfaEnrollment, updateOrganization, updateOrganizationMember, updateOrganizationRolePolicy, updateProject, updateProjectMember, updateWorkspace, updateWorkspaceMember } from "./lib/api";
|
||||
import { addProjectMember, changeProjectLifecycle, completeMfaEnrollment, createJob, createOrganization, createProject, createWorkspace, fetchAuthSession, fetchPlatformContext, fetchProject, getAuthSession, getClientContext, inviteMember, login, loginWithMfa, logout, redeemSsoTicket, resendOrganizationInvitation, revokeOrganizationInvitation, searchPlatform, startMfaEnrollment, updateOrganization, updateOrganizationMember, updateOrganizationRolePolicy, updateProject, updateProjectMember, updateWorkspace, updateWorkspaceMember } from "./lib/api";
|
||||
import { buildAllExports, buildModelRequest, downloadJson } from "./lib/exporters";
|
||||
import { projectQa } from "./lib/qa";
|
||||
import { getPlatformSummary, platformData } from "./platform/platformData";
|
||||
@@ -688,7 +689,9 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
|
||||
const usage = platform.usage;
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState("writer");
|
||||
const [inviteScope, setInviteScope] = useState(workspaces.length ? "workspace" : "organization");
|
||||
const [inviteWorkspaceId, setInviteWorkspaceId] = useState(workspaces[0]?.id || "");
|
||||
const [inviteProjectId, setInviteProjectId] = useState("");
|
||||
const [organizationName, setOrganizationName] = useState("");
|
||||
const [workspaceName, setWorkspaceName] = useState("");
|
||||
const [projectName, setProjectName] = useState("");
|
||||
@@ -705,6 +708,13 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
|
||||
const [inviteLink, setInviteLink] = useState("");
|
||||
const [inviteActionId, setInviteActionId] = useState("");
|
||||
|
||||
const inviteProjects = projects.filter((item) => !inviteWorkspaceId || item.workspace_id === inviteWorkspaceId || item.workspaceId === inviteWorkspaceId);
|
||||
const inviteRoleOptions = inviteScope === "organization"
|
||||
? [["org_member", "组织成员"], ["org_admin", "组织管理员"]]
|
||||
: inviteScope === "project"
|
||||
? [["project_editor", "项目编辑"], ["project_viewer", "项目查看者"]]
|
||||
: [["writer", "编剧"], ["producer", "制片"], ["art_director", "资产美术"], ["voice_editor", "配音/字幕"], ["reviewer", "审片"]];
|
||||
|
||||
useEffect(() => {
|
||||
setMemberDrafts(Object.fromEntries(members.map((member) => [member.user_id || member.id, { roleKey: member.role_key || "org_member", status: member.status || "active" }])));
|
||||
}, [platform.members]);
|
||||
@@ -723,9 +733,17 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
|
||||
}, [context?.currentOrganization?.id, context?.currentWorkspace?.id, context?.currentProject?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspaceMemberDrafts(Object.fromEntries((platform.workspaceMembers || []).map((member) => [member.user_id || member.id, { roleKey: member.role_key || "producer", status: member.status || "active" }])));
|
||||
setWorkspaceMemberDrafts(Object.fromEntries((platform.workspaceMembers || []).map((member) => [member.user_id || member.id, { roleKey: member.role_key || "producer", accessMode: member.access_mode || "all", status: member.status || "active" }])));
|
||||
}, [platform.workspaceMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaces.some((workspace) => workspace.id === inviteWorkspaceId)) setInviteWorkspaceId(workspaces[0]?.id || "");
|
||||
}, [platform.workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inviteProjects.some((project) => project.id === inviteProjectId)) setInviteProjectId(inviteProjects[0]?.id || "");
|
||||
}, [platform.projects, inviteWorkspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const availableRole = rolePolicies.roles.find((role) => role.key === selectedPolicyRoleKey) || rolePolicies.roles[0];
|
||||
if (availableRole && availableRole.key !== selectedPolicyRoleKey) setSelectedPolicyRoleKey(availableRole.key);
|
||||
@@ -743,6 +761,13 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
|
||||
}
|
||||
}
|
||||
|
||||
function changeInviteScope(scope) {
|
||||
setInviteScope(scope);
|
||||
if (scope === "organization") setInviteRole("org_member");
|
||||
else if (scope === "project") setInviteRole("project_editor");
|
||||
else setInviteRole("writer");
|
||||
}
|
||||
|
||||
function inviteUrl(invitation) {
|
||||
if (!invitation) return "";
|
||||
const path = invitation.acceptUrl || (invitation.inviteToken ? `/register?invite=${encodeURIComponent(invitation.inviteToken)}` : "");
|
||||
@@ -753,7 +778,18 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
|
||||
|
||||
const handleInvite = async (event) => {
|
||||
event.preventDefault();
|
||||
const result = await runAction(() => onInvite({ email: inviteEmail, roleKey: inviteRole, workspaceId: inviteWorkspaceId || undefined }), "邀请已创建;注册链接仅在当前管理会话中显示");
|
||||
if (inviteScope !== "organization" && !inviteWorkspaceId) {
|
||||
setNotice("工作区级或项目级邀请必须选择工作区");
|
||||
return;
|
||||
}
|
||||
if (inviteScope === "project" && !inviteProjectId) {
|
||||
setNotice("项目级邀请必须选择项目");
|
||||
return;
|
||||
}
|
||||
const body = { email: inviteEmail, roleKey: inviteRole };
|
||||
if (inviteScope !== "organization") body.workspaceId = inviteWorkspaceId;
|
||||
if (inviteScope === "project") body.projectId = inviteProjectId;
|
||||
const result = await runAction(() => onInvite(body), "邀请已创建;注册链接仅在当前管理会话中显示");
|
||||
if (result?.invitation) {
|
||||
setInviteLink(inviteUrl(result.invitation));
|
||||
setInviteEmail("");
|
||||
@@ -978,8 +1014,10 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
|
||||
</div>
|
||||
<form className="invite-form" onSubmit={handleInvite}>
|
||||
<div className="form-title"><UserPlus size={16} />邀请成员</div>
|
||||
<div className="form-grid"><input type="email" required value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="name@studio.local" aria-label="成员邮箱" /><select value={inviteRole} onChange={(event) => setInviteRole(event.target.value)} aria-label="成员角色"><option value="writer">编剧</option><option value="art_director">资产美术</option><option value="voice_editor">配音/字幕</option><option value="reviewer">审片</option><option value="producer">制片</option></select></div>
|
||||
<select value={inviteWorkspaceId} onChange={(event) => setInviteWorkspaceId(event.target.value)} aria-label="邀请工作区"><option value="">仅加入组织</option>{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select>
|
||||
<div className="form-grid"><input type="email" required value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="name@studio.local" aria-label="成员邮箱" /><select value={inviteScope} onChange={(event) => changeInviteScope(event.target.value)} aria-label="邀请范围"><option value="organization">组织级</option><option value="workspace" disabled={!workspaces.length}>工作区级</option><option value="project" disabled={!inviteProjects.length}>项目级</option></select></div>
|
||||
{inviteScope !== "organization" && <select value={inviteWorkspaceId} onChange={(event) => { setInviteWorkspaceId(event.target.value); if (inviteScope === "project") setInviteProjectId(""); }} aria-label="邀请工作区"><option value="">选择工作区</option>{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select>}
|
||||
{inviteScope === "project" && <select value={inviteProjectId} onChange={(event) => setInviteProjectId(event.target.value)} aria-label="邀请项目"><option value="">选择项目</option>{inviteProjects.map((project) => <option key={project.id} value={project.id}>{project.name || project.title}</option>)}</select>}
|
||||
<select value={inviteRole} onChange={(event) => setInviteRole(event.target.value)} aria-label="成员角色">{inviteRoleOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>
|
||||
<button className="subtle" type="submit"><Mail size={15} />发送邀请</button>
|
||||
</form>
|
||||
{inviteLink && <div className="invite-link-card">
|
||||
@@ -1000,7 +1038,7 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
|
||||
<div className="invite-list">
|
||||
{invitations.length ? invitations.map((invite) => <div className="invite-item" key={invite.id}>
|
||||
<Mail size={15} />
|
||||
<div className="invite-main"><strong>{invite.email}</strong><span>{invite.role_name || invite.role_key} · {invite.workspace_name || "组织级"}</span><small>有效期至 {invite.expires_at ? new Date(invite.expires_at).toLocaleString("zh-CN", { dateStyle: "short", timeStyle: "short" }) : "-"} · {invite.token_hint || "token hidden"}</small></div>
|
||||
<div className="invite-main"><strong>{invite.email}</strong><span>{invite.role_name || invite.role_key} · {invite.project_name ? `${invite.workspace_name} / ${invite.project_name}` : invite.workspace_name || "组织级"}</span><small>有效期至 {invite.expires_at ? new Date(invite.expires_at).toLocaleString("zh-CN", { dateStyle: "short", timeStyle: "short" }) : "-"} · {invite.token_hint || "token hidden"}</small></div>
|
||||
<div className="invite-actions"><StatusPill tone="warn">pending</StatusPill><button type="button" className="icon-text-button" onClick={() => handleResendInvitation(invite)} disabled={inviteActionId === invite.id}><RefreshCw size={13} />重发</button><button type="button" className="icon-text-button danger" onClick={() => handleRevokeInvitation(invite)} disabled={inviteActionId === invite.id}><XCircle size={13} />撤销</button></div>
|
||||
</div>) : <p className="empty-state">当前组织没有待处理邀请。</p>}
|
||||
</div>
|
||||
@@ -1011,8 +1049,8 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
|
||||
<div className="member-list workspace-member-list">
|
||||
{workspaceMembers.length ? workspaceMembers.map((member) => {
|
||||
const userId = member.user_id || member.id;
|
||||
const draft = workspaceMemberDrafts[userId] || { roleKey: member.role_key || "producer", status: member.status || "active" };
|
||||
return <div key={member.id || userId}><span className="member-avatar">{member.display_name?.slice(0, 1)}</span><div><strong>{member.display_name}</strong><span>{member.email}</span></div><div className="member-role-editor"><select aria-label={`${member.display_name} 工作区角色`} value={draft.roleKey} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, roleKey: event.target.value } }))}><option value="producer">制片</option><option value="writer">编剧</option><option value="art_director">资产美术</option><option value="voice_editor">配音/字幕</option><option value="reviewer">审片</option></select><select aria-label={`${member.display_name} 工作区状态`} value={draft.status} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, status: event.target.value } }))}><option value="active">启用</option><option value="suspended">停用</option></select><button className="icon-text-button" onClick={() => saveWorkspaceMember(member)}><Save size={13} />保存</button></div></div>;
|
||||
const draft = workspaceMemberDrafts[userId] || { roleKey: member.role_key || "producer", accessMode: member.access_mode || "all", status: member.status || "active" };
|
||||
return <div key={member.id || userId}><span className="member-avatar">{member.display_name?.slice(0, 1)}</span><div><strong>{member.display_name}</strong><span>{member.email}</span></div><div className="member-role-editor"><select aria-label={`${member.display_name} 工作区角色`} value={draft.roleKey} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, roleKey: event.target.value } }))}><option value="producer">制片</option><option value="writer">编剧</option><option value="art_director">资产美术</option><option value="voice_editor">配音/字幕</option><option value="reviewer">审片</option><option value="project_guest">项目受限成员</option></select><select aria-label={`${member.display_name} 项目访问范围`} value={draft.accessMode} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, accessMode: event.target.value } }))}><option value="all">全部项目</option><option value="project-only">仅授权项目</option></select><select aria-label={`${member.display_name} 工作区状态`} value={draft.status} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, status: event.target.value } }))}><option value="active">启用</option><option value="suspended">停用</option></select><button className="icon-text-button" onClick={() => saveWorkspaceMember(member)}><Save size={13} />保存</button></div></div>;
|
||||
}) : <p className="empty-state">当前角色没有工作区成员管理权限。</p>}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1141,6 +1179,140 @@ function DirectorDesk({ project, activeShot, setActiveShotId }) {
|
||||
);
|
||||
}
|
||||
|
||||
const searchTypeIcons = {
|
||||
project: FolderKanban,
|
||||
episode: BookOpen,
|
||||
script: BookOpen,
|
||||
shot: Clapperboard,
|
||||
asset: Images,
|
||||
task: ListChecks,
|
||||
job: Wand2,
|
||||
delivery: Download
|
||||
};
|
||||
|
||||
const searchStatusLabels = {
|
||||
production: "制作中",
|
||||
draft: "草稿",
|
||||
ready: "就绪",
|
||||
open: "待处理",
|
||||
in_progress: "进行中",
|
||||
blocked: "阻塞",
|
||||
done: "已完成",
|
||||
queued: "排队中",
|
||||
running: "运行中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
published: "已发布",
|
||||
submitted: "待审批",
|
||||
approved: "已批准"
|
||||
};
|
||||
|
||||
function GlobalSearchPalette({ open, onClose, contextOverrides, onNavigate }) {
|
||||
const inputRef = useRef(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [scope, setScope] = useState("workspace");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [payload, setPayload] = useState({ results: [], total: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const normalized = query.trim();
|
||||
if (!normalized) {
|
||||
setPayload({ results: [], total: 0 });
|
||||
setSelectedIndex(0);
|
||||
setError("");
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const next = await searchPlatform(normalized, { scope, limit: 40 }, contextOverrides);
|
||||
if (!cancelled) {
|
||||
setPayload(next);
|
||||
setSelectedIndex(0);
|
||||
setError("");
|
||||
}
|
||||
} catch (requestError) {
|
||||
if (!cancelled) {
|
||||
setPayload({ results: [], total: 0 });
|
||||
setError(requestError.message || "搜索失败");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}, 180);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [open, query, scope, contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((current) => Math.min(current + 1, Math.max(0, payload.results.length - 1)));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((current) => Math.max(0, current - 1));
|
||||
} else if (event.key === "Enter" && payload.results[selectedIndex]) {
|
||||
event.preventDefault();
|
||||
onNavigate(payload.results[selectedIndex]);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onClose, onNavigate, payload.results, selectedIndex]);
|
||||
|
||||
if (!open) return null;
|
||||
const results = payload.results || [];
|
||||
return (
|
||||
<div className="global-search-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && onClose()}>
|
||||
<section className="global-search-dialog" role="dialog" aria-modal="true" aria-label="全局搜索">
|
||||
<div className="global-search-head">
|
||||
<div className="global-search-input-wrap"><Search size={18} /><input ref={inputRef} value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、剧本、镜头、资产、任务…" aria-label="搜索项目、剧本、镜头、资产、任务" /></div>
|
||||
<button className="icon-only-button" type="button" onClick={onClose} title="关闭搜索" aria-label="关闭搜索"><XCircle size={17} /></button>
|
||||
</div>
|
||||
<div className="global-search-toolbar">
|
||||
<div className="global-search-scope" role="group" aria-label="搜索范围">
|
||||
<button type="button" className={scope === "project" ? "selected" : ""} onClick={() => setScope("project")} disabled={!contextOverrides?.projectId}>当前项目</button>
|
||||
<button type="button" className={scope === "workspace" ? "selected" : ""} onClick={() => setScope("workspace")}>当前工作区</button>
|
||||
</div>
|
||||
<span>{loading ? "搜索中…" : query.trim() ? `${payload.total || 0} 条结果` : "输入关键词开始搜索"}</span>
|
||||
</div>
|
||||
{error && <div className="global-search-error"><AlertTriangle size={15} />{error}</div>}
|
||||
<div className="global-search-results">
|
||||
{!query.trim() && <div className="global-search-empty"><Search size={24} /><strong>搜索整个生产工作区</strong><span>项目、剧本、分集、镜头、资产、任务、生成任务和交付记录都支持快速定位。</span></div>}
|
||||
{query.trim() && !loading && !error && !results.length && <div className="global-search-empty"><Search size={24} /><strong>没有找到匹配内容</strong><span>试试项目名、镜头编号、角色名或任务关键词。</span></div>}
|
||||
{results.map((result, index) => {
|
||||
const Icon = searchTypeIcons[result.type] || Search;
|
||||
return <button key={`${result.type}-${result.id}`} type="button" className={`global-search-result ${index === selectedIndex ? "selected" : ""}`} onMouseEnter={() => setSelectedIndex(index)} onClick={() => { onNavigate(result); onClose(); }}>
|
||||
<span className="global-search-result-icon"><Icon size={16} /></span>
|
||||
<span className="global-search-result-copy"><strong>{result.title}</strong><small><b>{result.typeLabel}</b>{result.subtitle ? ` · ${result.subtitle}` : ""}</small></span>
|
||||
{result.status && <span className="global-search-result-status">{searchStatusLabels[result.status] || result.status}</span>}
|
||||
<ChevronRight size={15} />
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
<div className="global-search-foot"><span><kbd>↑</kbd><kbd>↓</kbd>选择</span><span><kbd>Enter</kbd>打开</span><span><kbd>Esc</kbd>关闭</span></div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const storedContext = getClientContext();
|
||||
const storedAuthSession = getAuthSession();
|
||||
@@ -1160,6 +1332,7 @@ function App() {
|
||||
const [activeEpisodeId, setActiveEpisodeId] = useState(storedContext.episodeId || "");
|
||||
const [activeTabState, setActiveTabState] = useState(tabFromLocation);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [activeShotId, setActiveShotId] = useState("");
|
||||
const [adapterId, setAdapterId] = useState(emptyProject.production.defaultAdapter);
|
||||
const [taskLog, setTaskLog] = useState([
|
||||
@@ -1187,6 +1360,17 @@ function App() {
|
||||
return () => window.removeEventListener("hashchange", handleHashChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleGlobalShortcut(event) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
setSearchOpen(true);
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleGlobalShortcut);
|
||||
return () => window.removeEventListener("keydown", handleGlobalShortcut);
|
||||
}, []);
|
||||
|
||||
const activeTab = activeTabState;
|
||||
const adapterCatalog = useMemo(() => adapterCatalogForContext(platformContext), [platformContext?.platform?.adapterCatalog, platformContext?.platform?.modelRegistry]);
|
||||
|
||||
@@ -1352,6 +1536,19 @@ function App() {
|
||||
refreshContext({ organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId });
|
||||
}
|
||||
|
||||
async function navigateFromSearch(result) {
|
||||
const nextProjectId = result.projectId || (result.type === "project" ? result.id : activeProjectId);
|
||||
await refreshContext({
|
||||
organizationId: result.organizationId || activeOrgId,
|
||||
workspaceId: result.workspaceId || activeWorkspaceId,
|
||||
projectId: nextProjectId || ""
|
||||
});
|
||||
if (result.type === "shot" && typeof window !== "undefined") {
|
||||
window.setTimeout(() => setActiveShotId(result.id), 0);
|
||||
}
|
||||
setActiveTab(result.targetTab || "creator-home");
|
||||
}
|
||||
|
||||
async function inviteFromAdmin(body) {
|
||||
const result = await inviteMember(activeOrgId, body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
|
||||
await refreshContext();
|
||||
@@ -1597,6 +1794,7 @@ function App() {
|
||||
<label><FolderKanban size={14} /><span>工作区</span><select value={activeWorkspaceId} onChange={(event) => switchWorkspace(event.target.value)} disabled={contextLoading || !workspaces.length} aria-label="工作区切换"><option value="">选择工作区</option>{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select></label>
|
||||
<label><Film size={14} /><span>项目</span><select value={activeProjectId} onChange={(event) => switchProject(event.target.value)} disabled={contextLoading || !projects.length} aria-label="项目切换"><option value="">选择项目</option>{projects.map((projectItem) => <option key={projectItem.id} value={projectItem.id}>{projectItem.title || projectItem.name}</option>)}</select></label>
|
||||
</div>
|
||||
<button className="topbar-search-trigger" type="button" onClick={() => setSearchOpen(true)} title="打开全局搜索"><Search size={15} /><span>搜索生产内容</span><kbd>⌘K</kbd></button>
|
||||
<NotificationBell contextOverrides={contextOverrides} onOpen={setActiveTab} />
|
||||
<div className="user-context"><span className="user-avatar">{currentUser?.display_name?.slice(0, 1) || "林"}</span><div><strong>{currentUser?.display_name || "本地用户"}</strong><span>{currentRole}</span></div><button className="icon-only-button" title="账号与安全" aria-label="账号与安全" onClick={() => setActiveTab("account")}><KeyRound size={15} /></button><button className="icon-only-button" title="退出登录" aria-label="退出登录" onClick={handleLogout}><LogOut size={15} /></button></div>
|
||||
{canCreateJobNow && <select value={adapterId} onChange={(event) => setAdapterId(event.target.value)} aria-label="生成适配器">
|
||||
@@ -1679,7 +1877,7 @@ function App() {
|
||||
{activeTab === "delivery-portal" && <DeliveryAccessPage contextOverrides={contextOverrides} canApproveDelivery={canApproveDelivery} />}
|
||||
|
||||
{activeTab === "admin" && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
|
||||
{activeTab === "admin-overview" && <AdminOverviewPage platformContext={platformContext} setActiveTab={setActiveTab} />}
|
||||
{activeTab === "admin-overview" && <AdminOverviewPage platformContext={platformContext} contextOverrides={contextOverrides} setActiveTab={setActiveTab} />}
|
||||
{(activeTab === "admin-organizations" || activeTab === "admin-members") && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
|
||||
{activeTab === "admin-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />}
|
||||
{activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
||||
@@ -1732,6 +1930,7 @@ function App() {
|
||||
</aside>}
|
||||
</div>
|
||||
</main>
|
||||
<GlobalSearchPalette open={searchOpen} onClose={() => setSearchOpen(false)} contextOverrides={contextOverrides} onNavigate={navigateFromSearch} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user