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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1119,31 +1119,77 @@ function UsageTrendCard({ rows }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminOverviewPage({ platformContext, setActiveTab }) {
|
||||
export function AdminOverviewPage({ platformContext, contextOverrides = {}, setActiveTab }) {
|
||||
const platform = platformContext?.platform || {};
|
||||
const summary = platformContext?.summary || {};
|
||||
const permissions = new Set(platformContext?.context?.permissions || []);
|
||||
const canViewSystem = Boolean(platformContext?.context?.systemAdmin) || permissions.has("system:settings:view");
|
||||
const canManageQueue = permissions.has("queue:manage");
|
||||
const runners = platform.runnerHealth || [];
|
||||
const members = platform.members || [];
|
||||
const projects = platform.projects || [];
|
||||
const usage = platform.usage || {};
|
||||
const attention = runners.filter((runner) => runner.status !== "ready").length;
|
||||
const [health, setHealth] = useState({ services: [], summary: {} });
|
||||
const [worker, setWorker] = useState(null);
|
||||
const [readiness, setReadiness] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notice, setNotice] = useState("");
|
||||
const scopeKey = [contextOverrides.userId, contextOverrides.organizationId, contextOverrides.workspaceId, contextOverrides.projectId].join("|");
|
||||
|
||||
async function loadOperations() {
|
||||
setLoading(true);
|
||||
setNotice("");
|
||||
const [healthResult, workerResult, readinessResult] = await Promise.allSettled([
|
||||
fetchSystemHealth(contextOverrides),
|
||||
fetchWorkerStatus(contextOverrides),
|
||||
canViewSystem ? fetchSystemReadiness(contextOverrides) : Promise.resolve(null)
|
||||
]);
|
||||
const errors = [];
|
||||
if (healthResult.status === "fulfilled") setHealth(healthResult.value);
|
||||
else errors.push(`系统健康:${healthResult.reason?.message || "读取失败"}`);
|
||||
if (workerResult.status === "fulfilled") setWorker(workerResult.value.worker || workerResult.value);
|
||||
else errors.push(`Worker:${workerResult.reason?.message || "读取失败"}`);
|
||||
if (readinessResult.status === "fulfilled" && readinessResult.value) setReadiness(readinessResult.value);
|
||||
else if (canViewSystem && readinessResult.status === "rejected") errors.push(`生产就绪度:${readinessResult.reason?.message || "读取失败"}`);
|
||||
if (errors.length) setNotice(errors.join(";"));
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => { loadOperations(); }, [scopeKey, canViewSystem]);
|
||||
|
||||
const services = health.services?.length ? health.services : (platform.runnerHealth || []).map((runner) => ({
|
||||
id: runner.id,
|
||||
service_key: runner.id,
|
||||
label: runner.name,
|
||||
kind: "runner",
|
||||
status: runner.status,
|
||||
queue_depth: runner.queueDepth,
|
||||
last_heartbeat: runner.lastHeartbeat,
|
||||
metadata: {}
|
||||
}));
|
||||
const liveSummary = health.summary?.total ? health.summary : summary;
|
||||
const queueDepth = Number(worker?.queueDepth ?? liveSummary.queueDepth ?? summary.queueDepth ?? 0);
|
||||
const attention = Number(liveSummary.attention ?? services.filter((service) => service.status !== "ready").length);
|
||||
const workerStatus = worker?.healthStatus || worker?.status || "unknown";
|
||||
const readinessChecks = readiness?.checks || [];
|
||||
const statusLabel = (status) => ({ ready: "正常", configured: "已配置", "active-local": "本地运行", "not-configured": "未配置", "needs-config": "需配置", missing: "缺失", unsafe: "不安全", paused: "已暂停", stale: "已过期", starting: "启动中", draining: "排空中", "waiting-model": "等待模型" }[status] || status || "未知");
|
||||
const statusTone = (status) => ["ready", "configured", "active-local"].includes(status) ? "active" : ["not-configured", "needs-config", "missing", "unsafe", "stale", "failed"].includes(status) ? "failed" : "needs-evidence";
|
||||
return (
|
||||
<div className="enterprise-page">
|
||||
<AdminHeader eyebrow="ADMIN CONSOLE / OVERVIEW" title="管理概览" description="组织管理员在这里查看平台运行状态、团队协作、队列容量、用量和需要处理的治理事项。" action={<button className="subtle" onClick={() => window.location.reload()}><RefreshCw size={15} />刷新概览</button>} />
|
||||
<AdminHeader eyebrow="ADMIN CONSOLE / OVERVIEW" title="管理概览" description="组织管理员在这里查看平台运行状态、团队协作、队列容量、用量和需要处理的治理事项。" action={<div className="header-actions"><StatusBadge status={loading ? "needs-evidence" : attention ? "needs-evidence" : "active"} label={loading ? "同步中" : attention ? `${attention} 项需要关注` : "运行正常"} /><button className="subtle" onClick={loadOperations} disabled={loading}><RefreshCw size={15} />{loading ? "同步中…" : "刷新概览"}</button></div>} />
|
||||
{notice && <div className="inline-notice warn"><AlertTriangle size={15} /><span>{notice}</span></div>}
|
||||
<div className="admin-kpi-grid">
|
||||
<AdminKpi icon={Users} label="组织成员" value={members.length} detail="活跃成员与待邀请" tone="ok" />
|
||||
<AdminKpi icon={Workflow} label="生产项目" value={projects.length} detail={`${summary.activeProjects ?? 0} 个制作中`} tone="neutral" />
|
||||
<AdminKpi icon={Network} label="模型 / Runner" value={`${summary.modelCount || platform.modelRegistry?.length || 0} / ${summary.runnerCount || runners.length}`} detail={`${attention} 个需要关注`} tone={attention ? "warn" : "ok"} />
|
||||
<AdminKpi icon={Gauge} label="队列深度" value={summary.queueDepth || 0} detail="本地任务等待处理" tone={summary.queueDepth ? "warn" : "ok"} />
|
||||
<AdminKpi icon={Network} label="模型 / 服务" value={`${summary.modelCount || platform.modelRegistry?.length || 0} / ${liveSummary.total || services.length}`} detail={`${attention} 个需要关注`} tone={attention ? "warn" : "ok"} />
|
||||
<AdminKpi icon={Gauge} label="队列深度" value={queueDepth} detail={worker ? `Worker ${statusLabel(workerStatus)}` : "本地任务等待处理"} tone={queueDepth ? "warn" : "ok"} />
|
||||
</div>
|
||||
<div className="enterprise-grid enterprise-grid-main">
|
||||
<section className="studio-card wide-card">
|
||||
<SectionBar title="平台运营状态" detail="当前组织 · 当前工作区" action={canManageQueue && <ContextLink onClick={() => setActiveTab("admin-queue")}>打开队列</ContextLink>} />
|
||||
<SectionBar title="平台运营状态" detail={health.checkedAt ? `真实服务探针 · ${formatAdminDate(health.checkedAt)}` : "当前组织 · 当前工作区"} action={canManageQueue && <ContextLink onClick={() => setActiveTab("admin-queue")}>打开队列</ContextLink>} />
|
||||
<div className="service-overview-list">
|
||||
{runners.map((runner) => <div className="service-overview-row" key={runner.id}><div className="service-name"><span className={`service-dot ${runner.status === "ready" ? "ok" : "warn"}`} /><div><strong>{runner.name}</strong><span>{runner.lastHeartbeat || "local"}</span></div></div><div className="service-meter"><div><span style={{ width: `${Math.min(100, 18 + runner.queueDepth * 16)}%` }} /></div><small>queue {runner.queueDepth}</small></div><StatusBadge status={runner.status} label={runner.status === "ready" ? "正常" : runner.status === "planned" ? "规划中" : "待接入模型"} /></div>)}
|
||||
{services.map((service) => <div className="service-overview-row" key={service.id || service.service_key}><div className="service-name"><span className={`service-dot ${service.status === "ready" ? "ok" : "warn"}`} /><div><strong>{service.label || service.service_key}</strong><span>{service.kind} · {service.last_heartbeat || service.updated_at || "未上报心跳"}</span></div></div><div className="service-meter"><div><span style={{ width: `${Math.min(100, 18 + Number(service.queue_depth || 0) * 16)}%` }} /></div><small>queue {Number(service.queue_depth || 0)}</small></div><StatusBadge status={statusTone(service.status)} label={statusLabel(service.status)} /></div>)}
|
||||
{worker && <div className="service-overview-row"><div className="service-name"><span className={`service-dot ${workerStatus === "ready" ? "ok" : "warn"}`} /><div><strong>本地 Worker · {worker.workerId || "default"}</strong><span>并发 {worker.maxConcurrency || 0} · 心跳 {worker.lastHeartbeatAt || "未上报"}</span></div></div><div className="service-meter"><div><span style={{ width: `${Math.min(100, Number(worker.inFlight || 0) * 34 + 12)}%` }} /></div><small>{worker.inFlight || 0} 执行中</small></div><StatusBadge status={statusTone(workerStatus)} label={statusLabel(workerStatus)} /></div>}
|
||||
{!services.length && !worker && <div className="empty-table">还没有可读取的服务健康记录。</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="studio-card">
|
||||
@@ -1151,7 +1197,7 @@ export function AdminOverviewPage({ platformContext, setActiveTab }) {
|
||||
<div className="attention-list">
|
||||
<div><ShieldAlert size={17} /><div><strong>外部云连接已禁用</strong><span>符合 local-runner-only 策略</span></div></div>
|
||||
<div><AlertTriangle size={17} /><div><strong>声音授权证据缺失</strong><span>1 个策略需要补充材料</span></div></div>
|
||||
<div><Clock3 size={17} /><div><strong>{summary.queueDepth || 0} 个任务排队</strong><span>可以调整队列并发或优先级</span></div></div>
|
||||
<div><Clock3 size={17} /><div><strong>{queueDepth} 个任务排队</strong><span>可以调整队列并发或优先级</span></div></div>
|
||||
</div>
|
||||
{canViewSystem && <button className="subtle full-width" onClick={() => setActiveTab("system-generation")}><Settings2 size={15} />查看系统策略</button>}
|
||||
</section>
|
||||
@@ -1160,6 +1206,13 @@ export function AdminOverviewPage({ platformContext, setActiveTab }) {
|
||||
<div className="usage-hero"><strong>{usage.totalCost ?? 86}</strong><span>本月本地计量成本</span></div>
|
||||
{(usage.quotas || []).slice(0, 3).map((quota) => <div className="quota-row" key={`${quota.metric}-${quota.unit}`}><div><span>{quota.metric}</span><b>{quota.used_value}/{quota.limit_value} {quota.unit}</b></div><div className="quota-track"><span style={{ width: `${Math.min(100, Math.round((quota.used_value / quota.limit_value) * 100))}%` }} /></div></div>)}
|
||||
</section>
|
||||
{canViewSystem && <section className="studio-card wide-card">
|
||||
<SectionBar title="生产就绪度" detail={readiness ? `${readiness.profile} · 检查于 ${formatAdminDate(readiness.checkedAt)}` : "系统管理员可查看部署前置条件"} action={<ContextLink onClick={() => setActiveTab("system-storage")}>查看系统设置</ContextLink>} />
|
||||
<div className="policy-check-list">
|
||||
{readinessChecks.slice(0, 8).map((check) => <div key={check.key}><ShieldCheck size={17} /><span>{check.label}<small>{check.detail}</small></span><StatusBadge status={statusTone(check.status)} label={statusLabel(check.status)} /></div>)}
|
||||
{!readiness && !loading && <div className="empty-table">当前没有生产就绪度结果。</div>}
|
||||
</div>
|
||||
</section>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -118,6 +118,11 @@ export async function fetchPlatformContext(overrides = {}) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function searchPlatform(query, { scope = "workspace", limit = 40 } = {}, overrides = {}) {
|
||||
const params = new URLSearchParams({ q: String(query || ""), scope, limit: String(limit) });
|
||||
return apiFetch(`/api/search?${params.toString()}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchWorkItems(overrides = {}) {
|
||||
return apiFetch("/api/work-items", {}, overrides);
|
||||
}
|
||||
|
||||
+50
-1
@@ -197,7 +197,9 @@ button {
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scope-switchers {
|
||||
@@ -238,7 +240,8 @@ button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 112px;
|
||||
min-width: 142px;
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 8px 4px 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
@@ -247,6 +250,7 @@ button {
|
||||
|
||||
.user-context > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@@ -268,12 +272,18 @@ button {
|
||||
}
|
||||
|
||||
.user-context strong {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-context span:last-child {
|
||||
overflow: hidden;
|
||||
color: var(--faint);
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-avatar,
|
||||
@@ -6006,6 +6016,39 @@ body {
|
||||
.form-grid-two .span-two { grid-column: 1 / -1; }
|
||||
.identity-form .subsection-heading { margin-bottom: 2px; }
|
||||
|
||||
/* Cross-module command search */
|
||||
.topbar-search-trigger { display: inline-flex; align-items: center; gap: 7px; min-height: 38px; padding: 0 10px; color: var(--muted); background: #ffffff; border: 1px solid var(--line); border-radius: 8px; white-space: nowrap; }
|
||||
.topbar-search-trigger:hover { color: var(--accent); border-color: #a8c5b6; background: #f7fbf8; }
|
||||
.topbar-search-trigger kbd, .global-search-foot kbd { min-width: 22px; padding: 2px 5px; color: var(--faint); background: #f1f4f1; border: 1px solid #dce3de; border-radius: 4px; font-size: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; text-align: center; }
|
||||
.global-search-backdrop { position: fixed; z-index: 120; inset: 0; display: grid; place-items: start center; padding: 10vh 16px 24px; background: rgba(17, 29, 24, .42); }
|
||||
.global-search-dialog { display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; width: min(780px, 100%); max-height: min(720px, 82vh); overflow: hidden; background: #ffffff; border: 1px solid #c9d7ce; border-radius: 10px; box-shadow: 0 28px 80px rgba(15, 31, 23, .24); }
|
||||
.global-search-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid #e5ebe7; }
|
||||
.global-search-input-wrap { display: flex; align-items: center; gap: 9px; min-width: 0; flex: 1; color: var(--accent); }
|
||||
.global-search-input-wrap input { width: 100%; min-height: 36px; padding: 0; color: var(--ink); background: transparent; border: 0; outline: 0; font-size: 15px; }
|
||||
.global-search-input-wrap input::placeholder { color: #9aa59f; }
|
||||
.global-search-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 14px; color: var(--faint); background: #fafcfb; border-bottom: 1px solid #edf1ee; font-size: 10px; }
|
||||
.global-search-scope { display: flex; gap: 4px; padding: 3px; background: #eef3ef; border-radius: 6px; }
|
||||
.global-search-scope button { min-height: 27px; padding: 0 9px; color: var(--muted); background: transparent; border: 0; border-radius: 5px; font-size: 10px; }
|
||||
.global-search-scope button.selected { color: var(--accent); background: #ffffff; box-shadow: 0 1px 4px rgba(33, 70, 51, .1); font-weight: 800; }
|
||||
.global-search-scope button:disabled { color: #b2bbb5; cursor: not-allowed; }
|
||||
.global-search-error { display: flex; align-items: center; gap: 7px; margin: 10px 14px 0; padding: 9px 10px; color: #8d4f25; background: #fff4e8; border: 1px solid #efd4b8; border-radius: 6px; font-size: 11px; }
|
||||
.global-search-results { min-height: 170px; overflow: auto; padding: 8px; }
|
||||
.global-search-result { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto 16px; gap: 9px; align-items: center; width: 100%; min-height: 54px; padding: 8px 9px; color: var(--ink); background: transparent; border: 0; border-radius: 7px; text-align: left; }
|
||||
.global-search-result:hover, .global-search-result.selected { background: #eef6f0; }
|
||||
.global-search-result-icon { display: grid; width: 30px; height: 30px; place-items: center; color: var(--accent); background: #e7f2ea; border-radius: 7px; }
|
||||
.global-search-result-copy { display: grid; gap: 4px; min-width: 0; }
|
||||
.global-search-result-copy strong { overflow: hidden; color: var(--ink); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.global-search-result-copy small { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.global-search-result-copy b { color: var(--accent); font-weight: 800; }
|
||||
.global-search-result-status { max-width: 80px; overflow: hidden; color: var(--faint); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.global-search-result > svg { color: #9baa9f; }
|
||||
.global-search-empty { display: grid; place-items: center; gap: 9px; min-height: 220px; padding: 32px; color: var(--faint); text-align: center; }
|
||||
.global-search-empty svg { color: #a9b9ae; }
|
||||
.global-search-empty strong { color: var(--ink); font-size: 13px; }
|
||||
.global-search-empty span { max-width: 390px; font-size: 11px; line-height: 1.6; }
|
||||
.global-search-foot { display: flex; justify-content: flex-end; gap: 14px; padding: 9px 14px; color: var(--faint); border-top: 1px solid #e5ebe7; font-size: 10px; }
|
||||
.global-search-foot span { display: inline-flex; align-items: center; gap: 4px; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.tenant-settings-grid { grid-template-columns: 1fr; }
|
||||
.identity-policy-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@@ -6017,6 +6060,12 @@ body {
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.global-search-backdrop { padding: 16px 10px; }
|
||||
.global-search-dialog { max-height: calc(100vh - 32px); }
|
||||
.global-search-toolbar { align-items: flex-start; flex-direction: column; }
|
||||
.global-search-foot { justify-content: space-between; gap: 6px; }
|
||||
.topbar-search-trigger span, .topbar-search-trigger kbd { display: none; }
|
||||
.topbar-search-trigger { min-width: 38px; justify-content: center; padding: 0 9px; }
|
||||
.mfa-status-row { grid-template-columns: 36px minmax(0, 1fr); }
|
||||
.mfa-status-row > .status-badge { grid-column: 2; justify-self: start; }
|
||||
.mfa-disable-form { grid-template-columns: 1fr; }
|
||||
|
||||
Reference in New Issue
Block a user