Commit 9b34b595 authored by xuchentao's avatar xuchentao

feat: add site management and recycle bin

parent 57609ddd
......@@ -90,6 +90,7 @@ export const config = {
export const runtimePaths = {
sites: config.sitesDir,
archivedSites: path.join(config.sitesDir, ".trash"),
builds: path.join(config.runtimeDir, "builds"),
uploads: path.join(config.runtimeDir, "uploads"),
pnpmStore: path.join(config.runtimeDir, "pnpm-store"),
......
......@@ -18,6 +18,7 @@ import { CreateSiteService } from "./sites/create-site.js";
import { SiteAgentService } from "./sites/site-agent-service.js";
import { SiteRepository } from "./sites/site-repository.js";
import { SiteVersionService } from "./sites/site-version-service.js";
import { SiteLifecycleService } from "./sites/site-lifecycle-service.js";
import { DomainDeploymentService } from "./domains/domain-deployment-service.js";
import { DomainRepository } from "./domains/domain-repository.js";
import { DomainRoutingConfig } from "./domains/domain-routing-config.js";
......@@ -42,8 +43,10 @@ const siteAgent = new SiteAgentService(sites, git, new AgentLoop());
const versions = new SiteVersionService(sites, git, builds, previews);
const domainRepository = new DomainRepository();
const domainRouting = new DomainRoutingConfig(domainRepository);
const domainProvider = createDomainProvider();
const domainDeployments = new DomainDeploymentService(sites, domainRepository, git, builds, domainRouting);
const domainService = new DomainService(sites, domainRepository, domainRouting, createDomainProvider());
const domainService = new DomainService(sites, domainRepository, domainRouting, domainProvider);
const siteLifecycle = new SiteLifecycleService(sites, previews, domainRepository, domainRouting, domainProvider);
app.addHook("onRequest", async (request, reply) => {
if (!request.url.startsWith("/api/") || request.url === "/api/health" || request.url === "/api/login") return;
......@@ -67,8 +70,15 @@ app.post("/api/logout", async (request) => {
app.get("/api/session", async (request) => request.auth!);
app.get("/api/sites", async (request) => sites.list(requireTenant(request.auth)));
app.get("/api/archived-sites", async (request) => sites.listArchived(requireTenant(request.auth)));
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId", async (request) => sites.get(requireTenant(request.auth), request.params.siteId));
app.post("/api/sites", async (request, reply) => reply.code(201).send(await createSite.execute(requireTenant(request.auth), createSiteSchema.parse(request.body))));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/archive", async (request) => siteLifecycle.archive(requireTenant(request.auth), request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/archived-sites/:siteId/restore", async (request) => siteLifecycle.restore(requireTenant(request.auth), request.params.siteId));
app.delete<{ Params: { siteId: string } }>("/api/archived-sites/:siteId", async (request, reply) => {
await siteLifecycle.permanentlyDelete(requireTenant(request.auth), request.params.siteId);
return reply.code(204).send();
});
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/chat", async (request) => {
const body = chatSchema.parse(request.body);
return siteAgent.execute(requireTenant(request.auth), request.params.siteId, body.message);
......@@ -168,6 +178,7 @@ app.setErrorHandler((error, _request, reply) => {
});
await sites.ensureRuntime();
await siteLifecycle.purgeExpired().catch((error) => app.log.warn(error));
await domainRouting.sync();
await rm(runtimePaths.builds, { recursive: true, force: true });
await mkdir(runtimePaths.builds, { recursive: true });
......@@ -219,6 +230,7 @@ const checkPendingDomains = async () => {
} finally { checkingDomains = false; }
};
setInterval(() => void checkPendingDomains(), config.domains.checkIntervalMs).unref();
setInterval(() => void siteLifecycle.purgeExpired().catch((error) => app.log.warn(error)), 24 * 60 * 60 * 1000).unref();
function bearerToken(header: string | undefined): string | undefined {
const match = header?.match(/^Bearer\s+(.+)$/i);
......
import path from "node:path";
import { mkdir, rename, rm, stat } from "node:fs/promises";
import type { SiteInfo } from "@webagent/shared";
import { config, runtimePaths } from "../config.js";
import type { DomainProvider } from "../domains/domain-provider.js";
import { DomainRepository } from "../domains/domain-repository.js";
import { DomainRoutingConfig } from "../domains/domain-routing-config.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { SiteRepository } from "./site-repository.js";
export class SiteLifecycleService {
constructor(
private readonly sites: SiteRepository,
private readonly previews: PreviewProcessManager,
private readonly domains: DomainRepository,
private readonly routing: DomainRoutingConfig,
private readonly provider: DomainProvider,
) {}
async archive(tenantId: string, siteId: string): Promise<SiteInfo> {
const site = await this.sites.get(tenantId, siteId);
const archivedAt = new Date();
const bindings = await this.domains.list(tenantId, siteId);
for (const domain of bindings) await this.provider.remove(domain);
if (bindings.length) {
await this.domains.updateSite(tenantId, siteId, () => ({
providerHostnameId: undefined,
providerStatus: undefined,
deploymentStatus: "pending",
deployedCommit: undefined,
tlsStatus: this.provider.initialTlsStatus(),
}));
}
await this.sites.update(tenantId, siteId, {
lifecycleStatus: "archived",
archivedAt: archivedAt.toISOString(),
deleteAfter: new Date(archivedAt.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(),
});
await this.previews.stop(tenantId, siteId);
await this.move(this.sites.getSiteRoot(tenantId, siteId), this.sites.getArchivedSiteRoot(tenantId, siteId), true);
await Promise.all(this.artifactRoots().map(({ active, archived }) => this.move(path.join(active, tenantId, siteId), path.join(archived, tenantId, siteId), false)));
await this.routing.sync();
return this.sites.getArchived(tenantId, siteId);
}
async restore(tenantId: string, siteId: string): Promise<SiteInfo> {
await this.sites.getArchived(tenantId, siteId);
if (await this.exists(this.sites.getSiteRoot(tenantId, siteId))) throw Object.assign(new Error("已有同标识的网站,无法恢复"), { statusCode: 409 });
await this.move(this.sites.getArchivedSiteRoot(tenantId, siteId), this.sites.getSiteRoot(tenantId, siteId), true);
await Promise.all(this.artifactRoots().map(({ active, archived }) => this.move(path.join(archived, tenantId, siteId), path.join(active, tenantId, siteId), false)));
const restored = await this.sites.update(tenantId, siteId, { lifecycleStatus: "active", archivedAt: undefined, deleteAfter: undefined });
const previewPath = path.join(config.previewsDir, tenantId, siteId);
if (await this.exists(previewPath)) {
await this.previews.start(tenantId, siteId, previewPath, restored.previewPort).catch(async (error) => {
await this.sites.update(tenantId, siteId, { status: "failed", lastError: error instanceof Error ? error.message : String(error) });
});
}
await this.routing.sync();
return this.sites.get(tenantId, siteId);
}
async permanentlyDelete(tenantId: string, siteId: string): Promise<void> {
await this.sites.getArchived(tenantId, siteId);
await Promise.all([
rm(this.sites.getArchivedSiteRoot(tenantId, siteId), { recursive: true, force: true }),
...this.artifactRoots().flatMap(({ active, archived }) => [
rm(path.join(active, tenantId, siteId), { recursive: true, force: true }),
rm(path.join(archived, tenantId, siteId), { recursive: true, force: true }),
]),
]);
}
async purgeExpired(now = new Date()): Promise<number> {
const expired = (await this.sites.listAllArchived()).filter((site) => site.deleteAfter && new Date(site.deleteAfter).getTime() <= now.getTime());
for (const site of expired) await this.permanentlyDelete(site.tenantId, site.siteId);
return expired.length;
}
private artifactRoots(): Array<{ active: string; archived: string }> {
return [config.previewsDir, config.productionDir, config.customDomainsDir].map((active) => ({ active, archived: path.join(active, ".trash") }));
}
private async move(source: string, destination: string, required: boolean): Promise<void> {
if (!await this.exists(source)) {
if (required) throw Object.assign(new Error("站点不存在"), { statusCode: 404 });
return;
}
await mkdir(path.dirname(destination), { recursive: true });
await rename(source, destination);
}
private async exists(target: string): Promise<boolean> {
return stat(target).then(() => true).catch(() => false);
}
}
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rename, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
......@@ -32,5 +32,11 @@ test("repository paths and queries are tenant-scoped", async () => {
await assert.rejects(() => sites.get(tenantA, siteB), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
assert.equal(sites.getProjectPath(tenantA, siteA), path.join(directory, "sites", tenantA, siteA, "project"));
assert.notEqual(sites.getSiteRoot(tenantA, siteA), sites.getSiteRoot(tenantB, siteA));
await mkdir(path.dirname(sites.getArchivedSiteRoot(tenantA, siteA)), { recursive: true });
await rename(sites.getSiteRoot(tenantA, siteA), sites.getArchivedSiteRoot(tenantA, siteA));
assert.deepEqual(await sites.list(tenantA), []);
assert.deepEqual((await sites.listArchived(tenantA)).map((site) => site.siteId), [siteA]);
assert.equal((await sites.getArchived(tenantA, siteA)).lifecycleStatus, "archived");
} finally { await rm(directory, { recursive: true, force: true }); }
});
......@@ -22,6 +22,12 @@ export class SiteRepository {
return path.join(runtimePaths.sites, tenantId, siteId);
}
getArchivedSiteRoot(tenantId: string, siteId: string): string {
this.assertTenantId(tenantId);
this.assertSiteId(siteId);
return path.join(runtimePaths.archivedSites, tenantId, siteId);
}
getProjectPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(tenantId, siteId), "project");
}
......@@ -56,6 +62,30 @@ export class SiteRepository {
return results.filter((site): site is SiteInfo => site !== null).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
async getArchived(tenantId: string, siteId: string): Promise<SiteInfo> {
const raw = await readFile(path.join(this.getArchivedSiteRoot(tenantId, siteId), "metadata", "site.json"), "utf8");
const stored = JSON.parse(raw) as SiteInfo;
if (stored.tenantId !== tenantId || stored.siteId !== siteId) throw Object.assign(new Error("站点不存在"), { statusCode: 404 });
return { ...stored, lifecycleStatus: "archived", previewUrl: "", productionUrl: undefined };
}
async listArchived(tenantId: string): Promise<SiteInfo[]> {
await this.ensureRuntime();
const root = path.join(runtimePaths.archivedSites, tenantId);
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
const results = await Promise.all(entries.filter((entry) => entry.isDirectory() && /^site_[a-z0-9]+$/.test(entry.name)).map(async (entry) => {
try { return await this.getArchived(tenantId, entry.name); } catch { return null; }
}));
return results.filter((site): site is SiteInfo => site !== null).sort((a, b) => (b.archivedAt || b.updatedAt).localeCompare(a.archivedAt || a.updatedAt));
}
async listAllArchived(): Promise<SiteInfo[]> {
await this.ensureRuntime();
const tenants = await readdir(runtimePaths.archivedSites, { withFileTypes: true }).catch(() => []);
const groups = await Promise.all(tenants.filter((entry) => entry.isDirectory() && /^tenant_[a-z0-9_]+$/.test(entry.name)).map((entry) => this.listArchived(entry.name)));
return groups.flat().sort((a, b) => (b.archivedAt || b.updatedAt).localeCompare(a.archivedAt || a.updatedAt));
}
async listAll(): Promise<SiteInfo[]> {
await this.ensureRuntime();
const tenants = await readdir(runtimePaths.sites, { withFileTypes: true }).catch(() => []);
......
import { useEffect, useRef, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft, ArrowRight, BookOpen, Bot, Check, CheckCircle2, ChevronDown, Clock3, Code2, Copy, ExternalLink,
Globe2, History, Laptop, Link2, LoaderCircle, MessageSquareText, Monitor,
Plus, RefreshCw, Rocket, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Trash2, Undo2,
Archive, ArrowLeft, ArrowRight, BookOpen, Bot, Check, CheckCircle2, ChevronDown, Clock3, Code2, Copy, ExternalLink,
Globe2, History, Laptop, LayoutGrid, Link2, LoaderCircle, MessageSquareText, Monitor,
Plus, RefreshCw, Rocket, RotateCcw, Search, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Trash2, Undo2,
WandSparkles, X,
} from "lucide-react";
import type { CreateSiteInput, CreateTenantInput, DomainBinding, SessionInfo, SiteInfo, TenantAdminInfo } from "@webagent/shared";
......@@ -19,6 +19,7 @@ export default function App() {
const healthQuery = useQuery({ queryKey: ["health"], queryFn: api.health });
const [currentSiteId, setCurrentSiteId] = useState(() => localStorage.getItem("webagent-current-site") || "");
const [creating, setCreating] = useState(false);
const [managing, setManaging] = useState(false);
useEffect(() => {
const invalidate = () => { setAuthenticated(false); setCreating(false); queryClient.clear(); };
......@@ -41,7 +42,7 @@ export default function App() {
}, [sessionQuery.isError]);
const openSite = (site: SiteInfo) => {
setCurrentSiteId(site.siteId); setCreating(false);
setCurrentSiteId(site.siteId); setCreating(false); setManaging(false);
queryClient.invalidateQueries({ queryKey: ["sites"] });
};
......@@ -56,8 +57,11 @@ export default function App() {
if (sessionQuery.isLoading) return <Splash />;
if (sessionQuery.data?.accountType === "system_admin") return <AdminWorkspace session={sessionQuery.data} onLogout={logout} />;
if (sitesQuery.isLoading) return <Splash />;
if (managing) {
return <SiteManagementWorkspace sites={sitesQuery.data || []} currentSiteId={currentSiteId} onEdit={(site) => openSite(site)} onCreate={() => { setManaging(false); setCreating(true); }} onBack={() => setManaging(false)} onCurrentSiteChange={setCurrentSiteId} onLogout={logout} />;
}
if (creating || !sitesQuery.data?.length) {
return <CreateSiteWorkspace sites={sitesQuery.data || []} currentSiteId={currentSiteId} onCreated={openSite} onCancel={sitesQuery.data?.length ? () => setCreating(false) : undefined} onSelectSite={(id) => { setCurrentSiteId(id); setCreating(false); }} onLogout={logout} />;
return <CreateSiteWorkspace sites={sitesQuery.data || []} currentSiteId={currentSiteId} onCreated={openSite} onCancel={sitesQuery.data?.length ? () => setCreating(false) : undefined} onSelectSite={(id) => { setCurrentSiteId(id); setCreating(false); }} onManage={() => setManaging(true)} onLogout={logout} />;
}
return <Workspace
siteId={currentSiteId}
......@@ -65,6 +69,7 @@ export default function App() {
agentMode={healthQuery.data?.agentMode || "local"}
onSelectSite={setCurrentSiteId}
onCreate={() => setCreating(true)}
onManage={() => setManaging(true)}
onLogout={logout}
/>;
}
......@@ -251,13 +256,13 @@ function RailSettings({ onLogout }: { onLogout: () => void }) {
</div>;
}
function CreateSiteWorkspace({ sites, currentSiteId, onCreated, onCancel, onSelectSite, onLogout }: { sites: SiteInfo[]; currentSiteId: string; onCreated: (site: SiteInfo) => void; onCancel?: () => void; onSelectSite: (id: string) => void; onLogout: () => void }) {
function CreateSiteWorkspace({ sites, currentSiteId, onCreated, onCancel, onSelectSite, onManage, onLogout }: { sites: SiteInfo[]; currentSiteId: string; onCreated: (site: SiteInfo) => void; onCancel?: () => void; onSelectSite: (id: string) => void; onManage: () => void; onLogout: () => void }) {
const selectedSite = sites.find((site) => site.siteId === currentSiteId) || sites[0];
return <main className="workspace create-workspace">
<aside className="rail">
<Logo compact />
<button className="rail-create-top active" title="创建官网" data-tooltip="创建官网" aria-label="创建官网"><Plus size={18} /></button>
<div className="rail-nav"><button title="Agent 工作台" data-tooltip="Agent 工作台" aria-label="Agent 工作台" onClick={onCancel}><Monitor size={18} /></button></div>
<div className="rail-nav"><button title="Agent 工作台" data-tooltip="Agent 工作台" aria-label="Agent 工作台" onClick={onCancel}><Monitor size={18} /></button><button title="网站管理" data-tooltip="网站管理" aria-label="网站管理" onClick={onManage}><LayoutGrid size={18} /></button></div>
<RailSettings onLogout={onLogout} />
</aside>
<section className="control-panel create-control-panel">
......@@ -312,7 +317,91 @@ function SiteSwitcher({ site, sites, onSelect }: { site: SiteInfo; sites: SiteIn
</div>;
}
function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onLogout }: { siteId: string; sites: SiteInfo[]; agentMode: "model" | "local"; onSelectSite: (id: string) => void; onCreate: () => void; onLogout: () => void }) {
type SiteManagementFilter = "all" | "published" | "unpublished" | "failed" | "archived";
function SiteManagementWorkspace({ sites, currentSiteId, onEdit, onCreate, onBack, onCurrentSiteChange, onLogout }: { sites: SiteInfo[]; currentSiteId: string; onEdit: (site: SiteInfo) => void; onCreate: () => void; onBack: () => void; onCurrentSiteChange: (siteId: string) => void; onLogout: () => void }) {
const queryClient = useQueryClient();
const archivedQuery = useQuery({ queryKey: ["archived-sites"], queryFn: api.archivedSites });
const [filter, setFilter] = useState<SiteManagementFilter>("all");
const [search, setSearch] = useState("");
const [notice, setNotice] = useState("");
const [archiveTarget, setArchiveTarget] = useState<SiteInfo>();
const [deleteTarget, setDeleteTarget] = useState<SiteInfo>();
const [deleteConfirmation, setDeleteConfirmation] = useState("");
const refresh = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["sites"] }),
queryClient.invalidateQueries({ queryKey: ["archived-sites"] }),
]);
};
const archiveMutation = useMutation({
mutationFn: (siteId: string) => api.archiveSite(siteId),
onSuccess: async (archived) => {
if (currentSiteId === archived.siteId) onCurrentSiteChange(sites.find((site) => site.siteId !== archived.siteId)?.siteId || "");
setArchiveTarget(undefined); setNotice(`“${archived.name}”已移入回收站,可在 30 天内恢复。`);
await refresh();
},
});
const restoreMutation = useMutation({
mutationFn: (siteId: string) => api.restoreSite(siteId),
onSuccess: async (restored) => { setNotice(`“${restored.name}”已恢复。`); setFilter("all"); await refresh(); },
});
const deleteMutation = useMutation({
mutationFn: (siteId: string) => api.deleteSite(siteId),
onSuccess: async () => { setNotice(`“${deleteTarget?.name || "网站"}”已永久删除。`); setDeleteTarget(undefined); setDeleteConfirmation(""); await refresh(); },
});
const archived = archivedQuery.data || [];
const isFailed = (site: SiteInfo) => site.status === "failed" || site.publishStatus === "failed" || Boolean(site.lastError || site.lastPublishError);
const counts: Record<SiteManagementFilter, number> = {
all: sites.length,
published: sites.filter((site) => Boolean(site.publishedCommit)).length,
unpublished: sites.filter((site) => !site.publishedCommit).length,
failed: sites.filter(isFailed).length,
archived: archived.length,
};
const source = filter === "archived" ? archived : sites.filter((site) => filter === "all" || (filter === "published" ? Boolean(site.publishedCommit) : filter === "unpublished" ? !site.publishedCommit : isFailed(site)));
const keyword = search.trim().toLowerCase();
const visible = source.filter((site) => !keyword || `${site.name} ${site.industry} ${site.siteId}`.toLowerCase().includes(keyword));
const pendingError = archiveMutation.error || restoreMutation.error || deleteMutation.error;
return <main className="site-management-workspace">
<aside className="rail">
<Logo compact />
<button className="rail-create-top" onClick={onCreate} title="创建官网" data-tooltip="创建官网" aria-label="创建官网"><Plus size={18} /></button>
<div className="rail-nav"><button onClick={onBack} title="Agent 工作台" data-tooltip="Agent 工作台" aria-label="Agent 工作台"><Monitor size={18} /></button><button className="active" title="网站管理" data-tooltip="网站管理" aria-label="网站管理"><LayoutGrid size={18} /></button></div>
<RailSettings onLogout={onLogout} />
</aside>
<section className="site-management-page">
<header className="site-management-header"><div><span>WEBSITE MANAGEMENT</span><h1>网站管理</h1><p>集中查看和管理当前租户下的所有网站与发布环境。</p></div><button type="button" onClick={onCreate}><Plus size={16} />创建新网站</button></header>
{notice && <div className="management-notice"><CheckCircle2 size={16} /><span>{notice}</span><button type="button" onClick={() => setNotice("")}><X size={14} /></button></div>}
{pendingError && <div className="form-error"><X size={15} />{pendingError.message}</div>}
<nav className="site-management-filters" aria-label="网站状态筛选">{([
["all", "全部"], ["published", "已上线"], ["unpublished", "未上线"], ["failed", "异常"], ["archived", "回收站"],
] 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="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.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>
</section>
{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>}
</main>;
}
function ManagedSiteCard({ site, selected, busy, onEdit, onArchive }: { site: SiteInfo; selected: boolean; busy: boolean; onEdit: () => void; onArchive: () => void }) {
const failed = site.status === "failed" || site.publishStatus === "failed" || Boolean(site.lastError || site.lastPublishError);
return <article className={`managed-site-card ${selected ? "selected" : ""}`}><span className="managed-site-avatar">{site.name.slice(0, 1)}</span><div className="managed-site-main"><div><strong>{site.name}</strong>{selected && <em>当前网站</em>}<span className={failed ? "failed" : site.publishedCommit ? "published" : "unpublished"}>{failed ? "存在异常" : site.publishedCommit ? "已上线" : "未上线"}</span></div><small>{site.industry} · 更新于 {formatDate(site.updatedAt)}</small><div className="managed-site-states"><span><i className={site.status === "ready" ? "ok" : site.status} />测试环境:{site.lastError ? "异常" : site.status === "ready" ? "正常" : site.status === "building" ? "构建中" : site.status === "creating" ? "创建中" : "异常"}</span><span><i className={site.publishedCommit && !site.lastPublishError ? "ok" : site.publishStatus} />生产环境:{site.lastPublishError ? "异常" : site.publishedCommit ? "正常" : "未发布"}</span>{site.draftBaseCommit && <span className="draft"><Sparkles size={11} />存在工作草稿</span>}</div></div><div className="managed-site-actions"><button className="primary" type="button" onClick={onEdit}>进入编辑</button><a href={site.previewUrl} target="_blank" rel="noreferrer"><Monitor size={13} />测试预览</a>{site.publishedCommit && site.productionUrl && <a href={site.productionUrl} target="_blank" rel="noreferrer"><Globe2 size={13} />线上网站</a>}<button className="archive" type="button" disabled={busy} onClick={onArchive} title="移入回收站"><Archive size={14} /></button></div></article>;
}
function ArchivedSiteCard({ site, busy, onRestore, onDelete }: { site: SiteInfo; busy: boolean; onRestore: () => void; onDelete: () => void }) {
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>;
}
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>;
}
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 siteQuery = useQuery({ queryKey: ["site", siteId], queryFn: () => api.site(siteId) });
const historyQuery = useQuery({ queryKey: ["history", siteId], queryFn: () => api.history(siteId) });
......@@ -421,7 +510,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onLogout
<aside className="rail">
<Logo compact />
<button className="rail-create-top" onClick={onCreate} title="创建官网" data-tooltip="创建官网" aria-label="创建官网"><Plus size={18} /></button>
<div className="rail-nav"><button className={tab !== "domains" ? "active" : ""} onClick={() => setTab("chat")} title="Agent 工作台" data-tooltip="Agent 工作台" aria-label="Agent 工作台"><Monitor size={18} /></button></div>
<div className="rail-nav"><button className={tab !== "domains" ? "active" : ""} onClick={() => setTab("chat")} title="Agent 工作台" data-tooltip="Agent 工作台" aria-label="Agent 工作台"><Monitor size={18} /></button><button onClick={onManage} title="网站管理" data-tooltip="网站管理" aria-label="网站管理"><LayoutGrid size={18} /></button></div>
<RailSettings onLogout={onLogout} />
</aside>
<section className="control-panel">
......
......@@ -24,8 +24,12 @@ export const api = {
session: () => request<SessionInfo>("/api/session"),
health: () => request<{ status: string; agentMode: "model" | "local" }>("/api/health"),
sites: () => request<SiteInfo[]>("/api/sites"),
archivedSites: () => request<SiteInfo[]>("/api/archived-sites"),
site: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId),
createSite: (input: CreateSiteInput) => request<SiteInfo>("/api/sites", { method: "POST", body: JSON.stringify(input) }),
archiveSite: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId + "/archive", { method: "POST" }),
restoreSite: (siteId: string) => request<SiteInfo>("/api/archived-sites/" + siteId + "/restore", { method: "POST" }),
deleteSite: (siteId: string) => request<void>("/api/archived-sites/" + siteId, { method: "DELETE" }),
chat: (siteId: string, message: string) => request<ChatResult>("/api/sites/" + siteId + "/chat", { method: "POST", body: JSON.stringify({ message }) }),
history: (siteId: string) => request<GitHistoryItem[]>("/api/sites/" + siteId + "/history"),
previewVersion: (siteId: string, commit: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/preview-version", { method: "POST", body: JSON.stringify({ commit }) }),
......
......@@ -65,3 +65,14 @@
.admin-page{min-height:100vh;background:#f6f6fa;color:#252333}.admin-topbar{height:72px;padding:0 34px;display:flex;align-items:center;justify-content:space-between;background:#fff;border-bottom:1px solid #ebe9f1}.admin-topbar>div{display:flex;align-items:center;gap:10px}.admin-topbar>div>span:nth-child(2){display:flex;flex-direction:column}.admin-topbar small{color:#8a8795;font-size:11px}.admin-topbar button{margin-left:14px;padding:8px 12px;display:flex;align-items:center;gap:6px;border:1px solid #e3e0ea;border-radius:9px;background:#fff;color:#666273}.admin-layout{min-height:calc(100vh - 73px);display:grid;grid-template-columns:286px 1fr}.admin-sidebar{padding:28px 20px;background:#fff;border-right:1px solid #ebe9f1}.admin-sidebar>div{padding:0 8px 18px;display:flex;justify-content:space-between;color:#706c7b;font-size:13px}.admin-sidebar>div strong{padding:2px 7px;border-radius:20px;background:#f0edf7;color:#6d43cf}.admin-create-button{width:100%;padding:11px 14px;display:flex;align-items:center;justify-content:center;gap:7px;border:0;border-radius:10px;background:#6d3fe0;color:#fff;font-weight:700}.admin-sidebar nav{margin-top:18px;display:grid;gap:5px}.admin-sidebar nav button{width:100%;padding:10px;display:grid;grid-template-columns:36px 1fr 8px;align-items:center;gap:9px;text-align:left;border:1px solid transparent;border-radius:11px;background:transparent}.admin-sidebar nav button.active{background:#f5f1ff;border-color:#e7dcff}.admin-sidebar nav button>span:first-child{width:34px;height:34px;display:grid;place-items:center;border-radius:9px;background:#ece8f6;color:#6337c8;font-weight:800}.admin-sidebar nav button>span:nth-child(2){min-width:0;display:flex;flex-direction:column}.admin-sidebar nav strong,.admin-sidebar nav small{overflow:hidden;text-overflow:ellipsis}.admin-sidebar nav small{margin-top:3px;color:#9894a1}.admin-sidebar nav i{width:7px;height:7px;border-radius:50%}.admin-sidebar nav i.enabled{background:#35ae75}.admin-sidebar nav i.disabled{background:#bbb7c1}.admin-content{max-width:1040px;width:100%;padding:48px 56px;margin:0 auto}.admin-heading{display:flex;align-items:flex-start;justify-content:space-between}.admin-heading>div>span{color:#7548dd;font-size:11px;font-weight:800;letter-spacing:.14em}.admin-heading h1{margin:6px 0 8px;font-size:30px}.admin-heading p{margin:0;color:#85818f}.admin-heading code{font-size:12px}.admin-heading>button{border:0;background:transparent}.admin-tenant-heading h1{display:flex;align-items:center;gap:10px}.admin-tenant-heading h1 em{padding:4px 8px;border-radius:6px;background:#eee7ff;color:#6940c5;font-size:11px;font-style:normal}.admin-tenant-heading>button{padding:9px 14px;border-radius:9px;font-weight:700}.admin-tenant-heading>button.disable{background:#fff0f0;color:#bf4545}.admin-tenant-heading>button.enable{background:#e9f8f0;color:#27835a}.admin-stats{margin:28px 0 22px;display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.admin-stats article{padding:22px;border:1px solid #e8e5ed;border-radius:14px;background:#fff;display:flex;flex-direction:column;gap:5px}.admin-stats strong{font-size:24px}.admin-stats strong.ok{font-size:17px;color:#27835a}.admin-stats strong.off{font-size:17px;color:#b84747}.admin-stats span{color:#8c8896;font-size:12px}.admin-card{padding:26px;border:1px solid #e8e5ed;border-radius:16px;background:#fff;box-shadow:0 12px 40px rgba(47,36,76,.04)}.admin-card+.admin-card{margin-top:18px}.admin-section-title h2{margin:0 0 5px;font-size:17px}.admin-section-title p{margin:0;color:#8c8896;font-size:12px}.admin-account-list{margin-top:18px;display:grid;gap:10px}.admin-account-list article{padding:13px 0;display:grid;grid-template-columns:38px 1fr auto auto;align-items:center;gap:10px;border-top:1px solid #f0edf3}.admin-account-list article>div{display:flex;flex-direction:column;gap:3px}.admin-account-list small{color:#9995a2;font-size:10px}.admin-account-list button{padding:7px 10px;border:1px solid #dfdbe7;border-radius:8px;background:#fff;color:#615d6c}.admin-info-card dl{margin:18px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.admin-info-card dl div{display:flex;flex-direction:column;gap:5px}.admin-info-card dt{color:#9894a1;font-size:11px}.admin-info-card dd{margin:0;font-size:13px}.admin-create-form{max-width:700px;margin:20px auto}.admin-form-grid{margin:28px 0;display:grid;grid-template-columns:1fr 1fr;gap:16px}.admin-empty{min-height:400px;display:grid;place-content:center;justify-items:center;gap:10px;color:#85818f}.admin-empty h2{margin:5px}.admin-empty button{padding:10px 15px;border:0;border-radius:9px;background:#6d3fe0;color:#fff}.settings-avatar{width:34px;height:34px;display:grid;place-items:center;border-radius:9px;background:#ebe5fa;color:#6138c1;font-weight:800}
.admin-sites-card>.admin-section-title{display:flex;align-items:center;justify-content:space-between}.admin-sites-card>.admin-section-title>span{padding:3px 8px;border-radius:20px;background:#f0edf7;color:#6d43cf;font-size:12px;font-weight:700}.admin-site-list{margin-top:18px;display:grid;gap:9px}.admin-site-list article{padding:14px;display:grid;grid-template-columns:40px minmax(0,1fr) auto auto;align-items:center;gap:12px;border:1px solid #ece9f1;border-radius:12px;background:#fcfbfd}.admin-site-avatar{width:40px;height:40px;display:grid;place-items:center;border-radius:10px;background:#eee8fc;color:#6740c4;font-weight:800}.admin-site-copy{min-width:0;display:flex;flex-direction:column;gap:4px}.admin-site-copy strong,.admin-site-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.admin-site-copy small{color:#8f8b98;font-size:10px}.admin-site-copy code{font-size:9px}.admin-site-state{display:flex;align-items:center;gap:5px}.admin-site-state span{padding:4px 7px;border-radius:6px;background:#f0eff3;color:#777380;font-size:9px;white-space:nowrap}.admin-site-state span.ready,.admin-site-state span.published{color:#187558;background:#e7f8f1}.admin-site-state span.building,.admin-site-state span.creating,.admin-site-state span.publishing{color:#946300;background:#fff6df}.admin-site-state span.failed{color:#b74343;background:#fff0f0}.admin-site-links{display:flex;align-items:center;gap:6px}.admin-site-links a,.admin-site-links>span{height:31px;padding:0 9px;display:flex;align-items:center;gap:5px;border:1px solid #ded9e7;border-radius:8px;color:#6540bc;background:#fff;font-size:9px;font-weight:700;text-decoration:none;white-space:nowrap}.admin-site-links a.production{color:#187558}.admin-site-links>span{color:#aaa6b1;background:#f5f4f6}.admin-sites-loading,.admin-sites-empty{min-height:90px;display:flex;align-items:center;justify-content:center;gap:8px;color:#8c8896;font-size:12px}
@media(max-width:800px){.admin-layout{grid-template-columns:1fr}.admin-sidebar{border-right:0;border-bottom:1px solid #ebe9f1}.admin-sidebar nav{grid-template-columns:repeat(2,minmax(0,1fr))}.admin-content{padding:28px 20px}.admin-stats{grid-template-columns:1fr}.admin-account-list article{grid-template-columns:38px 1fr}.admin-account-list button{grid-column:auto}.admin-info-card dl,.admin-form-grid{grid-template-columns:1fr}.admin-site-list article{grid-template-columns:40px minmax(0,1fr)}.admin-site-state,.admin-site-links{grid-column:2;justify-content:flex-start}.admin-topbar{padding:0 18px}}
/* Tenant website management */
.site-management-workspace{height:100vh;height:100dvh;min-height:0;display:grid;grid-template-columns:64px 1fr;overflow:hidden;background:#f6f6fa}.site-management-page{min-width:0;overflow:auto;padding:46px clamp(24px,5vw,76px) 64px}.site-management-header{max-width:1180px;margin:0 auto 28px;display:flex;align-items:flex-end;justify-content:space-between;gap:24px}.site-management-header>div>span{color:var(--primary);font-size:10px;font-weight:850;letter-spacing:.14em}.site-management-header h1{margin:7px 0 7px;font-size:30px;letter-spacing:-.035em}.site-management-header p{margin:0;color:var(--muted);font-size:12px}.site-management-header>button{height:40px;display:flex;align-items:center;gap:7px;padding:0 15px;color:white;background:var(--gradient);border:0;border-radius:10px;font-size:10px;font-weight:800;box-shadow:0 9px 22px rgba(112,40,255,.18)}
.management-notice{max-width:1180px;margin:0 auto 14px;min-height:42px;display:flex;align-items:center;gap:9px;padding:8px 12px;color:#176d55;background:#eaf8f3;border:1px solid #ccecdf;border-radius:10px;font-size:10px}.management-notice>span{flex:1}.management-notice button{display:grid;place-items:center;width:27px;height:27px;color:#4b806f;background:transparent;border:0;border-radius:7px}.site-management-page>.form-error{max-width:1180px;margin:0 auto 14px}
.site-management-filters{max-width:1180px;margin:0 auto;display:flex;gap:4px;padding-bottom:13px;border-bottom:1px solid #e5e3ea}.site-management-filters button{height:34px;display:flex;align-items:center;gap:6px;padding:0 12px;color:#777482;background:transparent;border:0;border-radius:8px;font-size:10px;font-weight:700}.site-management-filters button:hover,.site-management-filters button.active{color:var(--primary);background:var(--soft)}.site-management-filters span{min-width:18px;padding:2px 5px;color:#8f8a99;background:#ebe9ef;border-radius:12px;font-size:8px;text-align:center}.site-management-filters button.active span{color:var(--primary);background:#e5dcff}
.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-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}
@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}}
export type SiteStatus = "creating" | "ready" | "building" | "failed";
export type PublishStatus = "unpublished" | "publishing" | "published" | "failed";
export type SiteLifecycleStatus = "active" | "archived";
export interface CreateSiteInput {
name: string;
......@@ -35,6 +36,9 @@ export interface SiteInfo {
updatedAt: string;
lastError?: string;
lastPublishError?: string;
lifecycleStatus?: SiteLifecycleStatus;
archivedAt?: string;
deleteAfter?: string;
}
export type AccountType = "system_admin" | "tenant_user";
......
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