Commit 467e65cb authored by xuchentao's avatar xuchentao

feat: 重构官网模板与文章发布流程

parent 56b080e4
# 网站修改规范 # 网站规范
修改前必须读完本文档 改代码前读完本文
1. `src/_platform/``src/content.config.ts` 是平台能力内核,禁止修改。 1. 禁改 `src/_platform/``src/content.config.ts`、Astro 配置、依赖、产物及 Git 文件。
2. 网站资料和默认 TDK 在 `src/data/site.json`;首页内容在 `src/data/home.json` 2. 资料与 TDK 在 `src/data/site.json`;首页内容在 `src/data/home.json`
3. 文章由平台外部存储,禁止在源码中创建或修改文章文件。产品或服务放 `src/content/products/*.md`。仅已发布内容公开。 3. 文章及图片由平台外存,禁止写入源码;正文图片用 `/images/articles/文件名`
4. 页面、布局、组件和样式可自由修改,但公开页面必须通过 `BaseLayout` 保留 TDK、canonical、robots、OG 和 Schema。 4. 样式自由;公开页须用 `BaseLayout` 保留 SEO/GEO。
5. 图片只能放 `public/images/`,链接和资源路径必须兼容 `SITE_BASE_PATH` 5. 页面图片放 `public/images/`,路径兼容 `SITE_BASE_PATH`
6. 不得修改依赖、Astro 配置、构建产物或 Git 文件,不得访问站点外目录。 6. 禁止访问站点外目录、部署或提交;完成后检查 diff。
7. 完成后检查改动文件并执行允许的 Git diff 检查;不得部署或提交。
import { defineConfig } from "astro/config"; import { defineConfig } from "astro/config";
const configuredBase = process.env.SITE_BASE_PATH || "/";
const basePath = configuredBase === "/" ? "/" : `/${configuredBase.replace(/^\/+|\/+$/g, "")}/`;
function remarkArticleImages() {
return (tree) => {
const visit = (node) => {
if (node?.type === "image" && typeof node.url === "string" && node.url.startsWith("/images/articles/")) {
node.url = `${basePath}${node.url.replace(/^\/+/, "")}`.replace(/\/+/g, "/");
}
if (Array.isArray(node?.children)) node.children.forEach(visit);
};
visit(tree);
};
}
export default defineConfig({ export default defineConfig({
output: "static", output: "static",
site: process.env.SITE_PUBLIC_ORIGIN || "https://example.com", site: process.env.SITE_PUBLIC_ORIGIN || "https://example.com",
base: process.env.SITE_BASE_PATH || "/", base: configuredBase,
markdown: { remarkPlugins: [remarkArticleImages] },
server: { host: "127.0.0.1" }, server: { host: "127.0.0.1" },
}); });
---
title: 用 AI 建立可持续的官网内容增长机制
summary: 从选题、撰写到发布,建立一套兼顾品牌表达、SEO 与 GEO 的内容工作流。
status: published
author: 内容团队
cover: /images/articles/ai-content-growth.svg
tags:
- AI
- 内容增长
- GEO
publishedAt: 2026-07-18
updatedAt: 2026-07-18
seo:
title: AI 官网内容增长指南
description: 了解如何用 AI 建立稳定、可复用的官网内容生产与优化流程。
ogDescription: 一套兼顾品牌、SEO 与 GEO 的官网内容增长方法。
ogImage: /images/articles/ai-content-growth.svg
noindex: false
---
官网内容不应是一次性交付物,而应该成为持续积累的品牌资产。AI 最适合承担资料整理、结构生成和初稿优化,人仍然负责事实、判断与品牌语气。
![AI 内容增长工作流](/images/articles/ai-content-growth.svg)
## 从真实问题开始
优先整理销售、客服和项目交付中反复出现的问题。每篇文章解决一个明确问题,并给出可验证的结论、过程和下一步行动。
## 同时服务搜索与阅读
标题要说明主题,摘要要直接回答核心问题,正文使用清晰的层级。补充作者、发布日期、结构化数据和开放图谱信息,可以帮助搜索引擎与 AI 系统理解内容。
## 保持稳定更新
每月复查旧文章中的数据、链接和产品描述。持续更新比盲目增加低质量页面更有价值。
---
title: 如何把产品能力写成客户愿意读的故事
summary: 一篇用于演示草稿状态、Markdown 编辑和图片插入的示例文章。
status: draft
author: 品牌团队
tags:
- 产品内容
- 品牌表达
updatedAt: 2026-07-20
seo:
noindex: true
---
客户真正关心的不是功能列表,而是产品能否解决他的具体问题。先描述使用场景,再解释方案如何工作,最后补充结果与证据。
## 推荐结构
1. 客户处于什么场景;
2. 原有方法为什么不够;
3. 产品如何完成关键任务;
4. 可以获得什么可验证的结果。
这是一篇草稿,不会出现在公开文章列表中。你可以在文章中心继续编辑并发布它。
---
title: 企业官网上线前的 SEO 检查清单
summary: 用一份简洁清单检查 TDK、网站地图、Robots、结构化数据和分享摘要。
status: published
author: 官网运营团队
cover: /images/articles/seo-checklist.svg
tags:
- SEO
- 官网
- 上线检查
publishedAt: 2026-07-12
updatedAt: 2026-07-16
seo:
title: 企业官网 SEO 上线检查清单
description: 企业官网发布前必须检查的 SEO 与 GEO 基础配置。
ogDescription: 发布官网前,用这份清单完成 SEO 与 GEO 基础检查。
ogImage: /images/articles/seo-checklist.svg
noindex: false
---
网站上线前,先确认每个可索引页面都有独立、准确的标题和摘要。首页、产品页和文章页不应复用同一组 TDK。
![官网 SEO 检查清单](/images/articles/seo-checklist.svg)
## 基础文件
- 网站地图包含所有正式页面,不包含草稿和测试地址。
- Robots 文件允许抓取正式内容,并指向网站地图。
- Favicon、Canonical 和分享图片可以正常访问。
## 内容结构
- 每页只有一个清晰的主标题。
- 标题层级连续,链接文字能表达目标内容。
- 图片包含有意义的替代文本。
## GEO 信息
为组织、网站和文章输出 Schema 结构,同时维护 `llms.txt``llms-full.txt`,让 AI 系统更容易获取站点的权威摘要与完整内容索引。
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="t d"><title id="t">AI 内容增长</title><desc id="d">紫色渐变背景上的内容增长流程示意</desc><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#30108c"/><stop offset="1" stop-color="#7755ff"/></linearGradient></defs><rect width="1200" height="630" rx="36" fill="url(#g)"/><g fill="none" stroke="#fff" stroke-width="10" opacity=".9"><circle cx="260" cy="315" r="82"/><circle cx="600" cy="315" r="82"/><circle cx="940" cy="315" r="82"/><path d="M342 315h176m164 0h176"/></g><g fill="#fff" font-family="system-ui,sans-serif" text-anchor="middle"><text x="260" y="330" font-size="38">选题</text><text x="600" y="330" font-size="38">创作</text><text x="940" y="330" font-size="38">增长</text><text x="600" y="115" font-size="54" font-weight="700">AI 内容增长工作流</text></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="t d"><title id="t">SEO 上线检查</title><desc id="d">带有勾选项的搜索优化检查清单</desc><rect width="1200" height="630" rx="36" fill="#f2efff"/><rect x="255" y="70" width="690" height="490" rx="32" fill="#fff" stroke="#6f45df" stroke-width="8"/><g fill="none" stroke="#6f45df" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"><path d="m335 190 24 26 48-62m-72 151 24 26 48-62m-72 151 24 26 48-62"/><path d="M470 188h380M470 303h310M470 418h350"/></g><text x="600" y="125" fill="#2b175f" font-family="system-ui,sans-serif" font-size="45" font-weight="700" text-anchor="middle">官网 SEO 上线检查</text></svg>
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.4", "@astrojs/check": "^0.9.4",
"@types/node": "^22.15.3",
"astro": "^5.7.0", "astro": "^5.7.0",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
......
...@@ -3,6 +3,7 @@ import { render, type CollectionEntry } from "astro:content"; ...@@ -3,6 +3,7 @@ import { render, type CollectionEntry } from "astro:content";
import ContentLayout from "../../layouts/ContentLayout.astro"; import ContentLayout from "../../layouts/ContentLayout.astro";
import { entryPath, publishedArticles } from "../../_platform/content/public-content"; import { entryPath, publishedArticles } from "../../_platform/content/public-content";
import { articleSchema, breadcrumbSchema } from "../../_platform/seo/schema"; import { articleSchema, breadcrumbSchema } from "../../_platform/seo/schema";
import { withBase } from "../../_platform/seo/metadata";
export async function getStaticPaths() { export async function getStaticPaths() {
return (await publishedArticles()).map((entry) => ({ params: { id: entry.id.replace(/\.(md|mdx)$/i, "") }, props: { entry } })); return (await publishedArticles()).map((entry) => ({ params: { id: entry.id.replace(/\.(md|mdx)$/i, "") }, props: { entry } }));
...@@ -29,6 +30,7 @@ const description = entry.data.seo.ogDescription || entry.data.seo.description | ...@@ -29,6 +30,7 @@ const description = entry.data.seo.ogDescription || entry.data.seo.description |
]} ]}
> >
<h1>{entry.data.title}</h1> <h1>{entry.data.title}</h1>
{entry.data.cover && <img class="article-cover" src={withBase(entry.data.cover)} alt={entry.data.title} />}
<div class="content-meta"> <div class="content-meta">
{entry.data.publishedAt && <time datetime={entry.data.publishedAt.toISOString()}>{entry.data.publishedAt.toLocaleDateString("zh-CN")}</time>} {entry.data.publishedAt && <time datetime={entry.data.publishedAt.toISOString()}>{entry.data.publishedAt.toLocaleDateString("zh-CN")}</time>}
{entry.data.author && <span>{entry.data.author}</span>} {entry.data.author && <span>{entry.data.author}</span>}
......
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { publishedArticles, publishedProducts } from "../_platform/content/public-content"; import { publishedArticles, publishedProducts } from "../_platform/content/public-content";
import { site } from "../_platform/seo/metadata"; import { absoluteUrl, site } from "../_platform/seo/metadata";
const MAX_CHARACTERS = 200_000; const MAX_CHARACTERS = 200_000;
...@@ -11,7 +11,7 @@ export const GET: APIRoute = async () => { ...@@ -11,7 +11,7 @@ export const GET: APIRoute = async () => {
]; ];
const sections = [`# ${site.name}\n\n> ${site.description}`]; const sections = [`# ${site.name}\n\n> ${site.description}`];
for (const entry of entries) { for (const entry of entries) {
const body = entry.body || entry.data.summary; const body = (entry.body || entry.data.summary).replace(/\]\((\/images\/articles\/[^)]+)\)/g, (_match, image: string) => `](${absoluteUrl(image)})`);
const section = `## ${entry.data.title}\n\n${entry.data.summary}\n\n${body}`; const section = `## ${entry.data.title}\n\n${entry.data.summary}\n\n${body}`;
if (sections.join("\n\n").length + section.length > MAX_CHARACTERS) break; if (sections.join("\n\n").length + section.length > MAX_CHARACTERS) break;
sections.push(section); sections.push(section);
......
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
.content-list p { margin: 0 0 16px; color: var(--color-text-secondary); line-height: 1.75; } .content-list p { margin: 0 0 16px; color: var(--color-text-secondary); line-height: 1.75; }
.content-list a { color: var(--color-primary); font-size: 14px; font-weight: 750; } .content-list a { color: var(--color-primary); font-size: 14px; font-weight: 750; }
.content-meta { display: flex; flex-wrap: wrap; gap: 10px 20px; margin: 0 0 42px; color: var(--color-text-muted); font-size: 13px; } .content-meta { display: flex; flex-wrap: wrap; gap: 10px 20px; margin: 0 0 42px; color: var(--color-text-muted); font-size: 13px; }
.article-cover { display: block; width: 100%; height: auto; margin: 0 0 28px; border-radius: 22px; }
.prose { font-size: 17px; line-height: 1.9; } .prose { font-size: 17px; line-height: 1.9; }
.prose h2, .prose h3 { margin: 2em 0 .7em; line-height: 1.3; } .prose h2, .prose h3 { margin: 2em 0 .7em; line-height: 1.3; }
.prose p, .prose ul, .prose ol { margin: 0 0 1.35em; } .prose p, .prose ul, .prose ol { margin: 0 0 1.35em; }
......
...@@ -92,6 +92,7 @@ export class AgentOrchestrator { ...@@ -92,6 +92,7 @@ export class AgentOrchestrator {
dist = await this.builds.build(workspace, "agent_" + runId, { dist = await this.builds.build(workspace, "agent_" + runId, {
basePath: getPublicPreviewUrl(run.tenantId, run.siteId), indexable: false, basePath: getPublicPreviewUrl(run.tenantId, run.siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(run.tenantId, run.siteId), articlesDirectory: this.sites.getArticlesPath(run.tenantId, run.siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(run.tenantId, run.siteId),
}); });
buildValidation = { passed: true, buildExitCode: 0, durationMs: Date.now() - buildStarted, output: "网站构建通过" }; buildValidation = { passed: true, buildExitCode: 0, durationMs: Date.now() - buildStarted, output: "网站构建通过" };
this.runs.transition(runId, "validating_build", { validation: buildValidation }); this.runs.transition(runId, "validating_build", { validation: buildValidation });
...@@ -107,6 +108,7 @@ export class AgentOrchestrator { ...@@ -107,6 +108,7 @@ export class AgentOrchestrator {
const latest = this.requireRun(runId); const latest = this.requireRun(runId);
const summary = (latest.summary || "更新网站").replace(/\s+/g, " ").slice(0, 120); const summary = (latest.summary || "更新网站").replace(/\s+/g, " ").slice(0, 120);
await this.builds.publishPreview(run.tenantId, run.siteId, dist); await this.builds.publishPreview(run.tenantId, run.siteId, dist);
await this.sites.markArticlesCompiled(run.tenantId, run.siteId);
await this.sites.update(run.tenantId, run.siteId, { await this.sites.update(run.tenantId, run.siteId, {
status: "ready", previewCommit: baseCommit, status: "ready", previewCommit: baseCommit,
draftBaseCommit: baseCommit, draftUpdatedAt: new Date().toISOString(), draftSummary: summary, draftBaseCommit: baseCommit, draftUpdatedAt: new Date().toISOString(), draftSummary: summary,
......
...@@ -15,6 +15,8 @@ test("article mutations use external content storage without creating Git-manage ...@@ -15,6 +15,8 @@ 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 images = path.join(root, "content", "images");
const articleState = path.join(root, "metadata", "articles.json");
const dist = path.join(root, "dist"); const dist = path.join(root, "dist");
await mkdir(path.join(project, "src"), { recursive: true }); await mkdir(path.join(project, "src"), { recursive: true });
await writeFile(path.join(project, "src", "content.config.ts"), "export const collections = {};\n", "utf8"); await writeFile(path.join(project, "src", "content.config.ts"), "export const collections = {};\n", "utf8");
...@@ -26,6 +28,7 @@ test("article mutations use external content storage without creating Git-manage ...@@ -26,6 +28,7 @@ test("article mutations use external content storage without creating Git-manage
createdAt: now, updatedAt: now, createdAt: now, updatedAt: now,
}; };
let buildArticlesDirectory = ""; let buildArticlesDirectory = "";
let buildCalls = 0;
const sites = { const sites = {
get: async () => site, get: async () => site,
update: async (_tenantId: string, _siteId: string, values: Partial<SiteInfo>) => { update: async (_tenantId: string, _siteId: string, values: Partial<SiteInfo>) => {
...@@ -35,9 +38,18 @@ test("article mutations use external content storage without creating Git-manage ...@@ -35,9 +38,18 @@ 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,
getArticleImagesPath: () => images,
getArticleStatePath: () => articleState,
markArticlesCompiled: async () => {
const compiledAt = new Date().toISOString();
await mkdir(path.dirname(articleState), { recursive: true });
await writeFile(articleState, JSON.stringify({ pendingSlugs: [], lastCompiledAt: compiledAt }), "utf8");
return compiledAt;
},
} as unknown as SiteRepository; } as unknown as SiteRepository;
const builds = { const builds = {
build: async (_workspace: string, _taskId: string, context: SiteBuildContext) => { build: async (_workspace: string, _taskId: string, context: SiteBuildContext) => {
buildCalls += 1;
buildArticlesDirectory = context.articlesDirectory || ""; buildArticlesDirectory = context.articlesDirectory || "";
assert.match(await readFile(path.join(buildArticlesDirectory, "hello.md"), "utf8"), /title: Hello/); assert.match(await readFile(path.join(buildArticlesDirectory, "hello.md"), "utf8"), /title: Hello/);
await mkdir(dist, { recursive: true }); await mkdir(dist, { recursive: true });
...@@ -56,8 +68,23 @@ test("article mutations use external content storage without creating Git-manage ...@@ -56,8 +68,23 @@ test("article mutations use external content storage without creating Git-manage
assert.equal(result.article?.slug, "hello"); assert.equal(result.article?.slug, "hello");
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.notEqual(buildArticlesDirectory, articles); assert.equal(buildCalls, 0);
assert.match(buildArticlesDirectory, /\.articles-next-/); assert.equal((await new ArticleService(sites, builds, previews).list("tenant_test", "site_test")).pendingBuild, true);
const published = await new ArticleService(sites, builds, previews).update("tenant_test", "site_test", "hello", { ...article, status: "published" });
assert.equal(published.article?.status, "published");
assert.match(await readFile(path.join(articles, "hello.md"), "utf8"), /status: published/);
assert.equal(buildCalls, 0);
await new ArticleService(sites, builds, previews).compilePreview("tenant_test", "site_test");
assert.equal(buildCalls, 1);
assert.equal(buildArticlesDirectory, articles);
assert.equal((await new ArticleService(sites, builds, previews).list("tenant_test", "site_test")).pendingBuild, false);
const uploaded = await new ArticleService(sites, builds, previews).uploadImage("tenant_test", "site_test", {
filename: "pixel.png",
dataUrl: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
});
assert.match(uploaded.path, /^\/images\/articles\/pixel-[a-f0-9]{10}\.png$/);
assert.equal(await exists(path.join(images, uploaded.filename)), true);
assert.equal(await exists(path.join(project, "public", "images", "articles", uploaded.filename)), false);
} finally { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
......
import crypto from "node:crypto"; import crypto from "node:crypto";
import path from "node:path"; import path from "node:path";
import { access, cp, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; import { access, cp, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
import type { ArticleDocument, ArticleInput, ArticleListResult, ArticleMutationResult, ArticleSeoInput, SiteInfo } from "@webagent/shared"; import type { ArticleCompileResult, ArticleDocument, ArticleImageUploadInput, ArticleImageUploadResult, ArticleInput, ArticleListResult, ArticleMutationResult, ArticleSeoInput, SiteInfo } from "@webagent/shared";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { BuildError, BuildManager } from "../build/build-manager.js"; import { BuildError, BuildManager } from "../build/build-manager.js";
import { getPublicPreviewUrl } from "../config.js"; import { getPublicPreviewUrl } from "../config.js";
...@@ -10,6 +10,7 @@ import { articleInputSchema } from "../schemas.js"; ...@@ -10,6 +10,7 @@ import { articleInputSchema } from "../schemas.js";
import { SiteRepository } from "../sites/site-repository.js"; import { SiteRepository } from "../sites/site-repository.js";
type Frontmatter = Record<string, unknown>; type Frontmatter = Record<string, unknown>;
type ArticleBuildState = { pendingSlugs: string[]; lastCompiledAt?: string };
export class ArticleService { export class ArticleService {
private readonly queues = new Map<string, Promise<unknown>>(); private readonly queues = new Map<string, Promise<unknown>>();
...@@ -22,16 +23,21 @@ export class ArticleService { ...@@ -22,16 +23,21 @@ 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: [] }; if (!await this.supported(this.sites.getProjectPath(tenantId, siteId))) return { supported: false, articles: [], pendingBuild: false, pendingCount: 0 };
const directory = this.sites.getArticlesPath(tenantId, siteId); const directory = this.sites.getArticlesPath(tenantId, siteId);
await mkdir(directory, { recursive: true }); await mkdir(directory, { 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((file) => this.readArticleFile(path.join(directory, file), file.replace(/\.md$/i, ""))));
const state = await this.readState(tenantId, siteId);
const pending = new Set(state.pendingSlugs);
return { return {
supported: true, supported: true,
articles: articles.map(({ body: _body, ...article }) => article).sort((a, b) => { pendingBuild: pending.size > 0,
pendingCount: pending.size,
lastCompiledAt: state.lastCompiledAt,
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 || "";
return right.localeCompare(left) || a.title.localeCompare(b.title, "zh-CN"); return right.localeCompare(left) || a.title.localeCompare(b.title, "zh-CN");
...@@ -52,7 +58,7 @@ export class ArticleService { ...@@ -52,7 +58,7 @@ export class ArticleService {
if (await this.exists(destination)) throw Object.assign(new Error("Slug 已存在,请更换后再保存"), { statusCode: 409 }); if (await this.exists(destination)) throw Object.assign(new Error("Slug 已存在,请更换后再保存"), { statusCode: 409 });
await mkdir(path.dirname(destination), { recursive: true }); await mkdir(path.dirname(destination), { recursive: true });
await writeFile(destination, serializeArticle(input), "utf8"); await writeFile(destination, serializeArticle(input), "utf8");
return { article: input, summary: `新增文章:${input.title}` }; return { article: input, changedSlugs: [input.slug] };
}); });
} }
...@@ -70,7 +76,7 @@ export class ArticleService { ...@@ -70,7 +76,7 @@ export class ArticleService {
if (previous === next) throw Object.assign(new Error("文章没有实际变化"), { statusCode: 409 }); if (previous === next) throw Object.assign(new Error("文章没有实际变化"), { statusCode: 409 });
await writeFile(destination, next, "utf8"); await writeFile(destination, next, "utf8");
if (destination !== source) await rm(source, { force: true }); if (destination !== source) await rm(source, { force: true });
return { article: input, summary: `更新文章:${input.title}` }; return { article: input, changedSlugs: [input.slug] };
}); });
} }
...@@ -81,7 +87,7 @@ export class ArticleService { ...@@ -81,7 +87,7 @@ export class ArticleService {
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);
await rm(source, { force: true }); await rm(source, { force: true });
return { deletedSlug: slug, summary: `删除文章:${article.title}` }; return { deletedSlug: slug, changedSlugs: [slug] };
}); });
} }
...@@ -90,11 +96,25 @@ export class ArticleService { ...@@ -90,11 +96,25 @@ export class ArticleService {
return this.create(tenantId, siteId, articleInputSchema.parse(parsed)); return this.create(tenantId, siteId, articleInputSchema.parse(parsed));
} }
private async mutate( async uploadImage(tenantId: string, siteId: string, input: ArticleImageUploadInput): Promise<ArticleImageUploadResult> {
tenantId: string, await this.sites.get(tenantId, siteId);
siteId: string, await this.assertSupported(this.sites.getProjectPath(tenantId, siteId));
operation: (articlesDirectory: string) => Promise<{ article?: ArticleInput; deletedSlug?: string; summary: string }>, const encoded = input.dataUrl.slice(input.dataUrl.indexOf(",") + 1);
): Promise<ArticleMutationResult> { const content = Buffer.from(encoded, "base64");
if (!content.length || content.length > 5 * 1024 * 1024) {
throw Object.assign(new Error("图片大小必须在 5MB 以内"), { statusCode: 400 });
}
const extension = imageExtension(content);
if (!extension) throw Object.assign(new Error("图片内容无效,仅支持 PNG、JPEG、WebP 或 GIF"), { statusCode: 400 });
const stem = slugify(path.basename(input.filename, path.extname(input.filename))).slice(0, 60);
const filename = `${stem}-${crypto.randomBytes(5).toString("hex")}.${extension}`;
const directory = this.sites.getArticleImagesPath(tenantId, siteId);
await mkdir(directory, { recursive: true });
await writeFile(path.join(directory, filename), content);
return { path: `/images/articles/${filename}`, filename, size: content.length };
}
async compilePreview(tenantId: string, siteId: string): Promise<ArticleCompileResult> {
return this.withLock(`${tenantId}/${siteId}`, async () => { return this.withLock(`${tenantId}/${siteId}`, async () => {
const site = await this.sites.get(tenantId, siteId); const site = await this.sites.get(tenantId, siteId);
if (site.status === "building") throw Object.assign(new Error("网站正在执行其他构建,请稍后重试"), { statusCode: 409 }); if (site.status === "building") throw Object.assign(new Error("网站正在执行其他构建,请稍后重试"), { statusCode: 409 });
...@@ -105,12 +125,45 @@ export class ArticleService { ...@@ -105,12 +125,45 @@ export class ArticleService {
const workspace = await this.readWorkspace(site); const workspace = await this.readWorkspace(site);
await this.assertSupported(project); await this.assertSupported(project);
const articlesDirectory = this.sites.getArticlesPath(tenantId, siteId); const articlesDirectory = this.sites.getArticlesPath(tenantId, siteId);
await this.sites.update(tenantId, siteId, { status: "building", lastError: undefined });
try {
const dist = await this.builds.build(workspace, `articles_${crypto.randomBytes(4).toString("hex")}`, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false, articlesDirectory,
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
});
const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(tenantId, siteId, published, site.previewPort);
const compiledAt = await this.sites.markArticlesCompiled(tenantId, siteId);
await this.sites.update(tenantId, siteId, {
status: "ready", previewCommit: site.draftBaseCommit || site.currentCommit,
environmentVersion: 4, lastError: undefined,
});
return { previewUrl: site.previewUrl, compiledAt, articleCount: await this.articleCount(articlesDirectory) };
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(tenantId, siteId, { status: site.previewCommit ? "ready" : "failed", lastError: details });
throw error;
}
});
}
private async mutate(
tenantId: string,
siteId: string,
operation: (articlesDirectory: string) => Promise<{ article?: ArticleInput; deletedSlug?: string; changedSlugs: string[] }>,
): Promise<ArticleMutationResult> {
return this.withLock(`${tenantId}/${siteId}`, async () => {
const site = await this.sites.get(tenantId, siteId);
if (site.status === "building") throw Object.assign(new Error("网站正在执行其他构建,请稍后重试"), { statusCode: 409 });
const project = this.sites.getProjectPath(tenantId, siteId);
await this.assertSupported(project);
const articlesDirectory = this.sites.getArticlesPath(tenantId, siteId);
const suffix = crypto.randomBytes(5).toString("hex"); const suffix = crypto.randomBytes(5).toString("hex");
const stagingDirectory = path.join(path.dirname(articlesDirectory), `.articles-next-${suffix}`); const stagingDirectory = path.join(path.dirname(articlesDirectory), `.articles-next-${suffix}`);
const previousDirectory = path.join(path.dirname(articlesDirectory), `.articles-previous-${suffix}`); const previousDirectory = path.join(path.dirname(articlesDirectory), `.articles-previous-${suffix}`);
await mkdir(articlesDirectory, { recursive: true }); await mkdir(articlesDirectory, { recursive: true });
await cp(articlesDirectory, stagingDirectory, { recursive: true }); await cp(articlesDirectory, stagingDirectory, { recursive: true });
let changed: { article?: ArticleInput; deletedSlug?: string; summary: string }; let changed: { article?: ArticleInput; deletedSlug?: string; changedSlugs: string[] };
try { try {
changed = await operation(stagingDirectory); changed = await operation(stagingDirectory);
} catch (error) { } catch (error) {
...@@ -118,35 +171,22 @@ export class ArticleService { ...@@ -118,35 +171,22 @@ export class ArticleService {
throw error; throw error;
} }
await this.sites.update(tenantId, siteId, { status: "building", lastError: undefined });
let swapped = false; let swapped = false;
try { try {
const dist = await this.builds.build(workspace, `article_${crypto.randomBytes(4).toString("hex")}`, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false, articlesDirectory: stagingDirectory,
});
await rename(articlesDirectory, previousDirectory); await rename(articlesDirectory, previousDirectory);
await rename(stagingDirectory, articlesDirectory); await rename(stagingDirectory, articlesDirectory);
swapped = true; swapped = true;
const published = await this.builds.publishPreview(tenantId, siteId, dist); const state = await this.readState(tenantId, siteId);
await this.previews.start(tenantId, siteId, published, site.previewPort); await this.writeState(tenantId, siteId, { ...state, pendingSlugs: [...new Set([...state.pendingSlugs, ...changed.changedSlugs])] });
await this.sites.update(tenantId, siteId, {
status: "ready", previewCommit: site.draftBaseCommit || site.currentCommit,
environmentVersion: 4, lastError: undefined,
});
const article = changed.article const article = changed.article
? await this.readArticleFile(this.articlePath(articlesDirectory, changed.article.slug), changed.article.slug) ? await this.readArticleFile(this.articlePath(articlesDirectory, changed.article.slug), changed.article.slug)
: undefined; : undefined;
return { article, deletedSlug: changed.deletedSlug, previewUrl: site.previewUrl }; return { article, deletedSlug: changed.deletedSlug, previewUrl: site.previewUrl, pendingBuild: true };
} catch (error) { } catch (error) {
if (swapped) { if (swapped) {
await rm(articlesDirectory, { recursive: true, force: true }); await rm(articlesDirectory, { recursive: true, force: true });
await rename(previousDirectory, articlesDirectory).catch(() => undefined); await rename(previousDirectory, articlesDirectory).catch(() => undefined);
} }
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(tenantId, siteId, {
status: site.previewCommit ? "ready" : "failed",
lastError: details,
});
throw error; throw error;
} finally { } finally {
await Promise.all([ await Promise.all([
...@@ -175,6 +215,23 @@ export class ArticleService { ...@@ -175,6 +215,23 @@ export class ArticleService {
} }
} }
private async readState(tenantId: string, siteId: string): Promise<ArticleBuildState> {
return readFile(this.sites.getArticleStatePath(tenantId, siteId), "utf8")
.then((raw) => JSON.parse(raw) as ArticleBuildState)
.then((state) => ({ pendingSlugs: Array.isArray(state.pendingSlugs) ? state.pendingSlugs.filter((slug) => typeof slug === "string") : [], lastCompiledAt: state.lastCompiledAt }))
.catch(() => ({ pendingSlugs: [] }));
}
private async writeState(tenantId: string, siteId: string, state: ArticleBuildState): Promise<void> {
const destination = this.sites.getArticleStatePath(tenantId, siteId);
await mkdir(path.dirname(destination), { recursive: true });
const temporary = `${destination}.tmp`;
await writeFile(temporary, JSON.stringify(state, null, 2) + "\n", "utf8");
await rename(temporary, destination);
}
private async articleCount(directory: string): Promise<number> {
return (await readdir(directory, { withFileTypes: true })).filter((entry) => entry.isFile() && /\.md$/i.test(entry.name)).length;
}
private articlePath(directory: string, slug: string): string { this.assertSlug(slug); return path.join(directory, `${slug}.md`); } private articlePath(directory: string, slug: string): string { this.assertSlug(slug); return path.join(directory, `${slug}.md`); }
private async supported(workspace: string): Promise<boolean> { private async supported(workspace: string): Promise<boolean> {
return this.exists(path.join(workspace, "src", "content.config.ts")); return this.exists(path.join(workspace, "src", "content.config.ts"));
...@@ -278,3 +335,11 @@ function wordCount(body: string): number { ...@@ -278,3 +335,11 @@ function wordCount(body: string): number {
const han = textBody.match(/[\u3400-\u9fff]/g)?.length || 0; const han = textBody.match(/[\u3400-\u9fff]/g)?.length || 0;
return latin + han; return latin + han;
} }
function imageExtension(content: Buffer): "png" | "jpg" | "webp" | "gif" | undefined {
if (content.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png";
if (content[0] === 0xff && content[1] === 0xd8 && content[2] === 0xff) return "jpg";
if (content.subarray(0, 6).toString("ascii") === "GIF87a" || content.subarray(0, 6).toString("ascii") === "GIF89a") return "gif";
if (content.subarray(0, 4).toString("ascii") === "RIFF" && content.subarray(8, 12).toString("ascii") === "WEBP") return "webp";
return undefined;
}
...@@ -13,6 +13,7 @@ export interface SiteBuildContext { ...@@ -13,6 +13,7 @@ export interface SiteBuildContext {
publicOrigin?: string; publicOrigin?: string;
indexable?: boolean; indexable?: boolean;
articlesDirectory?: string; articlesDirectory?: string;
articleImagesDirectory?: string;
} }
export class BuildManager { export class BuildManager {
...@@ -26,6 +27,7 @@ export class BuildManager { ...@@ -26,6 +27,7 @@ export class BuildManager {
const options = typeof context === "string" ? { basePath: context } : context; const options = typeof context === "string" ? { basePath: context } : context;
const publicOrigin = (options.publicOrigin || config.frontendOrigin).replace(/\/$/, ""); const publicOrigin = (options.publicOrigin || config.frontendOrigin).replace(/\/$/, "");
if (options.articlesDirectory) await mkdir(options.articlesDirectory, { recursive: true }); if (options.articlesDirectory) await mkdir(options.articlesDirectory, { recursive: true });
if (options.articleImagesDirectory) await mkdir(options.articleImagesDirectory, { recursive: true });
try { try {
const install = await execa("pnpm", ["install", "--store-dir", runtimePaths.pnpmStore, "--prefer-offline"], { const install = await execa("pnpm", ["install", "--store-dir", runtimePaths.pnpmStore, "--prefer-offline"], {
cwd: projectPath, cwd: projectPath,
...@@ -44,6 +46,12 @@ export class BuildManager { ...@@ -44,6 +46,12 @@ export class BuildManager {
}, },
}); });
output += build.stdout + "\n" + build.stderr; output += build.stdout + "\n" + build.stderr;
if (options.articleImagesDirectory) {
const target = path.join(projectPath, "dist", "images", "articles");
await rm(target, { recursive: true, force: true });
await mkdir(path.dirname(target), { recursive: true });
await cp(options.articleImagesDirectory, target, { recursive: true });
}
await writeFile(path.join(runtimePaths.logs, taskId + ".log"), output, "utf8"); await writeFile(path.join(runtimePaths.logs, taskId + ".log"), output, "utf8");
return path.join(projectPath, "dist"); return path.join(projectPath, "dist");
} catch (error) { } catch (error) {
......
...@@ -46,6 +46,7 @@ export class DomainDeploymentService { ...@@ -46,6 +46,7 @@ export class DomainDeploymentService {
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.getArticlesPath(tenantId, siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
}); });
await this.builds.publishCustomDomain(tenantId, siteId, dist); await this.builds.publishCustomDomain(tenantId, siteId, dist);
await this.domains.updateSite(tenantId, siteId, (domain) => domain.ownershipStatus === "verified" await this.domains.updateSite(tenantId, siteId, (domain) => domain.ownershipStatus === "verified"
......
...@@ -60,6 +60,11 @@ export const articleImportSchema = z.object({ ...@@ -60,6 +60,11 @@ export const articleImportSchema = z.object({
markdown: z.string().min(1).max(256 * 1024), markdown: z.string().min(1).max(256 * 1024),
}); });
export const articleImageUploadSchema = z.object({
filename: z.string().trim().min(1).max(180),
dataUrl: z.string().max(7 * 1024 * 1024).regex(/^data:image\/(?:png|jpeg|webp|gif);base64,/i, "仅支持 PNG、JPEG、WebP 或 GIF 图片"),
});
export const addDomainSchema = z.object({ export const addDomainSchema = z.object({
hostname: z.string().trim().min(4, "请输入完整域名").max(253), hostname: z.string().trim().min(4, "请输入完整域名").max(253),
}); });
......
...@@ -12,7 +12,7 @@ import { config, runtimePaths } from "./config.js"; ...@@ -12,7 +12,7 @@ import { config, runtimePaths } from "./config.js";
import { GitManager } from "./git/git-manager.js"; import { GitManager } from "./git/git-manager.js";
import { PreviewProcessManager } from "./preview/preview-process-manager.js"; import { PreviewProcessManager } from "./preview/preview-process-manager.js";
import { import {
addDomainSchema, agentSettingsSchema, chatSchema, createSiteSchema, createTenantSchema, loginSchema, resetPasswordSchema, saveDraftSchema, addDomainSchema, agentSettingsSchema, articleImageUploadSchema, articleImportSchema, articleInputSchema, chatSchema, createSiteSchema, createTenantSchema, loginSchema, resetPasswordSchema, saveDraftSchema,
tenantStatusSchema, updateDomainSchema, versionCommitSchema, tenantStatusSchema, updateDomainSchema, versionCommitSchema,
} from "./schemas.js"; } from "./schemas.js";
import { CreateSiteService } from "./sites/create-site.js"; import { CreateSiteService } from "./sites/create-site.js";
...@@ -24,12 +24,13 @@ import { DomainRepository } from "./domains/domain-repository.js"; ...@@ -24,12 +24,13 @@ import { DomainRepository } from "./domains/domain-repository.js";
import { DomainRoutingConfig } from "./domains/domain-routing-config.js"; import { DomainRoutingConfig } from "./domains/domain-routing-config.js";
import { DomainService } from "./domains/domain-service.js"; import { DomainService } from "./domains/domain-service.js";
import { createDomainProvider } from "./domains/domain-provider.js"; import { createDomainProvider } from "./domains/domain-provider.js";
import { ArticleService } from "./articles/article-service.js";
declare module "fastify" { declare module "fastify" {
interface FastifyRequest { auth?: SessionInfo } interface FastifyRequest { auth?: SessionInfo }
} }
const app = Fastify({ logger: { level: process.env.LOG_LEVEL || "info" }, bodyLimit: 1024 * 1024 }); const app = Fastify({ logger: { level: process.env.LOG_LEVEL || "info" }, bodyLimit: 8 * 1024 * 1024 });
await app.register(cors, { origin: [config.frontendOrigin, "http://127.0.0.1:5173"] }); await app.register(cors, { origin: [config.frontendOrigin, "http://127.0.0.1:5173"] });
const auth = new AuthRepository(); const auth = new AuthRepository();
...@@ -48,6 +49,7 @@ const domainProvider = createDomainProvider(); ...@@ -48,6 +49,7 @@ const domainProvider = createDomainProvider();
const domainDeployments = new DomainDeploymentService(sites, domainRepository, git, builds, domainRouting); const domainDeployments = new DomainDeploymentService(sites, domainRepository, git, builds, domainRouting);
const domainService = new DomainService(sites, domainRepository, domainRouting, domainProvider); const domainService = new DomainService(sites, domainRepository, domainRouting, domainProvider);
const siteLifecycle = new SiteLifecycleService(sites, previews, domainRepository, domainRouting, domainProvider); const siteLifecycle = new SiteLifecycleService(sites, previews, domainRepository, domainRouting, domainProvider);
const articles = new ArticleService(sites, builds, previews);
app.addHook("onRequest", async (request, reply) => { app.addHook("onRequest", async (request, reply) => {
if (!request.url.startsWith("/api/") || request.url === "/api/health" || request.url === "/api/login") return; if (!request.url.startsWith("/api/") || request.url === "/api/health" || request.url === "/api/login") return;
...@@ -164,6 +166,31 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (re ...@@ -164,6 +166,31 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (re
void domainDeployments.schedule(tenantId, request.params.siteId, result.commit).catch((error) => app.log.error(error)); void domainDeployments.schedule(tenantId, request.params.siteId, result.commit).catch((error) => app.log.error(error));
return result; return result;
}); });
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/articles", async (request) => (
articles.list(requireTenant(request.auth), request.params.siteId)
));
app.get<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articles/:slug", async (request) => (
articles.get(requireTenant(request.auth), request.params.siteId, request.params.slug)
));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/articles", async (request, reply) => (
reply.code(201).send(await articles.create(requireTenant(request.auth), request.params.siteId, articleInputSchema.parse(request.body)))
));
app.put<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articles/:slug", async (request) => (
articles.update(requireTenant(request.auth), request.params.siteId, request.params.slug, articleInputSchema.parse(request.body))
));
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)
));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/articles/import", async (request, reply) => {
const body = articleImportSchema.parse(request.body);
return reply.code(201).send(await articles.importMarkdown(requireTenant(request.auth), request.params.siteId, body.filename, body.markdown));
});
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/articles/compile", async (request) => (
articles.compilePreview(requireTenant(request.auth), request.params.siteId)
));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/article-images", async (request, reply) => (
reply.code(201).send(await articles.uploadImage(requireTenant(request.auth), request.params.siteId, articleImageUploadSchema.parse(request.body)))
));
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/history", async (request) => { app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/history", async (request) => {
const tenantId = requireTenant(request.auth); const tenantId = requireTenant(request.auth);
await sites.get(tenantId, request.params.siteId); await sites.get(tenantId, request.params.siteId);
...@@ -252,7 +279,7 @@ await mkdir(runtimePaths.builds, { recursive: true }); ...@@ -252,7 +279,7 @@ await mkdir(runtimePaths.builds, { recursive: true });
for (let site of await sites.listAll()) { for (let site of await sites.listAll()) {
await git.pruneWorktrees(sites.getProjectPath(site.tenantId, site.siteId)); await git.pruneWorktrees(sites.getProjectPath(site.tenantId, site.siteId));
await builds.ensureProductionPlaceholder(site.tenantId, site.siteId, site.name); await builds.ensureProductionPlaceholder(site.tenantId, site.siteId, site.name);
if (site.environmentVersion !== 3) { if (site.environmentVersion !== 4) {
await versions.rebuild(site.tenantId, site.siteId).catch((error) => app.log.warn(error)); await versions.rebuild(site.tenantId, site.siteId).catch((error) => app.log.warn(error));
site = await sites.get(site.tenantId, site.siteId); site = await sites.get(site.tenantId, site.siteId);
} }
......
...@@ -28,10 +28,15 @@ export class CreateSiteService { ...@@ -28,10 +28,15 @@ 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);
await Promise.all([mkdir(projectPath, { recursive: true }), mkdir(articlesDirectory, { recursive: true })]); const articleImagesDirectory = this.sites.getArticleImagesPath(tenantId, siteId);
await Promise.all([mkdir(projectPath, { recursive: true }), mkdir(articlesDirectory, { 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"].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)) });
await Promise.all([
cp(path.join(config.templateDir, "examples", "articles"), articlesDirectory, { recursive: true }),
cp(path.join(config.templateDir, "examples", "images"), articleImagesDirectory, { recursive: true }),
]);
await rm(path.join(projectPath, "src/data/company.json"), { force: true }); await rm(path.join(projectPath, "src/data/company.json"), { force: true });
const siteDataPath = path.join(projectPath, "src/data/site.json"); const siteDataPath = path.join(projectPath, "src/data/site.json");
const siteData = JSON.parse(await readFile(siteDataPath, "utf8")) as { const siteData = JSON.parse(await readFile(siteDataPath, "utf8")) as {
...@@ -57,12 +62,13 @@ export class CreateSiteService { ...@@ -57,12 +62,13 @@ export class CreateSiteService {
const theme = (await readFile(themePath, "utf8")).replaceAll("#7028ff", input.brandColor.toLowerCase()); const theme = (await readFile(themePath, "utf8")).replaceAll("#7028ff", input.brandColor.toLowerCase());
await writeFile(themePath, theme, "utf8"); await writeFile(themePath, theme, "utf8");
const dist = await this.builds.build(projectPath, "create_" + siteId, { const dist = await this.builds.build(projectPath, "create_" + siteId, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false, articlesDirectory, basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false, articlesDirectory, articleImagesDirectory,
}); });
const commit = await this.git.init(projectPath); const commit = await this.git.init(projectPath);
await this.sites.update(tenantId, siteId, { currentCommit: commit }); await this.sites.update(tenantId, siteId, { currentCommit: commit });
const published = await this.builds.publishPreview(tenantId, siteId, dist); const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(tenantId, siteId, published, previewPort); await this.previews.start(tenantId, siteId, published, previewPort);
await this.sites.markArticlesCompiled(tenantId, siteId);
await this.builds.ensureProductionPlaceholder(tenantId, siteId, input.name); await this.builds.ensureProductionPlaceholder(tenantId, siteId, input.name);
return await this.sites.update(tenantId, siteId, { status: "ready", currentCommit: commit, previewCommit: commit, lastError: undefined }); return await this.sites.update(tenantId, siteId, { status: "ready", currentCommit: commit, previewCommit: commit, lastError: undefined });
} catch (error) { } catch (error) {
......
...@@ -40,6 +40,24 @@ export class SiteRepository { ...@@ -40,6 +40,24 @@ export class SiteRepository {
return path.join(this.getSiteRoot(tenantId, siteId), "content", "articles"); return path.join(this.getSiteRoot(tenantId, siteId), "content", "articles");
} }
getArticleImagesPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(tenantId, siteId), "content", "images");
}
getArticleStatePath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(tenantId, siteId), "metadata", "articles.json");
}
async markArticlesCompiled(tenantId: string, siteId: string): Promise<string> {
const destination = this.getArticleStatePath(tenantId, siteId);
const compiledAt = new Date().toISOString();
await mkdir(path.dirname(destination), { recursive: true });
const temporary = `${destination}.tmp`;
await writeFile(temporary, JSON.stringify({ pendingSlugs: [], lastCompiledAt: compiledAt }, null, 2) + "\n", "utf8");
await rename(temporary, destination);
return compiledAt;
}
getMetadataPath(tenantId: string, siteId: string): string { getMetadataPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(tenantId, siteId), "metadata", "site.json"); return path.join(this.getSiteRoot(tenantId, siteId), "metadata", "site.json");
} }
......
import crypto from "node:crypto"; import crypto from "node:crypto";
import path from "node:path"; import path from "node:path";
import { readFile } from "node:fs/promises";
import type { DraftPreviewResult, PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared"; import type { DraftPreviewResult, PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared";
import { getPublicPreviewUrl, getPublicProductionUrl, runtimePaths } from "../config.js"; import { getPublicPreviewUrl, getPublicProductionUrl, runtimePaths } from "../config.js";
import { BuildError, BuildManager } from "../build/build-manager.js"; import { BuildError, BuildManager } from "../build/build-manager.js";
...@@ -53,9 +54,11 @@ export class SiteVersionService { ...@@ -53,9 +54,11 @@ export class SiteVersionService {
const dist = await this.builds.build(workspace, taskId, { const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false, basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId), articlesDirectory: this.sites.getArticlesPath(tenantId, siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
}); });
const published = await this.builds.publishPreview(tenantId, siteId, dist); const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(tenantId, siteId, published, site.previewPort); await this.previews.start(tenantId, siteId, published, site.previewPort);
await this.sites.markArticlesCompiled(tenantId, siteId);
await this.sites.update(tenantId, siteId, { await this.sites.update(tenantId, siteId, {
status: "ready", previewCommit: site.draftBaseCommit, status: "ready", previewCommit: site.draftBaseCommit,
draftUpdatedAt: new Date().toISOString(), draftUpdatedAt: new Date().toISOString(),
...@@ -85,6 +88,7 @@ export class SiteVersionService { ...@@ -85,6 +88,7 @@ export class SiteVersionService {
const dist = await this.builds.build(workspace, taskId, { const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false, basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId), articlesDirectory: this.sites.getArticlesPath(tenantId, siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
}); });
const draftCommit = await this.git.hasChanges(workspace) const draftCommit = await this.git.hasChanges(workspace)
? await this.git.commit(workspace, message) ? await this.git.commit(workspace, message)
...@@ -92,6 +96,7 @@ export class SiteVersionService { ...@@ -92,6 +96,7 @@ export class SiteVersionService {
const commit = await this.git.fastForward(project, draftCommit, site.draftBaseCommit); const commit = await this.git.fastForward(project, draftCommit, site.draftBaseCommit);
const published = await this.builds.publishPreview(tenantId, siteId, dist); const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(tenantId, siteId, published, site.previewPort); await this.previews.start(tenantId, siteId, published, site.previewPort);
await this.sites.markArticlesCompiled(tenantId, siteId);
await this.sites.update(tenantId, siteId, { await this.sites.update(tenantId, siteId, {
status: "ready", currentCommit: commit, previewCommit: commit, status: "ready", currentCommit: commit, previewCommit: commit,
draftBaseCommit: undefined, draftUpdatedAt: undefined, draftSummary: undefined, draftBaseCommit: undefined, draftUpdatedAt: undefined, draftSummary: undefined,
...@@ -133,10 +138,12 @@ export class SiteVersionService { ...@@ -133,10 +138,12 @@ export class SiteVersionService {
const dist = await this.builds.build(workspace, taskId, { const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false, basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId), articlesDirectory: this.sites.getArticlesPath(tenantId, siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
}); });
const commit = await this.git.restoreAsCommit(project, targetCommit); const commit = await this.git.restoreAsCommit(project, targetCommit);
const published = await this.builds.publishPreview(tenantId, siteId, dist); const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(tenantId, siteId, published, site.previewPort); await this.previews.start(tenantId, siteId, published, site.previewPort);
await this.sites.markArticlesCompiled(tenantId, siteId);
await this.sites.update(tenantId, siteId, { await this.sites.update(tenantId, siteId, {
status: "ready", currentCommit: commit, previewCommit: commit, status: "ready", currentCommit: commit, previewCommit: commit,
environmentVersion: 4, lastError: undefined, environmentVersion: 4, lastError: undefined,
...@@ -153,6 +160,12 @@ export class SiteVersionService { ...@@ -153,6 +160,12 @@ export class SiteVersionService {
const site = await this.sites.get(tenantId, siteId); const site = await this.sites.get(tenantId, siteId);
if (site.status !== "ready") throw new Error("测试环境尚未构建成功,不能发布到生产环境"); if (site.status !== "ready") throw new Error("测试环境尚未构建成功,不能发布到生产环境");
if (site.draftBaseCommit) throw new Error("存在未保存修改,请先在“版本”中保存或放弃草稿后再发布"); if (site.draftBaseCommit) throw new Error("存在未保存修改,请先在“版本”中保存或放弃草稿后再发布");
const articleState = await readFile(this.sites.getArticleStatePath(tenantId, siteId), "utf8")
.then((raw) => JSON.parse(raw) as { pendingSlugs?: unknown[] })
.catch(() => ({ pendingSlugs: [] }));
if (articleState.pendingSlugs?.length) {
throw Object.assign(new Error("存在尚未编译的文章修改,请先在“文章”中统一编译测试预览"), { statusCode: 409 });
}
const taskId = "publish_" + crypto.randomBytes(4).toString("hex"); const taskId = "publish_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace"); const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(tenantId, siteId, { publishStatus: "publishing", lastPublishError: undefined }); await this.sites.update(tenantId, siteId, { publishStatus: "publishing", lastPublishError: undefined });
...@@ -164,6 +177,7 @@ export class SiteVersionService { ...@@ -164,6 +177,7 @@ export class SiteVersionService {
const dist = await this.builds.build(workspace, taskId, { const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicProductionUrl(tenantId, siteId), indexable: true, basePath: getPublicProductionUrl(tenantId, siteId), indexable: true,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId), articlesDirectory: this.sites.getArticlesPath(tenantId, siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
}); });
await this.builds.publishProduction(tenantId, siteId, dist); await this.builds.publishProduction(tenantId, siteId, dist);
const publishedAt = new Date().toISOString(); const publishedAt = new Date().toISOString();
...@@ -199,9 +213,11 @@ export class SiteVersionService { ...@@ -199,9 +213,11 @@ export class SiteVersionService {
const dist = await this.builds.build(workspace, taskId, { const dist = await this.builds.build(workspace, taskId, {
basePath: getPublicPreviewUrl(site.tenantId, site.siteId), indexable: false, basePath: getPublicPreviewUrl(site.tenantId, site.siteId), indexable: false,
articlesDirectory: this.sites.getArticlesPath(site.tenantId, site.siteId), articlesDirectory: this.sites.getArticlesPath(site.tenantId, site.siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(site.tenantId, site.siteId),
}); });
const published = await this.builds.publishPreview(site.tenantId, site.siteId, dist); const published = await this.builds.publishPreview(site.tenantId, site.siteId, dist);
await this.previews.start(site.tenantId, site.siteId, published, site.previewPort); await this.previews.start(site.tenantId, site.siteId, published, site.previewPort);
await this.sites.markArticlesCompiled(site.tenantId, site.siteId);
await this.sites.update(site.tenantId, site.siteId, { await this.sites.update(site.tenantId, site.siteId, {
...metadata, ...metadata,
status: "ready", previewCommit: commit, environmentVersion: 4, lastError: undefined, status: "ready", previewCommit: commit, environmentVersion: 4, lastError: undefined,
......
...@@ -4,9 +4,9 @@ import { ...@@ -4,9 +4,9 @@ import {
Archive, ArrowLeft, ArrowRight, BookOpen, Bot, Check, CheckCircle2, ChevronDown, Clock3, Code2, Copy, ExternalLink, Archive, ArrowLeft, ArrowRight, BookOpen, Bot, Check, CheckCircle2, ChevronDown, Clock3, Code2, Copy, ExternalLink,
Globe2, History, Laptop, LayoutGrid, Link2, LoaderCircle, MessageSquareText, Monitor, Globe2, History, Laptop, LayoutGrid, Link2, LoaderCircle, MessageSquareText, Monitor,
Plus, RefreshCw, Rocket, RotateCcw, Search, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Trash2, Plus, RefreshCw, Rocket, RotateCcw, Search, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Trash2,
WandSparkles, X, FileText, ImagePlus, Save, Upload, WandSparkles, X,
} from "lucide-react"; } from "lucide-react";
import type { AgentRunEvent, CreateSiteInput, CreateTenantInput, DomainBinding, GitHistoryItem, SessionInfo, SiteInfo, TenantAdminInfo } from "@webagent/shared"; import type { AgentRunEvent, ArticleInput, 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 };
...@@ -468,7 +468,9 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -468,7 +468,9 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
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) });
const historyQuery = useQuery({ queryKey: ["history", siteId], queryFn: () => api.history(siteId) }); const historyQuery = useQuery({ queryKey: ["history", siteId], queryFn: () => api.history(siteId) });
const [tab, setTab] = useState<"chat" | "history" | "domains">("chat"); const [tab, setTab] = useState<"chat" | "history" | "articles" | "domains">("chat");
const [selectedArticleSlug, setSelectedArticleSlug] = useState("");
const [creatingArticle, setCreatingArticle] = useState(false);
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("");
...@@ -488,6 +490,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -488,6 +490,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }, [messages]); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }, [messages]);
useEffect(() => { useEffect(() => {
setEnvironment("preview"); setEnvironment("preview");
setSelectedArticleSlug(""); setCreatingArticle(false);
setPendingPreviewCommit(""); setPendingPreviewCommit("");
setMessages([{ role: "agent", text: "已切换到这个网站。你可以继续描述修改要求。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" }]); setMessages([{ role: "agent", text: "已切换到这个网站。你可以继续描述修改要求。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" }]);
}, [siteId, agentMode]); }, [siteId, agentMode]);
...@@ -629,16 +632,16 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -629,16 +632,16 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
publishMutation.reset(); publishMutation.reset();
setWorkspaceDialog({ kind: "publish" }); setWorkspaceDialog({ kind: "publish" });
}; };
return <main className={`workspace ${tab === "domains" ? "domains-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={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><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={() => 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} /> <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={onSelectSite} />
<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 === "domains" ? "active" : ""} onClick={() => setTab("domains")}><Globe2 size={15} /> 域名</button></div> <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>
{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>
...@@ -680,9 +683,9 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -680,9 +683,9 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
</div> </div>
</article>; </article>;
})} })}
</div> : <DomainSidePanel site={site} />} </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} />}
</section> </section>
{tab === "domains" ? <DomainManagement site={site} /> : <section className="preview-shell"> {tab === "domains" ? <DomainManagement site={site} /> : tab === "articles" ? <ArticleManagement site={site} selectedSlug={selectedArticleSlug} creating={creatingArticle} onSelect={setSelectedArticleSlug} onCreating={setCreatingArticle} /> : <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 +720,135 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, ...@@ -717,6 +720,135 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
</main>; </main>;
} }
function emptyArticle(): ArticleInput {
return {
slug: `article-${Date.now()}`, title: "", summary: "", body: "", status: "draft", author: "", cover: "", tags: [],
publishedAt: "", updatedAt: "", seo: { title: "", description: "", ogDescription: "", ogImage: "", noindex: false },
};
}
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 }) {
const queryClient = useQueryClient();
const importInput = useRef<HTMLInputElement>(null);
const [notice, setNotice] = useState("");
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 refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ["articles", site.siteId] });
};
const compileMutation = useMutation({
mutationFn: () => api.compileArticles(site.siteId),
onSuccess: async () => { setNotice("文章已发布到测试环境;确认后请返回 Agent 工作台点击“重新上线”。"); await Promise.all([refresh(), onUpdated()]); },
});
const importMutation = useMutation({
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(); },
});
useEffect(() => {
if (!selectedSlug && !creating && articlesQuery.data?.articles[0]) onSelect(articlesQuery.data.articles[0].slug);
}, [articlesQuery.data, creating, onSelect, selectedSlug]);
const pending = articlesQuery.data?.pendingBuild || false;
const error = compileMutation.error || importMutation.error;
const busy = compileMutation.isPending || importMutation.isPending;
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-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 ${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>}
<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">{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>;
}
function ArticleManagement({ site, selectedSlug, creating, onSelect, onCreating }: { site: SiteInfo; selectedSlug: string; creating: boolean; onSelect: (slug: string) => void; onCreating: (creating: boolean) => void }) {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<ArticleInput>(emptyArticle);
const [notice, setNotice] = useState("");
const imageInput = useRef<HTMLInputElement>(null);
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 });
useEffect(() => {
if (creating) setDraft(emptyArticle());
}, [creating]);
useEffect(() => {
if (articleQuery.data && !creating) setDraft(articleQuery.data);
}, [articleQuery.data, creating]);
const refresh = async (slug?: string) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["articles", site.siteId] }),
queryClient.invalidateQueries({ queryKey: ["article", site.siteId] }),
]);
if (slug) onSelect(slug);
};
const saveMutation = useMutation({
mutationFn: (status: ArticleInput["status"]) => {
const today = new Date().toISOString().slice(0, 10);
const input = { ...draft, status, publishedAt: status === "published" ? draft.publishedAt || today : draft.publishedAt || undefined, updatedAt: today };
return creating ? api.createArticle(site.siteId, input) : api.updateArticle(site.siteId, selectedSlug, input);
},
onSuccess: async (result) => {
const slug = result.article?.slug || draft.slug;
if (result.article) setDraft(result.article);
onCreating(false);
setNotice(result.article?.status === "published" ? "已加入发布队列,尚未编译。" : "草稿已保存,本次没有触发编译。");
await refresh(slug);
},
});
const deleteMutation = useMutation({
mutationFn: () => api.deleteArticle(site.siteId, selectedSlug),
onSuccess: async () => { onSelect(""); onCreating(false); setDraft(emptyArticle()); setNotice("文章已删除,待统一编译后从预览移除。"); await refresh(); },
});
const imageMutation = useMutation({
mutationFn: async (file: File) => api.uploadArticleImage(site.siteId, file.name, await fileDataUrl(file)),
onSuccess: (result) => {
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 }));
setNotice("图片已上传并插入正文;保存文章并统一编译后即可在预览中查看。");
},
});
const error = saveMutation.error || deleteMutation.error || imageMutation.error || articleQuery.error;
const busy = saveMutation.isPending || deleteMutation.isPending || imageMutation.isPending;
const update = <K extends keyof ArticleInput>(key: K, value: ArticleInput[K]) => setDraft((current) => ({ ...current, [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;
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>;
return <section className="article-workspace">
<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">
<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>
{(notice || error) && <div className={`article-notice ${error ? "error" : ""}`}>{error ? <X size={14} /> : <CheckCircle2 size={14} />}{error ? error.message : notice}</div>}
<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><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>
<div className="article-publish-help"><span>内容状态</span><strong>{draft.status === "published" ? selectedPending ? "等待统一编译" : "测试预览已公开" : "仅保存为草稿"}</strong><small>保存不会触发构建,请在左侧统一编译。</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><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 type="date" value={draft.publishedAt || ""} onChange={(event) => update("publishedAt", event.target.value)} /></label>
<label><span>封面地址</span><input value={draft.cover || ""} placeholder="/images/articles/example.jpg" onChange={(event) => update("cover", event.target.value)} /></label>
<div className="article-body-field wide"><div><span>Markdown 正文</span><button type="button" disabled={busy} onClick={() => imageInput.current?.click()}>{imageMutation.isPending ? <LoaderCircle className="spin" size={13} /> : <ImagePlus size={13} />}上传并插入图片</button><input ref={imageInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={(event) => { const file = event.target.files?.[0]; if (file) imageMutation.mutate(file); event.target.value = ""; }} /></div><textarea rows={18} value={draft.body} placeholder={'## 小标题\n\n正文内容\n\n![图片说明](/images/articles/example.jpg)'} onChange={(event) => update("body", event.target.value)} /></div>
</div>
<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>
</div>
</section>;
}
function fileDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("图片读取失败"));
reader.onerror = () => reject(new Error("图片读取失败"));
reader.readAsDataURL(file);
});
}
function DomainSidePanel({ site }: { site: SiteInfo }) { function DomainSidePanel({ site }: { site: SiteInfo }) {
const domainsQuery = useQuery({ queryKey: ["domains", site.siteId], queryFn: () => api.domains(site.siteId) }); const domainsQuery = useQuery({ queryKey: ["domains", site.siteId], queryFn: () => api.domains(site.siteId) });
const active = domainsQuery.data?.domains.filter((domain) => domain.status === "active").length || 0; const active = domainsQuery.data?.domains.filter((domain) => domain.status === "active").length || 0;
......
import type { AgentRunEvent, AgentRunInfo, AgentSettings, CreateSiteInput, CreateTenantInput, DomainBinding, DomainListResult, DraftPreviewResult, GitHistoryItem, PreviewVersionResult, PublishResult, SessionInfo, SiteInfo, TenantAdminInfo, TenantAdminSiteInfo } from "@webagent/shared"; import type { AgentRunEvent, AgentRunInfo, AgentSettings, ArticleCompileResult, ArticleDocument, ArticleImageUploadResult, ArticleInput, ArticleListResult, ArticleMutationResult, CreateSiteInput, CreateTenantInput, DomainBinding, DomainListResult, DraftPreviewResult, GitHistoryItem, PreviewVersionResult, PublishResult, SessionInfo, SiteInfo, TenantAdminInfo, TenantAdminSiteInfo } from "@webagent/shared";
async function request<T>(url: string, options?: RequestInit): Promise<T> { async function request<T>(url: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers); const headers = new Headers(options?.headers);
...@@ -52,6 +52,14 @@ export const api = { ...@@ -52,6 +52,14 @@ export const api = {
restoreVersion: (siteId: string, commit: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/restore-version", { method: "POST", body: JSON.stringify({ commit }) }), restoreVersion: (siteId: string, commit: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/restore-version", { method: "POST", body: JSON.stringify({ commit }) }),
rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }), rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }),
publish: (siteId: string) => request<PublishResult>("/api/sites/" + siteId + "/publish", { method: "POST" }), publish: (siteId: string) => request<PublishResult>("/api/sites/" + siteId + "/publish", { method: "POST" }),
articles: (siteId: string) => request<ArticleListResult>(`/api/sites/${siteId}/articles`),
article: (siteId: string, slug: string) => request<ArticleDocument>(`/api/sites/${siteId}/articles/${slug}`),
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) }),
deleteArticle: (siteId: string, slug: string) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/${slug}`, { method: "DELETE" }),
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" }),
uploadArticleImage: (siteId: string, filename: string, dataUrl: string) => request<ArticleImageUploadResult>(`/api/sites/${siteId}/article-images`, { method: "POST", body: JSON.stringify({ filename, dataUrl }) }),
domains: (siteId: string) => request<DomainListResult>("/api/sites/" + siteId + "/domains"), domains: (siteId: string) => request<DomainListResult>("/api/sites/" + siteId + "/domains"),
addDomain: (siteId: string, hostname: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains", { method: "POST", body: JSON.stringify({ hostname }) }), addDomain: (siteId: string, hostname: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains", { method: "POST", body: JSON.stringify({ hostname }) }),
verifyDomain: (siteId: string, domainId: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains/" + domainId + "/verify", { method: "POST" }), verifyDomain: (siteId: string, domainId: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains/" + domainId + "/verify", { method: "POST" }),
......
...@@ -33,7 +33,7 @@ ...@@ -33,7 +33,7 @@
@container(max-width:620px){.preview-toolbar{min-height:0;grid-template-columns:minmax(0,1fr);grid-template-rows:auto;grid-template-areas:"title" "modes" "actions";gap:8px;padding:9px 10px}.preview-title{justify-content:space-between}.preview-modes,.toolbar-actions{justify-content:center}.toolbar-actions{flex-wrap:wrap}} @container(max-width:620px){.preview-toolbar{min-height:0;grid-template-columns:minmax(0,1fr);grid-template-rows:auto;grid-template-areas:"title" "modes" "actions";gap:8px;padding:9px 10px}.preview-title{justify-content:space-between}.preview-modes,.toolbar-actions{justify-content:center}.toolbar-actions{flex-wrap:wrap}}
@media(max-width:800px){.landing-header,.create-layout{width:min(100% - 30px,700px)}.landing-status{display:none}.feature-row{grid-template-columns:1fr}.create-card{padding:26px 20px}.form-grid{grid-template-columns:1fr}.field.wide{grid-column:auto}.workspace{grid-template-columns:52px 1fr}.control-panel{border-right:0}.preview-shell{display:none}.create-intro h1{font-size:48px}.create-layout{padding-bottom:35px}.create-workspace{grid-template-columns:52px 1fr}.create-workspace .create-control-panel{display:none}.workspace-create-content{padding:18px}.login-card{padding:30px 24px}} @media(max-width:800px){.landing-header,.create-layout{width:min(100% - 30px,700px)}.landing-status{display:none}.feature-row{grid-template-columns:1fr}.create-card{padding:26px 20px}.form-grid{grid-template-columns:1fr}.field.wide{grid-column:auto}.workspace{grid-template-columns:52px 1fr}.control-panel{border-right:0}.preview-shell{display:none}.create-intro h1{font-size:48px}.create-layout{padding-bottom:35px}.create-workspace{grid-template-columns:52px 1fr}.create-workspace .create-control-panel{display:none}.workspace-create-content{padding:18px}.login-card{padding:30px 24px}}
.panel-tabs{grid-template-columns:repeat(3,1fr)} .panel-tabs{grid-template-columns:repeat(4,1fr);padding-inline:8px}.panel-tabs button{gap:4px;font-size:9px}
.domain-side-panel{flex:1;min-height:0;overflow:auto;padding:30px 25px;background:linear-gradient(180deg,#fff,#faf9ff)} .domain-side-panel{flex:1;min-height:0;overflow:auto;padding:30px 25px;background:linear-gradient(180deg,#fff,#faf9ff)}
.domain-side-icon{display:grid;place-items:center;width:42px;height:42px;color:var(--primary);background:var(--soft);border:1px solid #dfd6ff;border-radius:13px} .domain-side-icon{display:grid;place-items:center;width:42px;height:42px;color:var(--primary);background:var(--soft);border:1px solid #dfd6ff;border-radius:13px}
.domain-side-panel h2{margin:18px 0 8px;font-size:20px}.domain-side-panel>p{margin:0;color:var(--muted);font-size:10px;line-height:1.8} .domain-side-panel h2{margin:18px 0 8px;font-size:20px}.domain-side-panel>p{margin:0;color:var(--muted);font-size:10px;line-height:1.8}
...@@ -79,3 +79,10 @@ ...@@ -79,3 +79,10 @@
.toolbar-actions .publish-button.unavailable,.toolbar-actions .publish-button.unavailable:hover{color:#8e9099;background:#efeff4;border:1px solid #e1e1e7;box-shadow:none;cursor:pointer} .toolbar-actions .publish-button.unavailable,.toolbar-actions .publish-button.unavailable:hover{color:#8e9099;background:#efeff4;border:1px solid #e1e1e7;box-shadow:none;cursor:pointer}
@media(max-width:1000px){.managed-site-card{grid-template-columns:46px minmax(0,1fr)}.managed-site-actions{grid-column:2;justify-content:flex-start;flex-wrap:wrap}.managed-site-states{flex-wrap:wrap}} @media(max-width:1000px){.managed-site-card{grid-template-columns:46px minmax(0,1fr)}.managed-site-actions{grid-column:2;justify-content:flex-start;flex-wrap:wrap}.managed-site-states{flex-wrap:wrap}}
@media(max-width:800px){.site-management-workspace{grid-template-columns:52px 1fr}.site-management-page{padding:28px 16px 42px}.site-management-header{align-items:flex-start;flex-direction:column}.site-management-filters{overflow-x:auto}.site-management-tools{align-items:flex-start;flex-direction:column}.site-management-tools label{width:100%}.managed-site-card{grid-template-columns:38px minmax(0,1fr);padding:13px}.managed-site-avatar{width:38px;height:38px}.managed-site-states{gap:8px}.managed-site-actions a,.managed-site-actions button{height:30px}.site-management-header>button{height:36px}} @media(max-width:800px){.site-management-workspace{grid-template-columns:52px 1fr}.site-management-page{padding:28px 16px 42px}.site-management-header{align-items:flex-start;flex-direction:column}.site-management-filters{overflow-x:auto}.site-management-tools{align-items:flex-start;flex-direction:column}.site-management-tools label{width:100%}.managed-site-card{grid-template-columns:38px minmax(0,1fr);padding:13px}.managed-site-avatar{width:38px;height:38px}.managed-site-states{gap:8px}.managed-site-actions a,.managed-site-actions button{height:30px}.site-management-header>button{height:36px}}
/* External Markdown article management */
.article-workspace{min-width:0;min-height:0;display:flex;flex-direction:column;background:#f4f5f9}.article-loading,.article-unsupported{align-items:center;justify-content:center;gap:10px;color:var(--muted)}.article-unsupported h1{margin:5px 0 0;color:var(--text);font-size:20px}.article-unsupported p{margin:0;font-size:10px}.article-header{height:66px;flex:0 0 66px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 22px;background:#fff;border-bottom:1px solid var(--border)}.article-header>div{display:flex;align-items:center;gap:10px}.article-header>div:first-child>span{display:grid;place-items:center;width:35px;height:35px;color:var(--primary);background:var(--soft);border-radius:10px}.article-header h1{margin:0;font-size:15px}.article-header p{margin:4px 0 0;color:var(--muted);font-size:8px}.article-header button,.article-import{height:33px;display:flex;align-items:center;gap:6px;padding:0 11px;color:#fff;background:var(--gradient);border:0;border-radius:9px;font-size:8px;font-weight:800;cursor:pointer}.article-import{color:var(--primary);background:var(--soft);border:1px solid #ded5ff}.article-import input,.article-body-field input{display:none}.article-layout{min-height:0;display:grid;grid-template-columns:250px minmax(0,1fr);flex:1}.article-list{min-height:0;overflow:auto;padding:13px;background:#fff;border-right:1px solid var(--border)}.article-list-heading{height:35px;display:flex;align-items:center;justify-content:space-between;padding:0 5px}.article-list-heading strong{font-size:10px}.article-list-heading span{display:grid;place-items:center;width:22px;height:22px;color:var(--primary);background:var(--soft);border-radius:7px;font-size:8px;font-weight:800}.article-list>button{width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px;margin-bottom:6px;padding:11px;color:var(--text);text-align:left;background:#fff;border:1px solid transparent;border-radius:10px}.article-list>button:hover,.article-list>button.active{background:#f7f5ff;border-color:#e1d9ff}.article-list>button>span{min-width:0}.article-list>button strong,.article-list>button small{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.article-list>button strong{font-size:9px}.article-list>button span small{margin-top:4px;color:var(--muted);font-size:7px}.article-list>button>small{grid-column:1/-1;color:var(--muted);font-size:7px}.article-list em{padding:3px 5px;border-radius:5px;font-size:7px;font-style:normal;font-weight:800}.article-list em.published{color:#13765b;background:#e7f8f2}.article-list em.draft{color:#8c6200;background:#fff7df}.article-empty{height:180px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;color:var(--muted);font-size:9px}.article-empty button{padding:6px 9px;color:var(--primary);background:var(--soft);border:0;border-radius:7px;font-size:8px;font-weight:750}.article-editor{min-width:0;overflow:auto;padding:22px}.article-editor-heading,.article-editor-heading>div{display:flex;align-items:center}.article-editor-heading{justify-content:space-between;gap:14px;margin-bottom:14px}.article-editor-heading>div:last-child{gap:6px}.article-editor-heading span{color:var(--primary);font-size:7px;font-weight:850;letter-spacing:.12em}.article-editor-heading h2{margin:4px 0 0;font-size:18px}.article-editor-heading button,.article-editor-heading a{height:31px;display:flex;align-items:center;gap:5px;padding:0 9px;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:8px;font-size:8px;font-weight:750;text-decoration:none}.article-editor-heading .article-delete{color:#b42318}.article-editor-heading .article-save{color:#fff;background:var(--gradient);border:0}.article-notice{display:flex;align-items:center;gap:6px;margin-bottom:12px;padding:9px 11px;color:#13765b;background:#e9f9f3;border-radius:9px;font-size:8px}.article-notice.error{color:#b42318;background:#fff0ee}.article-fields{display:grid;grid-template-columns:1fr 1fr;gap:12px;padding:17px;background:#fff;border:1px solid var(--border);border-radius:14px}.article-fields label{min-width:0;display:flex;flex-direction:column;gap:6px}.article-fields label>span,.article-body-field>div>span{color:var(--text-2);font-size:8px;font-weight:750}.article-fields .wide{grid-column:1/-1}.article-fields input,.article-fields textarea,.article-fields select,.article-body-field textarea{width:100%;padding:9px 10px;color:var(--text);background:#fafafd;border:1px solid var(--border);border-radius:8px;outline:0;font:inherit;font-size:10px;line-height:1.6}.article-fields input:focus,.article-fields textarea:focus,.article-fields select:focus,.article-body-field textarea:focus{background:#fff;border-color:#a98cff;box-shadow:0 0 0 3px rgba(112,40,255,.07)}.article-body-field>div{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.article-body-field button{height:28px;display:flex;align-items:center;gap:5px;padding:0 8px;color:var(--primary);background:var(--soft);border:1px solid #ded5ff;border-radius:7px;font-size:8px;font-weight:750}.article-body-field textarea{resize:vertical;font-family:"SFMono-Regular",Consolas,monospace}.article-seo{margin-top:12px;padding:14px 17px;background:#fff;border:1px solid var(--border);border-radius:14px}.article-seo summary{color:var(--text-2);font-size:9px;font-weight:800;cursor:pointer}.article-seo[open] summary{margin-bottom:12px}.article-seo .article-fields{padding:0;border:0}.article-checkbox{flex-direction:row!important;align-items:center}.article-checkbox input{width:auto}.article-side-panel{background:linear-gradient(180deg,#fff,#f8f5ff)}
@media(max-width:1150px){.article-layout{grid-template-columns:210px minmax(0,1fr)}.article-fields{grid-template-columns:1fr}.article-fields .wide{grid-column:auto}.article-header p{display:none}}
@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-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}
...@@ -17,6 +17,9 @@ importers: ...@@ -17,6 +17,9 @@ importers:
'@astrojs/check': '@astrojs/check':
specifier: ^0.9.4 specifier: ^0.9.4
version: 0.9.9(prettier@3.9.5)(typescript@5.8.3) version: 0.9.9(prettier@3.9.5)(typescript@5.8.3)
'@types/node':
specifier: ^22.15.3
version: 22.20.1
astro: astro:
specifier: ^5.7.0 specifier: ^5.7.0
version: 5.18.2(@types/node@22.20.1)(rollup@4.62.2)(tsx@4.23.1)(typescript@5.8.3)(yaml@2.9.0) version: 5.18.2(@types/node@22.20.1)(rollup@4.62.2)(tsx@4.23.1)(typescript@5.8.3)(yaml@2.9.0)
......
...@@ -222,6 +222,7 @@ export interface ArticleInput { ...@@ -222,6 +222,7 @@ export interface ArticleInput {
export interface ArticleSummary extends Omit<ArticleInput, "body"> { export interface ArticleSummary extends Omit<ArticleInput, "body"> {
wordCount: number; wordCount: number;
pendingBuild: boolean;
} }
export interface ArticleDocument extends ArticleInput { export interface ArticleDocument extends ArticleInput {
...@@ -231,12 +232,33 @@ export interface ArticleDocument extends ArticleInput { ...@@ -231,12 +232,33 @@ export interface ArticleDocument extends ArticleInput {
export interface ArticleListResult { export interface ArticleListResult {
supported: boolean; supported: boolean;
articles: ArticleSummary[]; articles: ArticleSummary[];
pendingBuild: boolean;
pendingCount: number;
lastCompiledAt?: string;
} }
export interface ArticleMutationResult { export interface ArticleMutationResult {
article?: ArticleDocument; article?: ArticleDocument;
deletedSlug?: string; deletedSlug?: string;
previewUrl: string; previewUrl: string;
pendingBuild: boolean;
}
export interface ArticleCompileResult {
previewUrl: string;
compiledAt: string;
articleCount: number;
}
export interface ArticleImageUploadInput {
filename: string;
dataUrl: string;
}
export interface ArticleImageUploadResult {
path: string;
filename: string;
size: number;
} }
export type DomainOwnershipStatus = "pending" | "verified" | "failed"; export type DomainOwnershipStatus = "pending" | "verified" | "failed";
......
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