Commit bb9d04f4 authored by xuchentao's avatar xuchentao

优化文章双版本管理流程

parent 467e65cb
...@@ -8,6 +8,7 @@ import type { BuildManager, SiteBuildContext } from "../build/build-manager.js"; ...@@ -8,6 +8,7 @@ import type { BuildManager, SiteBuildContext } from "../build/build-manager.js";
import type { PreviewProcessManager } from "../preview/preview-process-manager.js"; import type { PreviewProcessManager } from "../preview/preview-process-manager.js";
import type { SiteRepository } from "../sites/site-repository.js"; import type { SiteRepository } from "../sites/site-repository.js";
import { ArticleService } from "./article-service.js"; import { ArticleService } from "./article-service.js";
import { syncPublishedArticles } from "./article-storage.js";
const exists = (target: string) => access(target).then(() => true).catch(() => false); const exists = (target: string) => access(target).then(() => true).catch(() => false);
...@@ -15,6 +16,7 @@ test("article mutations use external content storage without creating Git-manage ...@@ -15,6 +16,7 @@ test("article mutations use external content storage without creating Git-manage
const root = await mkdtemp(path.join(os.tmpdir(), "webagent-articles-")); const root = await mkdtemp(path.join(os.tmpdir(), "webagent-articles-"));
const project = path.join(root, "project"); const project = path.join(root, "project");
const articles = path.join(root, "content", "articles"); const articles = path.join(root, "content", "articles");
const liveArticles = path.join(root, "content", "live-articles");
const images = path.join(root, "content", "images"); const images = path.join(root, "content", "images");
const articleState = path.join(root, "metadata", "articles.json"); const articleState = path.join(root, "metadata", "articles.json");
const dist = path.join(root, "dist"); const dist = path.join(root, "dist");
...@@ -38,6 +40,7 @@ test("article mutations use external content storage without creating Git-manage ...@@ -38,6 +40,7 @@ test("article mutations use external content storage without creating Git-manage
getProjectPath: () => project, getProjectPath: () => project,
getDraftPath: () => path.join(root, "draft"), getDraftPath: () => path.join(root, "draft"),
getArticlesPath: () => articles, getArticlesPath: () => articles,
getLiveArticlesPath: () => liveArticles,
getArticleImagesPath: () => images, getArticleImagesPath: () => images,
getArticleStatePath: () => articleState, getArticleStatePath: () => articleState,
markArticlesCompiled: async () => { markArticlesCompiled: async () => {
...@@ -69,16 +72,31 @@ test("article mutations use external content storage without creating Git-manage ...@@ -69,16 +72,31 @@ test("article mutations use external content storage without creating Git-manage
assert.equal(await exists(path.join(articles, "hello.md")), true); assert.equal(await exists(path.join(articles, "hello.md")), true);
assert.equal(await exists(path.join(project, "src", "content", "articles")), false); assert.equal(await exists(path.join(project, "src", "content", "articles")), false);
assert.equal(buildCalls, 0); assert.equal(buildCalls, 0);
assert.equal((await new ArticleService(sites, builds, previews).list("tenant_test", "site_test")).pendingBuild, true); const service = new ArticleService(sites, builds, previews);
const published = await new ArticleService(sites, builds, previews).update("tenant_test", "site_test", "hello", { ...article, status: "published" }); const initialList = await service.list("tenant_test", "site_test");
assert.equal(initialList.pendingBuild, true);
assert.equal(initialList.articles[0]?.liveState, "draft");
const published = await service.update("tenant_test", "site_test", "hello", { ...article, status: "published" });
assert.equal(published.article?.status, "published"); assert.equal(published.article?.status, "published");
assert.match(await readFile(path.join(articles, "hello.md"), "utf8"), /status: published/); assert.match(await readFile(path.join(articles, "hello.md"), "utf8"), /status: published/);
assert.equal(buildCalls, 0); assert.equal(buildCalls, 0);
await new ArticleService(sites, builds, previews).compilePreview("tenant_test", "site_test"); await service.compilePreview("tenant_test", "site_test");
assert.equal(buildCalls, 1); assert.equal(buildCalls, 1);
assert.equal(buildArticlesDirectory, articles); assert.equal(buildArticlesDirectory, articles);
assert.equal((await new ArticleService(sites, builds, previews).list("tenant_test", "site_test")).pendingBuild, false); assert.equal((await service.list("tenant_test", "site_test")).pendingBuild, false);
const uploaded = await new ArticleService(sites, builds, previews).uploadImage("tenant_test", "site_test", { await syncPublishedArticles(articles, liveArticles);
assert.equal((await service.list("tenant_test", "site_test")).articles[0]?.liveState, "live");
await service.update("tenant_test", "site_test", "hello", { ...article, body: "Updated draft", status: "published" });
assert.equal((await service.list("tenant_test", "site_test")).articles[0]?.liveState, "modified");
await service.restoreLive("tenant_test", "site_test", "hello");
assert.equal((await service.list("tenant_test", "site_test")).articles[0]?.liveState, "live");
const pendingRemoval = await service.remove("tenant_test", "site_test", "hello");
assert.equal(pendingRemoval.article?.liveState, "pending-removal");
assert.equal(await exists(path.join(articles, "hello.md")), true);
await syncPublishedArticles(articles, liveArticles);
assert.equal(await exists(path.join(liveArticles, "hello.md")), false);
assert.equal((await service.list("tenant_test", "site_test")).articles[0]?.liveState, "draft");
const uploaded = await service.uploadImage("tenant_test", "site_test", {
filename: "pixel.png", filename: "pixel.png",
dataUrl: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", dataUrl: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
}); });
......
...@@ -8,9 +8,11 @@ import { getPublicPreviewUrl } from "../config.js"; ...@@ -8,9 +8,11 @@ import { getPublicPreviewUrl } from "../config.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js"; import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { articleInputSchema } from "../schemas.js"; import { articleInputSchema } from "../schemas.js";
import { SiteRepository } from "../sites/site-repository.js"; import { SiteRepository } from "../sites/site-repository.js";
import { articleLiveState } from "./article-storage.js";
type Frontmatter = Record<string, unknown>; type Frontmatter = Record<string, unknown>;
type ArticleBuildState = { pendingSlugs: string[]; lastCompiledAt?: string }; type ArticleBuildState = { pendingSlugs: string[]; lastCompiledAt?: string };
type StoredArticle = Omit<ArticleDocument, "liveState">;
export class ArticleService { export class ArticleService {
private readonly queues = new Map<string, Promise<unknown>>(); private readonly queues = new Map<string, Promise<unknown>>();
...@@ -23,13 +25,19 @@ export class ArticleService { ...@@ -23,13 +25,19 @@ export class ArticleService {
async list(tenantId: string, siteId: string): Promise<ArticleListResult> { async list(tenantId: string, siteId: string): Promise<ArticleListResult> {
const site = await this.sites.get(tenantId, siteId); const site = await this.sites.get(tenantId, siteId);
if (!await this.supported(this.sites.getProjectPath(tenantId, siteId))) return { supported: false, articles: [], pendingBuild: false, pendingCount: 0 }; if (!await this.supported(this.sites.getProjectPath(tenantId, siteId))) return { supported: false, articles: [], pendingBuild: false, pendingCount: 0, pendingLiveCount: 0 };
const directory = this.sites.getArticlesPath(tenantId, siteId); const directory = this.sites.getArticlesPath(tenantId, siteId);
await mkdir(directory, { recursive: true }); const liveDirectory = this.sites.getLiveArticlesPath(tenantId, siteId);
await Promise.all([mkdir(directory, { recursive: true }), mkdir(liveDirectory, { recursive: true })]);
const files = (await readdir(directory, { withFileTypes: true })) const files = (await readdir(directory, { withFileTypes: true }))
.filter((entry) => entry.isFile() && !entry.name.startsWith("_") && /\.md$/i.test(entry.name)) .filter((entry) => entry.isFile() && !entry.name.startsWith("_") && /\.md$/i.test(entry.name))
.map((entry) => entry.name); .map((entry) => entry.name);
const articles = await Promise.all(files.map((file) => this.readArticleFile(path.join(directory, file), file.replace(/\.md$/i, "")))); const articles = await Promise.all(files.map(async (file) => {
const slug = file.replace(/\.md$/i, "");
const draftFile = path.join(directory, file);
const article = await this.readArticleFile(draftFile, slug);
return { ...article, liveState: await articleLiveState(draftFile, this.articlePath(liveDirectory, slug), article.status) };
}));
const state = await this.readState(tenantId, siteId); const state = await this.readState(tenantId, siteId);
const pending = new Set(state.pendingSlugs); const pending = new Set(state.pendingSlugs);
return { return {
...@@ -37,6 +45,7 @@ export class ArticleService { ...@@ -37,6 +45,7 @@ export class ArticleService {
pendingBuild: pending.size > 0, pendingBuild: pending.size > 0,
pendingCount: pending.size, pendingCount: pending.size,
lastCompiledAt: state.lastCompiledAt, lastCompiledAt: state.lastCompiledAt,
pendingLiveCount: articles.filter((article) => article.liveState === "modified" || article.liveState === "pending-removal" || (article.liveState === "draft" && article.status === "published")).length,
articles: articles.map(({ body: _body, ...article }) => ({ ...article, pendingBuild: pending.has(article.slug) })).sort((a, b) => { articles: articles.map(({ body: _body, ...article }) => ({ ...article, pendingBuild: pending.has(article.slug) })).sort((a, b) => {
const left = a.updatedAt || a.publishedAt || ""; const left = a.updatedAt || a.publishedAt || "";
const right = b.updatedAt || b.publishedAt || ""; const right = b.updatedAt || b.publishedAt || "";
...@@ -49,7 +58,9 @@ export class ArticleService { ...@@ -49,7 +58,9 @@ export class ArticleService {
this.assertSlug(slug); this.assertSlug(slug);
await this.sites.get(tenantId, siteId); await this.sites.get(tenantId, siteId);
await this.assertSupported(this.sites.getProjectPath(tenantId, siteId)); await this.assertSupported(this.sites.getProjectPath(tenantId, siteId));
return this.readArticleFile(this.articlePath(this.sites.getArticlesPath(tenantId, siteId), slug), slug); const draftFile = this.articlePath(this.sites.getArticlesPath(tenantId, siteId), slug);
const article = await this.readArticleFile(draftFile, slug);
return { ...article, liveState: await articleLiveState(draftFile, this.articlePath(this.sites.getLiveArticlesPath(tenantId, siteId), slug), article.status) };
} }
async create(tenantId: string, siteId: string, input: ArticleInput): Promise<ArticleMutationResult> { async create(tenantId: string, siteId: string, input: ArticleInput): Promise<ArticleMutationResult> {
...@@ -86,11 +97,29 @@ export class ArticleService { ...@@ -86,11 +97,29 @@ export class ArticleService {
const source = this.articlePath(directory, slug); const source = this.articlePath(directory, slug);
if (!await this.exists(source)) throw Object.assign(new Error("文章不存在"), { statusCode: 404 }); if (!await this.exists(source)) throw Object.assign(new Error("文章不存在"), { statusCode: 404 });
const article = await this.readArticleFile(source, slug); const article = await this.readArticleFile(source, slug);
const liveFile = this.articlePath(this.sites.getLiveArticlesPath(tenantId, siteId), slug);
if (await this.exists(liveFile)) {
const next = { ...article, status: "draft" as const };
await writeFile(source, serializeArticle(next), "utf8");
return { article: next, changedSlugs: [slug] };
}
await rm(source, { force: true }); await rm(source, { force: true });
return { deletedSlug: slug, changedSlugs: [slug] }; return { deletedSlug: slug, changedSlugs: [slug] };
}); });
} }
async restoreLive(tenantId: string, siteId: string, slug: string): Promise<ArticleMutationResult> {
this.assertSlug(slug);
return this.mutate(tenantId, siteId, async (directory) => {
const liveFile = this.articlePath(this.sites.getLiveArticlesPath(tenantId, siteId), slug);
if (!await this.exists(liveFile)) throw Object.assign(new Error("文章还没有线上版本"), { statusCode: 409 });
const destination = this.articlePath(directory, slug);
await cp(liveFile, destination);
const { wordCount: _wordCount, ...article } = await this.readArticleFile(liveFile, slug);
return { article, changedSlugs: [slug] };
});
}
async importMarkdown(tenantId: string, siteId: string, filename: string, markdown: string): Promise<ArticleMutationResult> { async importMarkdown(tenantId: string, siteId: string, filename: string, markdown: string): Promise<ArticleMutationResult> {
const parsed = importedArticle(filename, markdown); const parsed = importedArticle(filename, markdown);
return this.create(tenantId, siteId, articleInputSchema.parse(parsed)); return this.create(tenantId, siteId, articleInputSchema.parse(parsed));
...@@ -178,9 +207,7 @@ export class ArticleService { ...@@ -178,9 +207,7 @@ export class ArticleService {
swapped = true; swapped = true;
const state = await this.readState(tenantId, siteId); const state = await this.readState(tenantId, siteId);
await this.writeState(tenantId, siteId, { ...state, pendingSlugs: [...new Set([...state.pendingSlugs, ...changed.changedSlugs])] }); await this.writeState(tenantId, siteId, { ...state, pendingSlugs: [...new Set([...state.pendingSlugs, ...changed.changedSlugs])] });
const article = changed.article const article = changed.article ? await this.articleDocument(tenantId, siteId, changed.article.slug) : undefined;
? await this.readArticleFile(this.articlePath(articlesDirectory, changed.article.slug), changed.article.slug)
: undefined;
return { article, deletedSlug: changed.deletedSlug, previewUrl: site.previewUrl, pendingBuild: true }; return { article, deletedSlug: changed.deletedSlug, previewUrl: site.previewUrl, pendingBuild: true };
} catch (error) { } catch (error) {
if (swapped) { if (swapped) {
...@@ -204,7 +231,13 @@ export class ArticleService { ...@@ -204,7 +231,13 @@ export class ArticleService {
return draft; return draft;
} }
private async readArticleFile(file: string, slug: string): Promise<ArticleDocument> { private async articleDocument(tenantId: string, siteId: string, slug: string): Promise<ArticleDocument> {
const draftFile = this.articlePath(this.sites.getArticlesPath(tenantId, siteId), slug);
const article = await this.readArticleFile(draftFile, slug);
return { ...article, liveState: await articleLiveState(draftFile, this.articlePath(this.sites.getLiveArticlesPath(tenantId, siteId), slug), article.status) };
}
private async readArticleFile(file: string, slug: string): Promise<StoredArticle> {
try { try {
const parsed = parseMarkdown(await readFile(file, "utf8")); const parsed = parseMarkdown(await readFile(file, "utf8"));
const input = articleInputSchema.parse({ slug, ...parsed.data, body: parsed.body }); const input = articleInputSchema.parse({ slug, ...parsed.data, body: parsed.body });
......
import crypto from "node:crypto";
import path from "node:path";
import { access, cp, mkdir, readFile, readdir, rename, rm } from "node:fs/promises";
import { parse as parseYaml } from "yaml";
import type { ArticleLiveState, ArticleStatus } from "@webagent/shared";
export async function articleLiveState(draftFile: string, liveFile: string, status: ArticleStatus): Promise<ArticleLiveState> {
if (!await exists(liveFile)) return "draft";
if (status !== "published") return "pending-removal";
const [draft, live] = await Promise.all([readFile(draftFile, "utf8"), readFile(liveFile, "utf8")]);
return draft === live ? "live" : "modified";
}
export async function syncPublishedArticles(draftDirectory: string, liveDirectory: string): Promise<void> {
const suffix = crypto.randomBytes(5).toString("hex");
const staging = path.join(path.dirname(liveDirectory), `.live-articles-next-${suffix}`);
const previous = path.join(path.dirname(liveDirectory), `.live-articles-previous-${suffix}`);
await mkdir(draftDirectory, { recursive: true });
await mkdir(staging, { recursive: true });
const entries = await readdir(draftDirectory, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || entry.name.startsWith("_") || !/\.md$/i.test(entry.name)) continue;
const source = path.join(draftDirectory, entry.name);
if (articleStatus(await readFile(source, "utf8")) !== "published") continue;
await cp(source, path.join(staging, entry.name));
}
const hasCurrent = await exists(liveDirectory);
if (hasCurrent) await rename(liveDirectory, previous);
try {
await rename(staging, liveDirectory);
} catch (error) {
if (hasCurrent) await rename(previous, liveDirectory).catch(() => undefined);
await rm(staging, { recursive: true, force: true });
throw error;
}
await rm(previous, { recursive: true, force: true });
}
function articleStatus(markdown: string): ArticleStatus {
const normalized = markdown.replaceAll("\r\n", "\n");
if (!normalized.startsWith("---\n")) return "draft";
const boundary = normalized.indexOf("\n---", 4);
if (boundary < 0) return "draft";
const data = parseYaml(normalized.slice(4, boundary));
return data && typeof data === "object" && "status" in data && data.status === "published" ? "published" : "draft";
}
function exists(target: string): Promise<boolean> {
return access(target).then(() => true).catch(() => false);
}
...@@ -45,7 +45,7 @@ export class DomainDeploymentService { ...@@ -45,7 +45,7 @@ export class DomainDeploymentService {
|| bindings.find((domain) => domain.ownershipStatus === "verified"); || bindings.find((domain) => domain.ownershipStatus === "verified");
const dist = await this.builds.build(workspace, taskId, { const dist = await this.builds.build(workspace, taskId, {
basePath: "/", publicOrigin: primary ? `https://${primary.hostname}` : undefined, indexable: true, basePath: "/", publicOrigin: primary ? `https://${primary.hostname}` : undefined, indexable: true,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId), articlesDirectory: this.sites.getLiveArticlesPath(tenantId, siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId), articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
}); });
await this.builds.publishCustomDomain(tenantId, siteId, dist); await this.builds.publishCustomDomain(tenantId, siteId, dist);
......
...@@ -181,6 +181,9 @@ app.put<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articl ...@@ -181,6 +181,9 @@ app.put<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articl
app.delete<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articles/:slug", async (request) => ( app.delete<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articles/:slug", async (request) => (
articles.remove(requireTenant(request.auth), request.params.siteId, request.params.slug) articles.remove(requireTenant(request.auth), request.params.siteId, request.params.slug)
)); ));
app.post<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articles/:slug/restore-live", async (request) => (
articles.restoreLive(requireTenant(request.auth), request.params.siteId, request.params.slug)
));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/articles/import", async (request, reply) => { app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/articles/import", async (request, reply) => {
const body = articleImportSchema.parse(request.body); const body = articleImportSchema.parse(request.body);
return reply.code(201).send(await articles.importMarkdown(requireTenant(request.auth), request.params.siteId, body.filename, body.markdown)); return reply.code(201).send(await articles.importMarkdown(requireTenant(request.auth), request.params.siteId, body.filename, body.markdown));
......
...@@ -28,8 +28,9 @@ export class CreateSiteService { ...@@ -28,8 +28,9 @@ export class CreateSiteService {
createdAt: now, updatedAt: now, createdAt: now, updatedAt: now,
}; };
const articlesDirectory = this.sites.getArticlesPath(tenantId, siteId); const articlesDirectory = this.sites.getArticlesPath(tenantId, siteId);
const liveArticlesDirectory = this.sites.getLiveArticlesPath(tenantId, siteId);
const articleImagesDirectory = this.sites.getArticleImagesPath(tenantId, siteId); const articleImagesDirectory = this.sites.getArticleImagesPath(tenantId, siteId);
await Promise.all([mkdir(projectPath, { recursive: true }), mkdir(articlesDirectory, { recursive: true }), mkdir(articleImagesDirectory, { recursive: true })]); await Promise.all([mkdir(projectPath, { recursive: true }), mkdir(articlesDirectory, { recursive: true }), mkdir(liveArticlesDirectory, { recursive: true }), mkdir(articleImagesDirectory, { recursive: true })]);
await this.sites.save(site); await this.sites.save(site);
try { try {
await cp(config.templateDir, projectPath, { recursive: true, filter: (source) => !["node_modules", "dist", ".astro", ".git", "pnpm-lock.yaml", "examples"].includes(path.basename(source)) }); await cp(config.templateDir, projectPath, { recursive: true, filter: (source) => !["node_modules", "dist", ".astro", ".git", "pnpm-lock.yaml", "examples"].includes(path.basename(source)) });
......
...@@ -2,6 +2,7 @@ import path from "node:path"; ...@@ -2,6 +2,7 @@ import path from "node:path";
import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import type { SiteInfo } from "@webagent/shared"; import type { SiteInfo } from "@webagent/shared";
import { config, getPublicPreviewUrl, getPublicProductionUrl, runtimePaths } from "../config.js"; import { config, getPublicPreviewUrl, getPublicProductionUrl, runtimePaths } from "../config.js";
import { syncPublishedArticles } from "../articles/article-storage.js";
export class SiteRepository { export class SiteRepository {
private runtimeReady?: Promise<void>; private runtimeReady?: Promise<void>;
...@@ -40,6 +41,10 @@ export class SiteRepository { ...@@ -40,6 +41,10 @@ export class SiteRepository {
return path.join(this.getSiteRoot(tenantId, siteId), "content", "articles"); return path.join(this.getSiteRoot(tenantId, siteId), "content", "articles");
} }
getLiveArticlesPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(tenantId, siteId), "content", "live-articles");
}
getArticleImagesPath(tenantId: string, siteId: string): string { getArticleImagesPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(tenantId, siteId), "content", "images"); return path.join(this.getSiteRoot(tenantId, siteId), "content", "images");
} }
...@@ -155,6 +160,7 @@ export class SiteRepository { ...@@ -155,6 +160,7 @@ export class SiteRepository {
]); ]);
await this.migrateLegacySites(); await this.migrateLegacySites();
await this.migrateFlatSites(); await this.migrateFlatSites();
await this.migrateArticleLiveSlots();
await Promise.all([ await Promise.all([
this.migrateLegacyPreviews(), this.migrateLegacyPreviews(),
this.migrateFlatArtifacts(config.previewsDir), this.migrateFlatArtifacts(config.previewsDir),
...@@ -163,6 +169,26 @@ export class SiteRepository { ...@@ -163,6 +169,26 @@ export class SiteRepository {
]); ]);
} }
private async migrateArticleLiveSlots(): Promise<void> {
const tenants = await readdir(runtimePaths.sites, { withFileTypes: true }).catch(() => []);
for (const tenant of tenants) {
if (!tenant.isDirectory() || !/^tenant_[a-z0-9_]+$/.test(tenant.name)) continue;
const tenantRoot = path.join(runtimePaths.sites, tenant.name);
const siteEntries = await readdir(tenantRoot, { withFileTypes: true }).catch(() => []);
for (const entry of siteEntries) {
if (!entry.isDirectory() || !/^site_[a-z0-9]+$/.test(entry.name)) continue;
const siteRoot = path.join(tenantRoot, entry.name);
const metadata = await readFile(path.join(siteRoot, "metadata", "site.json"), "utf8")
.then((raw) => JSON.parse(raw) as SiteInfo)
.catch(() => undefined);
if (!metadata?.publishedCommit) continue;
const liveDirectory = path.join(siteRoot, "content", "live-articles");
if (await stat(liveDirectory).then(() => true).catch(() => false)) continue;
await syncPublishedArticles(path.join(siteRoot, "content", "articles"), liveDirectory);
}
}
}
private async migrateLegacySites(): Promise<void> { private async migrateLegacySites(): Promise<void> {
const legacySitesDir = path.join(config.runtimeDir, "sites"); const legacySitesDir = path.join(config.runtimeDir, "sites");
if (path.resolve(legacySitesDir) === path.resolve(runtimePaths.sites)) return; if (path.resolve(legacySitesDir) === path.resolve(runtimePaths.sites)) return;
......
...@@ -8,6 +8,7 @@ import { GitManager } from "../git/git-manager.js"; ...@@ -8,6 +8,7 @@ import { GitManager } from "../git/git-manager.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js"; import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { ensureEnvironmentConfig } from "./ensure-environment-config.js"; import { ensureEnvironmentConfig } from "./ensure-environment-config.js";
import { SiteRepository } from "./site-repository.js"; import { SiteRepository } from "./site-repository.js";
import { syncPublishedArticles } from "../articles/article-storage.js";
export class SiteVersionService { export class SiteVersionService {
constructor( constructor(
...@@ -180,6 +181,7 @@ export class SiteVersionService { ...@@ -180,6 +181,7 @@ export class SiteVersionService {
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId), articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
}); });
await this.builds.publishProduction(tenantId, siteId, dist); await this.builds.publishProduction(tenantId, siteId, dist);
await syncPublishedArticles(this.sites.getArticlesPath(tenantId, siteId), this.sites.getLiveArticlesPath(tenantId, siteId));
const publishedAt = new Date().toISOString(); const publishedAt = new Date().toISOString();
const productionUrl = getPublicProductionUrl(tenantId, siteId); const productionUrl = getPublicProductionUrl(tenantId, siteId);
await this.sites.update(tenantId, siteId, { await this.sites.update(tenantId, siteId, {
......
...@@ -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,
FileText, ImagePlus, Save, Upload, WandSparkles, X, FileText, ImagePlus, Save, Upload, WandSparkles, X,
} from "lucide-react"; } from "lucide-react";
import type { AgentRunEvent, ArticleInput, CreateSiteInput, CreateTenantInput, DomainBinding, GitHistoryItem, SessionInfo, SiteInfo, TenantAdminInfo } from "@webagent/shared"; import type { AgentRunEvent, ArticleInput, ArticleSummary, 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 };
...@@ -99,7 +99,7 @@ function LoginPage({ onAuthenticated }: { onAuthenticated: () => void }) { ...@@ -99,7 +99,7 @@ function LoginPage({ onAuthenticated }: { onAuthenticated: () => void }) {
<p>登录你的官网工作台,在一个空间内完成创建、编辑、版本预览与上线。</p> <p>登录你的官网工作台,在一个空间内完成创建、编辑、版本预览与上线。</p>
<div className="feature-row"> <div className="feature-row">
<div><span><Bot size={18} /></span><strong>Agent 自由创作</strong><small>从文案到布局,持续迭代</small></div> <div><span><Bot size={18} /></span><strong>Agent 自由创作</strong><small>从文案到布局,持续迭代</small></div>
<div><span><ShieldCheck size={18} /></span><strong>草稿自动保存</strong><small>未完成修改随时继续</small></div> <div><span><ShieldCheck size={18} /></span><strong>草稿安全保护</strong><small>离开前提醒未保存修改</small></div>
<div><span><History size={18} /></span><strong>版本随时回看</strong><small>测试与线上相互隔离</small></div> <div><span><History size={18} /></span><strong>版本随时回看</strong><small>测试与线上相互隔离</small></div>
</div> </div>
</section> </section>
...@@ -464,6 +464,14 @@ type WorkspaceDialog = ...@@ -464,6 +464,14 @@ type WorkspaceDialog =
| { kind: "publish" } | { kind: "publish" }
| { kind: "publish-blocked"; title: string; description: string; goToVersions: boolean }; | { kind: "publish-blocked"; title: string; description: string; goToVersions: boolean };
type ArticleNavigation =
| { kind: "select"; slug: string }
| { kind: "create" }
| { kind: "tab"; tab: "chat" | "history" | "articles" | "domains" }
| { kind: "site"; siteId: string }
| { kind: "create-site" }
| { kind: "manage" };
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) });
...@@ -471,6 +479,8 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -471,6 +479,8 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
const [tab, setTab] = useState<"chat" | "history" | "articles" | "domains">("chat"); const [tab, setTab] = useState<"chat" | "history" | "articles" | "domains">("chat");
const [selectedArticleSlug, setSelectedArticleSlug] = useState(""); const [selectedArticleSlug, setSelectedArticleSlug] = useState("");
const [creatingArticle, setCreatingArticle] = useState(false); const [creatingArticle, setCreatingArticle] = useState(false);
const [articleDirty, setArticleDirty] = useState(false);
const [articleNavigation, setArticleNavigation] = useState<ArticleNavigation>();
const [device, setDevice] = useState<"desktop" | "mobile">("desktop"); const [device, setDevice] = useState<"desktop" | "mobile">("desktop");
const [environment, setEnvironment] = useState<"preview" | "production">("preview"); const [environment, setEnvironment] = useState<"preview" | "production">("preview");
const [pendingPreviewCommit, setPendingPreviewCommit] = useState(""); const [pendingPreviewCommit, setPendingPreviewCommit] = useState("");
...@@ -491,6 +501,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -491,6 +501,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
useEffect(() => { useEffect(() => {
setEnvironment("preview"); setEnvironment("preview");
setSelectedArticleSlug(""); setCreatingArticle(false); setSelectedArticleSlug(""); setCreatingArticle(false);
setArticleDirty(false); setArticleNavigation(undefined);
setPendingPreviewCommit(""); setPendingPreviewCommit("");
setMessages([{ role: "agent", text: "已切换到这个网站。你可以继续描述修改要求。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" }]); setMessages([{ role: "agent", text: "已切换到这个网站。你可以继续描述修改要求。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" }]);
}, [siteId, agentMode]); }, [siteId, agentMode]);
...@@ -632,16 +643,32 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -632,16 +643,32 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
publishMutation.reset(); publishMutation.reset();
setWorkspaceDialog({ kind: "publish" }); setWorkspaceDialog({ kind: "publish" });
}; };
const runArticleNavigation = (navigation: ArticleNavigation) => {
setArticleDirty(false);
if (navigation.kind === "select") { setSelectedArticleSlug(navigation.slug); setCreatingArticle(false); }
if (navigation.kind === "create") { setSelectedArticleSlug(""); setCreatingArticle(true); }
if (navigation.kind === "tab") {
setTab(navigation.tab);
if (navigation.tab === "history") { void siteQuery.refetch(); void historyQuery.refetch(); }
}
if (navigation.kind === "site") onSelectSite(navigation.siteId);
if (navigation.kind === "create-site") onCreate();
if (navigation.kind === "manage") onManage();
};
const requestArticleNavigation = (navigation: ArticleNavigation) => {
if (tab === "articles" && articleDirty) { setArticleNavigation(navigation); return; }
runArticleNavigation(navigation);
};
return <main className={`workspace ${tab === "domains" ? "domains-mode" : tab === "articles" ? "articles-mode" : ""}`}> return <main className={`workspace ${tab === "domains" ? "domains-mode" : tab === "articles" ? "articles-mode" : ""}`}>
<aside className="rail"> <aside className="rail">
<Logo compact /> <Logo compact />
<button className="rail-create-top" onClick={onCreate} title="创建官网" data-tooltip="创建官网" aria-label="创建官网"><Plus size={18} /></button> <button className="rail-create-top" onClick={() => requestArticleNavigation({ kind: "create-site" })} title="创建官网" data-tooltip="创建官网" aria-label="创建官网"><Plus size={18} /></button>
<div className="rail-nav"><button className={tab !== "domains" && tab !== "articles" ? "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> <div className="rail-nav"><button className={tab !== "domains" && tab !== "articles" ? "active" : ""} onClick={() => requestArticleNavigation({ kind: "tab", tab: "chat" })} title="Agent 工作台" data-tooltip="Agent 工作台" aria-label="Agent 工作台"><Monitor size={18} /></button><button onClick={() => requestArticleNavigation({ kind: "manage" })} title="网站管理" data-tooltip="网站管理" aria-label="网站管理"><LayoutGrid size={18} /></button></div>
<RailSettings onLogout={onLogout} /> <RailSettings onLogout={onLogout} />
</aside> </aside>
<section className="control-panel"> <section className="control-panel">
<SiteSwitcher site={site} sites={sites} onSelect={onSelectSite} /> <SiteSwitcher site={site} sites={sites} onSelect={(id) => requestArticleNavigation({ kind: "site", siteId: id })} />
<div className="panel-tabs"><button className={tab === "chat" ? "active" : ""} onClick={() => setTab("chat")}><MessageSquareText size={15} /> Agent</button><button className={tab === "history" ? "active" : ""} onClick={() => { setTab("history"); void siteQuery.refetch(); void historyQuery.refetch(); }}><History size={15} /> 版本</button><button className={tab === "articles" ? "active" : ""} onClick={() => setTab("articles")}><BookOpen size={15} /> 文章</button><button className={tab === "domains" ? "active" : ""} onClick={() => setTab("domains")}><Globe2 size={15} /> 域名</button></div> <div className="panel-tabs"><button className={tab === "chat" ? "active" : ""} onClick={() => requestArticleNavigation({ kind: "tab", tab: "chat" })}><MessageSquareText size={15} /> Agent</button><button className={tab === "history" ? "active" : ""} onClick={() => requestArticleNavigation({ kind: "tab", tab: "history" })}><History size={15} /> 版本</button><button className={tab === "articles" ? "active" : ""} onClick={() => requestArticleNavigation({ kind: "tab", tab: "articles" })}><BookOpen size={15} /> 文章</button><button className={tab === "domains" ? "active" : ""} onClick={() => requestArticleNavigation({ kind: "tab", tab: "domains" })}><Globe2 size={15} /> 域名</button></div>
{tab === "chat" ? <> {tab === "chat" ? <>
<div className="chat-scroll" ref={scrollRef}> <div className="chat-scroll" ref={scrollRef}>
<div className="chat-date">今天</div> <div className="chat-date">今天</div>
...@@ -683,9 +710,9 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -683,9 +710,9 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
</div> </div>
</article>; </article>;
})} })}
</div> : tab === "articles" ? <ArticleSidePanel site={site} selectedSlug={selectedArticleSlug} creating={creatingArticle} onSelect={(slug) => { setSelectedArticleSlug(slug); setCreatingArticle(false); }} onCreate={() => { setSelectedArticleSlug(""); setCreatingArticle(true); }} onUpdated={refreshData} onGoToAgent={() => setTab("chat")} /> : <DomainSidePanel site={site} />} </div> : tab === "articles" ? <ArticleSidePanel site={site} selectedSlug={selectedArticleSlug} creating={creatingArticle} articleDirty={articleDirty} onSelect={(slug) => requestArticleNavigation({ kind: "select", slug })} onCreate={() => requestArticleNavigation({ kind: "create" })} onUpdated={refreshData} /> : <DomainSidePanel site={site} />}
</section> </section>
{tab === "domains" ? <DomainManagement site={site} /> : tab === "articles" ? <ArticleManagement site={site} selectedSlug={selectedArticleSlug} creating={creatingArticle} onSelect={setSelectedArticleSlug} onCreating={setCreatingArticle} /> : <section className="preview-shell"> {tab === "domains" ? <DomainManagement site={site} /> : tab === "articles" ? <ArticleManagement site={site} selectedSlug={selectedArticleSlug} creating={creatingArticle} onSelect={setSelectedArticleSlug} onCreating={setCreatingArticle} onDirtyChange={setArticleDirty} /> : <section className="preview-shell">
<header className="preview-toolbar"> <header className="preview-toolbar">
<div className="preview-title"><div><span className={`status-dot ${showingProduction ? "production" : ""}`} /><strong>{site.name}</strong><em className={showingProduction ? "production" : ""}>{showingProduction ? "生产环境" : "测试环境"}</em></div><span className={`build-status ${showingProduction ? site.lastPublishError ? "failed" : site.publishStatus : site.lastError ? "failed" : site.status}`}>{busy ? <LoaderCircle className="spin" size={12} /> : showingProduction ? site.lastPublishError ? <X size={12} /> : <Check size={12} /> : site.lastError ? <X size={12} /> : <Check size={12} />}{publishMutation.isPending ? "正在发布" : saveDraftMutation.isPending ? "正在保存版本" : discardDraftMutation.isPending ? "正在放弃草稿" : previewDraftMutation.isPending ? "正在构建草稿" : chatMutation.isPending ? "正在修改草稿" : rebuildMutation.isPending || previewVersionMutation.isPending || restoreVersionMutation.isPending ? "正在构建" : environmentStatus}</span></div> <div className="preview-title"><div><span className={`status-dot ${showingProduction ? "production" : ""}`} /><strong>{site.name}</strong><em className={showingProduction ? "production" : ""}>{showingProduction ? "生产环境" : "测试环境"}</em></div><span className={`build-status ${showingProduction ? site.lastPublishError ? "failed" : site.publishStatus : site.lastError ? "failed" : site.status}`}>{busy ? <LoaderCircle className="spin" size={12} /> : showingProduction ? site.lastPublishError ? <X size={12} /> : <Check size={12} /> : site.lastError ? <X size={12} /> : <Check size={12} />}{publishMutation.isPending ? "正在发布" : saveDraftMutation.isPending ? "正在保存版本" : discardDraftMutation.isPending ? "正在放弃草稿" : previewDraftMutation.isPending ? "正在构建草稿" : chatMutation.isPending ? "正在修改草稿" : rebuildMutation.isPending || previewVersionMutation.isPending || restoreVersionMutation.isPending ? "正在构建" : environmentStatus}</span></div>
<div className="preview-modes"> <div className="preview-modes">
...@@ -717,6 +744,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -717,6 +744,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
{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" && <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); }} />} {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>}
{articleNavigation && <AppDialog title="还有未保存的文章修改" description="继续操作会放弃当前编辑内容。你可以先取消并保存草稿,或确认放弃本次修改。" confirmLabel="放弃修改并继续" tone="danger" icon={<FileText size={20} />} onCancel={() => setArticleNavigation(undefined)} onConfirm={() => { const navigation = articleNavigation; setArticleNavigation(undefined); runArticleNavigation(navigation); }} />}
</main>; </main>;
} }
...@@ -727,55 +755,112 @@ function emptyArticle(): ArticleInput { ...@@ -727,55 +755,112 @@ function emptyArticle(): ArticleInput {
}; };
} }
function ArticleSidePanel({ site, selectedSlug, creating, onSelect, onCreate, onUpdated, onGoToAgent }: { site: SiteInfo; selectedSlug: string; creating: boolean; onSelect: (slug: string) => void; onCreate: () => void; onUpdated: () => Promise<void>; onGoToAgent: () => void }) { function articleSlugFromTitle(title: string, fallback: string): string {
const slug = title.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 100);
return slug.length >= 2 ? slug : fallback;
}
type ArticleFilter = "all" | "pending" | "published" | "draft";
function articleMainState(article: ArticleSummary): string {
if (article.liveState === "live") return "已上线";
if (article.liveState === "modified") return "已修改";
if (article.liveState === "pending-removal") return "待下线";
return "草稿";
}
function articleSyncState(article: ArticleSummary): string {
if (article.pendingBuild) return "待更新测试";
if (article.liveState === "modified") return "测试已更新,待上线";
if (article.liveState === "pending-removal") return "测试已更新,待下线";
if (article.liveState === "draft" && article.status === "published") return "测试已更新,待首次上线";
if (article.liveState === "live") return "测试与线上已同步";
return "仅保存草稿";
}
function ArticleSidePanel({ site, selectedSlug, creating, articleDirty, onSelect, onCreate, onUpdated }: { site: SiteInfo; selectedSlug: string; creating: boolean; articleDirty: boolean; onSelect: (slug: string) => void; onCreate: () => void; onUpdated: () => Promise<void> }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const importInput = useRef<HTMLInputElement>(null); const importInput = useRef<HTMLInputElement>(null);
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<ArticleFilter>("all");
const [confirmPublish, setConfirmPublish] = useState(false);
const articlesQuery = useQuery({ queryKey: ["articles", site.siteId], queryFn: () => api.articles(site.siteId) }); const articlesQuery = useQuery({ queryKey: ["articles", site.siteId], queryFn: () => api.articles(site.siteId) });
const published = articlesQuery.data?.articles.filter((article) => article.status === "published").length || 0; const testVisible = articlesQuery.data?.articles.filter((article) => article.status === "published" && !article.pendingBuild).length || 0;
const refresh = async () => { const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ["articles", site.siteId] }); await queryClient.invalidateQueries({ queryKey: ["articles", site.siteId] });
}; };
const compileMutation = useMutation({ const compileMutation = useMutation({
mutationFn: () => api.compileArticles(site.siteId), mutationFn: () => api.compileArticles(site.siteId),
onSuccess: async () => { setNotice("文章已发布到测试环境;确认后请返回 Agent 工作台点击“重新上线”。"); await Promise.all([refresh(), onUpdated()]); }, onSuccess: async () => { setNotice("测试预览已更新。确认内容无误后,可直接发布整个网站到线上。"); await Promise.all([refresh(), onUpdated()]); },
}); });
const importMutation = useMutation({ const importMutation = useMutation({
mutationFn: async (file: File) => api.importArticle(site.siteId, file.name, await file.text()), mutationFn: async (file: File) => api.importArticle(site.siteId, file.name, await file.text()),
onSuccess: async (result) => { if (result.article) onSelect(result.article.slug); setNotice("Markdown 已导入,尚未编译。"); await refresh(); }, onSuccess: async (result) => { if (result.article) onSelect(result.article.slug); setNotice("Markdown 已导入,修改尚未同步到测试预览。"); await refresh(); },
});
const publishMutation = useMutation({
mutationFn: () => api.publish(site.siteId),
onSuccess: async () => { setConfirmPublish(false); setNotice("整个网站已发布到线上,文章内容已同步。"); await Promise.all([refresh(), onUpdated()]); },
}); });
useEffect(() => { useEffect(() => {
if (!selectedSlug && !creating && articlesQuery.data?.articles[0]) onSelect(articlesQuery.data.articles[0].slug); if (!selectedSlug && !creating && articlesQuery.data?.articles[0]) onSelect(articlesQuery.data.articles[0].slug);
}, [articlesQuery.data, creating, onSelect, selectedSlug]); }, [articlesQuery.data, creating, onSelect, selectedSlug]);
const pending = articlesQuery.data?.pendingBuild || false; const pending = articlesQuery.data?.pendingBuild || false;
const error = compileMutation.error || importMutation.error; const normalizedSearch = search.trim().toLowerCase();
const busy = compileMutation.isPending || importMutation.isPending; const visibleArticles = (articlesQuery.data?.articles || []).filter((article) => {
const matchesSearch = !normalizedSearch || [article.title, article.slug, article.author || "", ...article.tags].some((value) => value.toLowerCase().includes(normalizedSearch));
const matchesFilter = filter === "all" || (filter === "pending" ? article.pendingBuild : article.status === filter);
return matchesSearch && matchesFilter;
});
const error = compileMutation.error || importMutation.error || publishMutation.error;
const busy = compileMutation.isPending || importMutation.isPending || publishMutation.isPending;
const pendingLiveCount = articlesQuery.data?.pendingLiveCount || 0;
const publishDisabledReason = articleDirty ? "请先保存当前文章" : pending ? "请先更新测试预览" : !pendingLiveCount ? "文章草稿与线上已同步" : site.draftBaseCommit ? "请先在版本管理中保存或放弃网站草稿" : site.status !== "ready" ? "测试环境构建成功后才能上线" : "";
return <div className="article-side-panel"> return <div className="article-side-panel">
<div className="article-side-intro"><span className="domain-side-icon"><BookOpen size={18} /></span><div><h2>文章中心</h2><p>先连续编辑多篇文章,最后统一编译,减少等待时间。</p></div></div> <div className="article-side-intro"><span className="domain-side-icon"><BookOpen size={18} /></span><div><h2>文章中心</h2><p>保存只更新草稿,测试确认后再统一上线。</p></div></div>
<div className="article-flow-guide"><strong>推荐流程</strong><ol><li><b>1</b><span>保存草稿或加入发布队列<small>编辑、导入和图片上传都不会触发构建。</small></span></li><li><b>2</b><span>发布到测试环境<small>一次编译全部待同步内容,先在测试环境确认。</small></span></li><li><b>3</b><span>返回 Agent 点击“重新上线”<small>确认测试效果后,将当前版本更新到正式网站。</small></span></li></ol></div> <div className={`article-batch-card ${articleDirty || pending ? "pending" : pendingLiveCount ? "staged" : "ready"}`}><div><span>{articleDirty ? "当前文章尚未保存" : pending ? `${articlesQuery.data?.pendingCount || 0} 篇待更新测试` : pendingLiveCount ? `${pendingLiveCount} 篇待上线` : "文章已同步"}</span><small>{articleDirty ? "先保存当前编辑,后续测试和上线操作才会包含本次修改" : pending ? "统一生成测试网站,不会影响线上内容" : pendingLiveCount ? "测试内容已准备好,线上版本仍保持不变" : articlesQuery.data?.lastCompiledAt ? `测试与线上已同步 · ${formatTime(articlesQuery.data.lastCompiledAt)}` : "还没有待处理的文章修改"}</small></div>{articleDirty ? <span className="article-sync-check pending"><Save size={12} />请先保存</span> : pending ? <button type="button" disabled={busy} onClick={() => compileMutation.mutate()}>{compileMutation.isPending ? <LoaderCircle className="spin" size={13} /> : <RefreshCw size={13} />}{compileMutation.isPending ? "更新中…" : "更新测试预览"}</button> : pendingLiveCount ? <a className="article-preview-link" href={site.previewUrl} target="_blank" rel="noreferrer"><Monitor size={12} />查看测试网站</a> : <span className="article-sync-check"><Check size={13} />无需操作</span>}<button className="article-go-live" type="button" aria-disabled={Boolean(publishDisabledReason) || busy} disabled={busy} title={publishDisabledReason || "把当前网站版本和文章草稿发布到生产环境"} onClick={() => { if (!publishDisabledReason) { publishMutation.reset(); setConfirmPublish(true); } }}><Rocket size={13} />发布整个网站到线上</button>{publishDisabledReason && pendingLiveCount > 0 && <small className="article-publish-blocker">{publishDisabledReason}</small>}</div>
<div className={`article-batch-card ${pending ? "pending" : "ready"}`}><div><span>{pending ? `${articlesQuery.data?.pendingCount || 0} 项待发布` : "测试环境已同步"}</span><small>{articlesQuery.data?.lastCompiledAt ? `上次发布 ${formatTime(articlesQuery.data.lastCompiledAt)}` : "初始测试环境已生成"}</small></div><button type="button" disabled={busy || !pending} onClick={() => compileMutation.mutate()}>{compileMutation.isPending ? <LoaderCircle className="spin" size={13} /> : <RefreshCw size={13} />}{compileMutation.isPending ? "发布中…" : pending ? "发布到测试环境" : "测试环境已同步"}</button><button className="article-go-live" type="button" disabled={busy || pending} title={pending ? "请先发布到测试环境" : "返回 Agent 工作台后点击重新上线"} onClick={onGoToAgent}><Rocket size={13} />返回 Agent 点击重新上线</button></div>
{(notice || error) && <div className={`article-side-notice ${error ? "error" : ""}`}>{error ? error.message : notice}</div>} {(notice || error) && <div className={`article-side-notice ${error ? "error" : ""}`}>{error ? error.message : notice}</div>}
<div className="article-side-tools"><button type="button" onClick={onCreate}><Plus size={12} />新建</button><label><Upload size={12} />{importMutation.isPending ? "导入中" : "导入 Markdown"}<input ref={importInput} type="file" accept=".md,.markdown,text/markdown" disabled={busy} onChange={(event) => { const file = event.target.files?.[0]; if (file) importMutation.mutate(file); event.target.value = ""; }} /></label></div> <div className="article-side-tools"><button type="button" onClick={onCreate}><Plus size={12} />新建</button><label><Upload size={12} />{importMutation.isPending ? "导入中" : "导入 Markdown"}<input ref={importInput} type="file" accept=".md,.markdown,text/markdown" disabled={busy} onChange={(event) => { const file = event.target.files?.[0]; if (file) importMutation.mutate(file); event.target.value = ""; }} /></label></div>
<div className="article-side-list-heading"><strong>内容列表</strong><span>{articlesQuery.data?.articles.length || 0} 篇 · {published} 篇已设为发布</span></div> <div className="article-side-list-heading"><strong>内容列表</strong><span>{articlesQuery.data?.articles.length || 0} 篇 · {testVisible} 篇测试可见</span></div>
<div className="article-side-list">{articlesQuery.data?.articles.length ? articlesQuery.data.articles.map((article) => <button type="button" className={selectedSlug === article.slug && !creating ? "active" : ""} key={article.slug} onClick={() => onSelect(article.slug)}><span><strong>{article.title}</strong><small>{article.slug}</small></span><em className={article.status}>{article.status === "published" ? "已发布" : "草稿"}</em>{article.pendingBuild && <i>待编译</i>}</button>) : <div className="article-empty"><FileText size={22} /><span>还没有文章</span></div>}</div> <div className="article-list-controls"><label><Search size={11} /><input value={search} placeholder="搜索标题、Slug 或标签" onChange={(event) => setSearch(event.target.value)} /></label><select aria-label="筛选文章状态" value={filter} onChange={(event) => setFilter(event.target.value as ArticleFilter)}><option value="all">全部状态</option><option value="pending">待同步</option><option value="published">测试可见</option><option value="draft">草稿</option></select></div>
<div className="article-side-list">{visibleArticles.length ? visibleArticles.map((article) => <button type="button" className={selectedSlug === article.slug && !creating ? "active" : ""} key={article.slug} onClick={() => onSelect(article.slug)}><span><strong>{article.title}</strong><small>{article.slug}</small><i>{articleSyncState(article)}</i></span><em className={article.liveState}>{articleMainState(article)}</em></button>) : <div className="article-empty"><FileText size={22} /><span>{articlesQuery.data?.articles.length ? "没有符合条件的文章" : "还没有文章"}</span>{!articlesQuery.data?.articles.length && <button type="button" onClick={onCreate}>新建第一篇文章</button>}</div>}</div>
{confirmPublish && <AppDialog title="发布整个网站到线上?" description={`将把当前网站版本以及 ${testVisible} 篇测试可见文章发布到生产环境。发布期间原线上网站保持可用。`} confirmLabel="确认发布整个网站" busy={publishMutation.isPending} error={publishMutation.error?.message} icon={<Rocket size={20} />} onCancel={() => setConfirmPublish(false)} onConfirm={() => publishMutation.mutate()} />}
</div>; </div>;
} }
function ArticleManagement({ site, selectedSlug, creating, onSelect, onCreating }: { site: SiteInfo; selectedSlug: string; creating: boolean; onSelect: (slug: string) => void; onCreating: (creating: boolean) => void }) { function ArticleManagement({ site, selectedSlug, creating, onSelect, onCreating, onDirtyChange }: { site: SiteInfo; selectedSlug: string; creating: boolean; onSelect: (slug: string) => void; onCreating: (creating: boolean) => void; onDirtyChange: (dirty: boolean) => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [draft, setDraft] = useState<ArticleInput>(emptyArticle); const [draft, setDraft] = useState<ArticleInput>(emptyArticle);
const [baseline, setBaseline] = useState<ArticleInput>(draft);
const [slugEdited, setSlugEdited] = useState(false);
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const [articleAction, setArticleAction] = useState<"remove" | "restore">();
const imageInput = useRef<HTMLInputElement>(null); const imageInput = useRef<HTMLInputElement>(null);
const articlesQuery = useQuery({ queryKey: ["articles", site.siteId], queryFn: () => api.articles(site.siteId) }); const articlesQuery = useQuery({ queryKey: ["articles", site.siteId], queryFn: () => api.articles(site.siteId) });
const articleQuery = useQuery({ queryKey: ["article", site.siteId, selectedSlug], queryFn: () => api.article(site.siteId, selectedSlug), enabled: Boolean(selectedSlug) && !creating }); const articleQuery = useQuery({ queryKey: ["article", site.siteId, selectedSlug], queryFn: () => api.article(site.siteId, selectedSlug), enabled: Boolean(selectedSlug) && !creating });
useEffect(() => { useEffect(() => {
if (creating) setDraft(emptyArticle()); if (creating) {
const article = emptyArticle();
setDraft(article); setBaseline(article); setSlugEdited(false); setNotice("");
}
}, [creating]); }, [creating]);
useEffect(() => { useEffect(() => {
if (articleQuery.data && !creating) setDraft(articleQuery.data); if (articleQuery.data && !creating) {
setDraft(articleQuery.data); setBaseline(articleQuery.data); setSlugEdited(true); setNotice("");
}
}, [articleQuery.data, creating]); }, [articleQuery.data, creating]);
const dirty = JSON.stringify(draft) !== JSON.stringify(baseline);
useEffect(() => { onDirtyChange(dirty); }, [dirty, onDirtyChange]);
useEffect(() => {
if (!dirty) return;
const protect = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; };
window.addEventListener("beforeunload", protect);
return () => window.removeEventListener("beforeunload", protect);
}, [dirty]);
useEffect(() => () => onDirtyChange(false), [onDirtyChange]);
const refresh = async (slug?: string) => { const refresh = async (slug?: string) => {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ["articles", site.siteId] }), queryClient.invalidateQueries({ queryKey: ["articles", site.siteId] }),
...@@ -791,42 +876,70 @@ function ArticleManagement({ site, selectedSlug, creating, onSelect, onCreating ...@@ -791,42 +876,70 @@ function ArticleManagement({ site, selectedSlug, creating, onSelect, onCreating
}, },
onSuccess: async (result) => { onSuccess: async (result) => {
const slug = result.article?.slug || draft.slug; const slug = result.article?.slug || draft.slug;
if (result.article) setDraft(result.article); if (result.article) { setDraft(result.article); setBaseline(result.article); }
onCreating(false); onCreating(false);
setNotice(result.article?.status === "published" ? "已加入发布队列,尚未编译。" : "草稿已保存,本次没有触发编译。"); setNotice(result.article?.status === "published" ? result.article.liveState === "draft" ? "文章已加入测试,线上版本尚未创建。" : "草稿修改已保存,线上文章保持不变。" : result.article?.liveState === "pending-removal" ? "文章已标记为待下线,线上内容暂时保持不变。" : "草稿已保存,不会出现在测试或线上网站。");
await refresh(slug); await refresh(slug);
}, },
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: () => api.deleteArticle(site.siteId, selectedSlug), mutationFn: () => api.deleteArticle(site.siteId, selectedSlug),
onSuccess: async () => { onSelect(""); onCreating(false); setDraft(emptyArticle()); setNotice("文章已删除,待统一编译后从预览移除。"); await refresh(); }, onSuccess: async (result) => {
setArticleAction(undefined);
if (result.article) {
setDraft(result.article); setBaseline(result.article);
setNotice("文章已标记为待下线;更新测试后确认,再发布整个网站。 ");
await refresh(result.article.slug);
return;
}
const article = emptyArticle();
onSelect(""); onCreating(false); setDraft(article); setBaseline(article);
setNotice("草稿已删除。");
await refresh();
},
});
const restoreMutation = useMutation({
mutationFn: () => api.restoreLiveArticle(site.siteId, selectedSlug),
onSuccess: async (result) => {
setArticleAction(undefined);
if (result.article) { setDraft(result.article); setBaseline(result.article); }
setNotice("已恢复线上内容;更新测试预览后即可重新确认。 ");
await refresh(result.article?.slug || selectedSlug);
},
}); });
const imageMutation = useMutation({ const imageMutation = useMutation({
mutationFn: async (file: File) => api.uploadArticleImage(site.siteId, file.name, await fileDataUrl(file)), mutationFn: async (file: File) => api.uploadArticleImage(site.siteId, file.name, await fileDataUrl(file)),
onSuccess: (result) => { onSuccess: (result) => {
const alt = result.filename.replace(/-[a-f0-9]{10}\.[^.]+$/, ""); const alt = result.filename.replace(/-[a-f0-9]{10}\.[^.]+$/, "");
setDraft((value) => ({ ...value, body: `${value.body.trimEnd()}\n\n![${alt}](${result.path})\n`, cover: value.cover || result.path })); setDraft((value) => ({ ...value, body: `${value.body.trimEnd()}\n\n![${alt}](${result.path})\n`, cover: value.cover || result.path }));
setNotice("图片已上传并插入正文;保存文章并统一编译后即可在预览中查看。"); setNotice("图片已上传并插入正文。请保存文章,再更新测试预览查看效果。");
}, },
}); });
const error = saveMutation.error || deleteMutation.error || imageMutation.error || articleQuery.error; const error = saveMutation.error || deleteMutation.error || restoreMutation.error || imageMutation.error || articleQuery.error;
const busy = saveMutation.isPending || deleteMutation.isPending || imageMutation.isPending; const busy = saveMutation.isPending || deleteMutation.isPending || restoreMutation.isPending || imageMutation.isPending;
const update = <K extends keyof ArticleInput>(key: K, value: ArticleInput[K]) => setDraft((current) => ({ ...current, [key]: value })); const update = <K extends keyof ArticleInput>(key: K, value: ArticleInput[K]) => setDraft((current) => ({ ...current, [key]: value }));
const updateTitle = (title: string) => setDraft((current) => ({ ...current, title, slug: creating && !slugEdited ? articleSlugFromTitle(title, current.slug) : current.slug }));
const updateSeo = <K extends keyof ArticleInput["seo"]>(key: K, value: ArticleInput["seo"][K]) => setDraft((current) => ({ ...current, seo: { ...current.seo, [key]: value } })); const updateSeo = <K extends keyof ArticleInput["seo"]>(key: K, value: ArticleInput["seo"][K]) => setDraft((current) => ({ ...current, seo: { ...current.seo, [key]: value } }));
const selectedPending = articlesQuery.data?.articles.find((article) => article.slug === selectedSlug)?.pendingBuild || false; const selectedArticle = articlesQuery.data?.articles.find((article) => article.slug === selectedSlug);
const selectedPending = selectedArticle?.pendingBuild || false;
const liveState = articleQuery.data?.liveState || selectedArticle?.liveState || "draft";
const requiredComplete = Boolean(draft.title.trim() && draft.summary.trim() && draft.slug.trim());
const canSaveAs = (status: ArticleInput["status"]) => requiredComplete && (creating || dirty || draft.status !== status);
const articleStateLabel = liveState === "live" ? "已上线" : liveState === "modified" ? "已修改" : liveState === "pending-removal" ? "待下线" : "草稿";
const articleSyncLabel = dirty ? "当前修改尚未保存" : selectedPending ? "保存成功,等待更新测试预览" : liveState === "modified" ? "测试已更新,等待发布上线" : liveState === "pending-removal" ? "测试已更新,等待正式下线" : liveState === "draft" && draft.status === "published" ? "测试已更新,等待首次上线" : liveState === "live" ? "草稿与线上内容一致" : "仅保存在草稿中";
if (articlesQuery.isLoading) return <section className="article-workspace article-loading"><LoaderCircle className="spin" size={24} />正在加载文章…</section>; if (articlesQuery.isLoading) return <section className="article-workspace article-loading"><LoaderCircle className="spin" size={24} />正在加载文章…</section>;
if (articlesQuery.data && !articlesQuery.data.supported) return <section className="article-workspace article-unsupported"><BookOpen size={30} /><h1>当前站点尚未启用文章中心</h1><p>该站点使用旧版基础模版,升级模版后即可使用外部 Markdown 文章管理。</p></section>; if (articlesQuery.data && !articlesQuery.data.supported) return <section className="article-workspace article-unsupported"><BookOpen size={30} /><h1>当前站点尚未启用文章中心</h1><p>该站点使用旧版基础模版,升级模版后即可使用外部 Markdown 文章管理。</p></section>;
return <section className="article-workspace"> return <section className="article-workspace">
<header className="article-header"><div><span><BookOpen size={18} /></span><div><h1>{creating ? "新建文章" : "文章编辑器"}</h1><p>保存只写入内容库;完成多篇编辑后在左侧统一编译</p></div></div></header> <header className="article-header"><div><span><BookOpen size={18} /></span><div><h1>{creating ? "新建文章" : "文章编辑器"}</h1><p>先保存内容,再从左侧一次更新测试预览</p></div></div></header>
<div className="article-layout article-editor-only"> <div className="article-layout article-editor-only">
<form className="article-editor" onSubmit={(event) => event.preventDefault()}> <form className="article-editor" onSubmit={(event) => event.preventDefault()}>
<div className="article-editor-heading"><div><span>{creating ? "NEW ARTICLE" : "EDIT ARTICLE"}</span><h2>{creating ? "创建文章" : draft.title || "编辑文章"}</h2><em className={`article-status-badge ${draft.status}`}>{draft.status === "published" ? selectedPending ? "待编译发布" : "已发布" : selectedPending ? "待编译草稿" : "草稿"}</em></div><div>{!creating && draft.status === "published" && !selectedPending && <a href={`${site.previewUrl}articles/${draft.slug}/`} target="_blank" rel="noreferrer"><ExternalLink size={13} />打开预览</a>}{!creating && <button className="article-delete" type="button" disabled={busy} onClick={() => { if (window.confirm(`确定删除“${draft.title}”吗?`)) deleteMutation.mutate(); }}><Trash2 size={13} />删除</button>}<button className="article-save-draft" type="button" disabled={busy || !draft.title.trim() || !draft.summary.trim() || !draft.slug.trim()} onClick={() => saveMutation.mutate("draft")}>{saveMutation.isPending && saveMutation.variables === "draft" ? <LoaderCircle className="spin" size={14} /> : <Save size={14} />}{saveMutation.isPending && saveMutation.variables === "draft" ? "保存中…" : draft.status === "published" ? "转为草稿" : "保存草稿"}</button><button className="article-save" type="button" disabled={busy || !draft.title.trim() || !draft.summary.trim() || !draft.slug.trim()} onClick={() => saveMutation.mutate("published")}>{saveMutation.isPending && saveMutation.variables === "published" ? <LoaderCircle className="spin" size={14} /> : <Rocket size={14} />}{saveMutation.isPending && saveMutation.variables === "published" ? "保存中…" : draft.status === "published" ? "保存修改" : "加入发布队列"}</button></div></div> <div className="article-editor-heading"><div><span>{creating ? "NEW ARTICLE" : "EDIT ARTICLE"}</span><h2>{creating ? "创建文章" : draft.title || "编辑文章"}</h2><em className={`article-status-badge ${liveState}`}>{articleStateLabel}</em>{dirty ? <small className="article-dirty"><i />未保存修改</small> : creating ? <small className="article-unsaved">尚未保存</small> : <small className="article-saved"><Check size={11} />草稿已保存</small>}</div><div>{!creating && draft.status === "published" && !selectedPending && <a href={`${site.previewUrl}articles/${draft.slug}/`} target="_blank" rel="noreferrer"><ExternalLink size={13} />查看测试页</a>}{!creating && (liveState === "modified" || liveState === "pending-removal") && <button className="article-restore" type="button" disabled={busy} onClick={() => { restoreMutation.reset(); setArticleAction("restore"); }}><RotateCcw size={13} />恢复线上版本</button>}{!creating && liveState !== "pending-removal" && <button className="article-delete" type="button" disabled={busy} onClick={() => { deleteMutation.reset(); setArticleAction("remove"); }}><Trash2 size={13} />{liveState === "draft" ? "删除草稿" : "下线文章"}</button>}{(creating || draft.status === "draft") && <button className="article-save-draft" type="button" disabled={busy || !canSaveAs("draft")} onClick={() => saveMutation.mutate("draft")}>{saveMutation.isPending && saveMutation.variables === "draft" ? <LoaderCircle className="spin" size={14} /> : <Save size={14} />}{saveMutation.isPending && saveMutation.variables === "draft" ? "保存中…" : creating ? "仅保存草稿" : "保存修改"}</button>}<button className="article-save" type="button" disabled={busy || !canSaveAs("published")} onClick={() => saveMutation.mutate("published")}>{saveMutation.isPending && saveMutation.variables === "published" ? <LoaderCircle className="spin" size={14} /> : draft.status === "published" ? <Save size={14} /> : <Monitor size={14} />}{saveMutation.isPending && saveMutation.variables === "published" ? "保存中…" : creating ? "保存并加入测试" : draft.status === "published" ? "保存修改" : liveState === "pending-removal" ? "取消下线" : "加入测试"}</button></div></div>
{(notice || error) && <div className={`article-notice ${error ? "error" : ""}`}>{error ? <X size={14} /> : <CheckCircle2 size={14} />}{error ? error.message : notice}</div>} {(notice || error) && <div className={`article-notice ${error ? "error" : ""}`}>{error ? <X size={14} /> : <CheckCircle2 size={14} />}{error ? error.message : notice}</div>}
<div className="article-fields"> <div className="article-fields">
<label className="wide"><span>标题 *</span><input required maxLength={120} value={draft.title} onChange={(event) => update("title", event.target.value)} /></label> <label className="wide"><span>标题 *</span><input required maxLength={120} value={draft.title} onChange={(event) => updateTitle(event.target.value)} /></label>
<label><span>Slug *</span><input required pattern="[a-z0-9]+(?:-[a-z0-9]+)*" value={draft.slug} onChange={(event) => update("slug", event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} /></label> <label><span>Slug *</span><input required disabled={liveState !== "draft"} pattern="[a-z0-9]+(?:-[a-z0-9]+)*" value={draft.slug} onChange={(event) => { setSlugEdited(true); update("slug", event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "")); }} /><small className="article-field-help">{liveState === "draft" ? "新建时会根据英文或拼音标题自动生成,也可手动修改。" : "文章上线后固定 Slug,避免已有链接失效。"}</small></label>
<div className="article-publish-help"><span>内容状态</span><strong>{draft.status === "published" ? selectedPending ? "等待统一编译" : "测试预览已公开" : "仅保存为草稿"}</strong><small>保存不会触发构建,请在左侧统一编译。</small></div> <div className="article-publish-help"><span>当前状态</span><strong>{articleStateLabel}</strong><small>{articleSyncLabel}</small></div>
<label className="wide"><span>摘要 *</span><textarea required rows={2} maxLength={500} value={draft.summary} onChange={(event) => update("summary", event.target.value)} /></label> <label className="wide"><span>摘要 *</span><textarea required rows={2} maxLength={500} value={draft.summary} onChange={(event) => update("summary", event.target.value)} /></label>
<label><span>作者</span><input maxLength={80} value={draft.author || ""} onChange={(event) => update("author", event.target.value)} /></label> <label><span>作者</span><input maxLength={80} value={draft.author || ""} onChange={(event) => update("author", event.target.value)} /></label>
<label><span>标签(逗号分隔)</span><input value={draft.tags.join(", ")} onChange={(event) => update("tags", event.target.value.split(/[,,]/).map((tag) => tag.trim()).filter(Boolean).slice(0, 20))} /></label> <label><span>标签(逗号分隔)</span><input value={draft.tags.join(", ")} onChange={(event) => update("tags", event.target.value.split(/[,,]/).map((tag) => tag.trim()).filter(Boolean).slice(0, 20))} /></label>
...@@ -837,6 +950,8 @@ function ArticleManagement({ site, selectedSlug, creating, onSelect, onCreating ...@@ -837,6 +950,8 @@ function ArticleManagement({ site, selectedSlug, creating, onSelect, onCreating
<details className="article-seo"><summary>SEO / GEO 页面信息</summary><div className="article-fields"><label><span>SEO 标题</span><input maxLength={70} value={draft.seo.title || ""} onChange={(event) => updateSeo("title", event.target.value)} /></label><label><span>OG 图片</span><input value={draft.seo.ogImage || ""} onChange={(event) => updateSeo("ogImage", event.target.value)} /></label><label className="wide"><span>SEO 描述</span><textarea rows={2} maxLength={200} value={draft.seo.description || ""} onChange={(event) => updateSeo("description", event.target.value)} /></label><label className="wide"><span>OG 智能摘要</span><textarea rows={2} maxLength={240} value={draft.seo.ogDescription || ""} onChange={(event) => updateSeo("ogDescription", event.target.value)} /></label><label className="article-checkbox wide"><input type="checkbox" checked={draft.seo.noindex} onChange={(event) => updateSeo("noindex", event.target.checked)} /><span>禁止搜索引擎索引此文章</span></label></div></details> <details className="article-seo"><summary>SEO / GEO 页面信息</summary><div className="article-fields"><label><span>SEO 标题</span><input maxLength={70} value={draft.seo.title || ""} onChange={(event) => updateSeo("title", event.target.value)} /></label><label><span>OG 图片</span><input value={draft.seo.ogImage || ""} onChange={(event) => updateSeo("ogImage", event.target.value)} /></label><label className="wide"><span>SEO 描述</span><textarea rows={2} maxLength={200} value={draft.seo.description || ""} onChange={(event) => updateSeo("description", event.target.value)} /></label><label className="wide"><span>OG 智能摘要</span><textarea rows={2} maxLength={240} value={draft.seo.ogDescription || ""} onChange={(event) => updateSeo("ogDescription", event.target.value)} /></label><label className="article-checkbox wide"><input type="checkbox" checked={draft.seo.noindex} onChange={(event) => updateSeo("noindex", event.target.checked)} /><span>禁止搜索引擎索引此文章</span></label></div></details>
</form> </form>
</div> </div>
{articleAction === "remove" && <AppDialog title={liveState === "draft" ? "删除草稿?" : "下线这篇文章?"} description={liveState === "draft" ? `“${draft.title}”尚未上线,删除后无法恢复。` : `线上文章会继续保持可用;更新测试并发布整个网站后才会正式下线“${draft.title}”。`} confirmLabel={liveState === "draft" ? "删除草稿" : "标记为待下线"} busy={deleteMutation.isPending} error={deleteMutation.error?.message} tone="danger" icon={<Trash2 size={20} />} onCancel={() => setArticleAction(undefined)} onConfirm={() => deleteMutation.mutate()} />}
{articleAction === "restore" && <AppDialog title="恢复线上版本?" description={`将放弃“${draft.title}”当前草稿修改,并恢复为正在使用的线上内容。`} confirmLabel="恢复线上版本" busy={restoreMutation.isPending} error={restoreMutation.error?.message} icon={<RotateCcw size={20} />} onCancel={() => setArticleAction(undefined)} onConfirm={() => restoreMutation.mutate()} />}
</section>; </section>;
} }
......
...@@ -57,6 +57,7 @@ export const api = { ...@@ -57,6 +57,7 @@ export const api = {
createArticle: (siteId: string, input: ArticleInput) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles`, { method: "POST", body: JSON.stringify(input) }), createArticle: (siteId: string, input: ArticleInput) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles`, { method: "POST", body: JSON.stringify(input) }),
updateArticle: (siteId: string, slug: string, input: ArticleInput) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/${slug}`, { method: "PUT", body: JSON.stringify(input) }), updateArticle: (siteId: string, slug: string, input: ArticleInput) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/${slug}`, { method: "PUT", body: JSON.stringify(input) }),
deleteArticle: (siteId: string, slug: string) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/${slug}`, { method: "DELETE" }), deleteArticle: (siteId: string, slug: string) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/${slug}`, { method: "DELETE" }),
restoreLiveArticle: (siteId: string, slug: string) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/${slug}/restore-live`, { method: "POST" }),
importArticle: (siteId: string, filename: string, markdown: string) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/import`, { method: "POST", body: JSON.stringify({ filename, markdown }) }), importArticle: (siteId: string, filename: string, markdown: string) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/import`, { method: "POST", body: JSON.stringify({ filename, markdown }) }),
compileArticles: (siteId: string) => request<ArticleCompileResult>(`/api/sites/${siteId}/articles/compile`, { method: "POST" }), compileArticles: (siteId: string) => request<ArticleCompileResult>(`/api/sites/${siteId}/articles/compile`, { method: "POST" }),
uploadArticleImage: (siteId: string, filename: string, dataUrl: string) => request<ArticleImageUploadResult>(`/api/sites/${siteId}/article-images`, { method: "POST", body: JSON.stringify({ filename, dataUrl }) }), uploadArticleImage: (siteId: string, filename: string, dataUrl: string) => request<ArticleImageUploadResult>(`/api/sites/${siteId}/article-images`, { method: "POST", body: JSON.stringify({ filename, dataUrl }) }),
......
...@@ -86,3 +86,5 @@ ...@@ -86,3 +86,5 @@
@media(max-width:800px){.article-workspace{display:none}.workspace.articles-mode .control-panel{display:none}.workspace.articles-mode .article-workspace{display:flex;grid-column:2}.article-layout{grid-template-columns:170px minmax(0,1fr)}.article-header{padding:0 12px}.article-editor{padding:14px}.article-editor-heading{align-items:flex-start;flex-direction:column}.article-editor-heading>div:last-child{flex-wrap:wrap}} @media(max-width:800px){.article-workspace{display:none}.workspace.articles-mode .control-panel{display:none}.workspace.articles-mode .article-workspace{display:flex;grid-column:2}.article-layout{grid-template-columns:170px minmax(0,1fr)}.article-header{padding:0 12px}.article-editor{padding:14px}.article-editor-heading{align-items:flex-start;flex-direction:column}.article-editor-heading>div:last-child{flex-wrap:wrap}}
.article-editor-heading>div:first-child{flex-wrap:wrap}.article-status-badge{margin-left:8px;padding:4px 7px;border-radius:6px;font-size:7px;font-style:normal;font-weight:800}.article-status-badge.draft{color:#8c6200;background:#fff7df}.article-status-badge.published{color:#13765b;background:#e7f8f2}.article-editor-heading .article-save-draft{color:var(--primary);background:var(--soft);border-color:#d9ceff}.article-publish-help{display:flex;flex-direction:column;justify-content:center;gap:5px;padding:8px 10px;background:#fafafd;border:1px solid var(--border);border-radius:8px}.article-publish-help>span{color:var(--text-2);font-size:8px;font-weight:750}.article-publish-help strong{font-size:10px}.article-publish-help small{color:var(--muted);font-size:8px} .article-editor-heading>div:first-child{flex-wrap:wrap}.article-status-badge{margin-left:8px;padding:4px 7px;border-radius:6px;font-size:7px;font-style:normal;font-weight:800}.article-status-badge.draft{color:#8c6200;background:#fff7df}.article-status-badge.published{color:#13765b;background:#e7f8f2}.article-editor-heading .article-save-draft{color:var(--primary);background:var(--soft);border-color:#d9ceff}.article-publish-help{display:flex;flex-direction:column;justify-content:center;gap:5px;padding:8px 10px;background:#fafafd;border:1px solid var(--border);border-radius:8px}.article-publish-help>span{color:var(--text-2);font-size:8px;font-weight:750}.article-publish-help strong{font-size:10px}.article-publish-help small{color:var(--muted);font-size:8px}
.article-editor-only{display:block;overflow:auto}.article-editor-only .article-editor{width:min(980px,100%);margin:0 auto}.article-side-panel{min-height:0;display:flex;flex-direction:column;padding:17px 13px;background:linear-gradient(180deg,#fff,#faf9ff)}.article-side-intro{display:flex;align-items:center;gap:10px;padding:0 4px}.article-side-intro h2{margin:0;font-size:15px}.article-side-intro p{margin:4px 0 0;color:var(--muted);font-size:8px;line-height:1.5}.article-side-intro .domain-side-icon{width:35px;height:35px;flex:0 0 auto}.article-flow-guide{margin-top:13px;padding:11px;background:#f7f5ff;border:1px solid #e2dcf8;border-radius:11px}.article-flow-guide>strong{color:var(--primary);font-size:8px}.article-flow-guide ol{display:flex;flex-direction:column;gap:7px;margin:9px 0 0;padding:0;list-style:none}.article-flow-guide li{display:flex;align-items:flex-start;gap:7px}.article-flow-guide li>b{display:grid;place-items:center;width:18px;height:18px;flex:0 0 auto;color:#fff;background:var(--primary);border-radius:6px;font-size:7px}.article-flow-guide li span{font-size:8px;font-weight:750}.article-flow-guide li small{display:block;margin-top:2px;color:var(--muted);font-size:7px;font-weight:400;line-height:1.4}.article-batch-card{display:grid;grid-template-columns:1fr auto;gap:7px;margin-top:9px;padding:10px;background:#fff;border:1px solid var(--border);border-radius:11px}.article-batch-card.pending{border-color:#decf9a;background:#fffdf5}.article-batch-card>div span,.article-batch-card>div small{display:block}.article-batch-card>div span{font-size:9px;font-weight:800}.article-batch-card>div small{margin-top:4px;color:var(--muted);font-size:7px}.article-batch-card button{height:29px;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 8px;color:var(--primary);background:var(--soft);border:1px solid #ddd4ff;border-radius:7px;font-size:7px;font-weight:800}.article-batch-card button:disabled{opacity:.5}.article-batch-card .article-go-live{grid-column:1/-1;color:#fff;background:var(--gradient);border:0}.article-side-notice{margin-top:8px;padding:7px 9px;color:#13765b;background:#e9f9f3;border-radius:7px;font-size:7px}.article-side-notice.error{color:#b42318;background:#fff0ee}.article-side-tools{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:10px}.article-side-tools button,.article-side-tools label{height:29px;display:flex;align-items:center;justify-content:center;gap:5px;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:7px;font-size:7px;font-weight:750;cursor:pointer}.article-side-tools input{display:none}.article-side-list-heading{display:flex;align-items:center;justify-content:space-between;margin:13px 3px 7px}.article-side-list-heading strong{font-size:9px}.article-side-list-heading span{color:var(--muted);font-size:7px}.article-side-list{min-height:0;overflow:auto;display:flex;flex-direction:column;gap:4px}.article-side-list>button{width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px;padding:8px;color:var(--text);text-align:left;background:#fff;border:1px solid transparent;border-radius:8px}.article-side-list>button:hover,.article-side-list>button.active{background:#f5f2ff;border-color:#ddd4ff}.article-side-list>button>span{min-width:0}.article-side-list strong,.article-side-list small{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.article-side-list strong{font-size:8px}.article-side-list small{margin-top:3px;color:var(--muted);font-size:6px}.article-side-list em{padding:3px 4px;border-radius:4px;font-size:6px;font-style:normal;font-weight:800}.article-side-list em.draft{color:#8c6200;background:#fff7df}.article-side-list em.published{color:#13765b;background:#e7f8f2}.article-side-list i{grid-column:1/-1;width:max-content;color:#8c6200;font-size:6px;font-style:normal;font-weight:800} .article-editor-only{display:block;overflow:auto}.article-editor-only .article-editor{width:min(980px,100%);margin:0 auto}.article-side-panel{min-height:0;display:flex;flex-direction:column;padding:17px 13px;background:linear-gradient(180deg,#fff,#faf9ff)}.article-side-intro{display:flex;align-items:center;gap:10px;padding:0 4px}.article-side-intro h2{margin:0;font-size:15px}.article-side-intro p{margin:4px 0 0;color:var(--muted);font-size:8px;line-height:1.5}.article-side-intro .domain-side-icon{width:35px;height:35px;flex:0 0 auto}.article-flow-guide{margin-top:13px;padding:11px;background:#f7f5ff;border:1px solid #e2dcf8;border-radius:11px}.article-flow-guide>strong{color:var(--primary);font-size:8px}.article-flow-guide ol{display:flex;flex-direction:column;gap:7px;margin:9px 0 0;padding:0;list-style:none}.article-flow-guide li{display:flex;align-items:flex-start;gap:7px}.article-flow-guide li>b{display:grid;place-items:center;width:18px;height:18px;flex:0 0 auto;color:#fff;background:var(--primary);border-radius:6px;font-size:7px}.article-flow-guide li span{font-size:8px;font-weight:750}.article-flow-guide li small{display:block;margin-top:2px;color:var(--muted);font-size:7px;font-weight:400;line-height:1.4}.article-batch-card{display:grid;grid-template-columns:1fr auto;gap:7px;margin-top:9px;padding:10px;background:#fff;border:1px solid var(--border);border-radius:11px}.article-batch-card.pending{border-color:#decf9a;background:#fffdf5}.article-batch-card>div span,.article-batch-card>div small{display:block}.article-batch-card>div span{font-size:9px;font-weight:800}.article-batch-card>div small{margin-top:4px;color:var(--muted);font-size:7px}.article-batch-card button{height:29px;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 8px;color:var(--primary);background:var(--soft);border:1px solid #ddd4ff;border-radius:7px;font-size:7px;font-weight:800}.article-batch-card button:disabled{opacity:.5}.article-batch-card .article-go-live{grid-column:1/-1;color:#fff;background:var(--gradient);border:0}.article-side-notice{margin-top:8px;padding:7px 9px;color:#13765b;background:#e9f9f3;border-radius:7px;font-size:7px}.article-side-notice.error{color:#b42318;background:#fff0ee}.article-side-tools{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:10px}.article-side-tools button,.article-side-tools label{height:29px;display:flex;align-items:center;justify-content:center;gap:5px;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:7px;font-size:7px;font-weight:750;cursor:pointer}.article-side-tools input{display:none}.article-side-list-heading{display:flex;align-items:center;justify-content:space-between;margin:13px 3px 7px}.article-side-list-heading strong{font-size:9px}.article-side-list-heading span{color:var(--muted);font-size:7px}.article-side-list{min-height:0;overflow:auto;display:flex;flex-direction:column;gap:4px}.article-side-list>button{width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px;padding:8px;color:var(--text);text-align:left;background:#fff;border:1px solid transparent;border-radius:8px}.article-side-list>button:hover,.article-side-list>button.active{background:#f5f2ff;border-color:#ddd4ff}.article-side-list>button>span{min-width:0}.article-side-list strong,.article-side-list small{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.article-side-list strong{font-size:8px}.article-side-list small{margin-top:3px;color:var(--muted);font-size:6px}.article-side-list em{padding:3px 4px;border-radius:4px;font-size:6px;font-style:normal;font-weight:800}.article-side-list em.draft{color:#8c6200;background:#fff7df}.article-side-list em.published{color:#13765b;background:#e7f8f2}.article-side-list i{grid-column:1/-1;width:max-content;color:#8c6200;font-size:6px;font-style:normal;font-weight:800}
.article-editor-heading{position:sticky;z-index:5;top:0;margin:-10px -10px 14px;padding:10px;background:rgba(244,245,249,.96);border-bottom:1px solid rgba(224,222,230,.8);backdrop-filter:blur(10px)}.article-editor-heading button:disabled{opacity:.45;cursor:not-allowed}.article-status-badge.pending,.article-side-list em.pending{color:#8c6200;background:#fff1c9}.article-dirty,.article-saved,.article-unsaved{display:flex;align-items:center;gap:4px;margin-left:3px;font-size:7px;font-weight:750}.article-dirty{color:#9a6500}.article-dirty i{width:6px;height:6px;background:#e2a322;border-radius:50%}.article-saved{color:#18805f}.article-unsaved{color:var(--muted)}.article-field-help{color:var(--muted);font-size:7px;line-height:1.45}.article-list-controls{display:grid;grid-template-columns:minmax(0,1fr) 80px;gap:5px;margin-bottom:7px}.article-list-controls label{height:29px;display:flex;align-items:center;gap:5px;padding:0 7px;color:var(--muted);background:#fff;border:1px solid var(--border);border-radius:7px}.article-list-controls label:focus-within{color:var(--primary);border-color:#cfc0ff}.article-list-controls input,.article-list-controls select{min-width:0;width:100%;height:100%;padding:0;color:var(--text);background:transparent;border:0;outline:0;font-size:7px}.article-list-controls select{padding:0 5px;background:#fff;border:1px solid var(--border);border-radius:7px}.article-publish-blocker{grid-column:1/-1;margin:0!important;padding:0 2px;color:#9a6500!important;font-size:6px!important;line-height:1.4}.article-batch-card .article-go-live[aria-disabled="true"]{color:#8f8b97;background:#efeff4;border:1px solid #e0dde5;cursor:not-allowed}.article-empty{min-height:100px;height:auto}
.article-batch-card.staged{border-color:#cfc1fa;background:#faf8ff}.article-preview-link{height:29px;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 8px;color:var(--primary);background:#fff;border:1px solid #d9ceff;border-radius:7px;font-size:7px;font-weight:800;text-decoration:none}.article-sync-check{display:flex;align-items:center;gap:4px;color:#16805f;font-size:7px;font-weight:750}.article-sync-check.pending{color:#9a6500}.article-side-list>button>span i{display:block;margin-top:4px;color:var(--muted);font-size:6px;font-style:normal;font-weight:650}.article-side-list em.live,.article-status-badge.live{color:#13765b;background:#e7f8f2}.article-side-list em.modified,.article-status-badge.modified{color:#7040c3;background:#eee8ff}.article-side-list em.pending-removal,.article-status-badge.pending-removal{color:#a54242;background:#fff0f0}.article-status-badge.draft{color:#8c6200;background:#fff7df}.article-editor-heading .article-restore{color:#6845c5}.article-fields input:disabled{color:#8c8994;background:#f0eff3;cursor:not-allowed}
...@@ -197,6 +197,7 @@ export interface DraftPreviewResult { ...@@ -197,6 +197,7 @@ export interface DraftPreviewResult {
} }
export type ArticleStatus = "draft" | "published"; export type ArticleStatus = "draft" | "published";
export type ArticleLiveState = "draft" | "live" | "modified" | "pending-removal";
export interface ArticleSeoInput { export interface ArticleSeoInput {
title?: string; title?: string;
...@@ -223,10 +224,12 @@ export interface ArticleInput { ...@@ -223,10 +224,12 @@ export interface ArticleInput {
export interface ArticleSummary extends Omit<ArticleInput, "body"> { export interface ArticleSummary extends Omit<ArticleInput, "body"> {
wordCount: number; wordCount: number;
pendingBuild: boolean; pendingBuild: boolean;
liveState: ArticleLiveState;
} }
export interface ArticleDocument extends ArticleInput { export interface ArticleDocument extends ArticleInput {
wordCount: number; wordCount: number;
liveState: ArticleLiveState;
} }
export interface ArticleListResult { export interface ArticleListResult {
...@@ -234,6 +237,7 @@ export interface ArticleListResult { ...@@ -234,6 +237,7 @@ export interface ArticleListResult {
articles: ArticleSummary[]; articles: ArticleSummary[];
pendingBuild: boolean; pendingBuild: boolean;
pendingCount: number; pendingCount: number;
pendingLiveCount: number;
lastCompiledAt?: string; lastCompiledAt?: string;
} }
......
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