Commit 33d48dd2 authored by xuchentao's avatar xuchentao

feat: add unified action dialogs

parent 9b479a5e
...@@ -6,7 +6,7 @@ import { ...@@ -6,7 +6,7 @@ import {
Plus, RefreshCw, Rocket, RotateCcw, Search, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Trash2, Plus, RefreshCw, Rocket, RotateCcw, Search, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Trash2,
WandSparkles, X, WandSparkles, X,
} from "lucide-react"; } from "lucide-react";
import type { AgentRunEvent, CreateSiteInput, CreateTenantInput, DomainBinding, SessionInfo, SiteInfo, TenantAdminInfo } from "@webagent/shared"; import type { AgentRunEvent, CreateSiteInput, CreateTenantInput, DomainBinding, GitHistoryItem, SessionInfo, SiteInfo, TenantAdminInfo } from "@webagent/shared";
import { api } from "./api"; import { api } from "./api";
type ChatMessage = { role: "user" | "agent"; text: string; meta?: string }; type ChatMessage = { role: "user" | "agent"; text: string; meta?: string };
...@@ -127,6 +127,10 @@ function AdminWorkspace({ session, onLogout }: { session: SessionInfo; onLogout: ...@@ -127,6 +127,10 @@ function AdminWorkspace({ session, onLogout }: { session: SessionInfo; onLogout:
const [selectedTenantId, setSelectedTenantId] = useState(""); const [selectedTenantId, setSelectedTenantId] = useState("");
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState<CreateTenantInput>({ name: "", username: "", password: "" }); const [form, setForm] = useState<CreateTenantInput>({ name: "", username: "", password: "" });
const [passwordTarget, setPasswordTarget] = useState<{ tenantId: string; userId: string; username: string }>();
const [newPassword, setNewPassword] = useState("");
const [revokeTarget, setRevokeTarget] = useState<{ tenantId: string; userId: string; username: string }>();
const [adminNotice, setAdminNotice] = useState("");
const refresh = () => queryClient.invalidateQueries({ queryKey: ["admin-tenants"] }); const refresh = () => queryClient.invalidateQueries({ queryKey: ["admin-tenants"] });
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: api.createTenant, mutationFn: api.createTenant,
...@@ -135,11 +139,11 @@ function AdminWorkspace({ session, onLogout }: { session: SessionInfo; onLogout: ...@@ -135,11 +139,11 @@ function AdminWorkspace({ session, onLogout }: { session: SessionInfo; onLogout:
const statusMutation = useMutation({ mutationFn: ({ tenantId, enabled }: { tenantId: string; enabled: boolean }) => api.setTenantEnabled(tenantId, enabled), onSuccess: refresh }); const statusMutation = useMutation({ mutationFn: ({ tenantId, enabled }: { tenantId: string; enabled: boolean }) => api.setTenantEnabled(tenantId, enabled), onSuccess: refresh });
const passwordMutation = useMutation({ const passwordMutation = useMutation({
mutationFn: ({ tenantId, userId, password }: { tenantId: string; userId: string; password: string }) => api.resetTenantPassword(tenantId, userId, password), mutationFn: ({ tenantId, userId, password }: { tenantId: string; userId: string; password: string }) => api.resetTenantPassword(tenantId, userId, password),
onSuccess: () => window.alert("密码已重置,该账号的全部 session 已注销"), onSuccess: () => { setPasswordTarget(undefined); setNewPassword(""); setAdminNotice("密码已重置,该账号的全部 session 已注销。"); },
}); });
const revokeMutation = useMutation({ const revokeMutation = useMutation({
mutationFn: ({ tenantId, userId }: { tenantId: string; userId: string }) => api.revokeTenantSessions(tenantId, userId), mutationFn: ({ tenantId, userId }: { tenantId: string; userId: string }) => api.revokeTenantSessions(tenantId, userId),
onSuccess: () => window.alert("该账号的全部 session 已注销"), onSuccess: () => { setRevokeTarget(undefined); setAdminNotice("该账号的全部 session 已注销。"); },
}); });
const tenants = tenantsQuery.data || []; const tenants = tenantsQuery.data || [];
const selected = tenants.find((tenant) => tenant.tenantId === selectedTenantId) || tenants[0]; const selected = tenants.find((tenant) => tenant.tenantId === selectedTenantId) || tenants[0];
...@@ -149,11 +153,6 @@ function AdminWorkspace({ session, onLogout }: { session: SessionInfo; onLogout: ...@@ -149,11 +153,6 @@ function AdminWorkspace({ session, onLogout }: { session: SessionInfo; onLogout:
enabled: Boolean(selected && !showCreate), enabled: Boolean(selected && !showCreate),
}); });
const resetPassword = (tenant: TenantAdminInfo, userId: string) => {
const password = window.prompt("输入新密码(至少 8 个字符)");
if (password) passwordMutation.mutate({ tenantId: tenant.tenantId, userId, password });
};
return <main className="admin-page"> return <main className="admin-page">
<header className="admin-topbar"><Logo /><div><span className="settings-avatar">{session.username.slice(0, 1).toUpperCase()}</span><span><strong>{session.username}</strong><small>系统管理员</small></span><button type="button" onClick={onLogout}><ArrowLeft size={14} />退出</button></div></header> <header className="admin-topbar"><Logo /><div><span className="settings-avatar">{session.username.slice(0, 1).toUpperCase()}</span><span><strong>{session.username}</strong><small>系统管理员</small></span><button type="button" onClick={onLogout}><ArrowLeft size={14} />退出</button></div></header>
<div className="admin-layout"> <div className="admin-layout">
...@@ -179,7 +178,7 @@ function AdminWorkspace({ session, onLogout }: { session: SessionInfo; onLogout: ...@@ -179,7 +178,7 @@ function AdminWorkspace({ session, onLogout }: { session: SessionInfo; onLogout:
<div className="admin-stats"><article><strong>{selected.accounts.length}</strong><span>租户账号</span></article><article><strong>{selected.siteCount}</strong><span>站点数量</span></article><article><strong className={selected.enabled ? "ok" : "off"}>{selected.enabled ? "已启用" : "已禁用"}</strong><span>租户状态</span></article></div> <div className="admin-stats"><article><strong>{selected.accounts.length}</strong><span>租户账号</span></article><article><strong>{selected.siteCount}</strong><span>站点数量</span></article><article><strong className={selected.enabled ? "ok" : "off"}>{selected.enabled ? "已启用" : "已禁用"}</strong><span>租户状态</span></article></div>
<section className="admin-card"> <section className="admin-card">
<div className="admin-section-title"><div><h2>租户账号</h2><p>重置密码会同时注销该账号的所有现有 session。</p></div></div> <div className="admin-section-title"><div><h2>租户账号</h2><p>重置密码会同时注销该账号的所有现有 session。</p></div></div>
<div className="admin-account-list">{selected.accounts.map((account) => <article key={account.userId}><span className="settings-avatar">{account.username.slice(0, 1).toUpperCase()}</span><div><strong>{account.username}</strong><small>{account.userId} · {account.enabled ? "已启用" : "已禁用"}</small></div><button type="button" disabled={passwordMutation.isPending} onClick={() => resetPassword(selected, account.userId)}>重置密码</button><button type="button" disabled={revokeMutation.isPending} onClick={() => revokeMutation.mutate({ tenantId: selected.tenantId, userId: account.userId })}>注销全部 session</button></article>)}</div> <div className="admin-account-list">{selected.accounts.map((account) => <article key={account.userId}><span className="settings-avatar">{account.username.slice(0, 1).toUpperCase()}</span><div><strong>{account.username}</strong><small>{account.userId} · {account.enabled ? "已启用" : "已禁用"}</small></div><button type="button" disabled={passwordMutation.isPending} onClick={() => { passwordMutation.reset(); setNewPassword(""); setPasswordTarget({ tenantId: selected.tenantId, userId: account.userId, username: account.username }); }}>重置密码</button><button type="button" disabled={revokeMutation.isPending} onClick={() => { revokeMutation.reset(); setRevokeTarget({ tenantId: selected.tenantId, userId: account.userId, username: account.username }); }}>注销全部 session</button></article>)}</div>
</section> </section>
<section className="admin-card admin-sites-card"> <section className="admin-card admin-sites-card">
<div className="admin-section-title"><div><h2>租户站点</h2><p>管理员仅可查看站点状态并打开公开链接,不能编辑或发布。</p></div><span>{tenantSitesQuery.data?.length ?? selected.siteCount}</span></div> <div className="admin-section-title"><div><h2>租户站点</h2><p>管理员仅可查看站点状态并打开公开链接,不能编辑或发布。</p></div><span>{tenantSitesQuery.data?.length ?? selected.siteCount}</span></div>
...@@ -196,6 +195,9 @@ function AdminWorkspace({ session, onLogout }: { session: SessionInfo; onLogout: ...@@ -196,6 +195,9 @@ function AdminWorkspace({ session, onLogout }: { session: SessionInfo; onLogout:
</> : tenantsQuery.isLoading ? <Splash /> : <div className="admin-empty"><ShieldCheck size={30} /><h2>暂无租户</h2><button type="button" onClick={() => setShowCreate(true)}>创建第一个租户</button></div>} </> : tenantsQuery.isLoading ? <Splash /> : <div className="admin-empty"><ShieldCheck size={30} /><h2>暂无租户</h2><button type="button" onClick={() => setShowCreate(true)}>创建第一个租户</button></div>}
</section> </section>
</div> </div>
{passwordTarget && <AppDialog title="重置账号密码" description={`为账号“${passwordTarget.username}”设置新密码。保存后,该账号当前的全部登录会话将立即失效。`} confirmLabel="重置密码" busy={passwordMutation.isPending} confirmDisabled={newPassword.length < 8} error={passwordMutation.error?.message} icon={<ShieldCheck size={20} />} onCancel={() => { setPasswordTarget(undefined); setNewPassword(""); }} onConfirm={() => passwordMutation.mutate({ tenantId: passwordTarget.tenantId, userId: passwordTarget.userId, password: newPassword })} input={{ label: "新密码(至少 8 个字符)", value: newPassword, type: "password", placeholder: "请输入新密码", onChange: setNewPassword }} />}
{revokeTarget && <AppDialog title="注销全部登录会话?" description={`账号“${revokeTarget.username}”需要重新登录后才能继续使用,当前密码不会改变。`} confirmLabel="确认注销" busy={revokeMutation.isPending} error={revokeMutation.error?.message} tone="danger" icon={<ShieldCheck size={20} />} onCancel={() => setRevokeTarget(undefined)} onConfirm={() => revokeMutation.mutate({ tenantId: revokeTarget.tenantId, userId: revokeTarget.userId })} />}
{adminNotice && <AppDialog title="操作完成" description={adminNotice} confirmLabel="知道了" tone="success" icon={<CheckCircle2 size={20} />} onConfirm={() => setAdminNotice("")} />}
</main>; </main>;
} }
...@@ -410,12 +412,12 @@ function SiteManagementWorkspace({ sites, currentSiteId, onEdit, onCreate, onBac ...@@ -410,12 +412,12 @@ function SiteManagementWorkspace({ sites, currentSiteId, onEdit, onCreate, onBac
] as Array<[SiteManagementFilter, string]>).map(([value, label]) => <button type="button" className={filter === value ? "active" : ""} key={value} onClick={() => setFilter(value)}>{label}<span>{counts[value]}</span></button>)}</nav> ] as Array<[SiteManagementFilter, string]>).map(([value, label]) => <button type="button" className={filter === value ? "active" : ""} key={value} onClick={() => setFilter(value)}>{label}<span>{counts[value]}</span></button>)}</nav>
<div className="site-management-tools"><label><Search size={16} /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索网站名称、行业或 ID" /></label><span>{filter === "archived" ? "回收站内容保留 30 天" : "按最近更新时间排序"}</span></div> <div className="site-management-tools"><label><Search size={16} /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索网站名称、行业或 ID" /></label><span>{filter === "archived" ? "回收站内容保留 30 天" : "按最近更新时间排序"}</span></div>
<div className="managed-site-list"> <div className="managed-site-list">
{visible.map((site) => filter === "archived" ? <ArchivedSiteCard key={site.siteId} site={site} busy={restoreMutation.isPending || deleteMutation.isPending} onRestore={() => restoreMutation.mutate(site.siteId)} onDelete={() => { setDeleteTarget(site); setDeleteConfirmation(""); }} /> : <ManagedSiteCard key={site.siteId} site={site} selected={site.siteId === currentSiteId} busy={archiveMutation.isPending} onEdit={() => onEdit(site)} onArchive={() => setArchiveTarget(site)} />)} {visible.map((site) => filter === "archived" ? <ArchivedSiteCard key={site.siteId} site={site} busy={restoreMutation.isPending || deleteMutation.isPending} onRestore={() => restoreMutation.mutate(site.siteId)} onDelete={() => { deleteMutation.reset(); setDeleteTarget(site); setDeleteConfirmation(""); }} /> : <ManagedSiteCard key={site.siteId} site={site} selected={site.siteId === currentSiteId} busy={archiveMutation.isPending} onEdit={() => onEdit(site)} onArchive={() => setArchiveTarget(site)} />)}
{!visible.length && <div className="managed-sites-empty">{filter === "archived" ? <Archive size={28} /> : <LayoutGrid size={28} />}<h2>{search ? "没有匹配的网站" : filter === "archived" ? "回收站是空的" : "这个分类下暂无网站"}</h2><p>{search ? "换一个关键词试试。" : filter === "archived" ? "移入回收站的网站会显示在这里。" : "可以切换筛选条件或创建一个新网站。"}</p></div>} {!visible.length && <div className="managed-sites-empty">{filter === "archived" ? <Archive size={28} /> : <LayoutGrid size={28} />}<h2>{search ? "没有匹配的网站" : filter === "archived" ? "回收站是空的" : "这个分类下暂无网站"}</h2><p>{search ? "换一个关键词试试。" : filter === "archived" ? "移入回收站的网站会显示在这里。" : "可以切换筛选条件或创建一个新网站。"}</p></div>}
</div> </div>
</section> </section>
{archiveTarget && <ConfirmDialog title="移入回收站?" description={`${archiveTarget.name}”将停止编辑和发布,测试与线上访问会下线,数据保留 30 天。`} confirmLabel="移入回收站" busy={archiveMutation.isPending} onCancel={() => setArchiveTarget(undefined)} onConfirm={() => archiveMutation.mutate(archiveTarget.siteId)} />} {archiveTarget && <ConfirmDialog title="移入回收站?" description={`${archiveTarget.name}”将停止编辑和发布,测试与线上访问会下线,数据保留 30 天。`} confirmLabel="移入回收站" busy={archiveMutation.isPending} onCancel={() => setArchiveTarget(undefined)} onConfirm={() => archiveMutation.mutate(archiveTarget.siteId)} />}
{deleteTarget && <div className="confirm-backdrop" role="presentation"><section className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-site-title"><span className="confirm-icon danger"><Trash2 size={20} /></span><h2 id="delete-site-title">永久删除网站?</h2><p>网站代码、版本历史、草稿和构建文件都会被删除,此操作无法恢复。</p><label>请输入 <strong>{deleteTarget.name}</strong> 确认<input autoFocus value={deleteConfirmation} onChange={(event) => setDeleteConfirmation(event.target.value)} /></label><div><button type="button" onClick={() => { setDeleteTarget(undefined); setDeleteConfirmation(""); }}>取消</button><button className="danger" type="button" disabled={deleteConfirmation !== deleteTarget.name || deleteMutation.isPending} onClick={() => deleteMutation.mutate(deleteTarget.siteId)}>{deleteMutation.isPending && <LoaderCircle className="spin" size={14} />}永久删除</button></div></section></div>} {deleteTarget && <AppDialog title="永久删除网站?" description={`网站代码、版本历史、草稿和构建文件都会被删除。请输入“${deleteTarget.name}”确认,此操作无法恢复。`} confirmLabel="永久删除" busy={deleteMutation.isPending} confirmDisabled={deleteConfirmation !== deleteTarget.name} error={deleteMutation.error?.message} tone="danger" icon={<Trash2 size={20} />} input={{ label: "网站名称", value: deleteConfirmation, placeholder: deleteTarget.name, onChange: setDeleteConfirmation }} onCancel={() => { setDeleteTarget(undefined); setDeleteConfirmation(""); }} onConfirm={() => deleteMutation.mutate(deleteTarget.siteId)} />}
</main>; </main>;
} }
...@@ -428,10 +430,40 @@ function ArchivedSiteCard({ site, busy, onRestore, onDelete }: { site: SiteInfo; ...@@ -428,10 +430,40 @@ function ArchivedSiteCard({ site, busy, onRestore, onDelete }: { site: SiteInfo;
return <article className="managed-site-card archived"><span className="managed-site-avatar"><Archive size={18} /></span><div className="managed-site-main"><div><strong>{site.name}</strong><span className="archived">回收站</span></div><small>{site.industry} · 移入于 {formatDate(site.archivedAt || site.updatedAt)}</small><div className="managed-site-states"><span>自动清理时间:{site.deleteAfter ? formatDate(site.deleteAfter) : "30 天后"}</span><span><code>{site.siteId}</code></span></div></div><div className="managed-site-actions"><button className="restore" type="button" disabled={busy} onClick={onRestore}><RotateCcw size={13} />恢复网站</button><button className="delete" type="button" disabled={busy} onClick={onDelete}><Trash2 size={13} />永久删除</button></div></article>; return <article className="managed-site-card archived"><span className="managed-site-avatar"><Archive size={18} /></span><div className="managed-site-main"><div><strong>{site.name}</strong><span className="archived">回收站</span></div><small>{site.industry} · 移入于 {formatDate(site.archivedAt || site.updatedAt)}</small><div className="managed-site-states"><span>自动清理时间:{site.deleteAfter ? formatDate(site.deleteAfter) : "30 天后"}</span><span><code>{site.siteId}</code></span></div></div><div className="managed-site-actions"><button className="restore" type="button" disabled={busy} onClick={onRestore}><RotateCcw size={13} />恢复网站</button><button className="delete" type="button" disabled={busy} onClick={onDelete}><Trash2 size={13} />永久删除</button></div></article>;
} }
type AppDialogProps = {
title: string;
description: string;
confirmLabel: string;
busy?: boolean;
confirmDisabled?: boolean;
error?: string;
tone?: "default" | "danger" | "success";
icon?: React.ReactNode;
input?: { label: string; value: string; type?: string; placeholder?: string; onChange: (value: string) => void };
onCancel?: () => void;
onConfirm: () => void;
};
function AppDialog({ title, description, confirmLabel, busy = false, confirmDisabled = false, error, tone = "default", icon, input, onCancel, onConfirm }: AppDialogProps) {
return <div className="confirm-backdrop" role="presentation"><section className={`confirm-dialog ${tone}`} role="dialog" aria-modal="true" aria-labelledby="app-dialog-title">
<span className={`confirm-icon ${tone}`}>{icon || (tone === "success" ? <CheckCircle2 size={20} /> : <Archive size={20} />)}</span>
<h2 id="app-dialog-title">{title}</h2><p>{description}</p>
{input && <label>{input.label}<input autoFocus type={input.type || "text"} value={input.value} placeholder={input.placeholder} onChange={(event) => input.onChange(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !busy && !confirmDisabled) onConfirm(); }} /></label>}
{error && <div className="dialog-error"><X size={13} />{error}</div>}
<div>{onCancel && <button type="button" disabled={busy} onClick={onCancel}>取消</button>}<button className={tone === "danger" ? "danger" : "primary"} type="button" disabled={busy || confirmDisabled} onClick={onConfirm}>{busy && <LoaderCircle className="spin" size={14} />}{confirmLabel}</button></div>
</section></div>;
}
function ConfirmDialog({ title, description, confirmLabel, busy, onCancel, onConfirm }: { title: string; description: string; confirmLabel: string; busy: boolean; onCancel: () => void; onConfirm: () => void }) { function ConfirmDialog({ title, description, confirmLabel, busy, onCancel, onConfirm }: { title: string; description: string; confirmLabel: string; busy: boolean; onCancel: () => void; onConfirm: () => void }) {
return <div className="confirm-backdrop" role="presentation"><section className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="confirm-title"><span className="confirm-icon"><Archive size={20} /></span><h2 id="confirm-title">{title}</h2><p>{description}</p><div><button type="button" onClick={onCancel}>取消</button><button className="danger" type="button" disabled={busy} onClick={onConfirm}>{busy && <LoaderCircle className="spin" size={14} />}{confirmLabel}</button></div></section></div>; return <AppDialog title={title} description={description} confirmLabel={confirmLabel} busy={busy} tone="danger" onCancel={onCancel} onConfirm={onConfirm} />;
} }
type WorkspaceDialog =
| { kind: "discard" }
| { kind: "restore"; version: GitHistoryItem }
| { kind: "publish" }
| { kind: "publish-blocked"; title: string; description: string; goToVersions: boolean };
function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, onLogout }: { siteId: string; sites: SiteInfo[]; agentMode: "model" | "local"; onSelectSite: (id: string) => void; onCreate: () => void; onManage: () => void; onLogout: () => void }) { function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, onLogout }: { siteId: string; sites: SiteInfo[]; agentMode: "model" | "local"; onSelectSite: (id: string) => void; onCreate: () => void; onManage: () => void; onLogout: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const siteQuery = useQuery({ queryKey: ["site", siteId], queryFn: () => api.site(siteId) }); const siteQuery = useQuery({ queryKey: ["site", siteId], queryFn: () => api.site(siteId) });
...@@ -444,6 +476,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -444,6 +476,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
const [versionMessage, setVersionMessage] = useState(""); const [versionMessage, setVersionMessage] = useState("");
const [activeRunId, setActiveRunId] = useState(""); const [activeRunId, setActiveRunId] = useState("");
const [agentProgress, setAgentProgress] = useState(""); const [agentProgress, setAgentProgress] = useState("");
const [workspaceDialog, setWorkspaceDialog] = useState<WorkspaceDialog>();
const [iframeVersion, setIframeVersion] = useState(0); const [iframeVersion, setIframeVersion] = useState(0);
const [messages, setMessages] = useState<ChatMessage[]>([ const [messages, setMessages] = useState<ChatMessage[]>([
{ role: "agent", text: "网站已准备好。多轮修改会累积为未保存草稿,并自动更新测试预览;你可以在“版本”中决定何时保存。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" }, { role: "agent", text: "网站已准备好。多轮修改会累积为未保存草稿,并自动更新测试预览;你可以在“版本”中决定何时保存。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" },
...@@ -461,6 +494,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -461,6 +494,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
useEffect(() => { useEffect(() => {
setVersionMessage(""); setVersionMessage("");
versionMessageKeyRef.current = ""; versionMessageKeyRef.current = "";
setWorkspaceDialog(undefined);
}, [siteId]); }, [siteId]);
useEffect(() => { useEffect(() => {
if (!site?.draftBaseCommit) { if (!site?.draftBaseCommit) {
...@@ -580,6 +614,21 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -580,6 +614,21 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
const environmentStatus = showingProduction const environmentStatus = showingProduction
? site.lastPublishError ? "上线失败 · 线上未变" : !site.publishedCommit ? "尚未上线" : productionMatchesPreview ? "生产已同步" : "生产版本较旧" ? site.lastPublishError ? "上线失败 · 线上未变" : !site.publishedCommit ? "尚未上线" : productionMatchesPreview ? "生产已同步" : "生产版本较旧"
: site.lastError ? "构建失败 · 预览未变" : site.draftBaseCommit ? "未保存草稿预览" : site.status === "ready" ? "测试已同步" : "需要检查"; : site.lastError ? "构建失败 · 预览未变" : site.draftBaseCommit ? "未保存草稿预览" : site.status === "ready" ? "测试已同步" : "需要检查";
const requestPublish = () => {
if (site.draftBaseCommit) {
setWorkspaceDialog({ kind: "publish-blocked", title: "暂时不能上线", description: "请先进入左侧“版本”,保存或放弃未保存修改后,才能上线。", goToVersions: true });
return;
}
if (site.status !== "ready" || !site.previewCommit) {
const description = site.lastError
? "测试环境构建失败。请先修复问题并重新构建成功后,才能上线。"
: "请先等待测试环境构建成功,确认预览可用后,才能上线。";
setWorkspaceDialog({ kind: "publish-blocked", title: "暂时不能上线", description, goToVersions: false });
return;
}
publishMutation.reset();
setWorkspaceDialog({ kind: "publish" });
};
return <main className={`workspace ${tab === "domains" ? "domains-mode" : ""}`}> return <main className={`workspace ${tab === "domains" ? "domains-mode" : ""}`}>
<aside className="rail"> <aside className="rail">
<Logo compact /> <Logo compact />
...@@ -611,7 +660,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -611,7 +660,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
<div className="draft-card-heading"><span><Sparkles size={13} /></span><div><strong>未保存修改</strong><small>更新于 {site.draftUpdatedAt ? formatTime(site.draftUpdatedAt) : "刚刚"} · 基于 {site.draftBaseCommit.slice(0, 7)}</small></div></div> <div className="draft-card-heading"><span><Sparkles size={13} /></span><div><strong>未保存修改</strong><small>更新于 {site.draftUpdatedAt ? formatTime(site.draftUpdatedAt) : "刚刚"} · 基于 {site.draftBaseCommit.slice(0, 7)}</small></div></div>
<p className="draft-card-summary">{site.draftSummary || "多轮 Agent 修改正在此处累积,尚未写入 Git 历史。"}</p> <p className="draft-card-summary">{site.draftSummary || "多轮 Agent 修改正在此处累积,尚未写入 Git 历史。"}</p>
<label className="version-message-field"><span>版本说明</span><input value={versionMessage} maxLength={120} placeholder="例如:首页内容与品牌视觉优化" onChange={(event) => setVersionMessage(event.target.value)} /></label> <label className="version-message-field"><span>版本说明</span><input value={versionMessage} maxLength={120} placeholder="例如:首页内容与品牌视觉优化" onChange={(event) => setVersionMessage(event.target.value)} /></label>
<div className="draft-card-actions primary"><button type="button" disabled={busy || versionMessage.trim().length < 2} onClick={() => saveDraftMutation.mutate()}>{saveDraftMutation.isPending ? <LoaderCircle className="spin" size={11} /> : <CheckCircle2 size={11} />}{saveDraftMutation.isPending ? "保存中" : "保存为新版本"}</button><button type="button" disabled={busy} onClick={() => { if (window.confirm("确定放弃全部未保存修改吗?此操作不能撤销。")) discardDraftMutation.mutate(); }}><Trash2 size={11} />放弃修改</button></div> <div className="draft-card-actions primary"><button type="button" disabled={busy || versionMessage.trim().length < 2} onClick={() => saveDraftMutation.mutate()}>{saveDraftMutation.isPending ? <LoaderCircle className="spin" size={11} /> : <CheckCircle2 size={11} />}{saveDraftMutation.isPending ? "保存中" : "保存为新版本"}</button><button type="button" disabled={busy} onClick={() => { discardDraftMutation.reset(); setWorkspaceDialog({ kind: "discard" }); }}><Trash2 size={11} />放弃修改</button></div>
<div className="draft-card-actions secondary"><button type="button" onClick={() => setTab("chat")}><MessageSquareText size={11} />继续编辑</button><button type="button" disabled={busy} onClick={() => previewDraftMutation.mutate()}>{previewDraftMutation.isPending ? <LoaderCircle className="spin" size={11} /> : <Monitor size={11} />}{previewDraftMutation.isPending ? "构建中" : "重新构建预览"}</button></div> <div className="draft-card-actions secondary"><button type="button" onClick={() => setTab("chat")}><MessageSquareText size={11} />继续编辑</button><button type="button" disabled={busy} onClick={() => previewDraftMutation.mutate()}>{previewDraftMutation.isPending ? <LoaderCircle className="spin" size={11} /> : <Monitor size={11} />}{previewDraftMutation.isPending ? "构建中" : "重新构建预览"}</button></div>
</section>} </section>}
{historicalPreview && <div className="history-edit-lock history-panel-lock"><div><History size={14} /><span>{site.draftBaseCommit ? "正在查看旧版本,未保存修改仍保留" : "当前只是在预览历史版本;可恢复为新的当前版本"}</span></div></div>} {historicalPreview && <div className="history-edit-lock history-panel-lock"><div><History size={14} /><span>{site.draftBaseCommit ? "正在查看旧版本,未保存修改仍保留" : "当前只是在预览历史版本;可恢复为新的当前版本"}</span></div></div>}
...@@ -626,7 +675,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -626,7 +675,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
<span className="commit-info"><span><strong>{item.message}</strong>{isPreview && <em className="preview-badge">当前查看</em>}{isProduction && <em className="production-badge">线上版本</em>}{isCurrent && <em className="current-badge">最近保存</em>}</span><small><code>{item.shortHash}</code><span>·</span>{formatDate(item.date)}</small></span> <span className="commit-info"><span><strong>{item.message}</strong>{isPreview && <em className="preview-badge">当前查看</em>}{isProduction && <em className="production-badge">线上版本</em>}{isCurrent && <em className="current-badge">最近保存</em>}</span><small><code>{item.shortHash}</code><span>·</span>{formatDate(item.date)}</small></span>
<span className="history-version-actions"> <span className="history-version-actions">
{isPreview ? <span className="history-row-state">正在查看</span> : <button className="history-preview-button" type="button" disabled={busy} onClick={() => { setEnvironment("preview"); previewVersionMutation.mutate(item.hash); }}>{selecting ? <LoaderCircle className="spin" size={10} /> : <Monitor size={11} />}{selecting ? "切换中" : "预览"}</button>} {isPreview ? <span className="history-row-state">正在查看</span> : <button className="history-preview-button" type="button" disabled={busy} onClick={() => { setEnvironment("preview"); previewVersionMutation.mutate(item.hash); }}>{selecting ? <LoaderCircle className="spin" size={10} /> : <Monitor size={11} />}{selecting ? "切换中" : "预览"}</button>}
{!isCurrent && <button className="history-restore-button" type="button" disabled={busy || Boolean(site.draftBaseCommit)} onClick={() => { if (window.confirm(`确定恢复版本 ${item.shortHash} 吗?系统会创建一个新的恢复版本。`)) restoreVersionMutation.mutate(item.hash); }}><RotateCcw size={11} />恢复</button>} {!isCurrent && <button className="history-restore-button" type="button" disabled={busy || Boolean(site.draftBaseCommit)} onClick={() => { restoreVersionMutation.reset(); setWorkspaceDialog({ kind: "restore", version: item }); }}><RotateCcw size={11} />恢复</button>}
</span> </span>
</div> </div>
</article>; </article>;
...@@ -644,7 +693,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -644,7 +693,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
{!showingProduction && <button className="rebuild-button" disabled={busy} onClick={() => rebuildMutation.mutate()} title="重新构建测试环境"><RefreshCw className={rebuildMutation.isPending ? "spin" : ""} size={14} /><span>重新构建</span></button>} {!showingProduction && <button className="rebuild-button" disabled={busy} onClick={() => rebuildMutation.mutate()} title="重新构建测试环境"><RefreshCw className={rebuildMutation.isPending ? "spin" : ""} size={14} /><span>重新构建</span></button>}
<a className="environment-link preview-link" href={site.previewUrl} target="_blank" rel="noreferrer" title="打开测试环境"><Monitor size={13} /><span>测试链接</span></a> <a className="environment-link preview-link" href={site.previewUrl} target="_blank" rel="noreferrer" title="打开测试环境"><Monitor size={13} /><span>测试链接</span></a>
<a className="environment-link production-link" href={site.productionUrl} target="_blank" rel="noreferrer" title="打开生产环境"><Globe2 size={13} /><span>生产链接</span></a> <a className="environment-link production-link" href={site.productionUrl} target="_blank" rel="noreferrer" title="打开生产环境"><Globe2 size={13} /><span>生产链接</span></a>
{!showingProduction && <button className="publish-button" disabled={busy || !canPublish} onClick={() => { if (window.confirm(productionMatchesPreview ? "确定重新发布当前版本吗?这会重新构建并更新生产产物。" : "确定将当前测试预览版本发布到生产环境吗?")) publishMutation.mutate(); }} title={site.status !== "ready" ? "测试环境构建成功后才能发布" : productionMatchesPreview ? "重新构建并发布当前版本" : "发布当前测试预览版本到生产环境"}> {!showingProduction && <button className={`publish-button ${!canPublish ? "unavailable" : ""}`} aria-disabled={!canPublish || busy} disabled={busy} onClick={requestPublish} title={site.draftBaseCommit ? "请先保存或放弃未保存修改" : site.status !== "ready" ? "测试环境构建成功后才能发布" : productionMatchesPreview ? "重新构建并发布当前版本" : "发布当前测试预览版本到生产环境"}>
{publishMutation.isPending ? <LoaderCircle className="spin" size={14} /> : <Rocket size={14} />}<span>{productionMatchesPreview ? "重新上线" : "上线当前版本"}</span> {publishMutation.isPending ? <LoaderCircle className="spin" size={14} /> : <Rocket size={14} />}<span>{productionMatchesPreview ? "重新上线" : "上线当前版本"}</span>
</button>} </button>}
</div> </div>
...@@ -660,6 +709,10 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -660,6 +709,10 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
<span>{showingProduction ? <Globe2 size={12} /> : <Monitor size={12} />}当前查看 · {showingProduction ? `生产版本 ${productionVersion?.shortHash || site.publishedCommit?.slice(0, 7) || "尚未上线"}` : site.draftBaseCommit ? `未保存草稿 · 基于 ${site.draftBaseCommit.slice(0, 7)}` : `${historicalPreview ? "历史版本" : "测试版本"} ${previewVersion?.shortHash || site.currentCommit.slice(0, 7)}`} · {showingProduction ? site.publishedAt ? formatDate(site.publishedAt) : "尚未发布" : site.draftBaseCommit ? site.draftUpdatedAt ? formatDate(site.draftUpdatedAt) : "刚刚" : previewVersion ? formatDate(previewVersion.date) : formatDate(site.updatedAt)}{historicalPreview && !showingProduction ? " · 只读" : ""}</span> <span>{showingProduction ? <Globe2 size={12} /> : <Monitor size={12} />}当前查看 · {showingProduction ? `生产版本 ${productionVersion?.shortHash || site.publishedCommit?.slice(0, 7) || "尚未上线"}` : site.draftBaseCommit ? `未保存草稿 · 基于 ${site.draftBaseCommit.slice(0, 7)}` : `${historicalPreview ? "历史版本" : "测试版本"} ${previewVersion?.shortHash || site.currentCommit.slice(0, 7)}`} · {showingProduction ? site.publishedAt ? formatDate(site.publishedAt) : "尚未发布" : site.draftBaseCommit ? site.draftUpdatedAt ? formatDate(site.draftUpdatedAt) : "刚刚" : previewVersion ? formatDate(previewVersion.date) : formatDate(site.updatedAt)}{historicalPreview && !showingProduction ? " · 只读" : ""}</span>
<span><Clock3 size={12} />{site.publishedAt ? `线上版本 ${productionVersion?.shortHash || site.publishedCommit?.slice(0, 7)} · ${formatDate(site.publishedAt)}` : "生产环境尚未发布"}</span> <span><Clock3 size={12} />{site.publishedAt ? `线上版本 ${productionVersion?.shortHash || site.publishedCommit?.slice(0, 7)} · ${formatDate(site.publishedAt)}` : "生产环境尚未发布"}</span>
</footer> </footer>
{workspaceDialog?.kind === "discard" && <AppDialog title="放弃未保存修改?" description="全部工作草稿都会被删除,并恢复到最近保存的版本。此操作不能撤销。" confirmLabel="放弃修改" busy={discardDraftMutation.isPending} error={discardDraftMutation.error?.message} tone="danger" icon={<Trash2 size={20} />} onCancel={() => setWorkspaceDialog(undefined)} onConfirm={() => discardDraftMutation.mutate(undefined, { onSuccess: () => setWorkspaceDialog(undefined) })} />}
{workspaceDialog?.kind === "restore" && <AppDialog title={`恢复版本 ${workspaceDialog.version.shortHash}?`} description={`将恢复“${workspaceDialog.version.message}”,并创建一个新的恢复版本;已有历史不会删除。`} confirmLabel="确认恢复" busy={restoreVersionMutation.isPending} error={restoreVersionMutation.error?.message} icon={<RotateCcw size={20} />} onCancel={() => setWorkspaceDialog(undefined)} onConfirm={() => restoreVersionMutation.mutate(workspaceDialog.version.hash, { onSuccess: () => setWorkspaceDialog(undefined) })} />}
{workspaceDialog?.kind === "publish" && <AppDialog title={productionMatchesPreview ? "重新上线当前版本?" : "上线当前版本?"} description={productionMatchesPreview ? "系统会重新构建当前保存版本并更新生产环境。" : "系统会构建当前保存版本,并更新生产环境和已连接的自定义域名。"} confirmLabel={productionMatchesPreview ? "重新上线" : "确认上线"} busy={publishMutation.isPending} error={publishMutation.error?.message} icon={<Rocket size={20} />} onCancel={() => setWorkspaceDialog(undefined)} onConfirm={() => publishMutation.mutate(undefined, { onSuccess: () => setWorkspaceDialog(undefined) })} />}
{workspaceDialog?.kind === "publish-blocked" && <AppDialog title={workspaceDialog.title} description={workspaceDialog.description} confirmLabel={workspaceDialog.goToVersions ? "前往版本" : "知道了"} icon={<Rocket size={20} />} onCancel={() => setWorkspaceDialog(undefined)} onConfirm={() => { if (workspaceDialog.goToVersions) setTab("history"); setWorkspaceDialog(undefined); }} />}
</section>} </section>}
</main>; </main>;
} }
...@@ -683,6 +736,7 @@ function DomainManagement({ site }: { site: SiteInfo }) { ...@@ -683,6 +736,7 @@ function DomainManagement({ site }: { site: SiteInfo }) {
const [copied, setCopied] = useState(""); const [copied, setCopied] = useState("");
const [selectedDomainId, setSelectedDomainId] = useState(""); const [selectedDomainId, setSelectedDomainId] = useState("");
const [addingNew, setAddingNew] = useState(false); const [addingNew, setAddingNew] = useState(false);
const [removeTarget, setRemoveTarget] = useState<DomainBinding>();
const domainsQuery = useQuery({ queryKey: ["domains", site.siteId], queryFn: () => api.domains(site.siteId), refetchInterval: 15000 }); const domainsQuery = useQuery({ queryKey: ["domains", site.siteId], queryFn: () => api.domains(site.siteId), refetchInterval: 15000 });
const refresh = () => queryClient.invalidateQueries({ queryKey: ["domains", site.siteId] }); const refresh = () => queryClient.invalidateQueries({ queryKey: ["domains", site.siteId] });
const addMutation = useMutation({ const addMutation = useMutation({
...@@ -691,7 +745,7 @@ function DomainManagement({ site }: { site: SiteInfo }) { ...@@ -691,7 +745,7 @@ function DomainManagement({ site }: { site: SiteInfo }) {
}); });
const verifyMutation = useMutation({ mutationFn: (domainId: string) => api.verifyDomain(site.siteId, domainId), onSuccess: refresh }); const verifyMutation = useMutation({ mutationFn: (domainId: string) => api.verifyDomain(site.siteId, domainId), onSuccess: refresh });
const primaryMutation = useMutation({ mutationFn: (domainId: string) => api.setPrimaryDomain(site.siteId, domainId), onSuccess: refresh }); const primaryMutation = useMutation({ mutationFn: (domainId: string) => api.setPrimaryDomain(site.siteId, domainId), onSuccess: refresh });
const removeMutation = useMutation({ mutationFn: (domainId: string) => api.removeDomain(site.siteId, domainId), onSuccess: refresh }); const removeMutation = useMutation({ mutationFn: (domainId: string) => api.removeDomain(site.siteId, domainId), onSuccess: async () => { setRemoveTarget(undefined); await refresh(); } });
const copy = async (key: string, value: string) => { const copy = async (key: string, value: string) => {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
setCopied(key); setCopied(key);
...@@ -705,7 +759,7 @@ function DomainManagement({ site }: { site: SiteInfo }) { ...@@ -705,7 +759,7 @@ function DomainManagement({ site }: { site: SiteInfo }) {
} }
}, [domains, selectedDomainId]); }, [domains, selectedDomainId]);
useEffect(() => { useEffect(() => {
setHostname(""); setSelectedDomainId(""); setAddingNew(false); setHostname(""); setSelectedDomainId(""); setAddingNew(false); setRemoveTarget(undefined);
}, [site.siteId]); }, [site.siteId]);
const selectedDomain = domains.find((domain) => domain.domainId === selectedDomainId); const selectedDomain = domains.find((domain) => domain.domainId === selectedDomainId);
...@@ -762,10 +816,11 @@ function DomainManagement({ site }: { site: SiteInfo }) { ...@@ -762,10 +816,11 @@ function DomainManagement({ site }: { site: SiteInfo }) {
{wizardStep === 4 && selectedDomain && <div className="wizard-body wizard-success"><span><CheckCircle2 size={30} /></span><h3>域名已经连接成功</h3><p>现在可以使用 <strong>{selectedDomain.hostname}</strong> 访问官网。以后发布新版本时,这个域名会自动同步。</p><div><a href={selectedDomain.url} target="_blank" rel="noreferrer">打开网站测试 <ExternalLink size={14} /></a>{!selectedDomain.isPrimary && <button type="button" disabled={busy} onClick={() => primaryMutation.mutate(selectedDomain.domainId)}>设为主域名</button>}<button type="button" onClick={() => { setAddingNew(true); setHostname(""); }}><Plus size={14} />再连接一个域名</button></div><small>测试时请确认页面能打开,并且浏览器地址栏显示安全锁标志。</small></div>} {wizardStep === 4 && selectedDomain && <div className="wizard-body wizard-success"><span><CheckCircle2 size={30} /></span><h3>域名已经连接成功</h3><p>现在可以使用 <strong>{selectedDomain.hostname}</strong> 访问官网。以后发布新版本时,这个域名会自动同步。</p><div><a href={selectedDomain.url} target="_blank" rel="noreferrer">打开网站测试 <ExternalLink size={14} /></a>{!selectedDomain.isPrimary && <button type="button" disabled={busy} onClick={() => primaryMutation.mutate(selectedDomain.domainId)}>设为主域名</button>}<button type="button" onClick={() => { setAddingNew(true); setHostname(""); }}><Plus size={14} />再连接一个域名</button></div><small>测试时请确认页面能打开,并且浏览器地址栏显示安全锁标志。</small></div>}
</section> </section>
{domains.length > 0 && <section className="domain-overview"><div className="domain-section-heading"><div><h2>我的域名</h2><p>选择域名可以继续配置、查看状态或测试访问。</p></div><span>{domains.length}</span></div><div>{domains.map((domain) => <DomainOverviewRow key={domain.domainId} domain={domain} selected={domain.domainId === selectedDomainId && !addingNew} busy={busy} onSelect={() => { setSelectedDomainId(domain.domainId); setAddingNew(false); }} onPrimary={() => primaryMutation.mutate(domain.domainId)} onRemove={() => { if (window.confirm(`确定解除 ${domain.hostname} 的绑定吗?`)) removeMutation.mutate(domain.domainId); }} />)}</div></section>} {domains.length > 0 && <section className="domain-overview"><div className="domain-section-heading"><div><h2>我的域名</h2><p>选择域名可以继续配置、查看状态或测试访问。</p></div><span>{domains.length}</span></div><div>{domains.map((domain) => <DomainOverviewRow key={domain.domainId} domain={domain} selected={domain.domainId === selectedDomainId && !addingNew} busy={busy} onSelect={() => { setSelectedDomainId(domain.domainId); setAddingNew(false); }} onPrimary={() => primaryMutation.mutate(domain.domainId)} onRemove={() => { removeMutation.reset(); setRemoveTarget(domain); }} />)}</div></section>}
<ProviderGuides /> <ProviderGuides />
</div> </div>
{removeTarget && <AppDialog title="解除域名绑定?" description={`解除 ${removeTarget.hostname} 后,该域名将停止访问当前官网,系统默认生产链接不受影响。`} confirmLabel="解除绑定" busy={removeMutation.isPending} error={removeMutation.error?.message} tone="danger" icon={<Globe2 size={20} />} onCancel={() => setRemoveTarget(undefined)} onConfirm={() => removeMutation.mutate(removeTarget.domainId)} />}
</section>; </section>;
} }
......
...@@ -75,6 +75,7 @@ ...@@ -75,6 +75,7 @@
.site-management-tools{max-width:1180px;margin:17px auto 13px;display:flex;align-items:center;justify-content:space-between;gap:18px}.site-management-tools label{width:min(360px,100%);height:38px;display:flex;align-items:center;gap:8px;padding:0 11px;color:#9b98a3;background:#fff;border:1px solid #e4e1e9;border-radius:9px}.site-management-tools label:focus-within{color:var(--primary);border-color:#c7b6fb;box-shadow:0 0 0 3px rgba(112,40,255,.06)}.site-management-tools input{width:100%;height:100%;padding:0;background:transparent;border:0;outline:0;font-size:10px}.site-management-tools>span{color:#9a97a2;font-size:9px} .site-management-tools{max-width:1180px;margin:17px auto 13px;display:flex;align-items:center;justify-content:space-between;gap:18px}.site-management-tools label{width:min(360px,100%);height:38px;display:flex;align-items:center;gap:8px;padding:0 11px;color:#9b98a3;background:#fff;border:1px solid #e4e1e9;border-radius:9px}.site-management-tools label:focus-within{color:var(--primary);border-color:#c7b6fb;box-shadow:0 0 0 3px rgba(112,40,255,.06)}.site-management-tools input{width:100%;height:100%;padding:0;background:transparent;border:0;outline:0;font-size:10px}.site-management-tools>span{color:#9a97a2;font-size:9px}
.managed-site-list{max-width:1180px;margin:auto;display:grid;gap:10px}.managed-site-card{min-width:0;min-height:108px;padding:17px;display:grid;grid-template-columns:46px minmax(260px,1fr) auto;align-items:center;gap:14px;background:#fff;border:1px solid #e7e4eb;border-radius:14px;box-shadow:0 7px 24px rgba(41,31,69,.035);transition:.15s ease}.managed-site-card:hover{border-color:#d9d2e7;box-shadow:0 10px 30px rgba(41,31,69,.065)}.managed-site-card.selected{border-color:#d2c3ff;box-shadow:0 0 0 3px rgba(112,40,255,.045)}.managed-site-avatar{width:46px;height:46px;display:grid;place-items:center;color:#6739cd;background:linear-gradient(145deg,#f1edff,#e7e1fa);border-radius:12px;font-size:15px;font-weight:850}.managed-site-card.archived .managed-site-avatar{color:#77727f;background:#efedf2}.managed-site-main{min-width:0}.managed-site-main>div:first-child{display:flex;align-items:center;gap:7px}.managed-site-main strong{font-size:13px}.managed-site-main em,.managed-site-main>div:first-child>span{padding:3px 6px;border-radius:5px;font-size:8px;font-style:normal;font-weight:750}.managed-site-main em{color:var(--primary);background:var(--soft)}.managed-site-main>div:first-child>span.published{color:#177457;background:#e6f7f0}.managed-site-main>div:first-child>span.unpublished{color:#77727f;background:#efedf2}.managed-site-main>div:first-child>span.failed{color:#b23e3e;background:#fff0f0}.managed-site-main>div:first-child>span.archived{color:#68636e;background:#efedf2}.managed-site-main>small{display:block;margin-top:6px;color:#96929d;font-size:9px}.managed-site-states{display:flex;align-items:center;gap:15px;margin-top:11px;color:#706d79;font-size:8px}.managed-site-states>span{display:flex;align-items:center;gap:5px}.managed-site-states i{width:6px;height:6px;border-radius:50%;background:#aaa6b0}.managed-site-states i.ok{background:#25a777}.managed-site-states i.building,.managed-site-states i.creating,.managed-site-states i.publishing{background:#d89b20}.managed-site-states i.failed{background:#d55454}.managed-site-states span.draft{color:#7250bf}.managed-site-states code{font-size:8px} .managed-site-list{max-width:1180px;margin:auto;display:grid;gap:10px}.managed-site-card{min-width:0;min-height:108px;padding:17px;display:grid;grid-template-columns:46px minmax(260px,1fr) auto;align-items:center;gap:14px;background:#fff;border:1px solid #e7e4eb;border-radius:14px;box-shadow:0 7px 24px rgba(41,31,69,.035);transition:.15s ease}.managed-site-card:hover{border-color:#d9d2e7;box-shadow:0 10px 30px rgba(41,31,69,.065)}.managed-site-card.selected{border-color:#d2c3ff;box-shadow:0 0 0 3px rgba(112,40,255,.045)}.managed-site-avatar{width:46px;height:46px;display:grid;place-items:center;color:#6739cd;background:linear-gradient(145deg,#f1edff,#e7e1fa);border-radius:12px;font-size:15px;font-weight:850}.managed-site-card.archived .managed-site-avatar{color:#77727f;background:#efedf2}.managed-site-main{min-width:0}.managed-site-main>div:first-child{display:flex;align-items:center;gap:7px}.managed-site-main strong{font-size:13px}.managed-site-main em,.managed-site-main>div:first-child>span{padding:3px 6px;border-radius:5px;font-size:8px;font-style:normal;font-weight:750}.managed-site-main em{color:var(--primary);background:var(--soft)}.managed-site-main>div:first-child>span.published{color:#177457;background:#e6f7f0}.managed-site-main>div:first-child>span.unpublished{color:#77727f;background:#efedf2}.managed-site-main>div:first-child>span.failed{color:#b23e3e;background:#fff0f0}.managed-site-main>div:first-child>span.archived{color:#68636e;background:#efedf2}.managed-site-main>small{display:block;margin-top:6px;color:#96929d;font-size:9px}.managed-site-states{display:flex;align-items:center;gap:15px;margin-top:11px;color:#706d79;font-size:8px}.managed-site-states>span{display:flex;align-items:center;gap:5px}.managed-site-states i{width:6px;height:6px;border-radius:50%;background:#aaa6b0}.managed-site-states i.ok{background:#25a777}.managed-site-states i.building,.managed-site-states i.creating,.managed-site-states i.publishing{background:#d89b20}.managed-site-states i.failed{background:#d55454}.managed-site-states span.draft{color:#7250bf}.managed-site-states code{font-size:8px}
.managed-site-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px}.managed-site-actions button,.managed-site-actions a{height:32px;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 10px;color:#625e6b;background:#fff;border:1px solid #dfdbe5;border-radius:8px;font-size:8px;font-weight:750;text-decoration:none;white-space:nowrap}.managed-site-actions button:hover,.managed-site-actions a:hover{color:var(--primary);background:#faf9ff;border-color:#cfc1fa}.managed-site-actions button.primary{color:white;background:var(--gradient);border:0}.managed-site-actions button.archive{width:32px;padding:0;color:#8b6570}.managed-site-actions button.delete{color:#b13c3c}.managed-site-actions button.restore{color:#1a755a}.managed-sites-empty{min-height:310px;display:flex;align-items:center;justify-content:center;flex-direction:column;color:#aaa6b1;background:#fff;border:1px dashed #dedbe3;border-radius:14px}.managed-sites-empty h2{margin:14px 0 5px;color:#55515e;font-size:15px}.managed-sites-empty p{margin:0;font-size:10px} .managed-site-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px}.managed-site-actions button,.managed-site-actions a{height:32px;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 10px;color:#625e6b;background:#fff;border:1px solid #dfdbe5;border-radius:8px;font-size:8px;font-weight:750;text-decoration:none;white-space:nowrap}.managed-site-actions button:hover,.managed-site-actions a:hover{color:var(--primary);background:#faf9ff;border-color:#cfc1fa}.managed-site-actions button.primary{color:white;background:var(--gradient);border:0}.managed-site-actions button.archive{width:32px;padding:0;color:#8b6570}.managed-site-actions button.delete{color:#b13c3c}.managed-site-actions button.restore{color:#1a755a}.managed-sites-empty{min-height:310px;display:flex;align-items:center;justify-content:center;flex-direction:column;color:#aaa6b1;background:#fff;border:1px dashed #dedbe3;border-radius:14px}.managed-sites-empty h2{margin:14px 0 5px;color:#55515e;font-size:15px}.managed-sites-empty p{margin:0;font-size:10px}
.confirm-backdrop{position:fixed;z-index:100;inset:0;display:grid;place-items:center;padding:20px;background:rgba(27,22,39,.36);backdrop-filter:blur(4px)}.confirm-dialog{width:min(430px,100%);padding:27px;background:#fff;border:1px solid #e4dfeb;border-radius:18px;box-shadow:0 24px 75px rgba(24,14,50,.25)}.confirm-icon{display:grid;place-items:center;width:42px;height:42px;color:#9b5d19;background:#fff5df;border-radius:12px}.confirm-icon.danger{color:#b43d3d;background:#fff0f0}.confirm-dialog h2{margin:17px 0 8px;font-size:19px}.confirm-dialog p{margin:0;color:#77737f;font-size:11px;line-height:1.75}.confirm-dialog>label{display:block;margin-top:18px;color:#686471;font-size:10px;line-height:1.6}.confirm-dialog>label strong{color:#27242d}.confirm-dialog>label input{width:100%;height:39px;margin-top:7px;padding:0 11px;border:1px solid #ddd9e2;border-radius:9px;outline:0}.confirm-dialog>label input:focus{border-color:#c2aff8;box-shadow:0 0 0 3px rgba(112,40,255,.07)}.confirm-dialog>div:last-child{display:flex;justify-content:flex-end;gap:8px;margin-top:23px}.confirm-dialog>div:last-child button{height:36px;display:flex;align-items:center;gap:6px;padding:0 13px;color:#625e6a;background:#fff;border:1px solid #ded9e4;border-radius:9px;font-size:9px;font-weight:750}.confirm-dialog>div:last-child button.danger{color:white;background:#bd4141;border-color:#bd4141} .confirm-backdrop{position:fixed;z-index:100;inset:0;display:grid;place-items:center;padding:20px;background:rgba(27,22,39,.36);backdrop-filter:blur(4px)}.confirm-dialog{width:min(430px,100%);padding:27px;background:#fff;border:1px solid #e4dfeb;border-radius:18px;box-shadow:0 24px 75px rgba(24,14,50,.25)}.confirm-icon{display:grid;place-items:center;width:42px;height:42px;color:#6845c5;background:#f2edff;border-radius:12px}.confirm-icon.danger{color:#b43d3d;background:#fff0f0}.confirm-icon.success{color:#16805f;background:#e8f8f2}.confirm-dialog h2{margin:17px 0 8px;font-size:19px}.confirm-dialog p{margin:0;color:#77737f;font-size:11px;line-height:1.75}.confirm-dialog>label{display:block;margin-top:18px;color:#686471;font-size:10px;line-height:1.6}.confirm-dialog>label strong{color:#27242d}.confirm-dialog>label input{width:100%;height:39px;margin-top:7px;padding:0 11px;border:1px solid #ddd9e2;border-radius:9px;outline:0}.confirm-dialog>label input:focus{border-color:#c2aff8;box-shadow:0 0 0 3px rgba(112,40,255,.07)}.dialog-error{display:flex;align-items:flex-start;gap:6px;margin-top:14px;padding:9px 10px;color:#a83a3a;background:#fff1f1;border-radius:8px;font-size:9px;line-height:1.5}.dialog-error svg{flex:0 0 auto;margin-top:1px}.confirm-dialog>div:last-child{display:flex;justify-content:flex-end;gap:8px;margin-top:23px}.confirm-dialog>div:last-child button{height:36px;display:flex;align-items:center;gap:6px;padding:0 13px;color:#625e6a;background:#fff;border:1px solid #ded9e4;border-radius:9px;font-size:9px;font-weight:750}.confirm-dialog>div:last-child button.primary{color:#fff;background:var(--gradient);border-color:transparent}.confirm-dialog>div:last-child button.danger{color:white;background:#bd4141;border-color:#bd4141}.confirm-dialog>div:last-child button:disabled{opacity:.48;cursor:not-allowed}
.toolbar-actions .publish-button.unavailable,.toolbar-actions .publish-button.unavailable:hover{color:#8e9099;background:#efeff4;border:1px solid #e1e1e7;box-shadow:none;cursor:pointer}
@media(max-width:1000px){.managed-site-card{grid-template-columns:46px minmax(0,1fr)}.managed-site-actions{grid-column:2;justify-content:flex-start;flex-wrap:wrap}.managed-site-states{flex-wrap:wrap}} @media(max-width:1000px){.managed-site-card{grid-template-columns:46px minmax(0,1fr)}.managed-site-actions{grid-column:2;justify-content:flex-start;flex-wrap:wrap}.managed-site-states{flex-wrap:wrap}}
@media(max-width:800px){.site-management-workspace{grid-template-columns:52px 1fr}.site-management-page{padding:28px 16px 42px}.site-management-header{align-items:flex-start;flex-direction:column}.site-management-filters{overflow-x:auto}.site-management-tools{align-items:flex-start;flex-direction:column}.site-management-tools label{width:100%}.managed-site-card{grid-template-columns:38px minmax(0,1fr);padding:13px}.managed-site-avatar{width:38px;height:38px}.managed-site-states{gap:8px}.managed-site-actions a,.managed-site-actions button{height:30px}.site-management-header>button{height:36px}} @media(max-width:800px){.site-management-workspace{grid-template-columns:52px 1fr}.site-management-page{padding:28px 16px 42px}.site-management-header{align-items:flex-start;flex-direction:column}.site-management-filters{overflow-x:auto}.site-management-tools{align-items:flex-start;flex-direction:column}.site-management-tools label{width:100%}.managed-site-card{grid-template-columns:38px minmax(0,1fr);padding:13px}.managed-site-avatar{width:38px;height:38px}.managed-site-states{gap:8px}.managed-site-actions a,.managed-site-actions button{height:30px}.site-management-header>button{height:36px}}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment