Commit 61c4a3b5 authored by xuchentao's avatar xuchentao

feat: add versioned site creation templates

parent b542c952
{
"pageTemplate": { "id": "technology-corporate", "version": "1.0.0" },
"themeId": "violet-tech",
"layout": { "hero": "split", "density": "comfortable", "radius": "soft" }
}
---
import SeoHead from "../_platform/seo/SeoHead.astro";
import site from "../data/site.json";
import design from "../data/design.json";
interface Props {
title: string;
......@@ -25,5 +26,5 @@ const props = Astro.props;
<SeoHead {...props} />
<slot name="head" />
</head>
<body><slot /></body>
<body data-page-template={design.pageTemplate.id} data-hero={design.layout.hero} data-density={design.layout.density} data-radius={design.layout.radius}><slot /></body>
</html>
......@@ -18,6 +18,19 @@ button, input { font: inherit; }
}
.eyebrow::before { content: ""; width: 22px; height: 2px; background: var(--gradient-primary); }
.section { padding: 112px 0; }
body[data-density="compact"] .section { padding: 78px 0; }
body[data-radius="square"] :where(.visual,.contact-card,.service-grid article,.actions a,.nav-action) { border-radius: 3px !important; }
body[data-hero="centered"] .hero-grid { grid-template-columns: 1fr; text-align: center; }
body[data-hero="centered"] .hero-copy { max-width: 920px; margin: 0 auto; }
body[data-hero="centered"] .hero-copy > p { margin-left: auto; margin-right: auto; }
body[data-hero="centered"] .actions, body[data-hero="centered"] .metrics { justify-content: center; }
body[data-hero="centered"] .visual { display: none; }
body[data-page-template="professional-services"] .section-heading { margin-left: auto; margin-right: auto; text-align: center; }
body[data-page-template="professional-services"] .section-heading .eyebrow { justify-content: center; }
body[data-page-template="professional-services"] .service-grid article { border-top: 3px solid var(--color-primary); box-shadow: none; }
body[data-page-template="brand-story"] h1, body[data-page-template="brand-story"] h2 { font-family: Georgia, "Songti SC", serif; font-weight: 500; }
body[data-page-template="brand-story"] .service-grid { gap: 30px; }
body[data-page-template="brand-story"] .service-grid article { border: 0; box-shadow: 0 22px 60px color-mix(in srgb, var(--color-primary) 10%, transparent); }
.section-heading { max-width: 680px; margin-bottom: 56px; }
.section-heading h2 { margin: 14px 0 18px; font-size: clamp(34px, 4.6vw, 58px); letter-spacing: -.045em; line-height: 1.08; }
.section-heading p { margin: 0; color: var(--color-text-secondary); font-size: 17px; line-height: 1.8; }
......
......@@ -46,6 +46,7 @@ export const config = {
rootDir,
runtimeDir: path.resolve(currentDir, "../../.runtime"),
templateDir: path.resolve(currentDir, "../../astro-template"),
siteKitDir: path.resolve(currentDir, "../../site-kit"),
sitesDir,
previewsDir,
productionDir,
......
......@@ -8,6 +8,15 @@ export const createSiteSchema = z.object({
email: z.string().email(),
phone: z.string().trim().max(30).optional(),
brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "品牌色必须是 6 位十六进制颜色"),
pageTemplateId: z.string().regex(/^[a-z0-9-]+$/),
pageTemplateVersion: z.string().regex(/^\d+\.\d+\.\d+$/),
themeId: z.string().regex(/^[a-z0-9-]+$/),
layout: z.object({
hero: z.enum(["split", "centered"]),
density: z.enum(["comfortable", "compact"]),
radius: z.enum(["soft", "square"]),
}),
initialPrompt: z.string().trim().max(2000).optional().or(z.literal("").transform(() => undefined)),
});
export const loginSchema = z.object({
......
......@@ -16,6 +16,7 @@ import {
tenantStatusSchema, updateDomainSchema, versionCommitSchema,
} from "./schemas.js";
import { CreateSiteService } from "./sites/create-site.js";
import { SiteTemplateCatalogService } from "./sites/site-template-catalog.js";
import { SiteRepository } from "./sites/site-repository.js";
import { SiteVersionService } from "./sites/site-version-service.js";
import { SiteLifecycleService } from "./sites/site-lifecycle-service.js";
......@@ -39,7 +40,8 @@ const sites = new SiteRepository();
const git = new GitManager();
const builds = new BuildManager();
const previews = new PreviewProcessManager();
const createSite = new CreateSiteService(sites, git, builds, previews);
const templateCatalog = new SiteTemplateCatalogService();
const createSite = new CreateSiteService(sites, git, builds, previews, templateCatalog);
const agentRuns = new AgentRunRepository();
const agentProviders = createAgentProviders();
const versions = new SiteVersionService(sites, git, builds, previews);
......@@ -88,6 +90,7 @@ app.put("/api/agent-settings", async (request) => {
});
app.get("/api/sites", async (request) => sites.list(requireTenant(request.auth)));
app.get("/api/site-templates", async () => templateCatalog.list());
app.get("/api/archived-sites", async (request) => sites.listArchived(requireTenant(request.auth)));
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId", async (request) => sites.get(requireTenant(request.auth), request.params.siteId));
app.post("/api/sites", async (request, reply) => reply.code(201).send(await createSite.execute(requireTenant(request.auth), createSiteSchema.parse(request.body))));
......
......@@ -7,6 +7,7 @@ import { GitManager } from "../git/git-manager.js";
import { BuildManager } from "../build/build-manager.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { SiteRepository } from "./site-repository.js";
import { SiteTemplateCatalogService } from "./site-template-catalog.js";
export class CreateSiteService {
constructor(
......@@ -14,16 +15,26 @@ export class CreateSiteService {
private readonly git: GitManager,
private readonly builds: BuildManager,
private readonly previews: PreviewProcessManager,
private readonly templates: SiteTemplateCatalogService,
) {}
async execute(tenantId: string, input: CreateSiteInput): Promise<SiteInfo> {
await this.sites.ensureRuntime();
const selection = await this.templates.resolve(input.pageTemplateId, input.pageTemplateVersion, input.themeId, input.layout);
const siteId = "site_" + crypto.randomBytes(16).toString("hex");
const projectPath = this.sites.getProjectPath(tenantId, siteId);
const previewPort = await this.sites.allocatePreviewPort();
const now = new Date().toISOString();
const site: SiteInfo = {
tenantId, siteId, name: input.name, industry: input.industry, status: "creating", templateVersion: "1.0.0", environmentVersion: 4,
tenantId, siteId, name: input.name, industry: input.industry, status: "creating", templateVersion: selection.kernel.version, environmentVersion: 4,
template: {
kernel: selection.kernel,
createdFrom: {
pageTemplateId: selection.template.id, pageTemplateVersion: selection.template.version,
themeId: selection.theme.id, layout: input.layout,
},
...(input.initialPrompt ? { initialPrompt: input.initialPrompt } : {}),
},
previewPort, previewUrl: getPublicPreviewUrl(tenantId, siteId), currentCommit: "", publishStatus: "unpublished",
createdAt: now, updatedAt: now,
};
......@@ -56,12 +67,17 @@ export class CreateSiteService {
siteData.organization.legalName = input.name;
await writeFile(siteDataPath, JSON.stringify(siteData, null, 2) + "\n", "utf8");
const homePath = path.join(projectPath, "src/data/home.json");
const home = JSON.parse(await readFile(homePath, "utf8")) as { services: Array<{ number: string; title: string; description: string }> };
const home = JSON.parse(await readFile(homePath, "utf8")) as Record<string, unknown> & { services: Array<{ number: string; title: string; description: string }> };
Object.assign(home, selection.template.content);
home.services = input.services.map((service, index) => ({ number: String(index + 1).padStart(2, "0"), title: service, description: "以专业方法和可靠交付,为客户提供高质量的" + service + "服务。" }));
await writeFile(homePath, JSON.stringify(home, null, 2) + "\n", "utf8");
const themePath = path.join(projectPath, "src/styles/theme.css");
const theme = (await readFile(themePath, "utf8")).replaceAll("#7028ff", input.brandColor.toLowerCase());
await writeFile(themePath, theme, "utf8");
await writeFile(themePath, this.templates.renderTheme(selection.theme, input.brandColor), "utf8");
await writeFile(path.join(projectPath, "src/data/design.json"), JSON.stringify({
pageTemplate: { id: selection.template.id, version: selection.template.version },
themeId: selection.theme.id, layout: input.layout,
}, null, 2) + "\n", "utf8");
await writeFile(path.join(projectPath, "webagent.site.json"), JSON.stringify(site.template, null, 2) + "\n", "utf8");
const dist = await this.builds.build(projectPath, "create_" + siteId, {
basePath: getPublicPreviewUrl(tenantId, siteId), indexable: false, articlesDirectory, articleImagesDirectory,
});
......
import assert from "node:assert/strict";
import test from "node:test";
import { SiteTemplateCatalogService } from "./site-template-catalog.js";
test("catalog exposes the stable kernel and published page templates", async () => {
const service = new SiteTemplateCatalogService();
const catalog = await service.list();
assert.deepEqual(catalog.kernel, { id: "official-site", version: "1.0.0" });
assert.deepEqual(catalog.pageTemplates.map((template) => template.id).sort(), [
"brand-story", "professional-services", "technology-corporate",
]);
assert.equal(catalog.themes.length, 5);
});
test("catalog resolves only supported template, theme and layout combinations", async () => {
const service = new SiteTemplateCatalogService();
const selection = await service.resolve("technology-corporate", "1.0.0", "ocean-blue", {
hero: "split", density: "compact", radius: "soft",
});
assert.equal(selection.kernel.version, "1.0.0");
assert.equal(selection.template.id, "technology-corporate");
assert.equal(selection.theme.id, "ocean-blue");
await assert.rejects(() => service.resolve("technology-corporate", "1.0.0", "warm-amber", {
hero: "split", density: "compact", radius: "soft",
}), /不支持所选配色/);
});
test("theme renderer emits a complete semantic palette and custom primary color", async () => {
const service = new SiteTemplateCatalogService();
const { theme } = await service.resolve("brand-story", "1.0.0", "warm-amber", {
hero: "centered", density: "comfortable", radius: "soft",
});
const css = service.renderTheme(theme, "#123456");
assert.match(css, /--color-primary: #123456;/);
assert.match(css, /--color-text-primary:/);
assert.match(css, /--color-border:/);
assert.match(css, /--gradient-primary:/);
assert.doesNotMatch(css, /function shade/);
});
import path from "node:path";
import { readFile, readdir } from "node:fs/promises";
import type { PageTemplateInfo, SiteLayoutParameters, SiteTemplateCatalog, ThemePresetInfo } from "@webagent/shared";
import { config } from "../config.js";
type ThemeDefinition = ThemePresetInfo & {
primary: string; hover: string; light: string; secondary: string; dark: string;
background: string; soft: string; text: string; textSecondary: string; muted: string; border: string;
};
type PageTemplateDefinition = PageTemplateInfo & {
status: "draft" | "testing" | "published" | "retired";
compatibleKernel: string;
content: {
eyebrow: string; heroTitle: string; heroDescription: string; primaryAction: string; secondaryAction: string;
metrics: Array<{ value: string; label: string }>;
advantages: Array<{ title: string; description: string }>;
};
};
export class SiteTemplateCatalogService {
async list(): Promise<SiteTemplateCatalog> {
const kernel = await this.kernel();
const [themes, pageTemplates] = await Promise.all([this.themes(), this.pageTemplates()]);
return {
kernel, themes: themes.map(({ id, name, colors }) => ({ id, name, colors })),
pageTemplates: pageTemplates.map(({ status: _status, compatibleKernel: _compatible, content: _content, ...template }) => template),
};
}
async resolve(pageTemplateId: string, version: string, themeId: string, layout: SiteLayoutParameters) {
const [kernel, templates, themes] = await Promise.all([this.kernel(), this.pageTemplates(), this.themes()]);
const template = templates.find((item) => item.id === pageTemplateId && item.version === version);
if (!template) throw Object.assign(new Error("页面模板不存在或已下架"), { statusCode: 400 });
if (!template.compatibleKernel.startsWith(kernel.version.split(".")[0] + ".")) throw new Error("页面模板与当前官网内核不兼容");
if (!template.themeIds.includes(themeId)) throw Object.assign(new Error("当前页面模板不支持所选配色"), { statusCode: 400 });
const theme = themes.find((item) => item.id === themeId);
if (!theme) throw Object.assign(new Error("配色模板不存在"), { statusCode: 400 });
for (const key of ["hero", "density", "radius"] as const) {
if (!(template.layouts[key] as string[]).includes(layout[key])) throw Object.assign(new Error(`页面模板不支持所选${key}参数`), { statusCode: 400 });
}
return { kernel, template, theme };
}
renderTheme(theme: ThemeDefinition, brandColor: string): string {
const primary = brandColor.toLowerCase();
const hover = shade(primary, -16);
const light = shade(primary, 24);
return `:root {
--color-primary: ${primary};
--color-primary-hover: ${hover};
--color-primary-light: ${light};
--color-secondary: ${theme.secondary};
--color-primary-dark: ${theme.dark};
--color-background: ${theme.background};
--color-background-soft: ${theme.soft};
--color-surface: #ffffff;
--color-text-primary: ${theme.text};
--color-text-secondary: ${theme.textSecondary};
--color-text-muted: ${theme.muted};
--color-border: ${theme.border};
--color-divider: ${theme.border};
--gradient-primary: linear-gradient(135deg, ${primary} 0%, ${theme.secondary} 100%);
--gradient-dark: linear-gradient(135deg, ${theme.dark} 0%, ${theme.primary} 60%, ${theme.secondary} 100%);
--gradient-soft: linear-gradient(180deg, ${theme.soft} 0%, #ffffff 100%);
}
`;
}
private async kernel(): Promise<{ id: string; version: string }> {
const value = JSON.parse(await readFile(path.join(config.siteKitDir, "kernel", "manifest.json"), "utf8")) as { id: string; version: string; status: string };
if (value.status !== "stable") throw new Error("当前没有可用的稳定官网内核");
return { id: value.id, version: value.version };
}
private async themes(): Promise<ThemeDefinition[]> {
return JSON.parse(await readFile(path.join(config.siteKitDir, "themes.json"), "utf8")) as ThemeDefinition[];
}
private async pageTemplates(): Promise<PageTemplateDefinition[]> {
const root = path.join(config.siteKitDir, "page-templates");
const templateDirectories = await readdir(root, { withFileTypes: true });
const manifests: PageTemplateDefinition[] = [];
for (const templateDirectory of templateDirectories.filter((entry) => entry.isDirectory())) {
const versionRoot = path.join(root, templateDirectory.name);
const versions = await readdir(versionRoot, { withFileTypes: true });
for (const version of versions.filter((entry) => entry.isDirectory())) {
const manifest = JSON.parse(await readFile(path.join(versionRoot, version.name, "manifest.json"), "utf8")) as PageTemplateDefinition;
if (manifest.status === "published") manifests.push(manifest);
}
}
return manifests.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
}
}
function shade(hex: string, amount: number): string {
const value = Number.parseInt(hex.slice(1), 16);
const adjust = (channel: number) => Math.max(0, Math.min(255, channel + Math.round(255 * amount / 100)));
const channels = [value >> 16, (value >> 8) & 255, value & 255].map(adjust);
return "#" + channels.map((channel) => channel.toString(16).padStart(2, "0")).join("");
}
# 官网模板架构
官网创建由三个独立版本域组成:
1. **内核版本**:平台维护的构建、SEO/GEO、文章协议与发布能力。普通用户不选择,站点保存精确版本。
2. **页面成品版本**:用户在创建时选择的页面形状、默认内容和视觉方向。创建后成为站点自己的页面,不自动跟随模板升级。
3. **站点 Git 版本**:用户和 Agent 对生成结果的持续修改历史。
## 目录职责
- `site-kit/kernel/manifest.json`:当前稳定内核的身份和版本。
- `site-kit/page-templates/<id>/<version>/manifest.json`:已发布页面成品及其兼容范围、默认内容、支持配色和布局参数。
- `site-kit/themes.json`:完整语义配色方案。
- `astro-template/`:当前内核运行载体和页面渲染契约。
页面成品发布后不可原地修改;任何调整必须创建新版本。创建服务只接受目录中已发布且与当前内核兼容的精确版本组合。
## 创建不变量
- 先生成并构建不依赖模型的基础成品。
- 用户未填写 Prompt 时,基础成品直接成为第一版官网。
- 用户填写 Prompt 时,Agent 只在站点草稿中修改并接受路径与构建校验。
- 智能定制成功后保存为第二个 Git 版本;失败则丢弃草稿,保留已经可用的基础成品。
- `metadata/site.json` 和站点根目录的 `webagent.site.json` 记录精确创建来源;页面模板后续升级不会覆盖已有网站。
......@@ -6,7 +6,7 @@ import {
Plus, RefreshCw, Rocket, RotateCcw, Search, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Trash2,
FileText, ImagePlus, Save, Upload, WandSparkles, X,
} from "lucide-react";
import type { AgentRunEvent, ArticleInput, ArticleSummary, CreateSiteInput, CreateTenantInput, DomainBinding, GitHistoryItem, SessionInfo, SiteInfo, TenantAdminInfo } from "@webagent/shared";
import type { AgentRunEvent, ArticleInput, ArticleSummary, CreateSiteInput, CreateTenantInput, DomainBinding, GitHistoryItem, SessionInfo, SiteInfo, SiteTemplateCatalog, TenantAdminInfo } from "@webagent/shared";
import { api } from "./api";
type ChatMessage = { role: "user" | "agent"; text: string; meta?: string };
......@@ -202,20 +202,62 @@ function AdminWorkspace({ session, onLogout }: { session: SessionInfo; onLogout:
}
function CreateSiteForm({ onCreated, onCancel }: { onCreated: (site: SiteInfo) => void; onCancel?: () => void }) {
const templatesQuery = useQuery({ queryKey: ["site-templates"], queryFn: api.siteTemplates });
const [customizationWarning, setCustomizationWarning] = useState("");
const [form, setForm] = useState<CreateSiteInput>({
name: "云启科技", industry: "企业数字化服务", description: "专注于为成长型企业提供智能化、可持续的数字解决方案,让技术真正服务于业务增长。",
services: ["数字化咨询", "智能产品开发", "品牌体验设计"], email: "hello@yunqi.example", phone: "400-800-2026", brandColor: "#7028FF",
pageTemplateId: "technology-corporate", pageTemplateVersion: "1.0.0", themeId: "violet-tech",
layout: { hero: "split", density: "comfortable", radius: "soft" }, initialPrompt: "",
});
const [serviceText, setServiceText] = useState(form.services.join("、"));
const mutation = useMutation({ mutationFn: api.createSite, onSuccess: onCreated });
const mutation = useMutation({
mutationFn: async (input: CreateSiteInput) => {
setCustomizationWarning("");
const site = await api.createSite(input);
if (!input.initialPrompt?.trim()) return { site, warning: "" };
try {
await api.runAgent(site.siteId, input.initialPrompt);
await api.saveDraft(site.siteId, "首次智能定制");
return { site: await api.site(site.siteId), warning: "" };
} catch (error) {
await api.discardDraft(site.siteId).catch(() => undefined);
return { site: await api.site(site.siteId).catch(() => site), warning: error instanceof Error ? error.message : String(error) };
}
},
onSuccess: ({ site, warning }) => {
setCustomizationWarning(warning);
if (warning) window.setTimeout(() => onCreated(site), 1800);
else onCreated(site);
},
});
const submit = (event: FormEvent) => {
event.preventDefault();
mutation.mutate({ ...form, services: serviceText.split(/[、,,\n]/).map((item) => item.trim()).filter(Boolean) });
};
const catalog = templatesQuery.data;
const selectedTemplate = catalog?.pageTemplates.find((item) => item.id === form.pageTemplateId && item.version === form.pageTemplateVersion);
const availableThemes = catalog?.themes.filter((theme) => selectedTemplate?.themeIds.includes(theme.id)) || [];
const chooseTemplate = (template: SiteTemplateCatalog["pageTemplates"][number]) => {
const theme = catalog?.themes.find((item) => item.id === template.defaultThemeId);
setForm({ ...form, pageTemplateId: template.id, pageTemplateVersion: template.version, themeId: template.defaultThemeId, brandColor: theme?.colors[0] || form.brandColor, layout: template.defaults });
};
if (templatesQuery.isLoading) return <section className="workspace-create-content"><div className="template-loading"><LoaderCircle className="spin" size={20} />正在读取页面成品…</div></section>;
if (templatesQuery.error) return <section className="workspace-create-content"><div className="form-error template-catalog-error"><X size={15} />{templatesQuery.error.message}</div></section>;
return <section className="workspace-create-content">
<form className="create-card workspace-create-card" onSubmit={submit}>
<div className="card-heading"><div><span>创建新网站</span><h2>告诉我你的企业信息</h2><p>填写基础信息,Agent 将生成首个可编辑版本。</p></div>{onCancel && <button className="workspace-create-cancel" type="button" onClick={onCancel}><X size={15} />取消</button>}</div>
<div className="card-heading"><div><span>创建新网站</span><h2>选择成品,生成第一版官网</h2><p>成品本身即可使用,也可以调整参数或补充智能定制要求。</p></div>{onCancel && <button className="workspace-create-cancel" type="button" onClick={onCancel}><X size={15} />取消</button>}</div>
<CreateSection number="01" title="选择页面成品">
<div className="template-grid">{catalog?.pageTemplates.map((template) => <button className={`template-card ${selectedTemplate?.id === template.id ? "active" : ""}`} type="button" key={`${template.id}@${template.version}`} onClick={() => chooseTemplate(template)}>
<TemplatePreview template={template} />
<span><strong>{template.name}</strong><small>{template.description}</small><em>{template.industries.join(" · ")}</em></span>
{selectedTemplate?.id === template.id && <i><Check size={12} /></i>}
</button>)}</div>
</CreateSection>
<CreateSection number="02" title="企业资料">
<div className="form-grid">
<Field label="企业名称"><input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></Field>
<Field label="所属行业"><input required value={form.industry} onChange={(e) => setForm({ ...form, industry: e.target.value })} /></Field>
......@@ -223,17 +265,45 @@ function CreateSiteForm({ onCreated, onCancel }: { onCreated: (site: SiteInfo) =
<Field label="核心服务" hint="使用逗号或顿号分隔" wide><input required value={serviceText} onChange={(e) => setServiceText(e.target.value)} /></Field>
<Field label="联系邮箱"><input required type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></Field>
<Field label="联系电话"><input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></Field>
<Field label="品牌主色" wide>
<div className="color-field"><input type="color" value={form.brandColor} onChange={(e) => setForm({ ...form, brandColor: e.target.value.toUpperCase() })} /><span>{form.brandColor}</span><div className="color-swatches">{["#7028FF", "#536CFF", "#0F766E", "#1D4ED8", "#C2410C"].map((color) => <button type="button" aria-label={color} key={color} style={{ background: color }} onClick={() => setForm({ ...form, brandColor: color })} className={form.brandColor === color ? "active" : ""} />)}</div></div>
</Field>
</div>
</CreateSection>
<CreateSection number="03" title="样式与布局">
<div className="preset-label">配色方案</div>
<div className="theme-options">{availableThemes.map((theme) => <button type="button" className={form.themeId === theme.id ? "active" : ""} key={theme.id} onClick={() => setForm({ ...form, themeId: theme.id, brandColor: theme.colors[0] })}><span>{theme.colors.map((color) => <i key={color} style={{ background: color }} />)}</span><strong>{theme.name}</strong>{form.themeId === theme.id && <Check size={12} />}</button>)}</div>
<div className="brand-color-control"><span>品牌主色</span><label><input type="color" value={form.brandColor} onChange={(event) => setForm({ ...form, brandColor: event.target.value.toUpperCase() })} /><code>{form.brandColor}</code><small>可在配色方案基础上覆盖主色</small></label></div>
<div className="create-parameter-grid">
<PresetButtons label="首屏结构" value={form.layout.hero} options={selectedTemplate?.layouts.hero || []} labels={{ split: "左右结构", centered: "居中结构" }} onChange={(hero) => setForm({ ...form, layout: { ...form.layout, hero } })} />
<PresetButtons label="页面密度" value={form.layout.density} options={selectedTemplate?.layouts.density || []} labels={{ comfortable: "舒展", compact: "紧凑" }} onChange={(density) => setForm({ ...form, layout: { ...form.layout, density } })} />
<PresetButtons label="边角风格" value={form.layout.radius} options={selectedTemplate?.layouts.radius || []} labels={{ soft: "柔和圆角", square: "利落直角" }} onChange={(radius) => setForm({ ...form, layout: { ...form.layout, radius } })} />
</div>
</CreateSection>
<CreateSection number="04" title="智能定制(可选)">
<Field label="还有哪些具体要求?" hint="不填写即可直接使用成品" wide><textarea rows={4} placeholder="例如:整体更稳重,突出企业级服务,首屏增加预约咨询的表达……" value={form.initialPrompt || ""} onChange={(event) => setForm({ ...form, initialPrompt: event.target.value })} /></Field>
</CreateSection>
{mutation.error && <div className="form-error"><X size={15} />{mutation.error.message}</div>}
<button className="create-submit" disabled={mutation.isPending}>{mutation.isPending ? <><LoaderCircle className="spin" size={18} /> 正在创建、构建并启动预览…</> : <>生成我的企业官网 <ArrowRight size={18} /></>}</button>
{customizationWarning && <div className="form-warning"><CheckCircle2 size={15} /><span>官网成品已创建,智能定制未应用:{customizationWarning}</span></div>}
<button className="create-submit" disabled={mutation.isPending || !selectedTemplate}>{mutation.isPending ? <><LoaderCircle className="spin" size={18} /> {form.initialPrompt ? "正在生成成品并进行智能定制…" : "正在生成并构建官网…"}</> : <>生成第一版官网 <ArrowRight size={18} /></>}</button>
<p className="local-note"><ShieldCheck size={13} /> 创建完成后将直接进入工作台编辑</p>
</form>
</section>;
}
function CreateSection({ number, title, children }: { number: string; title: string; children: React.ReactNode }) {
return <section className="create-section"><header><span>{number}</span><h3>{title}</h3></header>{children}</section>;
}
function TemplatePreview({ template }: { template: SiteTemplateCatalog["pageTemplates"][number] }) {
return <span className={`template-preview ${template.preview.mode}`} style={{ background: template.preview.surface }}>
<i className="preview-nav"><b style={{ background: template.preview.accent }} /><small /><small /><small /></i>
<i className="preview-body"><span><small style={{ background: template.preview.accent }} /><strong /><strong /><em /><b style={{ background: template.preview.accent }} /></span><span className="preview-art" style={{ background: `linear-gradient(145deg, ${template.preview.accent}, #24213b)` }} /></i>
<i className="preview-cards"><small /><small /><small /></i>
</span>;
}
function PresetButtons<T extends string>({ label, value, options, labels, onChange }: { label: string; value: T; options: T[]; labels: Record<T, string>; onChange: (value: T) => void }) {
return <div className="preset-buttons"><span>{label}</span><div>{options.map((option) => <button type="button" className={option === value ? "active" : ""} key={option} onClick={() => onChange(option)}>{labels[option]}</button>)}</div></div>;
}
function Field({ label, hint, wide, children }: { label: string; hint?: string; wide?: boolean; children: React.ReactNode }) {
return <label className={wide ? "field wide" : "field"}><span>{label}{hint && <small>{hint}</small>}</span>{children}</label>;
}
......
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";
import type { AgentRunEvent, AgentRunInfo, AgentSettings, ArticleCompileResult, ArticleDocument, ArticleImageUploadResult, ArticleInput, ArticleListResult, ArticleMutationResult, CreateSiteInput, CreateTenantInput, DomainBinding, DomainListResult, DraftPreviewResult, GitHistoryItem, PreviewVersionResult, PublishResult, SessionInfo, SiteInfo, SiteTemplateCatalog, TenantAdminInfo, TenantAdminSiteInfo } from "@webagent/shared";
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers);
......@@ -26,6 +26,7 @@ export const api = {
agentSettings: () => request<AgentSettings>("/api/agent-settings"),
updateAgentSettings: (provider: AgentSettings["provider"]) => request<AgentSettings>("/api/agent-settings", { method: "PUT", body: JSON.stringify({ provider }) }),
sites: () => request<SiteInfo[]>("/api/sites"),
siteTemplates: () => request<SiteTemplateCatalog>("/api/site-templates"),
archivedSites: () => request<SiteInfo[]>("/api/archived-sites"),
site: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId),
createSite: (input: CreateSiteInput) => request<SiteInfo>("/api/sites", { method: "POST", body: JSON.stringify(input) }),
......
.workspace-create-card { width: min(940px, 100%); }
.template-loading { margin: auto; display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: 12px; }
.template-catalog-error { margin: auto; }
.create-section { padding: 25px 0; border-top: 1px solid var(--divider); }
.create-section > header { display: flex; align-items: center; gap: 9px; margin-bottom: 17px; }
.create-section > header span { display: grid; place-items: center; width: 24px; height: 24px; color: var(--primary); background: var(--soft); border-radius: 7px; font-size: 9px; font-weight: 800; }
.create-section > header h3 { margin: 0; font-size: 14px; }
.template-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.template-card { position: relative; min-width: 0; padding: 8px; text-align: left; background: #fff; border: 1px solid var(--border); border-radius: 13px; transition: .2s; }
.template-card:hover { border-color: #bba3f3; transform: translateY(-2px); }
.template-card.active { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(112,40,255,.08); }
.template-card > span:nth-child(2) { display: flex; flex-direction: column; padding: 11px 6px 7px; }
.template-card strong { font-size: 11px; }
.template-card small { margin-top: 5px; color: var(--muted); font-size: 8px; line-height: 1.55; }
.template-card em { margin-top: 8px; color: var(--primary); font-size: 7px; font-style: normal; }
.template-card > i { position: absolute; right: 12px; top: 12px; display: grid; place-items: center; width: 20px; height: 20px; color: #fff; background: var(--primary); border: 2px solid #fff; border-radius: 50%; }
.template-preview { height: 132px; display: flex !important; flex-direction: column !important; overflow: hidden; padding: 0 !important; border-radius: 8px; }
.preview-nav { height: 24px; padding: 0 10px; display: flex; align-items: center; gap: 8px; background: rgba(255,255,255,.86); }
.preview-nav b { width: 11px; height: 11px; border-radius: 3px; }
.preview-nav small { width: 18px; height: 2px; margin: 0; background: #c6c2cf; border-radius: 2px; }
.preview-nav small:first-of-type { margin-left: auto; }
.preview-body { flex: 1; display: grid; grid-template-columns: 1.15fr .85fr; align-items: center; gap: 11px; padding: 13px; }
.preview-body > span:first-child { display: flex; flex-direction: column; gap: 5px; }
.preview-body > span small { width: 26px; height: 3px; margin: 0; }
.preview-body strong { width: 84%; height: 6px; background: #2d2b35; border-radius: 2px; }
.preview-body strong + strong { width: 65%; }
.preview-body em { width: 92%; height: 3px; background: #a6a1ae; }
.preview-body b { width: 34px; height: 10px; border-radius: 3px; }
.preview-art { height: 60px; border-radius: 8px; }
.preview-cards { height: 26px; padding: 0 13px 9px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.preview-cards small { height: 17px; margin: 0; background: rgba(255,255,255,.92); border: 1px solid rgba(50,45,65,.08); border-radius: 4px; }
.preset-label { margin-bottom: 9px; color: var(--text-2); font-size: 10px; font-weight: 700; }
.theme-options { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
.theme-options button { min-width: 0; height: 52px; padding: 7px 9px; display: grid; grid-template-columns: 1fr auto; grid-template-rows: auto auto; align-items: center; text-align: left; background: #fff; border: 1px solid var(--border); border-radius: 9px; }
.theme-options button.active { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(112,40,255,.07); }
.theme-options button > span { display: flex; grid-column: 1 / -1; }
.theme-options button i { width: 17px; height: 8px; }
.theme-options button i:first-child { border-radius: 4px 0 0 4px; }
.theme-options button i:last-child { border-radius: 0 4px 4px 0; }
.theme-options button strong { font-size: 8px; }
.theme-options button svg { color: var(--primary); }
.brand-color-control { margin-top: 13px; display: grid; grid-template-columns: 78px 1fr; align-items: center; color: var(--text-2); font-size: 9px; font-weight: 700; }
.brand-color-control label { height: 38px; display: flex; align-items: center; gap: 10px; padding: 4px 9px; background: #fbfbfd; border: 1px solid var(--border); border-radius: 8px; }
.brand-color-control input { width: 28px; height: 28px; padding: 1px; border: 0; background: transparent; }
.brand-color-control code { color: var(--text-2); font-size: 9px; }
.brand-color-control small { margin-left: auto; color: var(--muted); font-size: 8px; font-weight: 500; }
.create-parameter-grid { margin-top: 16px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.preset-buttons > span { display: block; margin-bottom: 7px; color: var(--text-2); font-size: 9px; font-weight: 700; }
.preset-buttons > div { display: flex; padding: 3px; background: #f5f4f8; border-radius: 8px; }
.preset-buttons button { flex: 1; height: 29px; color: var(--muted); background: transparent; border: 0; border-radius: 6px; font-size: 8px; font-weight: 700; }
.preset-buttons button.active { color: var(--primary); background: #fff; box-shadow: 0 2px 7px rgba(32,24,52,.08); }
.form-warning { display: flex; align-items: flex-start; gap: 8px; margin-top: 16px; padding: 11px 13px; color: #8a5a12; background: #fff8e7; border: 1px solid #f4dfad; border-radius: 9px; font-size: 10px; line-height: 1.6; }
.form-warning svg { flex: none; margin-top: 1px; }
@media (max-width: 800px) { .template-grid, .create-parameter-grid { grid-template-columns: 1fr; } .theme-options { grid-template-columns: repeat(2, 1fr); } }
......@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./styles.css";
import "./create-template.css";
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } });
......
......@@ -10,6 +10,57 @@ export interface CreateSiteInput {
email: string;
phone?: string;
brandColor: string;
pageTemplateId: string;
pageTemplateVersion: string;
themeId: string;
layout: SiteLayoutParameters;
initialPrompt?: string;
}
export interface SiteLayoutParameters {
hero: "split" | "centered";
density: "comfortable" | "compact";
radius: "soft" | "square";
}
export interface ThemePresetInfo {
id: string;
name: string;
colors: string[];
}
export interface PageTemplateInfo {
id: string;
version: string;
name: string;
description: string;
industries: string[];
preview: { accent: string; surface: string; mode: "light" | "dark" };
defaultThemeId: string;
themeIds: string[];
layouts: {
hero: SiteLayoutParameters["hero"][];
density: SiteLayoutParameters["density"][];
radius: SiteLayoutParameters["radius"][];
};
defaults: SiteLayoutParameters;
}
export interface SiteTemplateCatalog {
kernel: { id: string; version: string };
pageTemplates: PageTemplateInfo[];
themes: ThemePresetInfo[];
}
export interface SiteTemplateProvenance {
kernel: { id: string; version: string };
createdFrom: {
pageTemplateId: string;
pageTemplateVersion: string;
themeId: string;
layout: SiteLayoutParameters;
};
initialPrompt?: string;
}
export interface SiteInfo {
......@@ -19,6 +70,7 @@ export interface SiteInfo {
industry: string;
status: SiteStatus;
templateVersion: string;
template?: SiteTemplateProvenance;
environmentVersion?: number;
previewPort: number;
previewUrl: string;
......
{
"id": "official-site",
"version": "1.0.0",
"status": "stable",
"source": "../../astro-template"
}
{
"id": "brand-story",
"version": "1.0.0",
"name": "品牌故事企业",
"description": "更有温度的叙事与舒展版式,适合消费品牌、文化创意和设计机构。",
"industries": ["消费品牌", "文化创意", "设计机构"],
"status": "published",
"compatibleKernel": "1.x",
"preview": { "accent": "#C2410C", "surface": "#FFF7ED", "mode": "light" },
"defaultThemeId": "warm-amber",
"themeIds": ["warm-amber", "graphite", "forest-green", "violet-tech"],
"layouts": { "hero": ["centered", "split"], "density": ["comfortable", "compact"], "radius": ["soft", "square"] },
"defaults": { "hero": "centered", "density": "comfortable", "radius": "soft" },
"content": {
"eyebrow": "MADE WITH PURPOSE",
"heroTitle": "把日常的灵感,\n做成值得记住的品牌",
"heroDescription": "我们相信好品牌来自真实的价值、细腻的表达,以及与每一位用户长久相处的诚意。",
"primaryAction": "认识我们的故事",
"secondaryAction": "探索品牌作品",
"metrics": [{"value":"36","label":"城市足迹"},{"value":"80万+","label":"用户选择"},{"value":"12项","label":"设计荣誉"}],
"advantages": [{"title":"真实表达","description":"从品牌本身出发,建立诚恳而鲜明的表达。"},{"title":"细节体验","description":"在每个接触点创造自然、统一的感受。"},{"title":"长久陪伴","description":"让品牌随着用户和时代持续成长。"}]
}
}
{
"id": "professional-services",
"version": "1.0.0",
"name": "专业服务企业",
"description": "稳重克制、强调方法与信任,适合咨询、财税、法律和企业服务。",
"industries": ["管理咨询", "法律财税", "专业服务"],
"status": "published",
"compatibleKernel": "1.x",
"preview": { "accent": "#175CD3", "surface": "#EFF8FF", "mode": "light" },
"defaultThemeId": "ocean-blue",
"themeIds": ["ocean-blue", "forest-green", "graphite", "violet-tech"],
"layouts": { "hero": ["centered", "split"], "density": ["comfortable", "compact"], "radius": ["square", "soft"] },
"defaults": { "hero": "centered", "density": "comfortable", "radius": "square" },
"content": {
"eyebrow": "TRUSTED PROFESSIONALS",
"heroTitle": "以专业判断,\n回应复杂商业问题",
"heroDescription": "从关键判断到可靠执行,我们与企业并肩工作,让每一项重要决策都有清晰依据。",
"primaryAction": "预约顾问",
"secondaryAction": "查看服务方法",
"metrics": [{"value":"15年","label":"行业经验"},{"value":"300+","label":"客户项目"},{"value":"92%","label":"长期合作客户"}],
"advantages": [{"title":"资深团队","description":"由具备一线经验的专业顾问直接参与。"},{"title":"事实驱动","description":"基于充分研究和可靠数据形成判断。"},{"title":"结果负责","description":"从建议延伸到执行,确保方案真正落地。"}]
}
}
{
"id": "technology-corporate",
"version": "1.0.0",
"name": "现代科技企业",
"description": "清晰有力的左右首屏与数据化视觉,适合 AI、软件和数字化服务企业。",
"industries": ["人工智能", "企业软件", "数字化服务"],
"status": "published",
"compatibleKernel": "1.x",
"preview": { "accent": "#7028FF", "surface": "#F3F0FF", "mode": "light" },
"defaultThemeId": "violet-tech",
"themeIds": ["violet-tech", "ocean-blue", "forest-green", "graphite"],
"layouts": { "hero": ["split", "centered"], "density": ["comfortable", "compact"], "radius": ["soft", "square"] },
"defaults": { "hero": "split", "density": "comfortable", "radius": "soft" },
"content": {
"eyebrow": "AI-POWERED BUSINESS",
"heroTitle": "让每一次数字化升级,\n都成为增长的起点",
"heroDescription": "从策略洞察到产品落地,我们用清晰的方法与先进技术,为企业构建可持续的数字竞争力。",
"primaryAction": "开始合作",
"secondaryAction": "了解我们的能力",
"metrics": [{"value":"120+","label":"服务企业"},{"value":"98%","label":"项目交付率"},{"value":"7×24","label":"专业支持"}],
"advantages": [{"title":"业务优先","description":"每一项方案都回应真实业务目标。"},{"title":"敏捷共创","description":"用短周期验证关键假设,让过程透明、结果可控。"},{"title":"长期主义","description":"交付可维护、可演进的系统,而不是一次性成果。"}]
}
}
[
{ "id": "violet-tech", "name": "科技紫", "colors": ["#7028FF", "#536CFF", "#F3F0FF"], "primary": "#7028ff", "hover": "#5c1fe0", "light": "#8b5cff", "secondary": "#536cff", "dark": "#24105f", "background": "#f8f8fc", "soft": "#f3f0ff", "text": "#191927", "textSecondary": "#4f5260", "muted": "#8b8e99", "border": "#e7e3f3" },
{ "id": "ocean-blue", "name": "商务蓝", "colors": ["#175CD3", "#2E90FA", "#EFF8FF"], "primary": "#175cd3", "hover": "#144da8", "light": "#53b1fd", "secondary": "#2e90fa", "dark": "#102a56", "background": "#f7faff", "soft": "#eff8ff", "text": "#101828", "textSecondary": "#475467", "muted": "#98a2b3", "border": "#d1e9ff" },
{ "id": "forest-green", "name": "自然绿", "colors": ["#087A5B", "#12B76A", "#ECFDF3"], "primary": "#087a5b", "hover": "#066149", "light": "#32d583", "secondary": "#12b76a", "dark": "#12372d", "background": "#f8fbf9", "soft": "#ecfdf3", "text": "#16251f", "textSecondary": "#486158", "muted": "#8ca198", "border": "#ccebdc" },
{ "id": "warm-amber", "name": "品牌暖橙", "colors": ["#C2410C", "#F59E0B", "#FFF7ED"], "primary": "#c2410c", "hover": "#9a3412", "light": "#fb923c", "secondary": "#f59e0b", "dark": "#431407", "background": "#fffbf7", "soft": "#fff7ed", "text": "#29201b", "textSecondary": "#65544a", "muted": "#9f8d82", "border": "#fed7aa" },
{ "id": "graphite", "name": "黑金质感", "colors": ["#B78932", "#E4C477", "#F6F2E8"], "primary": "#9a7025", "hover": "#795719", "light": "#d6b45f", "secondary": "#b78932", "dark": "#1e1c18", "background": "#f8f6f1", "soft": "#f6f2e8", "text": "#1d1c19", "textSecondary": "#5e594f", "muted": "#969083", "border": "#ded7c7" }
]
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