Commit ec99279b authored by xuchentao's avatar xuchentao

feat: 102 色系配色、公告栏选项与模版真实预览宿主

- 配色扩至 10 色系 102 色:theme-families.json 紧凑定义,后端 HSL 推导完整色板,选择器改为色系页签+细分色网格
- 新增公告栏开关:design.layout.announcementBar 贯穿创建链路,内核 Header 渲染公告条,各模版配独立文案
- 新增模版预览宿主 TemplatePreviewService:每模版构建真实内核站并占用 4400+ 独立端口,内容哈希驱动重建,本机 Chrome 截取真实首页截图
- 创建页改为卡片网格:真实截图缩略图 + 预览按钮直达对应 URL,移除 React 线框预览组件
- 内核首屏按模版 heroImage 声明渲染灰色宽高占位符,修复珠宝类模版误显数据仪表盘
parent f7f3b942
---
import company from "../data/site.json";
import home from "../data/home.json";
import design from "../data/design.json";
const showAnnouncement = design.layout.announcementBar && home.announcement;
---
{showAnnouncement && <div class="announcement-bar"><div class="container">{home.announcement}</div></div>}
<header class="header">
<div class="container nav">
<a class="brand" href="#top"><span class="brand-mark">W</span><span>{company.name}</span></a>
......@@ -9,6 +13,7 @@ import company from "../data/site.json";
</div>
</header>
<style>
.announcement-bar { position: relative; z-index: 21; padding: 9px 0; color: white; background: var(--color-primary-dark); font-size: 13px; font-weight: 600; text-align: center; letter-spacing: .02em; }
.header { position: absolute; z-index: 20; width: 100%; top: 0; }
.nav { height: 88px; display: flex; align-items: center; justify-content: space-between; }
.brand { display: flex; align-items: center; gap: 11px; font-size: 18px; font-weight: 800; letter-spacing: -.02em; }
......
---
import company from "../data/site.json";
import home from "../data/home.json";
import design from "../data/design.json";
const titleLines = home.heroTitle.split("\n");
const heroImage = (design as { heroImage?: { width: number; height: number } }).heroImage;
---
<section class="hero" id="top">
<div class="orb orb-one"></div><div class="orb orb-two"></div>
......@@ -16,6 +18,11 @@ const titleLines = home.heroTitle.split("\n");
</div>
<div class="metrics">{home.metrics.map((item) => <div><strong>{item.value}</strong><span>{item.label}</span></div>)}</div>
</div>
{heroImage ? (
<div class="visual image-placeholder" aria-label={`${company.name} 首屏主图占位`}>
<span class="placeholder-size">{heroImage.width}×{heroImage.height}</span>
</div>
) : (
<div class="visual" aria-label={`${company.name} 数字化能力展示`}>
<div class="visual-top"><span>BUSINESS INTELLIGENCE</span><span class="pulse">● LIVE</span></div>
<div class="visual-title"><span>增长动能</span><strong>+38.6%</strong></div>
......@@ -23,6 +30,7 @@ const titleLines = home.heroTitle.split("\n");
<div class="visual-footer"><div><span>智能决策</span><strong>实时洞察</strong></div><div><span>业务协同</span><strong>全链连接</strong></div></div>
<div class="float-card"><span class="float-icon">✦</span><div><small>AI 能力引擎</small><strong>持续进化中</strong></div></div>
</div>
)}
</div>
</section>
<style>
......@@ -57,6 +65,9 @@ const titleLines = home.heroTitle.split("\n");
.visual-footer span,.visual-footer strong { display:block; }.visual-footer span { opacity:.58;font-size:11px;margin-bottom:7px;}.visual-footer strong{font-size:14px;}
.float-card { position:absolute; display:flex; align-items:center; gap:12px; right:-38px; bottom:58px; padding:14px 17px; color:var(--color-text-primary); background:rgba(255,255,255,.94); border:1px solid white; border-radius:16px; box-shadow:0 18px 44px rgba(36,16,95,.22); backdrop-filter:blur(16px); }
.float-icon { display:grid; place-items:center; width:36px;height:36px;border-radius:10px;color:white;background:var(--gradient-primary); }
.image-placeholder { display:flex; align-items:center; justify-content:center; background:#dcdce1; border:1px solid #c9c9d1; box-shadow:0 34px 80px rgba(36,16,95,.12); }
.image-placeholder::after { border-color:rgba(0,0,0,.05); }
.placeholder-size { color:#83838e; font-size:15px; font-weight:600; letter-spacing:.04em; }
.float-card small,.float-card strong{display:block}.float-card small{color:var(--color-text-muted);font-size:10px;margin-bottom:4px}.float-card strong{font-size:13px}
@media(max-width:980px){.hero-grid{grid-template-columns:1fr}.visual{max-width:620px;transform:none}.hero{padding-top:150px}.float-card{right:-8px}}
@media(max-width:620px){.hero{min-height:auto}.actions{flex-direction:column}.actions a{width:100%}.metrics{gap:20px;justify-content:space-between}.visual{min-height:400px;padding:22px}.float-card{display:none}.visual-title strong{font-size:42px}}
......
......@@ -6,5 +6,5 @@
"fontVersion": "1.0.0",
"layoutPresetId": "modern-split",
"layoutPresetVersion": "1.0.0",
"layout": { "hero": "split", "density": "comfortable", "radius": "soft", "gradientEmphasis": false }
"layout": { "hero": "split", "density": "comfortable", "radius": "soft", "gradientEmphasis": false, "announcementBar": false }
}
{
"announcement": "欢迎访问我们的官方网站,了解更多服务与合作机会。",
"eyebrow": "AI-POWERED BUSINESS",
"heroTitle": "让每一次数字化升级,\n都成为增长的起点",
"heroDescription": "从策略洞察到产品落地,我们用清晰的方法与先进技术,为企业构建可持续的数字竞争力。",
......
......@@ -25,6 +25,7 @@
"fastify": "^5.3.2",
"mammoth": "^1.12.0",
"pdf-parse": "^2.4.5",
"playwright-core": "^1.62.0",
"word-extractor": "^1.0.4",
"yaml": "^2.9.0",
"zod": "^3.24.2"
......
......@@ -22,6 +22,7 @@ export const createSiteSchema = z.object({
density: z.enum(["comfortable", "compact"]),
radius: z.enum(["soft", "square"]),
gradientEmphasis: z.boolean().default(false),
announcementBar: z.boolean().default(false),
}),
}),
initialPrompt: z.string().trim().max(2000).optional().or(z.literal("").transform(() => undefined)),
......
import path from "node:path";
import { createReadStream } from "node:fs";
import { access, mkdir, rm } from "node:fs/promises";
import Fastify from "fastify";
import cors from "@fastify/cors";
......@@ -17,6 +18,7 @@ import {
} from "./schemas.js";
import { CreateSiteService } from "./sites/create-site.js";
import { SiteTemplateCatalogService } from "./sites/site-template-catalog.js";
import { TemplatePreviewService } from "./sites/template-preview-service.js";
import { SiteRepository } from "./sites/site-repository.js";
import { SiteVersionService } from "./sites/site-version-service.js";
import { SiteLifecycleService } from "./sites/site-lifecycle-service.js";
......@@ -42,6 +44,7 @@ const git = new GitManager();
const builds = new BuildManager();
const previews = new PreviewProcessManager();
const templateCatalog = new SiteTemplateCatalogService();
const templatePreviews = new TemplatePreviewService(templateCatalog, builds, previews);
const createSite = new CreateSiteService(sites, git, builds, previews, templateCatalog);
const agentRuns = new AgentRunRepository();
const agentProviders = createAgentProviders();
......@@ -92,7 +95,15 @@ 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/site-templates", async (request) => {
templatePreviews.kick();
return templatePreviews.decorate(await templateCatalog.list(), request.headers.host);
});
app.get<{ Params: { file: string } }>("/template-previews/screenshots/:file", async (request, reply) => {
const filePath = templatePreviews.resolveScreenshot(request.params.file);
if (!filePath || !(await access(filePath).then(() => true).catch(() => false))) return reply.code(404).send({ error: "截图不存在" });
return reply.header("content-type", "image/png").header("cache-control", "no-store").send(createReadStream(filePath));
});
app.post("/api/company-documents/extract", async (request) => companyDocuments.extract(companyDocumentExtractSchema.parse(request.body)));
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));
......@@ -309,6 +320,7 @@ for (let site of await sites.listAll()) {
}
await app.listen({ host: config.host, port: config.port });
templatePreviews.kick();
let checkingDomains = false;
const checkPendingDomains = async () => {
......
import crypto from "node:crypto";
import path from "node:path";
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { cp, mkdir, writeFile } from "node:fs/promises";
import type { CreateSiteInput, SiteInfo } from "@webagent/shared";
import { config, getPublicPreviewUrl } from "../config.js";
import { GitManager } from "../git/git-manager.js";
......@@ -8,6 +8,7 @@ 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";
import { materializeTemplateProject } from "./materialize-template-project.js";
export class CreateSiteService {
constructor(
......@@ -44,40 +45,16 @@ export class CreateSiteService {
await Promise.all([mkdir(projectPath, { recursive: true }), mkdir(articlesDirectory, { recursive: true }), mkdir(liveArticlesDirectory, { recursive: true }), mkdir(articleImagesDirectory, { recursive: true })]);
await this.sites.save(site);
try {
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 });
const siteDataPath = path.join(projectPath, "src/data/site.json");
const siteData = JSON.parse(await readFile(siteDataPath, "utf8")) as {
name: string; industry: string; description: string; email: string; phone: string;
seo: { defaultTitle: string; titleTemplate: string; description: string };
organization: { legalName: string };
};
siteData.name = input.name;
siteData.industry = input.industry;
siteData.description = input.description;
siteData.email = input.email;
siteData.phone = input.phone || "";
siteData.seo.defaultTitle = `${input.name}${input.industry}`;
siteData.seo.titleTemplate = `%s|${input.name}`;
siteData.seo.description = input.description;
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 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");
await writeFile(themePath, this.templates.renderTheme(selection.theme, input.design.brandColor, input.design.layout.gradientEmphasis), "utf8");
await writeFile(path.join(projectPath, "src/styles/fonts.css"), this.templates.renderFont(selection.font), "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, themeVersion: selection.theme.version, fontId: selection.font.id, fontVersion: selection.font.version, layoutPresetId: selection.layoutPreset.id, layoutPresetVersion: selection.layoutPreset.version, layout: input.design.layout,
}, null, 2) + "\n", "utf8");
await materializeTemplateProject(projectPath, {
templateDir: config.templateDir, selection, templates: this.templates,
brandColor: input.design.brandColor, layout: input.design.layout,
site: { name: input.name, industry: input.industry, description: input.description, email: input.email, phone: input.phone },
services: input.services,
});
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 path from "node:path";
import { cp, readFile, rm, writeFile } from "node:fs/promises";
import type { SiteDesignParameters } from "@webagent/shared";
import type { SiteTemplateCatalogService } from "./site-template-catalog.js";
type TemplateSelection = Awaited<ReturnType<SiteTemplateCatalogService["resolve"]>>;
export interface MaterializeTemplateProjectOptions {
templateDir: string;
selection: TemplateSelection;
templates: SiteTemplateCatalogService;
brandColor: string;
layout: SiteDesignParameters["layout"];
site: { name: string; industry: string; description: string; email: string; phone?: string };
services: string[];
}
export async function materializeTemplateProject(targetPath: string, options: MaterializeTemplateProjectOptions): Promise<void> {
const { selection } = options;
await cp(options.templateDir, targetPath, { recursive: true, filter: (source) => !["node_modules", "dist", ".astro", ".git", "pnpm-lock.yaml", "examples"].includes(path.basename(source)) });
await rm(path.join(targetPath, "src/data/company.json"), { force: true });
const siteDataPath = path.join(targetPath, "src/data/site.json");
const siteData = JSON.parse(await readFile(siteDataPath, "utf8")) as {
name: string; industry: string; description: string; email: string; phone: string;
seo: { defaultTitle: string; titleTemplate: string; description: string };
organization: { legalName: string };
};
siteData.name = options.site.name;
siteData.industry = options.site.industry;
siteData.description = options.site.description;
siteData.email = options.site.email;
siteData.phone = options.site.phone || "";
siteData.seo.defaultTitle = `${options.site.name}${options.site.industry}`;
siteData.seo.titleTemplate = `%s|${options.site.name}`;
siteData.seo.description = options.site.description;
siteData.organization.legalName = options.site.name;
await writeFile(siteDataPath, JSON.stringify(siteData, null, 2) + "\n", "utf8");
const homePath = path.join(targetPath, "src/data/home.json");
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 = options.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");
await writeFile(path.join(targetPath, "src/styles/theme.css"), options.templates.renderTheme(selection.theme, options.brandColor, options.layout.gradientEmphasis), "utf8");
await writeFile(path.join(targetPath, "src/styles/fonts.css"), options.templates.renderFont(selection.font), "utf8");
await writeFile(path.join(targetPath, "src/data/design.json"), JSON.stringify({
pageTemplate: { id: selection.template.id, version: selection.template.version },
themeId: selection.theme.id, themeVersion: selection.theme.version, fontId: selection.font.id, fontVersion: selection.font.version, layoutPresetId: selection.layoutPreset.id, layoutPresetVersion: selection.layoutPreset.version, layout: options.layout,
...(selection.template.preview.heroImage ? { heroImage: selection.template.preview.heroImage } : {}),
}, null, 2) + "\n", "utf8");
}
......@@ -10,35 +10,42 @@ test("catalog exposes the stable kernel and published page templates", async ()
"artisan-jewelry", "brand-story", "mobility-services",
"professional-services", "technology-corporate", "wellness-franchise",
]);
assert.equal(catalog.themes.length, 8);
assert.equal(catalog.themes.length, 102);
assert.equal(catalog.themeFamilies.length, 10);
assert.equal(catalog.fonts.length, 2);
assert.equal(catalog.layoutPresets.length, 4);
assert.ok(catalog.themes[0].primary && catalog.themes[0].soft, "目录应暴露完整色板供实时预览");
assert.ok(catalog.pageTemplates[0].content.heroTitle, "目录应暴露模版默认文案供实时预览");
for (const theme of catalog.themes) {
for (const key of ["primary", "hover", "light", "secondary", "dark", "background", "soft", "text", "textSecondary", "muted", "border"] as const) {
assert.match(theme[key], /^#[0-9a-f]{6}$/, `${theme.id}.${key} 应为合法 hex 颜色`);
}
assert.ok(theme.family && catalog.themeFamilies.some((family) => family.id === theme.family && family.themeIds.includes(theme.id)), `${theme.id} 应归属某个色系`);
}
});
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", { themeId: "ocean-blue", themeVersion: "1.0.0", brandColor: "#175CD3", fontId: "modern-sans", fontVersion: "1.0.0", layoutPresetId: "modern-split", layoutPresetVersion: "1.0.0", layout: {
hero: "split", density: "comfortable", radius: "soft", gradientEmphasis: false,
hero: "split", density: "comfortable", radius: "soft", gradientEmphasis: false, announcementBar: false,
} });
assert.equal(selection.kernel.version, "1.0.0");
assert.equal(selection.template.id, "technology-corporate");
assert.equal(selection.theme.id, "ocean-blue");
assert.equal(selection.font.id, "modern-sans");
const freeCombination = await service.resolve("technology-corporate", "1.0.0", { themeId: "warm-amber", themeVersion: "1.0.0", brandColor: "#C2410C", fontId: "elegant-serif", fontVersion: "1.0.0", layoutPresetId: "modern-split", layoutPresetVersion: "1.0.0", layout: {
hero: "split", density: "comfortable", radius: "soft", gradientEmphasis: false,
hero: "split", density: "comfortable", radius: "soft", gradientEmphasis: false, announcementBar: false,
} });
assert.equal(freeCombination.theme.id, "warm-amber");
await assert.rejects(() => service.resolve("technology-corporate", "1.0.0", { themeId: "no-such-theme", themeVersion: "1.0.0", brandColor: "#C2410C", fontId: "modern-sans", fontVersion: "1.0.0", layoutPresetId: "modern-split", layoutPresetVersion: "1.0.0", layout: {
hero: "split", density: "comfortable", radius: "soft", gradientEmphasis: false,
hero: "split", density: "comfortable", radius: "soft", gradientEmphasis: false, announcementBar: false,
} }), /配色模板不存在/);
});
test("font renderer emits commercial-free heading and body stacks", async () => {
const service = new SiteTemplateCatalogService();
const { font } = await service.resolve("artisan-jewelry", "1.0.0", { themeId: "heritage-crimson", themeVersion: "1.0.0", brandColor: "#D1251A", fontId: "elegant-serif", fontVersion: "1.0.0", layoutPresetId: "craft-split", layoutPresetVersion: "1.0.0", layout: {
hero: "split", density: "comfortable", radius: "square", gradientEmphasis: false,
hero: "split", density: "comfortable", radius: "square", gradientEmphasis: false, announcementBar: false,
} });
const css = service.renderFont(font);
assert.match(css, /--font-heading: .*Noto Serif CJK SC/);
......@@ -48,7 +55,7 @@ test("font renderer emits commercial-free heading and body stacks", async () =>
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", { themeId: "warm-amber", themeVersion: "1.0.0", brandColor: "#C2410C", fontId: "elegant-serif", fontVersion: "1.0.0", layoutPresetId: "editorial-centered", layoutPresetVersion: "1.0.0", layout: {
hero: "centered", density: "comfortable", radius: "soft", gradientEmphasis: false,
hero: "centered", density: "comfortable", radius: "soft", gradientEmphasis: false, announcementBar: false,
} });
const css = service.renderTheme(theme, "#123456", false);
assert.match(css, /--color-primary: #123456;/);
......@@ -61,7 +68,7 @@ test("theme renderer emits a complete semantic palette and custom primary color"
test("theme renderer only emits gradients when explicitly emphasized", async () => {
const service = new SiteTemplateCatalogService();
const { theme } = await service.resolve("technology-corporate", "1.0.0", { themeId: "violet-tech", themeVersion: "1.0.0", brandColor: "#7028FF", fontId: "modern-sans", fontVersion: "1.0.0", layoutPresetId: "modern-split", layoutPresetVersion: "1.0.0", layout: {
hero: "split", density: "comfortable", radius: "soft", gradientEmphasis: true,
hero: "split", density: "comfortable", radius: "soft", gradientEmphasis: true, announcementBar: false,
} });
assert.match(service.renderTheme(theme, "#7028FF", true), /--gradient-primary: linear-gradient/);
});
import path from "node:path";
import { readFile, readdir } from "node:fs/promises";
import type { FontPresetInfo, LayoutPresetInfo, PageTemplateInfo, SiteDesignParameters, SiteTemplateCatalog, ThemePresetInfo } from "@webagent/shared";
import type { FontPresetInfo, LayoutPresetInfo, PageTemplateInfo, SiteDesignParameters, SiteTemplateCatalog, ThemeFamilyInfo, ThemePresetInfo } from "@webagent/shared";
import { config } from "../config.js";
import { expandThemeVariant, type ThemeFamilyDefinition } from "./theme-palette.js";
type ThemeDefinition = ThemePresetInfo;
......@@ -15,9 +16,9 @@ type PageTemplateDefinition = PageTemplateInfo & {
export class SiteTemplateCatalogService {
async list(): Promise<SiteTemplateCatalog> {
const kernel = await this.kernel();
const [themes, fonts, pageTemplates, layoutPresets] = await Promise.all([this.themes(), this.fonts(), this.pageTemplates(), this.layoutPresets()]);
const [themes, themeFamilies, fonts, pageTemplates, layoutPresets] = await Promise.all([this.themes(), this.themeFamilies(), this.fonts(), this.pageTemplates(), this.layoutPresets()]);
return {
kernel, themes,
kernel, themes, themeFamilies,
fonts, layoutPresets, pageTemplates: pageTemplates.map(({ status: _status, compatibleKernel: _compatible, ...template }) => template),
};
}
......@@ -79,8 +80,18 @@ export class SiteTemplateCatalogService {
return { id: value.id, version: value.version };
}
private async themeFamilyDefinitions(): Promise<ThemeFamilyDefinition[]> {
return JSON.parse(await readFile(path.join(config.siteKitDir, "theme-families.json"), "utf8")) as ThemeFamilyDefinition[];
}
private async themes(): Promise<ThemeDefinition[]> {
return JSON.parse(await readFile(path.join(config.siteKitDir, "themes.json"), "utf8")) as ThemeDefinition[];
const families = await this.themeFamilyDefinitions();
return families.flatMap((family) => family.variants.map((variant) => expandThemeVariant(family, variant)));
}
private async themeFamilies(): Promise<ThemeFamilyInfo[]> {
const families = await this.themeFamilyDefinitions();
return families.map((family) => ({ id: family.id, name: family.name, themeIds: family.variants.map((variant) => variant.id) }));
}
private async fonts(): Promise<FontDefinition[]> {
......
import crypto from "node:crypto";
import path from "node:path";
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import type { SiteDesignParameters, SiteTemplateCatalog } from "@webagent/shared";
import { config } from "../config.js";
import { BuildManager } from "../build/build-manager.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { SiteTemplateCatalogService } from "./site-template-catalog.js";
import { materializeTemplateProject } from "./materialize-template-project.js";
interface TemplatePreviewEntry {
id: string;
version: string;
port: number;
status: "ready" | "failed";
screenshotFile?: string;
error?: string;
}
interface TemplatePreviewState {
hash: string;
entries: TemplatePreviewEntry[];
}
const PREVIEW_PORT_BASE = 4400;
const SCREENSHOT_ROUTE = "/template-previews/screenshots/";
export class TemplatePreviewService {
private state: TemplatePreviewState | undefined;
private pending: Promise<void> | null = null;
constructor(
private readonly templates: SiteTemplateCatalogService,
private readonly builds: BuildManager,
private readonly previews: PreviewProcessManager,
) {}
private get rootDir() { return path.join(config.runtimeDir, "template-previews"); }
private get projectsDir() { return path.join(this.rootDir, "projects"); }
private get screenshotsDir() { return path.join(this.rootDir, "screenshots"); }
private get statePath() { return path.join(this.rootDir, "state.json"); }
/** 内容哈希:site-kit 全部定义 + 内核源码,任一变化即触发重建。 */
private async computeHash(): Promise<string> {
const hash = crypto.createHash("sha1");
const feed = async (dir: string): Promise<void> => {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (["node_modules", "dist", ".astro", ".git", "examples"].includes(entry.name)) continue;
await feed(full);
} else if (!["pnpm-lock.yaml"].includes(entry.name)) {
hash.update(full);
hash.update(await readFile(full));
}
}
};
await feed(config.siteKitDir);
await feed(config.templateDir);
return hash.digest("hex");
}
private async loadState(): Promise<TemplatePreviewState | undefined> {
return JSON.parse(await readFile(this.statePath, "utf8").catch(() => "null")) as TemplatePreviewState | undefined ?? undefined;
}
kick(): void {
void this.ensure().catch((error) => console.error("[template-previews]", error));
}
async ensure(): Promise<void> {
if (!this.pending) this.pending = this.ensureInternal().finally(() => { this.pending = null; });
return this.pending;
}
private async ensureInternal(): Promise<void> {
const catalog = await this.templates.list();
const hash = await this.computeHash();
const persisted = this.state ?? await this.loadState();
if (persisted && persisted.hash === hash && persisted.entries.every((entry) => catalog.pageTemplates.some((template) => template.id === entry.id && template.version === entry.version))) {
this.state = persisted;
await this.startServers(persisted.entries);
let screenshotsChanged = false;
for (const entry of persisted.entries.filter((item) => item.status === "ready" && !item.screenshotFile)) {
const file = await this.captureScreenshot(this.previews.getPreviewUrl(entry.port), `${entry.id}@${entry.version}.png`);
if (file) { entry.screenshotFile = file; screenshotsChanged = true; }
}
if (screenshotsChanged) await writeFile(this.statePath, JSON.stringify(this.state, null, 2) + "\n", "utf8");
return;
}
await mkdir(this.projectsDir, { recursive: true });
await rm(this.screenshotsDir, { recursive: true, force: true });
await mkdir(this.screenshotsDir, { recursive: true });
const examplesArticles = path.join(this.rootDir, "examples", "articles");
const examplesImages = path.join(this.rootDir, "examples", "images");
await Promise.all([
cp(path.join(config.templateDir, "examples", "articles"), examplesArticles, { recursive: true }).catch(() => undefined),
cp(path.join(config.templateDir, "examples", "images"), examplesImages, { recursive: true }).catch(() => undefined),
]);
const entries: TemplatePreviewEntry[] = [];
for (const [index, template] of catalog.pageTemplates.entries()) {
const port = PREVIEW_PORT_BASE + index;
try {
const theme = catalog.themes.find((item) => item.id === template.defaultThemeId);
const font = catalog.fonts.find((item) => item.id === template.defaultFontId);
const layoutPreset = catalog.layoutPresets.find((item) => item.id === template.defaultLayoutPresetId);
if (!theme || !font || !layoutPreset) throw new Error("默认视觉配置缺失");
const design: SiteDesignParameters = {
themeId: theme.id, themeVersion: theme.version, brandColor: theme.colors[0],
fontId: font.id, fontVersion: font.version,
layoutPresetId: layoutPreset.id, layoutPresetVersion: layoutPreset.version,
layout: { ...layoutPreset.parameters, gradientEmphasis: false, announcementBar: true },
};
const selection = await this.templates.resolve(template.id, template.version, design);
const projectPath = path.join(this.projectsDir, template.id);
await rm(projectPath, { recursive: true, force: true });
await materializeTemplateProject(projectPath, {
templateDir: config.templateDir, selection, templates: this.templates,
brandColor: design.brandColor, layout: design.layout,
site: { name: template.name, industry: template.industries[0], description: template.description, email: "hello@example.com" },
services: template.industries.slice(0, 3),
});
const dist = await this.builds.build(projectPath, "template_" + template.id, { basePath: "/", indexable: false, articlesDirectory: examplesArticles, articleImagesDirectory: examplesImages });
const previewUrl = await this.previews.start("__template__", template.id, dist, port);
const screenshotFile = await this.captureScreenshot(previewUrl, `${template.id}@${template.version}.png`);
entries.push({ id: template.id, version: template.version, port, status: "ready", ...(screenshotFile ? { screenshotFile } : {}) });
} catch (error) {
entries.push({ id: template.id, version: template.version, port, status: "failed", error: error instanceof Error ? error.message : String(error) });
}
}
this.state = { hash, entries };
await writeFile(this.statePath, JSON.stringify(this.state, null, 2) + "\n", "utf8");
}
private async startServers(entries: TemplatePreviewEntry[]): Promise<void> {
for (const entry of entries.filter((item) => item.status === "ready")) {
const dist = path.join(this.projectsDir, entry.id, "dist");
const exists = await stat(dist).then((value) => value.isDirectory()).catch(() => false);
if (exists) await this.previews.start("__template__", entry.id, dist, entry.port).catch(() => { entry.status = "failed"; });
else entry.status = "failed";
}
}
private async captureScreenshot(url: string, file: string): Promise<string | undefined> {
try {
const { chromium } = await import("playwright-core");
const browser = await chromium.launch({ channel: "chrome", headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 });
await page.goto(url, { waitUntil: "networkidle", timeout: 45000 });
await page.screenshot({ path: path.join(this.screenshotsDir, file) });
return file;
} finally {
await browser.close();
}
} catch (error) {
console.warn("[template-previews] 截图失败:" + (error instanceof Error ? error.message : String(error)));
return undefined;
}
}
decorate(catalog: SiteTemplateCatalog, requestHost?: string): SiteTemplateCatalog {
const entries = new Map((this.state?.entries || []).map((entry) => [entry.id, entry]));
const screenshotBase = requestHost ? `http://${requestHost}${SCREENSHOT_ROUTE}` : SCREENSHOT_ROUTE;
return {
...catalog,
pageTemplates: catalog.pageTemplates.map((template) => {
const entry = entries.get(template.id);
if (!entry || entry.status !== "ready") return template;
return {
...template,
previewUrl: this.previews.getPreviewUrl(entry.port),
...(entry.screenshotFile ? { screenshotUrl: screenshotBase + entry.screenshotFile } : {}),
};
}),
};
}
resolveScreenshot(file: string): string | undefined {
if (!/^[a-z0-9@.\-]+\.png$/.test(file)) return undefined;
return path.join(this.screenshotsDir, file);
}
}
import type { ThemePresetInfo } from "@webagent/shared";
export interface ThemeVariantDefinition {
id: string;
name: string;
primary: string;
}
export interface ThemeFamilyDefinition {
id: string;
name: string;
version: string;
variants: ThemeVariantDefinition[];
}
function hexToHsl(hex: string): [number, number, number] {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l * 100];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
else if (max === g) h = ((b - r) / d + 2) * 60;
else h = ((r - g) / d + 4) * 60;
return [h, s * 100, l * 100];
}
function hslToHex(h: number, s: number, l: number): string {
const sn = Math.max(0, Math.min(100, s)) / 100;
const ln = Math.max(0, Math.min(100, l)) / 100;
const a = sn * Math.min(ln, 1 - ln);
const channel = (n: number) => {
const k = (n + h / 30) % 12;
const value = ln - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
return Math.round(value * 255).toString(16).padStart(2, "0");
};
return `#${channel(0)}${channel(8)}${channel(4)}`;
}
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
/** 由主色推导完整设计令牌:悬停、提亮、渐变辅色、深色带、页面底色、浅底、三级文字与边框。 */
export function expandThemeVariant(family: ThemeFamilyDefinition, variant: ThemeVariantDefinition): ThemePresetInfo {
const primary = variant.primary.toLowerCase();
const [h, s, l] = hexToHsl(primary);
const secondary = hslToHex((h + 18) % 360, clamp(s, 28, 78), clamp(l + 14, 32, 76));
const soft = hslToHex(h, clamp(s * 0.72, 18, 68), 95.5);
return {
id: variant.id,
version: family.version,
name: variant.name,
family: family.id,
colors: [primary, secondary, soft],
primary,
hover: hslToHex(h, clamp(s, 30, 92), clamp(l - 9, 10, 88)),
light: hslToHex(h, clamp(s, 30, 88), clamp(l + 22, 40, 82)),
secondary,
dark: hslToHex(h, clamp(s * 0.42, 8, 40), 14),
background: hslToHex(h, clamp(s * 0.22, 4, 28), 98.2),
soft,
text: hslToHex(h, clamp(s * 0.32, 8, 34), 13),
textSecondary: hslToHex(h, clamp(s * 0.2, 6, 24), 38),
muted: hslToHex(h, clamp(s * 0.14, 5, 18), 62),
border: hslToHex(h, clamp(s * 0.32, 10, 34), 90),
};
}
......@@ -12,11 +12,17 @@
- `site-kit/page-templates/<id>/<version>/manifest.json`:已发布官网外观模版及其兼容范围、默认内容、默认配色与字体、兼容布局参数。配色方案与外观模版自由组合,没有从属关系。
- `site-kit/layouts.json`:可复用、带版本的布局方案;官网外观模版仅声明兼容的布局 ID。
- `site-kit/fonts.json`:可复用、带版本的字体方案,均为免费商用字体栈;官网外观模版声明默认字体,创建时可改选。
- `site-kit/themes.json`:完整语义配色方案
- `site-kit/theme-families.json`:按色系组织的配色定义(色系 + 主色),后端据此推导完整语义色板
- `astro-template/`:当前内核运行载体和页面渲染契约。
官网外观模版发布后不可原地修改;任何调整必须创建新版本。创建服务只接受目录中已发布且与当前内核兼容的精确版本组合。
## 模版预览宿主
- 后端 `TemplatePreviewService` 为每个已发布模版构建一个真实预览站:内核 + 模版默认内容/配色/字体/布局(公告栏开启以作展示),占用 4400+ 的独立本地端口。
- 以 site-kit 与内核源码的内容哈希决定是否需要重建;构建完成后用本机 Chrome(playwright-core)截取真实首页截图。
- 目录 API 为每个模版附带 `previewUrl`(独立端口)与 `screenshotUrl`(公开静态路由 `/template-previews/screenshots/`,按请求来源生成绝对地址);创建页卡片展示真实截图,「预览」按钮直达对应 URL。
## 创建不变量
- 先生成并构建不依赖模型的基础成品。
......
This diff is collapsed.
import type { CSSProperties } from "react";
import type { FontPresetInfo, PageTemplateInfo, SiteLayoutParameters, ThemePresetInfo } from "@webagent/shared";
const NAV_LINKS = ["关于我们", "服务能力", "企业优势", "文章"];
const CHART_BARS = [32, 44, 39, 62, 58, 76, 92];
interface TemplateLivePreviewProps {
template: PageTemplateInfo;
theme: ThemePresetInfo;
font: FontPresetInfo;
brandColor: string;
layout: SiteLayoutParameters;
}
export function TemplateLivePreview({ template, theme, font, brandColor, layout }: TemplateLivePreviewProps) {
const { content, preview } = template;
const style = {
"--tlp-primary": brandColor,
"--tlp-light": theme.light,
"--tlp-secondary": theme.secondary,
"--tlp-dark": theme.dark,
"--tlp-bg": theme.background,
"--tlp-soft": theme.soft,
"--tlp-text": theme.text,
"--tlp-text2": theme.textSecondary,
"--tlp-muted": theme.muted,
"--tlp-border": theme.border,
"--tlp-heading-font": font.heading,
"--tlp-body-font": font.body,
"--tlp-radius": layout.radius === "square" ? "0.5cqw" : "1.8cqw",
"--tlp-radius-sm": layout.radius === "square" ? "0.3cqw" : "1.1cqw",
} as CSSProperties;
const heroStyle: CSSProperties = { background: layout.gradientEmphasis ? `linear-gradient(180deg, ${theme.soft} 0%, #ffffff 100%)` : theme.soft };
const visualStyle: CSSProperties = { background: layout.gradientEmphasis ? `linear-gradient(135deg, ${theme.dark} 0%, ${brandColor} 60%, ${theme.secondary} 100%)` : theme.dark };
return <div className={`template-live-preview${layout.density === "compact" ? " tlp-compact" : ""}`} style={style}>
<nav className="tlp-nav">
<span className="tlp-brand"><i>W</i><b /></span>
<span className="tlp-links">{NAV_LINKS.map((link) => <u key={link}>{link}</u>)}</span>
<span className="tlp-nav-action">联系合作 <i></i></span>
</nav>
<header className={`tlp-hero tlp-hero-${layout.hero}`} style={heroStyle}>
<div className="tlp-hero-copy">
<span className="tlp-eyebrow">{content.eyebrow}</span>
<h1>{content.heroTitle.split("\n").map((line) => <span key={line}>{line}</span>)}</h1>
<p>{content.heroDescription}</p>
<div className="tlp-actions"><a>{content.primaryAction}<i></i></a><a className="tlp-secondary">{content.secondaryAction}</a></div>
<div className="tlp-metrics">{content.metrics.map((metric) => <div key={metric.label}><strong>{metric.value}</strong><span>{metric.label}</span></div>)}</div>
</div>
{layout.hero === "split" && (preview.heroImage
? <div className="tlp-hero-image"><s>{preview.heroImage.width}×{preview.heroImage.height}</s></div>
: <div className="tlp-visual" style={visualStyle}>
<div className="tlp-visual-top"><span>BUSINESS INTELLIGENCE</span><em>● LIVE</em></div>
<div className="tlp-visual-title"><span>增长动能</span><strong>+38.6%</strong></div>
<div className="tlp-visual-chart">{CHART_BARS.map((height) => <i key={height} style={{ height: `${height}%` }} />)}</div>
<div className="tlp-visual-footer"><div><span>智能决策</span><strong>实时洞察</strong></div><div><span>业务协同</span><strong>全链连接</strong></div></div>
</div>)}
</header>
<section className="tlp-section tlp-services">
<div className="tlp-heading"><span className="tlp-eyebrow">WHAT WE DO</span><h2>聚焦关键问题,<br />提供完整解法。</h2></div>
<div className="tlp-service-grid">{template.industries.slice(0, 3).map((industry, index) => <article key={industry}>
<span className="tlp-number">{String(index + 1).padStart(2, "0")}</span>
<div className="tlp-icon"></div>
<h3>{industry}</h3>
<p>以专业方法和可靠交付,为客户提供高质量的{industry}服务。</p>
<a>探索服务 <i></i></a>
</article>)}</div>
</section>
<section className="tlp-section tlp-advantages" style={{ background: theme.dark }}>
<div className="tlp-advantage-intro">
<span className="tlp-eyebrow">WHY CHOOSE US</span>
<h2>不止完成项目,<br />更在意结果发生。</h2>
<p>清晰、克制、可靠,是我们对每一次合作的共同承诺。</p>
</div>
<div className="tlp-advantage-list">{content.advantages.map((advantage, index) => <article key={advantage.title}><span>0{index + 1}</span><div><h3>{advantage.title}</h3><p>{advantage.description}</p></div></article>)}</div>
</section>
<section className="tlp-cta"><h2>准备好开启数字化增长了吗?</h2><a>{content.primaryAction}<i></i></a></section>
<footer className="tlp-footer" style={{ background: theme.dark }}><i /><span /><span /><span /></footer>
</div>;
}
This diff is collapsed.
......@@ -56,6 +56,9 @@ importers:
pdf-parse:
specifier: ^2.4.5
version: 2.4.5
playwright-core:
specifier: ^1.62.0
version: 1.62.0
word-extractor:
specifier: ^1.0.4
version: 1.0.4
......@@ -2603,6 +2606,11 @@ packages:
resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==}
engines: {node: '>=16.20.0'}
playwright-core@1.62.0:
resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==}
engines: {node: '>=20'}
hasBin: true
postcss@8.5.18:
resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==}
engines: {node: ^10 || ^12 || >=14}
......@@ -6099,6 +6107,8 @@ snapshots:
pkce-challenge@5.0.0: {}
playwright-core@1.62.0: {}
postcss@8.5.18:
dependencies:
nanoid: 3.3.16
......
......@@ -31,6 +31,7 @@ export interface SiteLayoutParameters {
density: "comfortable" | "compact";
radius: "soft" | "square";
gradientEmphasis: boolean;
announcementBar: boolean;
}
export interface CompanyDocumentExtractInput {
......@@ -50,6 +51,7 @@ export interface ThemePresetInfo {
id: string;
version: string;
name: string;
family: string;
colors: string[];
primary: string;
hover: string;
......@@ -72,6 +74,12 @@ export interface LayoutPresetInfo {
parameters: Omit<SiteLayoutParameters, "gradientEmphasis">;
}
export interface ThemeFamilyInfo {
id: string;
name: string;
themeIds: string[];
}
export interface FontPresetInfo {
id: string;
version: string;
......@@ -99,6 +107,7 @@ export interface PageTemplatePreview {
}
export interface PageTemplateContent {
announcement: string;
eyebrow: string;
heroTitle: string;
heroDescription: string;
......@@ -120,12 +129,15 @@ export interface PageTemplateInfo {
layoutPresetIds: string[];
defaultLayoutPresetId: string;
content: PageTemplateContent;
previewUrl?: string;
screenshotUrl?: string;
}
export interface SiteTemplateCatalog {
kernel: { id: string; version: string };
pageTemplates: PageTemplateInfo[];
themes: ThemePresetInfo[];
themeFamilies: ThemeFamilyInfo[];
fonts: FontPresetInfo[];
layoutPresets: LayoutPresetInfo[];
}
......
......@@ -12,6 +12,7 @@
"layoutPresetIds": ["craft-split", "modern-split"],
"defaultLayoutPresetId": "craft-split",
"content": {
"announcement": "手作定制档期开放预约,欢迎沟通专属构想。",
"eyebrow": "HANDCRAFTED WITH CARE",
"heroTitle": "把重要的心意,\n做成一件长久信物",
"heroDescription": "从首饰零售、来料加工到专属雕刻与手作体验,以透明用料、匠人手作和长效售后,让每一件作品经得起时间。",
......
......@@ -12,6 +12,7 @@
"layoutPresetIds": ["editorial-centered", "modern-split"],
"defaultLayoutPresetId": "editorial-centered",
"content": {
"announcement": "新品系列已上市,欢迎到店体验。",
"eyebrow": "MADE WITH PURPOSE",
"heroTitle": "把日常的灵感,\n做成值得记住的品牌",
"heroDescription": "我们相信好品牌来自真实的价值、细腻的表达,以及与每一位用户长久相处的诚意。",
......
......@@ -12,6 +12,7 @@
"layoutPresetIds": ["editorial-centered", "professional-compact"],
"defaultLayoutPresetId": "editorial-centered",
"content": {
"announcement": "本月入行扶持活动进行中,欢迎到店详询。",
"eyebrow": "ONE-STOP LOCAL SERVICE",
"heroTitle": "入行大小事,\n一站一次办清",
"heroDescription": "车辆租售、证照代办、平台入驻与上岗培训,本地实体门店全程对接,合同与费用当面确认,让每一步都清楚透明。",
......
......@@ -12,6 +12,7 @@
"layoutPresetIds": ["professional-compact", "editorial-centered", "modern-split"],
"defaultLayoutPresetId": "professional-compact",
"content": {
"announcement": "本周咨询排期已开放,欢迎预约初次沟通。",
"eyebrow": "TRUSTED PROFESSIONALS",
"heroTitle": "以专业判断,\n回应复杂商业问题",
"heroDescription": "从关键判断到可靠执行,我们与企业并肩工作,让每一项重要决策都有清晰依据。",
......
......@@ -12,6 +12,7 @@
"layoutPresetIds": ["modern-split", "editorial-centered", "professional-compact"],
"defaultLayoutPresetId": "modern-split",
"content": {
"announcement": "新版本平台已上线,欢迎预约产品演示。",
"eyebrow": "AI-POWERED BUSINESS",
"heroTitle": "让每一次数字化升级,\n都成为增长的起点",
"heroDescription": "从策略洞察到产品落地,我们用清晰的方法与先进技术,为企业构建可持续的数字竞争力。",
......
......@@ -12,6 +12,7 @@
"layoutPresetIds": ["editorial-centered", "modern-split"],
"defaultLayoutPresetId": "editorial-centered",
"content": {
"announcement": "新客体验预约中,本期名额有限。",
"eyebrow": "HOLISTIC WELLNESS CARE",
"heroTitle": "以东方调理智慧,\n守护身体自然状态",
"heroDescription": "以非侵入式调理与周期管理,为消费者提供体重、塑形与日常健康管理的一体化方案,并与每一家门店长期共建。",
......
[
{
"id": "blue",
"name": "蓝色系",
"version": "1.0.0",
"variants": [
{ "id": "ocean-blue", "name": "商务蓝", "primary": "#175CD3" },
{ "id": "sky-blue", "name": "天际蓝", "primary": "#0EA5E9" },
{ "id": "royal-blue", "name": "宝石蓝", "primary": "#1D4ED8" },
{ "id": "midnight-blue", "name": "午夜蓝", "primary": "#1E3A8A" },
{ "id": "ice-blue", "name": "冰川蓝", "primary": "#38BDF8" },
{ "id": "indigo-blue", "name": "靛青蓝", "primary": "#4F46E5" },
{ "id": "lake-blue", "name": "湖水蓝", "primary": "#0284C7" },
{ "id": "mist-blue", "name": "雾蓝", "primary": "#64748B" },
{ "id": "navy-blue", "name": "藏青蓝", "primary": "#0F2B5B" },
{ "id": "azure-blue", "name": "蔚蓝", "primary": "#2563EB" },
{ "id": "steel-blue", "name": "钢青蓝", "primary": "#33658A" },
{ "id": "baby-blue", "name": "婴儿蓝", "primary": "#60A5FA" }
]
},
{
"id": "teal",
"name": "青色系",
"version": "1.0.0",
"variants": [
{ "id": "teal-wellness", "name": "青碧康养", "primary": "#16AE9C" },
{ "id": "peacock-teal", "name": "孔雀青", "primary": "#0D9488" },
{ "id": "deep-teal", "name": "深潭青", "primary": "#0F766E" },
{ "id": "cyan-teal", "name": "明青", "primary": "#06B6D4" },
{ "id": "jade-teal", "name": "玉青", "primary": "#2AA198" },
{ "id": "ink-teal", "name": "墨青", "primary": "#155E63" },
{ "id": "mint-teal", "name": "薄荷青", "primary": "#5BC8AF" },
{ "id": "ocean-teal", "name": "海青", "primary": "#128091" },
{ "id": "pine-teal", "name": "松烟青", "primary": "#1D7A74" },
{ "id": "verdigris-teal", "name": "铜绿", "primary": "#43A6A0" }
]
},
{
"id": "green",
"name": "绿色系",
"version": "1.0.0",
"variants": [
{ "id": "forest-green", "name": "自然绿", "primary": "#087A5B" },
{ "id": "emerald-green", "name": "祖母绿", "primary": "#059669" },
{ "id": "leaf-green", "name": "枝叶绿", "primary": "#16A34A" },
{ "id": "moss-green", "name": "苔藓绿", "primary": "#4D7C0F" },
{ "id": "olive-green", "name": "橄榄绿", "primary": "#6B8E23" },
{ "id": "pine-green", "name": "松绿", "primary": "#166534" },
{ "id": "jade-green", "name": "翡翠绿", "primary": "#00A86B" },
{ "id": "lime-green", "name": "青柠绿", "primary": "#65A30D" },
{ "id": "sage-green", "name": "鼠尾草绿", "primary": "#87A96B" },
{ "id": "bamboo-green", "name": "竹绿", "primary": "#3F9D63" }
]
},
{
"id": "violet",
"name": "紫色系",
"version": "1.0.0",
"variants": [
{ "id": "violet-tech", "name": "科技紫", "primary": "#7028FF" },
{ "id": "grape-violet", "name": "葡萄紫", "primary": "#7C3AED" },
{ "id": "lavender-violet", "name": "薰衣草紫", "primary": "#8B5CF6" },
{ "id": "deep-violet", "name": "深紫", "primary": "#5B21B6" },
{ "id": "orchid-violet", "name": "兰花紫", "primary": "#A855F7" },
{ "id": "mauve-violet", "name": "木槿紫", "primary": "#9661C5" },
{ "id": "indigo-violet", "name": "紫靛", "primary": "#6366F1" },
{ "id": "eggplant-violet", "name": "茄紫", "primary": "#6B21A8" },
{ "id": "lilac-violet", "name": "丁香紫", "primary": "#A78BFA" },
{ "id": "amethyst-violet", "name": "紫水晶", "primary": "#8854D0" }
]
},
{
"id": "red",
"name": "红色系",
"version": "1.0.0",
"variants": [
{ "id": "heritage-crimson", "name": "朱红金韵", "primary": "#D1251A" },
{ "id": "cinnabar-red", "name": "朱砂红", "primary": "#E34234" },
{ "id": "ruby-red", "name": "宝石红", "primary": "#BE123C" },
{ "id": "wine-red", "name": "酒红", "primary": "#7F1D2D" },
{ "id": "coral-red", "name": "珊瑚红", "primary": "#F97066" },
{ "id": "vermilion-red", "name": "朱瑾红", "primary": "#F04438" },
{ "id": "brick-red", "name": "砖红", "primary": "#B42318" },
{ "id": "rose-red", "name": "玫瑰红", "primary": "#E11D48" },
{ "id": "pomegranate-red", "name": "石榴红", "primary": "#C81E1E" },
{ "id": "maple-red", "name": "枫红", "primary": "#D92D20" }
]
},
{
"id": "orange",
"name": "橙色系",
"version": "1.0.0",
"variants": [
{ "id": "warm-amber", "name": "品牌暖橙", "primary": "#C2410C" },
{ "id": "vibrant-orange", "name": "活力暖橙", "primary": "#FF7D41" },
{ "id": "tangerine-orange", "name": "蜜橙", "primary": "#F97316" },
{ "id": "apricot-orange", "name": "杏橙", "primary": "#FB923C" },
{ "id": "burnt-orange", "name": "焦橙", "primary": "#EA580C" },
{ "id": "sunset-orange", "name": "落日橙", "primary": "#FF6B35" },
{ "id": "copper-orange", "name": "赤铜橙", "primary": "#B45309" },
{ "id": "peach-orange", "name": "蜜桃橙", "primary": "#FFA26B" },
{ "id": "rust-orange", "name": "锈橙", "primary": "#9A3412" },
{ "id": "marigold-orange", "name": "金盏橙", "primary": "#F59E0B" }
]
},
{
"id": "gold",
"name": "金黄系",
"version": "1.0.0",
"variants": [
{ "id": "imperial-gold", "name": "帝王金", "primary": "#C9A227" },
{ "id": "champagne-gold", "name": "香槟金", "primary": "#D4AF37" },
{ "id": "honey-gold", "name": "蜂蜜金", "primary": "#D97706" },
{ "id": "brass-gold", "name": "黄铜金", "primary": "#A16207" },
{ "id": "sand-gold", "name": "沙金", "primary": "#BE9E66" },
{ "id": "wheat-gold", "name": "麦金", "primary": "#DFAF2C" },
{ "id": "bronze-gold", "name": "古铜金", "primary": "#8C6A2F" },
{ "id": "gilded-gold", "name": "鎏金", "primary": "#B78932" },
{ "id": "mustard-gold", "name": "芥末金", "primary": "#CA8A04" },
{ "id": "sunflower-gold", "name": "向日葵金", "primary": "#EAB308" }
]
},
{
"id": "pink",
"name": "粉色系",
"version": "1.0.0",
"variants": [
{ "id": "sakura-pink", "name": "樱花粉", "primary": "#EC4899" },
{ "id": "rose-pink", "name": "蔷薇粉", "primary": "#F43F5E" },
{ "id": "flamingo-pink", "name": "火烈鸟粉", "primary": "#F472B6" },
{ "id": "magenta-pink", "name": "品红", "primary": "#DB2777" },
{ "id": "peach-pink", "name": "桃粉", "primary": "#FB7185" },
{ "id": "orchid-pink", "name": "兰粉", "primary": "#E879F9" },
{ "id": "raspberry-pink", "name": "树莓粉", "primary": "#BE185D" },
{ "id": "lotus-pink", "name": "荷粉", "primary": "#EE9FC4" },
{ "id": "rouge-pink", "name": "胭脂粉", "primary": "#C13584" },
{ "id": "coral-pink", "name": "珊瑚粉", "primary": "#FF8FA3" }
]
},
{
"id": "earth",
"name": "大地色系",
"version": "1.0.0",
"variants": [
{ "id": "coffee-brown", "name": "咖啡棕", "primary": "#78512B" },
{ "id": "chocolate-brown", "name": "可可棕", "primary": "#6B4226" },
{ "id": "caramel-brown", "name": "焦糖棕", "primary": "#9C6644" },
{ "id": "terracotta-brown", "name": "陶土棕", "primary": "#B0563A" },
{ "id": "khaki-brown", "name": "卡其棕", "primary": "#A08C5B" },
{ "id": "walnut-brown", "name": "胡桃棕", "primary": "#5F4B32" },
{ "id": "mocha-brown", "name": "摩卡棕", "primary": "#8A6848" },
{ "id": "clay-brown", "name": "黏土棕", "primary": "#8C5A44" },
{ "id": "desert-brown", "name": "沙漠棕", "primary": "#C19A6B" },
{ "id": "umber-brown", "name": "赭石棕", "primary": "#835531" }
]
},
{
"id": "neutral",
"name": "石墨中性系",
"version": "1.0.0",
"variants": [
{ "id": "graphite", "name": "黑金质感", "primary": "#9A7025" },
{ "id": "ink-black", "name": "墨黑", "primary": "#1F2937" },
{ "id": "carbon-black", "name": "碳黑", "primary": "#374151" },
{ "id": "slate-gray", "name": "岩灰", "primary": "#475569" },
{ "id": "silver-gray", "name": "银灰", "primary": "#94A3B8" },
{ "id": "stone-gray", "name": "石灰", "primary": "#78716C" },
{ "id": "platinum-gray", "name": "铂金灰", "primary": "#6B7280" },
{ "id": "obsidian-black", "name": "曜石黑", "primary": "#111827" },
{ "id": "ash-gray", "name": "灰烬", "primary": "#57534E" },
{ "id": "lead-gray", "name": "铅灰", "primary": "#4B5563" }
]
}
]
[
{ "id": "violet-tech", "version": "1.0.0", "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", "version": "1.0.0", "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", "version": "1.0.0", "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", "version": "1.0.0", "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", "version": "1.0.0", "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" },
{ "id": "teal-wellness", "version": "1.0.0", "name": "青碧康养", "colors": ["#16AE9C", "#0E727C", "#E4EFEA"], "primary": "#16ae9c", "hover": "#11897b", "light": "#43c4b3", "secondary": "#0e727c", "dark": "#173f38", "background": "#faf8f4", "soft": "#e4efea", "text": "#24322f", "textSecondary": "#5d6d68", "muted": "#93a59e", "border": "#d6e4de" },
{ "id": "vibrant-orange", "version": "1.0.0", "name": "活力暖橙", "colors": ["#FF7D41", "#FFA76B", "#FFF1E9"], "primary": "#ff7d41", "hover": "#e96a32", "light": "#ffa375", "secondary": "#ffa76b", "dark": "#231916", "background": "#f7f6f5", "soft": "#fff1e9", "text": "#231916", "textSecondary": "#6b625f", "muted": "#a89f9b", "border": "#e8e5e3" },
{ "id": "heritage-crimson", "version": "1.0.0", "name": "朱红金韵", "colors": ["#D1251A", "#C6A84B", "#F7F3EA"], "primary": "#d1251a", "hover": "#a81d15", "light": "#e5584e", "secondary": "#c6a84b", "dark": "#2b2420", "background": "#faf7f1", "soft": "#f7f3ea", "text": "#1e1916", "textSecondary": "#4b4541", "muted": "#9a9188", "border": "#e8e0d6" }
]
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