Commit 0c1766d3 authored by xuchentao's avatar xuchentao

feat: add custom domain management

parent f86dd1a6
......@@ -7,6 +7,25 @@ WEBAGENT_USER_PASSWORD=user
WEBAGENT_SITES_DIR=../WebAgent-sites
# 已上线的静态产物独立保存,不会被测试环境构建覆盖。
WEBAGENT_PRODUCTION_DIR=../WebAgent-production
# 自定义域名使用单独的根路径构建产物,不会覆盖上面的生产目录。
WEBAGENT_CUSTOM_DOMAINS_DIR=../WebAgent-custom-domains
# 用户需要把 CNAME 指向这个由你控制的接入域名,例如 domains.your-company.com。
WEBAGENT_DOMAIN_CNAME_TARGET=domains.webagent.local
# 根域名不支持 CNAME/ALIAS 时,可选配置稳定的边缘 IP。
WEBAGENT_DOMAIN_IPV4=
WEBAGENT_DOMAIN_IPV6=
# external 表示由外部 CDN/TLS 服务管理;也可填写 caddy 或 cloudflare 用于界面提示。
WEBAGENT_DOMAIN_HTTPS_MODE=external
# WEBAGENT_DOMAIN_HTTPS_MODE=cloudflare 时必填,需要 Custom Hostnames 编辑权限。
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ZONE_ID=
# 使用 Caddy On-Demand TLS 时,保护 /internal/domains/allow 授权端点。
WEBAGENT_DOMAIN_ASK_TOKEN=
# 生产服务器可开启自动校验并 reload Nginx;本地没有运行 Nginx 时保持 false。
WEBAGENT_NGINX_AUTO_RELOAD=false
WEBAGENT_NGINX_BIN=nginx
# 待连接域名的所有权、证书和 DNS 自动检查间隔,最小 15000ms。
WEBAGENT_DOMAIN_CHECK_INTERVAL_MS=60000
# 可选:任意兼容 OpenAI Chat Completions 的模型服务。
# 默认示例使用国内可访问的 DeepSeek;也可以替换为企业内部网关,
# 或阿里云百炼兼容地址:https://dashscope.aliyuncs.com/compatible-mode/v1
......
......@@ -49,6 +49,9 @@ WEBAGENT_PRODUCTION_DIR=../WebAgent-production
- 本地 iframe 预览与自动刷新
- 测试环境与生产环境使用独立目录和独立链接
- 用户确认后手动发布,并记录生产版本 commit 与发布时间
- 为每个官网绑定自定义域名,通过 TXT 验证所有权并检测 CNAME/A/AAAA
- 可接入 Cloudflare for SaaS 自动创建 Custom Hostname、展示证书验证记录并跟踪 HTTPS 状态
- 自定义域名使用独立根路径产物,域名故障不影响系统默认生产链接
- 浏览 Git 历史并安全回滚到任意版本
- 生成站点保存在独立的可配置目录,构建缓存等临时数据隔离在 `.runtime/`
......
......@@ -42,6 +42,10 @@ export class BuildManager {
return this.publishAtomically(path.join(config.productionDir, siteId), distPath);
}
async publishCustomDomain(siteId: string, distPath: string): Promise<string> {
return this.publishAtomically(path.join(config.customDomainsDir, siteId), distPath);
}
async ensureProductionPlaceholder(siteId: string, siteName: string): Promise<string> {
const target = path.join(config.productionDir, siteId);
const indexPath = path.join(target, "index.html");
......
......@@ -12,6 +12,14 @@ const configuredProductionDir = process.env.WEBAGENT_PRODUCTION_DIR?.trim() || "
const productionDir = path.isAbsolute(configuredProductionDir)
? configuredProductionDir
: path.resolve(rootDir, configuredProductionDir);
const configuredCustomDomainsDir = process.env.WEBAGENT_CUSTOM_DOMAINS_DIR?.trim() || "../WebAgent-custom-domains";
const customDomainsDir = path.isAbsolute(configuredCustomDomainsDir)
? configuredCustomDomainsDir
: path.resolve(rootDir, configuredCustomDomainsDir);
const configuredDomainCheckInterval = Number(process.env.WEBAGENT_DOMAIN_CHECK_INTERVAL_MS || 60000);
const domainCheckIntervalMs = Number.isFinite(configuredDomainCheckInterval)
? Math.max(15000, configuredDomainCheckInterval)
: 60000;
export const config = {
rootDir,
......@@ -19,6 +27,7 @@ export const config = {
templateDir: path.resolve(currentDir, "../../astro-template"),
sitesDir,
productionDir,
customDomainsDir,
host: process.env.HOST || "127.0.0.1",
port: Number(process.env.PORT || 3100),
frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173",
......@@ -31,6 +40,20 @@ export const config = {
baseUrl: (process.env.OPENAI_BASE_URL || "https://api.deepseek.com/v1").replace(/\/$/, ""),
model: process.env.OPENAI_MODEL || "deepseek-chat",
},
domains: {
cnameTarget: (process.env.WEBAGENT_DOMAIN_CNAME_TARGET || "domains.webagent.local").trim().toLowerCase().replace(/\.$/, ""),
ipv4: process.env.WEBAGENT_DOMAIN_IPV4?.trim() || "",
ipv6: process.env.WEBAGENT_DOMAIN_IPV6?.trim() || "",
httpsMode: (process.env.WEBAGENT_DOMAIN_HTTPS_MODE || "external") as "external" | "caddy" | "cloudflare",
askToken: process.env.WEBAGENT_DOMAIN_ASK_TOKEN?.trim() || "",
nginxAutoReload: process.env.WEBAGENT_NGINX_AUTO_RELOAD === "true",
nginxBin: process.env.WEBAGENT_NGINX_BIN?.trim() || "nginx",
checkIntervalMs: domainCheckIntervalMs,
cloudflare: {
apiToken: process.env.CLOUDFLARE_API_TOKEN?.trim() || "",
zoneId: process.env.CLOUDFLARE_ZONE_ID?.trim() || "",
},
},
};
export const runtimePaths = {
......@@ -40,6 +63,7 @@ export const runtimePaths = {
uploads: path.join(config.runtimeDir, "uploads"),
pnpmStore: path.join(config.runtimeDir, "pnpm-store"),
logs: path.join(config.runtimeDir, "logs"),
domainMaps: path.join(config.runtimeDir, "nginx"),
};
export function getPublicPreviewUrl(siteId: string): string {
......
import crypto from "node:crypto";
import path from "node:path";
import { runtimePaths } from "../config.js";
import { BuildError, BuildManager } from "../build/build-manager.js";
import { GitManager } from "../git/git-manager.js";
import { ensureEnvironmentConfig } from "../sites/ensure-environment-config.js";
import { SiteRepository } from "../sites/site-repository.js";
import { DomainRepository } from "./domain-repository.js";
import { DomainRoutingConfig } from "./domain-routing-config.js";
export class DomainDeploymentService {
private readonly inFlight = new Map<string, Promise<void>>();
constructor(
private readonly sites: SiteRepository,
private readonly domains: DomainRepository,
private readonly git: GitManager,
private readonly builds: BuildManager,
private readonly routing: DomainRoutingConfig,
) {}
schedule(siteId: string, commit: string): Promise<void> {
const current = this.inFlight.get(siteId);
if (current) return current.then(() => this.schedule(siteId, commit));
const operation = this.deploy(siteId, commit).finally(() => this.inFlight.delete(siteId));
this.inFlight.set(siteId, operation);
return operation;
}
private async deploy(siteId: string, commit: string): Promise<void> {
const bindings = await this.domains.list(siteId);
if (!bindings.some((domain) => domain.ownershipStatus === "verified")) return;
const project = this.sites.getProjectPath(siteId);
await this.git.assertCommit(project, commit);
const taskId = "domain_" + crypto.randomBytes(5).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.domains.updateSite(siteId, (domain) => domain.ownershipStatus === "verified"
? { deploymentStatus: "deploying", lastDeploymentError: undefined }
: {});
try {
await this.git.createWorktree(project, workspace, commit);
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, "/");
await this.builds.publishCustomDomain(siteId, dist);
await this.domains.updateSite(siteId, (domain) => domain.ownershipStatus === "verified"
? { deploymentStatus: "active", deployedCommit: commit, lastDeploymentError: undefined }
: {});
await this.routing.sync();
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.domains.updateSite(siteId, (domain) => domain.ownershipStatus === "verified"
? { deploymentStatus: "failed", lastDeploymentError: details }
: {});
throw error;
} finally {
await this.git.removeWorktree(project, workspace);
}
}
}
import type { DomainDnsRecord, DomainTlsStatus } from "@webagent/shared";
import { config } from "../config.js";
import type { StoredDomainBinding } from "./domain-repository.js";
export interface DomainProviderState {
providerHostnameId?: string;
providerStatus?: string;
tlsStatus: DomainTlsStatus;
certificateRecords?: DomainDnsRecord[];
error?: string;
}
export interface DomainProvider {
isConfigured(): boolean;
initialTlsStatus(): DomainTlsStatus;
sync(domain: StoredDomainBinding): Promise<DomainProviderState>;
remove(domain: StoredDomainBinding): Promise<void>;
}
class ManagedExternallyProvider implements DomainProvider {
isConfigured(): boolean { return true; }
initialTlsStatus(): DomainTlsStatus { return "managed"; }
async sync(): Promise<DomainProviderState> { return { tlsStatus: "managed", providerStatus: config.domains.httpsMode }; }
async remove(): Promise<void> {}
}
type CloudflareValidationRecord = {
status?: string;
txt_name?: string;
txt_value?: string;
cname?: string;
cname_target?: string;
};
type CloudflareHostname = {
id: string;
hostname: string;
status?: string;
ownership_verification?: { type?: "txt"; name?: string; value?: string };
verification_errors?: Array<string | { message?: string }>;
ssl?: {
status?: string;
validation_records?: CloudflareValidationRecord[];
validation_errors?: Array<string | { message?: string }>;
};
};
type CloudflareResponse<T> = {
success?: boolean;
result?: T;
errors?: Array<{ message?: string }>;
};
class CloudflareDomainProvider implements DomainProvider {
isConfigured(): boolean {
return Boolean(config.domains.cloudflare.apiToken && config.domains.cloudflare.zoneId);
}
initialTlsStatus(): DomainTlsStatus { return "pending"; }
async sync(domain: StoredDomainBinding): Promise<DomainProviderState> {
if (!this.isConfigured()) throw new Error("Cloudflare for SaaS 尚未配置 API Token 或 Zone ID");
let hostname: CloudflareHostname;
if (domain.providerHostnameId) {
hostname = await this.request<CloudflareHostname>(`/custom_hostnames/${domain.providerHostnameId}`);
} else {
const created = await this.request<CloudflareHostname>("/custom_hostnames", {
method: "POST",
body: JSON.stringify({
hostname: domain.hostname,
custom_metadata: { site_id: domain.siteId, domain_id: domain.domainId },
ssl: { method: "txt", type: "dv", settings: { min_tls_version: "1.2" } },
}),
});
hostname = await this.request<CloudflareHostname>(`/custom_hostnames/${created.id}`);
}
return this.toState(hostname);
}
async remove(domain: StoredDomainBinding): Promise<void> {
if (!domain.providerHostnameId || !this.isConfigured()) return;
await this.request<{ id?: string }>(`/custom_hostnames/${domain.providerHostnameId}`, { method: "DELETE" });
}
private async request<T>(pathname: string, options?: RequestInit): Promise<T> {
const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${config.domains.cloudflare.zoneId}${pathname}`, {
...options,
signal: options?.signal || AbortSignal.timeout(15000),
headers: {
authorization: `Bearer ${config.domains.cloudflare.apiToken}`,
"content-type": "application/json",
...options?.headers,
},
});
const data = await response.json() as CloudflareResponse<T> | T;
if (response.ok && options?.method === "DELETE" && "id" in (data as object)) return data as T;
const envelope = data as CloudflareResponse<T>;
if (!response.ok || !envelope.success || !envelope.result) {
throw new Error(envelope.errors?.map((error) => error.message).filter(Boolean).join(";") || `Cloudflare 请求失败(${response.status})`);
}
return envelope.result;
}
private toState(hostname: CloudflareHostname): DomainProviderState {
const sslStatus = hostname.ssl?.status || "initializing";
const hostnameStatus = hostname.status || "pending";
const active = hostnameStatus === "active" && sslStatus === "active";
const sslFailed = sslStatus !== "active" && sslStatus !== "initializing" && !sslStatus.startsWith("pending");
const hostnameFailed = hostnameStatus !== "active" && hostnameStatus !== "active_redeploying" && !hostnameStatus.startsWith("pending");
const records: DomainDnsRecord[] = [];
const ownership = hostname.ownership_verification;
if (ownership?.name && ownership.value) records.push({ type: "TXT", name: ownership.name, value: ownership.value, purpose: "hostname" });
for (const record of hostname.ssl?.validation_records || []) {
if (record.txt_name && record.txt_value) records.push({ type: "TXT", name: record.txt_name, value: record.txt_value, purpose: "certificate" });
else if (record.cname && record.cname_target) records.push({ type: "CNAME", name: record.cname, value: record.cname_target, purpose: "certificate" });
}
const unique = [...new Map(records.map((record) => [`${record.type}:${record.name}:${record.value}`, record])).values()];
const errors = [...(hostname.verification_errors || []), ...(hostname.ssl?.validation_errors || [])]
.map((error) => typeof error === "string" ? error : error.message)
.filter((message): message is string => Boolean(message));
return {
providerHostnameId: hostname.id,
providerStatus: `hostname:${hostnameStatus}; ssl:${sslStatus}`,
tlsStatus: active ? "active" : sslFailed || hostnameFailed ? "failed" : "pending",
certificateRecords: unique,
error: errors.join(";") || undefined,
};
}
}
export function createDomainProvider(): DomainProvider {
return config.domains.httpsMode === "cloudflare" ? new CloudflareDomainProvider() : new ManagedExternallyProvider();
}
import crypto from "node:crypto";
import path from "node:path";
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
import type { DomainBinding } from "@webagent/shared";
import { config, runtimePaths } from "../config.js";
export type StoredDomainBinding = Omit<DomainBinding, "status" | "verificationHost" | "verificationValue" | "cnameTarget" | "url"> & {
verificationToken: string;
};
export class DomainError extends Error {
constructor(message: string, public readonly statusCode = 400) { super(message); }
}
export class DomainRepository {
private mutationQueue: Promise<unknown> = Promise.resolve();
async list(siteId: string): Promise<StoredDomainBinding[]> {
this.assertSiteId(siteId);
return this.readSite(siteId);
}
async listAll(): Promise<StoredDomainBinding[]> {
const entries = await readdir(runtimePaths.sites, { withFileTypes: true }).catch(() => []);
const groups = await Promise.all(entries
.filter((entry) => entry.isDirectory() && /^site_[a-z0-9]+$/.test(entry.name))
.map((entry) => this.readSite(entry.name)));
return groups.flat();
}
async get(siteId: string, domainId: string): Promise<StoredDomainBinding> {
const domain = (await this.list(siteId)).find((item) => item.domainId === domainId);
if (!domain) throw new DomainError("域名绑定不存在", 404);
return domain;
}
async findByHostname(hostname: string): Promise<StoredDomainBinding | undefined> {
return (await this.listAll()).find((item) => item.hostname === hostname);
}
async create(siteId: string, hostname: string, verificationToken: string, tlsStatus: StoredDomainBinding["tlsStatus"]): Promise<StoredDomainBinding> {
return this.mutate(async () => {
if ((await this.listAll()).some((item) => item.hostname === hostname)) throw new DomainError("该域名已经绑定到其他官网", 409);
const current = await this.readSite(siteId);
const now = new Date().toISOString();
const domain: StoredDomainBinding = {
domainId: "domain_" + crypto.randomBytes(6).toString("hex"),
siteId,
hostname,
isPrimary: current.length === 0,
ownershipStatus: "pending",
dnsStatus: "pending",
deploymentStatus: "pending",
tlsStatus,
verificationToken,
createdAt: now,
updatedAt: now,
};
await this.writeSite(siteId, [...current, domain]);
return domain;
});
}
async update(siteId: string, domainId: string, values: Partial<StoredDomainBinding>): Promise<StoredDomainBinding> {
return this.mutate(async () => {
const current = await this.readSite(siteId);
const index = current.findIndex((item) => item.domainId === domainId);
if (index < 0) throw new DomainError("域名绑定不存在", 404);
const next = { ...current[index], ...values, domainId, siteId, updatedAt: new Date().toISOString() };
current[index] = next;
await this.writeSite(siteId, current);
return next;
});
}
async updateSite(siteId: string, values: (domain: StoredDomainBinding) => Partial<StoredDomainBinding>): Promise<StoredDomainBinding[]> {
return this.mutate(async () => {
const current = await this.readSite(siteId);
const updatedAt = new Date().toISOString();
const next = current.map((domain) => ({ ...domain, ...values(domain), domainId: domain.domainId, siteId, updatedAt }));
await this.writeSite(siteId, next);
return next;
});
}
async setPrimary(siteId: string, domainId: string): Promise<StoredDomainBinding> {
return this.mutate(async () => {
const current = await this.readSite(siteId);
if (!current.some((item) => item.domainId === domainId)) throw new DomainError("域名绑定不存在", 404);
const updatedAt = new Date().toISOString();
const next = current.map((item) => ({ ...item, isPrimary: item.domainId === domainId, updatedAt }));
await this.writeSite(siteId, next);
return next.find((item) => item.domainId === domainId)!;
});
}
async remove(siteId: string, domainId: string): Promise<void> {
await this.mutate(async () => {
const current = await this.readSite(siteId);
const removed = current.find((item) => item.domainId === domainId);
if (!removed) throw new DomainError("域名绑定不存在", 404);
const next = current.filter((item) => item.domainId !== domainId);
if (removed.isPrimary && next[0]) next[0] = { ...next[0], isPrimary: true, updatedAt: new Date().toISOString() };
await this.writeSite(siteId, next);
});
}
private getPath(siteId: string): string {
this.assertSiteId(siteId);
return path.join(runtimePaths.sites, siteId, "metadata", "domains.json");
}
private async readSite(siteId: string): Promise<StoredDomainBinding[]> {
try {
const domains = JSON.parse(await readFile(this.getPath(siteId), "utf8")) as StoredDomainBinding[];
return domains.map((domain) => {
const { lastError, ...stored } = domain;
return {
...stored,
tlsStatus: domain.tlsStatus || (config.domains.httpsMode === "cloudflare" ? "pending" : "managed"),
lastDeploymentError: domain.lastDeploymentError || lastError,
};
});
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return [];
throw error;
}
}
private async writeSite(siteId: string, domains: StoredDomainBinding[]): Promise<void> {
const target = this.getPath(siteId);
await mkdir(path.dirname(target), { recursive: true });
const temporary = target + ".tmp";
await writeFile(temporary, JSON.stringify(domains, null, 2) + "\n", "utf8");
await rename(temporary, target);
}
private async mutate<T>(operation: () => Promise<T>): Promise<T> {
const result = this.mutationQueue.then(operation, operation);
this.mutationQueue = result.then(() => undefined, () => undefined);
return result;
}
private assertSiteId(siteId: string): void {
if (!/^site_[a-z0-9]+$/.test(siteId)) throw new DomainError("无效的 siteId", 400);
}
}
import path from "node:path";
import { mkdir, rename, writeFile } from "node:fs/promises";
import { execa } from "execa";
import { config, runtimePaths } from "../config.js";
import { DomainRepository } from "./domain-repository.js";
export class DomainRoutingConfig {
constructor(private readonly domains: DomainRepository) {}
async sync(): Promise<void> {
const active = (await this.domains.listAll())
.filter((domain) => domain.ownershipStatus === "verified" && domain.deploymentStatus === "active")
.sort((a, b) => a.hostname.localeCompare(b.hostname));
const primaryBySite = new Map<string, string>();
for (const domain of active) {
if (domain.isPrimary || !primaryBySite.has(domain.siteId)) primaryBySite.set(domain.siteId, domain.hostname);
}
const routes = active.map((domain) => `${domain.hostname} ${domain.siteId};`).join("\n");
const redirects = active
.filter((domain) => primaryBySite.get(domain.siteId) !== domain.hostname)
.map((domain) => `${domain.hostname} ${primaryBySite.get(domain.siteId)};`)
.join("\n");
await Promise.all([
this.writeAtomically(path.join(runtimePaths.domainMaps, "domains", "routes.map"), routes),
this.writeAtomically(path.join(runtimePaths.domainMaps, "redirects", "routes.map"), redirects),
]);
if (config.domains.nginxAutoReload) {
await execa(config.domains.nginxBin, ["-t"]);
await execa(config.domains.nginxBin, ["-s", "reload"]);
}
}
private async writeAtomically(target: string, content: string): Promise<void> {
await mkdir(path.dirname(target), { recursive: true });
const temporary = target + ".tmp";
await writeFile(temporary, (content || "# No active custom domains") + "\n", "utf8");
await rename(temporary, target);
}
}
import crypto from "node:crypto";
import { promises as dns } from "node:dns";
import { domainToASCII } from "node:url";
import type { DomainBinding, DomainListResult, DomainStatus } from "@webagent/shared";
import { config } from "../config.js";
import { SiteRepository } from "../sites/site-repository.js";
import { DomainError, DomainRepository, type StoredDomainBinding } from "./domain-repository.js";
import type { DomainProvider } from "./domain-provider.js";
import { DomainRoutingConfig } from "./domain-routing-config.js";
export class DomainService {
constructor(
private readonly sites: SiteRepository,
private readonly domains: DomainRepository,
private readonly routing: DomainRoutingConfig,
private readonly provider: DomainProvider,
) {}
async list(siteId: string): Promise<DomainListResult> {
await this.sites.get(siteId);
return {
domains: (await this.domains.list(siteId)).map((domain) => this.present(domain)),
cnameTarget: config.domains.cnameTarget,
configured: !config.domains.cnameTarget.endsWith(".local") && this.provider.isConfigured(),
ipv4: config.domains.ipv4 || undefined,
ipv6: config.domains.ipv6 || undefined,
httpsMode: config.domains.httpsMode,
};
}
async add(siteId: string, input: string): Promise<DomainBinding> {
await this.sites.get(siteId);
if (config.domains.cnameTarget.endsWith(".local")) throw new DomainError("域名接入尚未配置,请先设置 WEBAGENT_DOMAIN_CNAME_TARGET");
if (!this.provider.isConfigured()) throw new DomainError("域名 HTTPS Provider 尚未配置完整");
const hostname = this.normalizeHostname(input);
const verificationToken = "webagent-verification=" + crypto.randomBytes(18).toString("base64url");
return this.present(await this.domains.create(siteId, hostname, verificationToken, this.provider.initialTlsStatus()));
}
async verify(siteId: string, domainId: string): Promise<DomainBinding> {
const domain = await this.domains.get(siteId, domainId);
const [ownership, routing] = await Promise.all([
domain.ownershipStatus === "verified"
? Promise.resolve<{ valid: boolean; error?: string }>({ valid: true })
: this.checkOwnership(domain),
this.checkRouting(domain.hostname),
]);
const errors = [ownership.error, routing.error].filter(Boolean);
let next = await this.domains.update(siteId, domainId, {
ownershipStatus: ownership.valid ? "verified" : "failed",
dnsStatus: routing.valid ? "valid" : "invalid",
lastCheckedAt: new Date().toISOString(),
lastDnsError: errors.length ? errors.join(";") : undefined,
});
if (next.ownershipStatus === "verified") {
try {
const provider = await this.provider.sync(next);
next = await this.domains.update(siteId, domainId, {
providerHostnameId: provider.providerHostnameId,
providerStatus: provider.providerStatus,
tlsStatus: provider.tlsStatus,
certificateRecords: provider.certificateRecords,
lastDnsError: routing.error,
lastTlsError: provider.error,
});
} catch (error) {
next = await this.domains.update(siteId, domainId, {
tlsStatus: "failed",
lastTlsError: error instanceof Error ? error.message : String(error),
});
}
}
return this.present(next);
}
async setPrimary(siteId: string, domainId: string): Promise<DomainBinding> {
const domain = this.present(await this.domains.setPrimary(siteId, domainId));
await this.routing.sync();
return domain;
}
async remove(siteId: string, domainId: string): Promise<void> {
const domain = await this.domains.get(siteId, domainId);
await this.provider.remove(domain);
await this.domains.remove(siteId, domainId);
await this.routing.sync();
}
async isCertificateAllowed(hostnameInput: string): Promise<boolean> {
let hostname: string;
try { hostname = this.normalizeHostname(hostnameInput); } catch { return false; }
const domain = await this.domains.findByHostname(hostname);
return Boolean(domain && domain.ownershipStatus === "verified" && domain.deploymentStatus === "active");
}
present(domain: StoredDomainBinding): DomainBinding {
const status = this.status(domain);
const { verificationToken, lastError: _legacyError, ...stored } = domain;
return {
...stored,
status,
verificationHost: `_webagent-verification.${domain.hostname}`,
verificationValue: verificationToken,
cnameTarget: config.domains.cnameTarget,
url: `https://${domain.hostname}/`,
lastError: [domain.lastDeploymentError, domain.lastTlsError, domain.lastDnsError, _legacyError].filter(Boolean).join(";") || undefined,
};
}
private status(domain: StoredDomainBinding): DomainStatus {
if (domain.deploymentStatus === "failed") return "failed";
if (domain.tlsStatus === "failed") return "failed";
if (domain.ownershipStatus !== "verified") return "pending_verification";
if (domain.deploymentStatus !== "active") return "deploying";
if (domain.tlsStatus === "pending") return "provisioning_tls";
if (domain.dnsStatus !== "valid") return "pending_dns";
return "active";
}
private normalizeHostname(input: string): string {
const raw = input.trim().toLowerCase().replace(/\.$/, "");
if (raw.includes("://") || raw.includes("/") || raw.includes(":") || raw.includes("@")) throw new DomainError("请输入域名,不要包含协议、路径或端口");
const hostname = domainToASCII(raw);
if (!hostname || hostname.length > 253 || !hostname.includes(".")) throw new DomainError("请输入有效的完整域名");
const labels = hostname.split(".");
if (labels.some((label) => !label || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label))) throw new DomainError("域名格式不正确");
if (["local", "localhost", "test", "invalid", "example"].includes(labels.at(-1)!)) throw new DomainError("不能绑定本地或保留域名");
return hostname;
}
private async checkOwnership(domain: StoredDomainBinding): Promise<{ valid: boolean; error?: string }> {
const host = `_webagent-verification.${domain.hostname}`;
try {
const records = await dns.resolveTxt(host);
const valid = records.some((chunks) => chunks.join("") === domain.verificationToken);
return valid ? { valid: true } : { valid: false, error: `未找到 ${host} 的正确 TXT 记录` };
} catch {
return { valid: false, error: `暂未查询到 ${host} 的 TXT 记录` };
}
}
private async checkRouting(hostname: string): Promise<{ valid: boolean; error?: string }> {
const target = config.domains.cnameTarget;
try {
const cnames = await dns.resolveCname(hostname).catch(() => []);
if (cnames.some((value) => value.toLowerCase().replace(/\.$/, "") === target)) return { valid: true };
const [hostV4, hostV6, targetV4, targetV6] = await Promise.all([
dns.resolve4(hostname).catch(() => []),
dns.resolve6(hostname).catch(() => []),
dns.resolve4(target).catch(() => []),
dns.resolve6(target).catch(() => []),
]);
const accepted = new Set([
...targetV4, ...targetV6,
...(config.domains.ipv4 ? [config.domains.ipv4] : []),
...(config.domains.ipv6 ? [config.domains.ipv6] : []),
]);
if ([...hostV4, ...hostV6].some((address) => accepted.has(address))) return { valid: true };
return { valid: false, error: `域名尚未指向 ${target}` };
} catch {
return { valid: false, error: `暂未查询到指向 ${target} 的 DNS 记录` };
}
}
}
......@@ -23,6 +23,14 @@ export const versionCommitSchema = z.object({
commit: z.string().regex(/^[0-9a-f]{7,40}$/),
});
export const addDomainSchema = z.object({
hostname: z.string().trim().min(4, "请输入完整域名").max(253),
});
export const updateDomainSchema = z.object({
isPrimary: z.literal(true),
});
export const sitePatchSchema = z.object({
summary: z.string().trim().min(2).max(200),
operations: z.array(z.discriminatedUnion("type", [
......
......@@ -9,11 +9,16 @@ import { BuildManager } from "./build/build-manager.js";
import { config, runtimePaths } from "./config.js";
import { GitManager } from "./git/git-manager.js";
import { PreviewProcessManager } from "./preview/preview-process-manager.js";
import { chatSchema, createSiteSchema, loginSchema, versionCommitSchema } from "./schemas.js";
import { addDomainSchema, chatSchema, createSiteSchema, loginSchema, updateDomainSchema, versionCommitSchema } from "./schemas.js";
import { CreateSiteService } from "./sites/create-site.js";
import { SiteAgentService } from "./sites/site-agent-service.js";
import { SiteRepository } from "./sites/site-repository.js";
import { SiteVersionService } from "./sites/site-version-service.js";
import { DomainDeploymentService } from "./domains/domain-deployment-service.js";
import { DomainRepository } from "./domains/domain-repository.js";
import { DomainRoutingConfig } from "./domains/domain-routing-config.js";
import { DomainService } from "./domains/domain-service.js";
import { createDomainProvider } from "./domains/domain-provider.js";
const app = Fastify({ logger: { level: process.env.LOG_LEVEL || "info" }, bodyLimit: 1024 * 1024 });
await app.register(cors, { origin: [config.frontendOrigin, "http://127.0.0.1:5173"] });
......@@ -25,6 +30,10 @@ const previews = new PreviewProcessManager();
const createSite = new CreateSiteService(sites, git, builds, previews);
const siteAgent = new SiteAgentService(sites, git, new AgentLoop());
const versions = new SiteVersionService(sites, git, builds, previews);
const domainRepository = new DomainRepository();
const domainRouting = new DomainRoutingConfig(domainRepository);
const domainDeployments = new DomainDeploymentService(sites, domainRepository, git, builds, domainRouting);
const domainService = new DomainService(sites, domainRepository, domainRouting, createDomainProvider());
const sessions = new Set<string>();
app.addHook("onRequest", async (request, reply) => {
......@@ -61,13 +70,46 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-version", a
const body = versionCommitSchema.parse(request.body); return versions.previewVersion(request.params.siteId, body.commit);
});
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-draft", async (request) => versions.previewDraft(request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (request) => versions.publish(request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (request) => {
const result = await versions.publish(request.params.siteId);
void domainDeployments.schedule(request.params.siteId, result.commit).catch((error) => app.log.error(error));
return result;
});
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/history", async (request) => git.history(sites.getProjectPath(request.params.siteId)));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/undo", async (request) => versions.undo(request.params.siteId));
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/domains", async (request) => domainService.list(request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/domains", async (request, reply) => {
const body = addDomainSchema.parse(request.body);
return reply.code(201).send(await domainService.add(request.params.siteId, body.hostname));
});
app.post<{ Params: { siteId: string; domainId: string } }>("/api/sites/:siteId/domains/:domainId/verify", async (request) => {
const domain = await domainService.verify(request.params.siteId, request.params.domainId);
const site = await sites.get(request.params.siteId);
if (domain.ownershipStatus === "verified" && site.publishedCommit) {
void domainDeployments.schedule(site.siteId, site.publishedCommit).catch((error) => app.log.error(error));
}
return domain;
});
app.patch<{ Params: { siteId: string; domainId: string } }>("/api/sites/:siteId/domains/:domainId", async (request) => {
updateDomainSchema.parse(request.body);
return domainService.setPrimary(request.params.siteId, request.params.domainId);
});
app.delete<{ Params: { siteId: string; domainId: string } }>("/api/sites/:siteId/domains/:domainId", async (request, reply) => {
await domainService.remove(request.params.siteId, request.params.domainId);
return reply.code(204).send();
});
app.get<{ Querystring: { domain?: string; token?: string } }>("/internal/domains/allow", async (request, reply) => {
if (config.domains.askToken && request.query.token !== config.domains.askToken) return reply.code(403).send();
if (!request.query.domain || !await domainService.isCertificateAllowed(request.query.domain)) return reply.code(404).send();
return reply.code(200).send();
});
app.setErrorHandler((error, _request, reply) => {
const isMissingFile = error instanceof Error && "code" in error && error.code === "ENOENT";
const status = error instanceof ZodError ? 400 : isMissingFile ? 404 : 500;
const explicitStatus = error instanceof Error && "statusCode" in error && typeof error.statusCode === "number" ? error.statusCode : undefined;
const status = error instanceof ZodError ? 400 : explicitStatus || (isMissingFile ? 404 : 500);
const message = error instanceof ZodError
? error.issues.map((item) => item.message).join(";")
: error instanceof Error ? error.message : "请求处理失败";
......@@ -76,14 +118,15 @@ app.setErrorHandler((error, _request, reply) => {
});
await sites.ensureRuntime();
await domainRouting.sync();
await rm(runtimePaths.builds, { recursive: true, force: true });
await mkdir(runtimePaths.builds, { recursive: true });
for (const site of await sites.list()) {
for (let site of await sites.list()) {
await git.pruneWorktrees(sites.getProjectPath(site.siteId));
await builds.ensureProductionPlaceholder(site.siteId, site.name);
if (site.environmentVersion !== 3) {
await versions.rebuild(site.siteId).catch((error) => app.log.warn(error));
continue;
site = await sites.get(site.siteId);
}
const previewPath = path.join(runtimePaths.previews, site.siteId);
if (await access(previewPath).then(() => true).catch(() => false)) {
......@@ -95,6 +138,41 @@ for (const site of await sites.list()) {
}))
.catch((error) => app.log.warn(error));
}
if (site.publishedCommit) {
const domains = await domainRepository.list(site.siteId);
if (domains.some((domain) => domain.ownershipStatus === "verified" && (domain.deploymentStatus !== "active" || domain.deployedCommit !== site.publishedCommit))) {
void domainDeployments.schedule(site.siteId, site.publishedCommit).catch((error) => app.log.error(error));
}
}
}
await app.listen({ host: config.host, port: config.port });
let checkingDomains = false;
const checkPendingDomains = async () => {
if (checkingDomains) return;
checkingDomains = true;
try {
const domains = await domainRepository.listAll();
for (const stored of domains) {
const fullyConnected = stored.ownershipStatus === "verified"
&& stored.dnsStatus === "valid"
&& (stored.tlsStatus === "active" || stored.tlsStatus === "managed")
&& stored.deploymentStatus === "active";
if (fullyConnected) continue;
const refreshed = await domainService.verify(stored.siteId, stored.domainId).catch((error) => {
app.log.warn(error);
return undefined;
});
if (!refreshed || refreshed.ownershipStatus !== "verified") continue;
const site = await sites.get(stored.siteId);
if (site.publishedCommit && refreshed.deploymentStatus !== "deploying"
&& (refreshed.deploymentStatus !== "active" || refreshed.deployedCommit !== site.publishedCommit)) {
void domainDeployments.schedule(site.siteId, site.publishedCommit).catch((error) => app.log.error(error));
}
}
} finally {
checkingDomains = false;
}
};
setInterval(() => void checkPendingDomains(), config.domains.checkIntervalMs).unref();
......@@ -78,6 +78,7 @@ export class SiteRepository {
await Promise.all([
...Object.values(runtimePaths).map((directory) => mkdir(directory, { recursive: true })),
mkdir(config.productionDir, { recursive: true }),
mkdir(config.customDomainsDir, { recursive: true }),
]);
await this.migrateLegacySites();
}
......
......@@ -18,6 +18,45 @@ pnpm --filter @webagent/backend start
- 测试预览:`http://主机地址/previews/site_xxx/`
- 生产站点:`http://主机地址/sites/site_xxx/`
## 自定义域名源站
主 Nginx 入口会在请求 `Host` 命中已验证域名映射时直接提供自定义域名产物;未命中时仍走原控制台、API、`/previews/``/sites/` 逻辑。因此 Cloudflare/CDN 使用现有公网源站地址即可,但必须保留用户原始 `Host`
`webagent.conf` 还额外监听 `127.0.0.1:8080`,供 Caddy、同机网关等需要独立源站端口的接入方式使用。外部接入层需要:
1. 保留用户请求的原始 `Host`
2. 将自定义域名流量转发到服务器的 `127.0.0.1:8080`
3. 用户 DNS CNAME 指向 `.env` 中的 `WEBAGENT_DOMAIN_CNAME_TARGET`
后端会生成以下映射文件,Nginx reload 后生效:
```text
.runtime/nginx/domains/routes.map
.runtime/nginx/redirects/routes.map
```
生产环境建议设置 `WEBAGENT_NGINX_AUTO_RELOAD=true`,后端会先执行 `nginx -t`,成功后再 reload。未开启时,修改域名配置后可手动执行:
```bash
nginx -t && nginx -s reload
```
自定义域名内容位于 `WEBAGENT_CUSTOM_DOMAINS_DIR/site_xxx/`,使用 `SITE_BASE_PATH=/` 单独构建。该流程失败不会影响 `/sites/site_xxx/`
### Cloudflare for SaaS
设置以下环境变量后,用户完成 WebAgent TXT 验证时,系统会自动创建 Custom Hostname,并在域名管理页面展示 Cloudflare 返回的 hostname validation 和 certificate validation 记录:
```bash
WEBAGENT_DOMAIN_HTTPS_MODE=cloudflare
CLOUDFLARE_API_TOKEN=具备Custom Hostnames编辑权限的Token
CLOUDFLARE_ZONE_ID=你的SaaS Zone ID
```
Cloudflare Zone 中仍需提前配置 Fallback Origin 和 `WEBAGENT_DOMAIN_CNAME_TARGET` 对应的 CNAME Target。
待连接域名默认每 60 秒自动刷新一次所有权、证书和 DNS 状态,可通过 `WEBAGENT_DOMAIN_CHECK_INTERVAL_MS` 调整。
Nginx 由 Homebrew service 管理,登录后会自动启动;Fastify 仍需使用 launchd、进程管理器或终端进程保持运行。更换项目目录时,需要修改 `webagent.conf` 中的 `root``alias`
当前 MVP 没有用户身份、站点权限和请求限流。可以在可信局域网演示;正式暴露到公网前必须补充 HTTPS、鉴权、限流和站点所有权校验。
map $host $webagent_custom_site_id {
hostnames;
default "";
include /Users/mac/Desktop/code/WebAgent/.runtime/nginx/domains/*.map;
}
map $host $webagent_custom_redirect_host {
hostnames;
default "";
include /Users/mac/Desktop/code/WebAgent/.runtime/nginx/redirects/*.map;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
......@@ -12,6 +24,18 @@ server {
gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
# Cloudflare/CDN 回源时保留用户 Host。只有命中后端生成映射的域名才进入
# 自定义站点目录;平台域名和未知 Host 继续使用原有控制台入口。
error_page 418 = @custom_domain_site;
if ($webagent_custom_redirect_host != "") {
return 308 https://$webagent_custom_redirect_host$request_uri;
}
if ($webagent_custom_site_id != "") {
return 418;
}
location /api/ {
proxy_pass http://127.0.0.1:3100;
proxy_http_version 1.1;
......@@ -39,6 +63,44 @@ server {
try_files $uri $uri/ /index.html;
}
location @custom_domain_site {
root /Users/mac/Desktop/code/WebAgent-custom-domains/$webagent_custom_site_id;
try_files $uri $uri/ $uri/index.html =404;
add_header Cache-Control "no-cache" always;
}
location ~ /\. {
deny all;
}
}
# 自定义域名专用源站入口。公网 HTTPS/CDN 只需要把用户域名流量转发到此端口,
# 不会修改上面的控制台、API、/previews/ 和 /sites/ 域名体系。
server {
listen 127.0.0.1:8080;
server_name _;
charset utf-8;
root /Users/mac/Desktop/code/WebAgent-custom-domains/$webagent_custom_site_id;
index index.html;
if ($webagent_custom_site_id = "") {
return 421;
}
if ($webagent_custom_redirect_host != "") {
return 308 https://$webagent_custom_redirect_host$request_uri;
}
location /api/ {
return 404;
}
location / {
try_files $uri $uri/ $uri/index.html =404;
add_header Cache-Control "no-cache" always;
}
location ~ /\. {
deny all;
}
......
......@@ -13,6 +13,9 @@ WebAgent-sites/
WebAgent-production/
└── site_xxxxxx/ # 用户确认发布的生产静态产物
WebAgent-custom-domains/
└── site_xxxxxx/ # 自定义域名根路径产物,使用 SITE_BASE_PATH=/ 构建
WebAgent/.runtime/
├── builds/
│ └── task_xxxxxx/
......@@ -21,10 +24,11 @@ WebAgent/.runtime/
│ └── site_xxxxxx/ # 当前代码最近一次构建成功的测试产物
├── uploads/ # 原始上传文件的临时落点
├── pnpm-store/ # 所有生成站点共享的 pnpm store
└── logs/ # Agent 与构建日志
├── logs/ # Agent 与构建日志
└── nginx/ # 根据已验证域名生成的 Host 路由映射
```
`WebAgent-sites/``WebAgent-production/` 是持久数据,应纳入服务器备份;`.runtime/` 是可重建的测试与运行缓存,不提交 Git。修改路径配置后,系统不会自动迁移外部目录之间的数据,需要由管理员手动移动。旧版本位于 `.runtime/sites/` 的站点会在首次启动时自动迁移到当前配置目录。
`WebAgent-sites/``WebAgent-production/``WebAgent-custom-domains/` 是持久数据,应纳入服务器备份;`.runtime/` 是可重建的测试与运行缓存,不提交 Git。修改路径配置后,系统不会自动迁移外部目录之间的数据,需要由管理员手动移动。旧版本位于 `.runtime/sites/` 的站点会在首次启动时自动迁移到当前配置目录。
## 不变量
......@@ -35,3 +39,5 @@ WebAgent/.runtime/
5. `metadata/site.json` 由 WebAgent 独占写入,站点代码不能反向引用它。
6. 删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。
7. 每个站点拥有稳定的 `siteId` 和预览端口;站点名称不参与路径计算。
8. 自定义域名必须通过 TXT 所有权验证后才允许生成路由和申请证书。
9. 自定义域名构建或 DNS 故障不能覆盖、阻塞或改变 `/sites/site_xxx/` 生产环境。
import { useEffect, useRef, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft, ArrowRight, Bot, Check, ChevronDown, Clock3, Code2, ExternalLink,
Globe2, History, Laptop, LoaderCircle, MessageSquareText, Monitor,
Plus, RefreshCw, Rocket, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Undo2,
ArrowLeft, ArrowRight, BookOpen, Bot, Check, CheckCircle2, ChevronDown, Clock3, Code2, Copy, ExternalLink,
Globe2, History, Laptop, Link2, LoaderCircle, MessageSquareText, Monitor,
Plus, RefreshCw, Rocket, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Trash2, Undo2,
WandSparkles, X,
} from "lucide-react";
import type { CreateSiteInput, SiteInfo } from "@webagent/shared";
import type { CreateSiteInput, DomainBinding, SiteInfo } from "@webagent/shared";
import { api } from "./api";
type ChatMessage = { role: "user" | "agent"; text: string; meta?: string };
......@@ -21,7 +21,9 @@ export default function App() {
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!currentSiteId && sitesQuery.data?.[0]) setCurrentSiteId(sitesQuery.data[0].siteId);
if (sitesQuery.data?.[0] && !sitesQuery.data.some((site) => site.siteId === currentSiteId)) {
setCurrentSiteId(sitesQuery.data[0].siteId);
}
}, [currentSiteId, sitesQuery.data]);
useEffect(() => {
if (currentSiteId) localStorage.setItem("webagent-current-site", currentSiteId);
......@@ -228,7 +230,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onLogout
const queryClient = useQueryClient();
const siteQuery = useQuery({ queryKey: ["site", siteId], queryFn: () => api.site(siteId) });
const historyQuery = useQuery({ queryKey: ["history", siteId], queryFn: () => api.history(siteId) });
const [tab, setTab] = useState<"chat" | "history">("chat");
const [tab, setTab] = useState<"chat" | "history" | "domains">("chat");
const [device, setDevice] = useState<"desktop" | "mobile">("desktop");
const [environment, setEnvironment] = useState<"preview" | "production">("preview");
const [pendingPreviewCommit, setPendingPreviewCommit] = useState("");
......@@ -329,16 +331,16 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onLogout
const environmentStatus = showingProduction
? site.lastPublishError ? "上线失败 · 线上未变" : !site.publishedCommit ? "尚未上线" : productionMatchesPreview ? "生产已同步" : "生产版本较旧"
: site.lastError ? "构建失败 · 预览未变" : site.status === "ready" ? "测试已同步" : "需要检查";
return <main className="workspace">
return <main className={`workspace ${tab === "domains" ? "domains-mode" : ""}`}>
<aside className="rail">
<Logo compact />
<button className="rail-create-top" onClick={onCreate} title="创建官网" data-tooltip="创建官网" aria-label="创建官网"><Plus size={18} /></button>
<div className="rail-nav"><button className="active" title="Agent 工作台" data-tooltip="Agent 工作台" aria-label="Agent 工作台"><Monitor size={18} /></button></div>
<div className="rail-nav"><button className={tab !== "domains" ? "active" : ""} onClick={() => setTab("chat")} title="Agent 工作台" data-tooltip="Agent 工作台" aria-label="Agent 工作台"><Monitor size={18} /></button></div>
<RailSettings onLogout={onLogout} />
</aside>
<section className="control-panel">
<SiteSwitcher site={site} sites={sites} onSelect={onSelectSite} />
<div className="panel-tabs"><button className={tab === "chat" ? "active" : ""} onClick={() => setTab("chat")}><MessageSquareText size={15} /> Agent</button><button className={tab === "history" ? "active" : ""} onClick={() => { setTab("history"); void siteQuery.refetch(); void historyQuery.refetch(); }}><History size={15} /> 版本历史</button></div>
<div className="panel-tabs"><button className={tab === "chat" ? "active" : ""} onClick={() => setTab("chat")}><MessageSquareText size={15} /> Agent</button><button className={tab === "history" ? "active" : ""} onClick={() => { setTab("history"); void siteQuery.refetch(); void historyQuery.refetch(); }}><History size={15} /> 版本</button><button className={tab === "domains" ? "active" : ""} onClick={() => setTab("domains")}><Globe2 size={15} /> 域名</button></div>
{tab === "chat" ? <>
<div className="chat-scroll" ref={scrollRef}>
<div className="chat-date">今天</div>
......@@ -354,7 +356,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onLogout
<textarea rows={3} value={input} disabled={busy || (historicalPreview && !site.draftBaseCommit)} placeholder={historicalPreview && !site.draftBaseCommit ? "切换到最近保存版本后才能继续修改…" : site.draftBaseCommit ? "继续描述对工作草稿的修改…" : "描述你想要的修改,例如:让首屏更有科技感…"} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }} />
<div><span>{site.draftBaseCommit ? <><span className="draft-dot" />工作草稿自动保存</> : agentMode === "model" ? <><span className="online-dot" />模型 Agent</> : <><Code2 size={12} />本地演示模式</>}</span><button onClick={() => send()} disabled={!input.trim() || busy || (historicalPreview && !site.draftBaseCommit)}><Send size={15} /></button></div>
</div>
</> : <div className="history-list">
</> : tab === "history" ? <div className="history-list">
<div className="history-heading"><div><h3>测试版本</h3><p>点击右侧“预览”切换测试版本,生产环境不会改变</p></div><span>{historyQuery.data?.length || 0}</span></div>
{site.draftBaseCommit && <section className="draft-card">
<div className="draft-card-heading"><span><Sparkles size={13} /></span><div><strong>工作草稿</strong><small>自动保存于 {site.draftUpdatedAt ? formatTime(site.draftUpdatedAt) : "刚刚"} · 基于 {site.draftBaseCommit.slice(0, 7)}</small></div></div>
......@@ -376,9 +378,9 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onLogout
</div>
</article>;
})}
</div>}
</div> : <DomainSidePanel site={site} />}
</section>
<section className="preview-shell">
{tab === "domains" ? <DomainManagement site={site} /> : <section className="preview-shell">
<header className="preview-toolbar">
<div className="preview-title"><div><span className={`status-dot ${showingProduction ? "production" : ""}`} /><strong>{site.name}</strong><em className={showingProduction ? "production" : ""}>{showingProduction ? "生产环境" : "测试环境"}</em></div><span className={`build-status ${showingProduction ? site.lastPublishError ? "failed" : site.publishStatus : site.lastError ? "failed" : site.status}`}>{busy ? <LoaderCircle className="spin" size={12} /> : showingProduction ? site.lastPublishError ? <X size={12} /> : <Check size={12} /> : site.lastError ? <X size={12} /> : <Check size={12} />}{publishMutation.isPending ? "正在发布" : previewDraftMutation.isPending ? "正在构建草稿" : chatMutation.isPending ? "正在修改草稿" : rebuildMutation.isPending || previewVersionMutation.isPending || undoMutation.isPending ? "正在构建" : !showingProduction && site.draftBaseCommit ? "草稿已保存" : environmentStatus}</span></div>
<div className="preview-modes">
......@@ -406,10 +408,147 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onLogout
<span>{showingProduction ? <Globe2 size={12} /> : <Monitor size={12} />}当前查看 · {showingProduction ? `生产版本 ${productionVersion?.shortHash || site.publishedCommit?.slice(0, 7) || "尚未上线"}` : `${historicalPreview ? "历史版本" : "测试版本"} ${previewVersion?.shortHash || site.currentCommit.slice(0, 7)}`} · {showingProduction ? site.publishedAt ? formatDate(site.publishedAt) : "尚未发布" : previewVersion ? formatDate(previewVersion.date) : formatDate(site.updatedAt)}{historicalPreview && !showingProduction ? " · 只读" : ""}</span>
<span><Clock3 size={12} />{site.publishedAt ? `线上版本 ${productionVersion?.shortHash || site.publishedCommit?.slice(0, 7)} · ${formatDate(site.publishedAt)}` : "生产环境尚未发布"}</span>
</footer>
</section>
</section>}
</main>;
}
function DomainSidePanel({ site }: { site: SiteInfo }) {
const domainsQuery = useQuery({ queryKey: ["domains", site.siteId], queryFn: () => api.domains(site.siteId) });
const active = domainsQuery.data?.domains.filter((domain) => domain.status === "active").length || 0;
return <div className="domain-side-panel">
<span className="domain-side-icon"><Globe2 size={18} /></span>
<h2>域名管理</h2>
<p>把客户自己购买的域名连接到当前官网,系统默认生产链接不会受到影响。</p>
<div className="domain-side-stats"><div><strong>{domainsQuery.data?.domains.length || 0}</strong><small>已添加域名</small></div><div><strong>{active}</strong><small>连接成功</small></div></div>
<ul><li><ShieldCheck size={12} />TXT 验证域名所有权</li><li><Link2 size={12} />CNAME 连接官网</li><li><RefreshCw size={12} />发布后自动同步内容</li></ul>
<div className="domain-side-default"><span>系统默认链接</span><code>{site.productionUrl}</code><small>始终保留,不会自动跳转</small></div>
</div>;
}
function DomainManagement({ site }: { site: SiteInfo }) {
const queryClient = useQueryClient();
const [hostname, setHostname] = useState("");
const [copied, setCopied] = useState("");
const [selectedDomainId, setSelectedDomainId] = useState("");
const [addingNew, setAddingNew] = useState(false);
const domainsQuery = useQuery({ queryKey: ["domains", site.siteId], queryFn: () => api.domains(site.siteId), refetchInterval: 15000 });
const refresh = () => queryClient.invalidateQueries({ queryKey: ["domains", site.siteId] });
const addMutation = useMutation({
mutationFn: () => api.addDomain(site.siteId, hostname),
onSuccess: async (domain) => { setHostname(""); setSelectedDomainId(domain.domainId); setAddingNew(false); await refresh(); },
});
const verifyMutation = useMutation({ mutationFn: (domainId: string) => api.verifyDomain(site.siteId, domainId), onSuccess: refresh });
const primaryMutation = useMutation({ mutationFn: (domainId: string) => api.setPrimaryDomain(site.siteId, domainId), onSuccess: refresh });
const removeMutation = useMutation({ mutationFn: (domainId: string) => api.removeDomain(site.siteId, domainId), onSuccess: refresh });
const copy = async (key: string, value: string) => {
await navigator.clipboard.writeText(value);
setCopied(key);
window.setTimeout(() => setCopied(""), 1600);
};
const domains = domainsQuery.data?.domains || [];
useEffect(() => {
if (!domains.length) return;
if (!domains.some((domain) => domain.domainId === selectedDomainId)) {
setSelectedDomainId((domains.find((domain) => domain.status !== "active") || domains[0]).domainId);
}
}, [domains, selectedDomainId]);
useEffect(() => {
setHostname(""); setSelectedDomainId(""); setAddingNew(false);
}, [site.siteId]);
const selectedDomain = domains.find((domain) => domain.domainId === selectedDomainId);
const wizardStep = addingNew || !selectedDomain ? 1 : selectedDomain.ownershipStatus !== "verified" ? 2 : selectedDomain.status === "active" ? 4 : 3;
const configured = Boolean(domainsQuery.data?.configured);
const readyToStart = Boolean(site.publishedCommit && configured);
const busy = addMutation.isPending || verifyMutation.isPending || primaryMutation.isPending || removeMutation.isPending;
const httpsLabel = domainsQuery.data?.httpsMode === "cloudflare" ? "Cloudflare 自动签发 HTTPS" : domainsQuery.data?.httpsMode === "caddy" ? "Caddy 自动签发 HTTPS" : "HTTPS 由接入层自动管理";
return <section className="domain-workspace">
<header className="domain-header"><div><span><Globe2 size={18} /></span><div><h1>连接自定义域名</h1><p>域名验证、官网发布与现有平台链接相互隔离。</p></div></div><div className="domain-header-status"><ShieldCheck size={14} />{httpsLabel}</div></header>
<div className="domain-scroll">
<section className="domain-prerequisites">
<div className="domain-section-heading"><div><h2>开始前,请先准备好这些</h2><p>缺少其中一项时先不要修改域名解析。</p></div><span>前置要求</span></div>
<div className="prerequisite-grid">
<div><i className="ready"><Check size={13} /></i><strong>已经购买域名</strong><small>例如在腾讯云、阿里云或 Cloudflare 购买。</small></div>
<div><i className="ready"><Check size={13} /></i><strong>能登录域名控制台</strong><small>需要找到“DNS”“解析设置”或“记录管理”。</small></div>
<div><i className={site.publishedCommit ? "ready" : "waiting"}>{site.publishedCommit ? <Check size={13} /> : <Clock3 size={13} />}</i><strong>官网已经上线</strong><small>{site.publishedCommit ? "当前官网已有可连接的线上版本。" : "请先回到 Agent 工作台,点击“上线当前版本”。"}</small></div>
<div><i className={configured ? "ready" : "waiting"}>{configured ? <Check size={13} /> : <Settings2 size={13} />}</i><strong>{configured ? "域名接入服务已就绪" : "域名接入服务尚未就绪"}</strong><small>{configured ? "系统可以开始验证和连接域名。" : "请联系平台管理员完成域名接入配置。"}</small></div>
</div>
<div className="email-record-warning"><ShieldCheck size={15} /><div><strong>使用企业邮箱的用户请注意</strong><span>不要删除 MX、SPF、DKIM、DMARC 等已有记录。本流程只要求你新增指定记录。</span></div></div>
</section>
<section className="domain-wizard">
<div className="wizard-heading"><div><span>DOMAIN SETUP</span><h2>{addingNew || !selectedDomain ? "连接一个新域名" : `正在配置 ${selectedDomain.hostname}`}</h2><p>按照下面的步骤操作,每完成一步再进入下一步。</p></div>{domains.length > 0 && !addingNew && <button type="button" onClick={() => { setAddingNew(true); setHostname(""); }}><Plus size={13} />添加另一个域名</button>}</div>
<div className="wizard-steps">{["填写域名", "验证所有权", "连接并测试", "配置完成"].map((label, index) => { const number = index + 1; return <div className={number < wizardStep ? "done" : number === wizardStep ? "current" : ""} key={label}><span>{number < wizardStep ? <Check size={13} /> : number}</span><strong>{label}</strong>{index < 3 && <i />}</div>; })}</div>
{wizardStep === 1 && <div className="wizard-body wizard-domain-entry">
<div className="wizard-copy"><span>第 1 步</span><h3>填写你希望访客使用的域名</h3><p>推荐填写带 <code>www</code> 的域名,例如 <code>www.example.com</code>,它最容易在不同域名服务商中配置。</p></div>
<form onSubmit={(event) => { event.preventDefault(); addMutation.mutate(); }}><label>你的官网域名</label><div><Globe2 size={17} /><input value={hostname} onChange={(event) => setHostname(event.target.value)} placeholder="www.example.com" aria-label="要连接的域名" /></div><button disabled={!hostname.trim() || addMutation.isPending || !readyToStart}>{addMutation.isPending ? <LoaderCircle className="spin" size={15} /> : <ArrowRight size={15} />}保存并进入下一步</button></form>
<div className="wizard-help"><strong>不知道填什么?</strong><span>如果你购买的是 <code>example.com</code>,建议先填写 <code>www.example.com</code>。根域名可以稍后再设置跳转。</span></div>
{!readyToStart && <div className="domain-error"><X size={14} />请先完成上方标记为“等待处理”的前置要求。</div>}
{addMutation.error && <div className="domain-error"><X size={14} />{addMutation.error.message}</div>}
</div>}
{wizardStep === 2 && selectedDomain && <div className="wizard-body">
<div className="wizard-copy"><span>第 2 步</span><h3>证明这个域名属于你</h3><p>这一步不会改变现有网站,只是在域名控制台新增一条 TXT 记录。</p></div>
<div className="instruction-list"><div><b>1</b><span><strong>登录购买域名的网站</strong><small>进入腾讯云、阿里云、Cloudflare 或你的域名服务商控制台。</small></span></div><div><b>2</b><span><strong>找到 DNS 或解析设置</strong><small>常见入口名称是“DNS 解析”“解析设置”“记录管理”。</small></span></div><div><b>3</b><span><strong>点击“添加记录”,类型选择 TXT</strong><small>TTL 保持默认值,然后按照下方内容填写。</small></span></div></div>
<DnsRecord title="需要添加的所有权验证记录" type="TXT" name={selectedDomain.verificationHost} domainHostname={selectedDomain.hostname} value={selectedDomain.verificationValue} copied={copied} copyKey={selectedDomain.domainId + "-txt"} onCopy={copy} />
<div className="wizard-action"><div><strong>保存后等待 5~10 分钟</strong><span>DNS 生效需要一点时间。如果检测失败,不要重复添加记录,稍后再试即可。</span></div><button type="button" disabled={busy} onClick={() => verifyMutation.mutate(selectedDomain.domainId)}>{verifyMutation.isPending ? <LoaderCircle className="spin" size={14} /> : <RefreshCw size={14} />}我已添加 TXT,开始检测</button></div>
{selectedDomain.lastError && <div className="domain-card-error"><X size={13} />{selectedDomain.lastError}</div>}
</div>}
{wizardStep === 3 && selectedDomain && <div className="wizard-body">
<div className="wizard-copy"><span>第 3 步</span><h3>连接官网并完成 HTTPS</h3><p>系统会准备官网和安全证书;你只需要把页面列出的记录逐条添加到同一个 DNS 管理页面。</p></div>
<div className="system-readiness"><div className={selectedDomain.deploymentStatus === "active" ? "ready" : "waiting"}>{selectedDomain.deploymentStatus === "active" ? <CheckCircle2 size={16} /> : <LoaderCircle className={selectedDomain.deploymentStatus === "deploying" ? "spin" : ""} size={16} />}<span><strong>官网版本</strong><small>{selectedDomain.deploymentStatus === "active" ? "已准备好" : selectedDomain.deploymentStatus === "failed" ? "准备失败,请点击下方重新检测" : "系统正在准备"}</small></span></div><div className={selectedDomain.tlsStatus === "active" || selectedDomain.tlsStatus === "managed" ? "ready" : "waiting"}>{selectedDomain.tlsStatus === "active" || selectedDomain.tlsStatus === "managed" ? <CheckCircle2 size={16} /> : <LoaderCircle className={selectedDomain.tlsStatus === "pending" ? "spin" : ""} size={16} />}<span><strong>HTTPS 安全证书</strong><small>{selectedDomain.tlsStatus === "active" || selectedDomain.tlsStatus === "managed" ? "已准备好" : selectedDomain.tlsStatus === "failed" ? "签发失败,请检查提示" : "正在签发"}</small></span></div></div>
{selectedDomain.certificateRecords?.length ? <div className="wizard-record-group"><div><strong>先添加证书验证记录</strong><span>如果下面有多条,请全部添加并保存。</span></div><div className="dns-records">{selectedDomain.certificateRecords.map((record, index) => <DnsRecord key={`${record.type}-${record.name}-${record.value}`} title={record.purpose === "certificate" ? "HTTPS 证书验证" : "域名激活验证"} type={record.type} name={record.name} domainHostname={selectedDomain.hostname} value={record.value} copied={copied} copyKey={`${selectedDomain.domainId}-provider-${index}`} onCopy={copy} />)}</div></div> : null}
<div className="wizard-record-group"><div><strong>最后添加网站连接记录</strong><span>添加后,访客访问你的域名就会进入当前官网。</span></div><DnsRecord title="官网连接记录" type={dnsHostLabel(selectedDomain.hostname, selectedDomain.hostname) === "@" ? "ALIAS / ANAME" : "CNAME"} name={selectedDomain.hostname} domainHostname={selectedDomain.hostname} value={selectedDomain.cnameTarget} copied={copied} copyKey={selectedDomain.domainId + "-cname"} onCopy={copy} /></div>
<div className="instruction-note"><strong>填写时注意</strong><span>TTL 保持默认;如果使用 Cloudflare DNS,检测期间请关闭橙色云朵代理。根域名无法选择 CNAME 时,改选 ALIAS、ANAME 或 CNAME Flattening。</span></div>
<div className="wizard-action"><div><strong>保存所有记录后进行测试</strong><span>系统也会自动检测,你可以点击按钮立即检查。</span></div><button type="button" disabled={busy} onClick={() => verifyMutation.mutate(selectedDomain.domainId)}>{verifyMutation.isPending ? <LoaderCircle className="spin" size={14} /> : <RefreshCw size={14} />}我已配置完成,检测连接</button></div>
{selectedDomain.lastError && <div className="domain-card-error"><X size={13} />{selectedDomain.lastError}</div>}
</div>}
{wizardStep === 4 && selectedDomain && <div className="wizard-body wizard-success"><span><CheckCircle2 size={30} /></span><h3>域名已经连接成功</h3><p>现在可以使用 <strong>{selectedDomain.hostname}</strong> 访问官网。以后发布新版本时,这个域名会自动同步。</p><div><a href={selectedDomain.url} target="_blank" rel="noreferrer">打开网站测试 <ExternalLink size={14} /></a>{!selectedDomain.isPrimary && <button type="button" disabled={busy} onClick={() => primaryMutation.mutate(selectedDomain.domainId)}>设为主域名</button>}<button type="button" onClick={() => { setAddingNew(true); setHostname(""); }}><Plus size={14} />再连接一个域名</button></div><small>测试时请确认页面能打开,并且浏览器地址栏显示安全锁标志。</small></div>}
</section>
{domains.length > 0 && <section className="domain-overview"><div className="domain-section-heading"><div><h2>我的域名</h2><p>选择域名可以继续配置、查看状态或测试访问。</p></div><span>{domains.length}</span></div><div>{domains.map((domain) => <DomainOverviewRow key={domain.domainId} domain={domain} selected={domain.domainId === selectedDomainId && !addingNew} busy={busy} onSelect={() => { setSelectedDomainId(domain.domainId); setAddingNew(false); }} onPrimary={() => primaryMutation.mutate(domain.domainId)} onRemove={() => { if (window.confirm(`确定解除 ${domain.hostname} 的绑定吗?`)) removeMutation.mutate(domain.domainId); }} />)}</div></section>}
<ProviderGuides />
</div>
</section>;
}
function DomainOverviewRow({ domain, selected, busy, onSelect, onPrimary, onRemove }: { domain: DomainBinding; selected: boolean; busy: boolean; onSelect: () => void; onPrimary: () => void; onRemove: () => void }) {
const labels = {
pending_verification: ["等待所有权验证", "pending"], deploying: ["正在部署官网", "pending"], provisioning_tls: ["正在签发 HTTPS", "pending"], pending_dns: ["等待 DNS 生效", "pending"], active: ["连接成功", "active"], failed: ["需要检查", "failed"],
} as const;
const [label, tone] = labels[domain.status];
return <div className={`domain-overview-row ${selected ? "selected" : ""}`}><button type="button" className="domain-overview-main" onClick={onSelect}><span><Globe2 size={16} /></span><div><strong>{domain.hostname}</strong><small>{domain.lastCheckedAt ? `上次检测 ${formatDate(domain.lastCheckedAt)}` : "尚未检测"}</small></div>{domain.isPrimary && <em>主域名</em>}<span className={`domain-state ${tone}`}>{domain.status === "active" ? <CheckCircle2 size={12} /> : domain.status === "failed" ? <X size={12} /> : <Clock3 size={12} />}{label}</span><b>{domain.status === "active" ? "查看" : "继续配置"}<ArrowRight size={12} /></b></button><div className="domain-overview-actions">{!domain.isPrimary && <button type="button" disabled={busy} onClick={onPrimary}>设为主域名</button>}<button type="button" className="danger" disabled={busy} onClick={onRemove} title="解除绑定"><Trash2 size={13} /></button></div></div>;
}
function DnsRecord({ title, type, name, domainHostname, value, copied, copyKey, onCopy }: { title: string; type: string; name: string; domainHostname: string; value: string; copied: string; copyKey: string; onCopy: (key: string, value: string) => void }) {
const host = dnsHostLabel(name, domainHostname);
return <div className="dns-record"><span><strong>{title}</strong><small>{type}</small></span><div><label>主机记录</label><code>{host}</code><button type="button" onClick={() => onCopy(copyKey + "-host", host)}>{copied === copyKey + "-host" ? <Check size={12} /> : <Copy size={12} />}</button></div><div><label>记录值</label><code>{value}</code><button type="button" onClick={() => onCopy(copyKey + "-value", value)}>{copied === copyKey + "-value" ? <Check size={12} /> : <Copy size={12} />}</button></div><p>完整记录名:<code>{name}</code></p></div>;
}
function dnsHostLabel(recordName: string, domainHostname: string): string {
const normalizedRecord = recordName.toLowerCase().replace(/\.$/, "");
const labels = domainHostname.toLowerCase().replace(/\.$/, "").split(".");
const compoundSuffixes = new Set(["com.cn", "net.cn", "org.cn", "gov.cn", "com.hk", "com.tw", "co.uk"]);
const suffix = labels.slice(-2).join(".");
const root = labels.slice(compoundSuffixes.has(suffix) ? -3 : -2).join(".");
if (normalizedRecord === root) return "@";
return normalizedRecord.endsWith(`.${root}`) ? normalizedRecord.slice(0, -(root.length + 1)) : normalizedRecord;
}
function ProviderGuides() {
const guides = [
{ name: "腾讯云 DNSPod", url: "https://cloud.tencent.com/document/product/302/3446", steps: ["进入 DNSPod 控制台并选择域名", "打开“记录管理”,点击“添加记录”", "分别添加页面上方的 TXT 与 CNAME 记录"] },
{ name: "阿里云 云解析 DNS", url: "https://help.aliyun.com/zh/dns/pubz-add-parsing-record", steps: ["进入云解析 DNS 的权威解析页面", "选择域名并进入“解析设置”", "点击“添加记录”,依次保存 TXT 和 CNAME"] },
{ name: "Cloudflare DNS", url: "https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/", steps: ["进入域名的 DNS Records 页面", "点击 Add record 添加 TXT 和 CNAME", "接入检测期间建议先关闭 CNAME 的代理开关"] },
];
return <section className="provider-guides"><div className="domain-section-heading"><div><h2>常见域名服务商配置教程</h2><p>控制台如果只要求“主机记录”,请填写完整记录名中去掉根域名后的部分。</p></div><BookOpen size={18} /></div><div className="provider-guide-grid">{guides.map((guide) => <article key={guide.name}><div><span><BookOpen size={14} /></span><strong>{guide.name}</strong></div><ol>{guide.steps.map((step) => <li key={step}>{step}</li>)}</ol><a href={guide.url} target="_blank" rel="noreferrer">查看官方教程 <ExternalLink size={12} /></a></article>)}</div><div className="provider-tip"><ShieldCheck size={14} /><span>请保留原有 MX、SPF、DKIM、DMARC 等邮箱记录。根域名不能直接添加 CNAME 时,请使用服务商提供的 ALIAS、ANAME 或 CNAME Flattening。</span></div></section>;
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}
......
import type { ChatResult, CreateSiteInput, GitHistoryItem, PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared";
import type { ChatResult, CreateSiteInput, DomainBinding, DomainListResult, GitHistoryItem, PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared";
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers);
......@@ -29,4 +29,9 @@ export const api = {
rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }),
publish: (siteId: string) => request<PublishResult>("/api/sites/" + siteId + "/publish", { method: "POST" }),
undo: (siteId: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/undo", { method: "POST" }),
domains: (siteId: string) => request<DomainListResult>("/api/sites/" + siteId + "/domains"),
addDomain: (siteId: string, hostname: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains", { method: "POST", body: JSON.stringify({ hostname }) }),
verifyDomain: (siteId: string, domainId: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains/" + domainId + "/verify", { method: "POST" }),
setPrimaryDomain: (siteId: string, domainId: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains/" + domainId, { method: "PATCH", body: JSON.stringify({ isPrimary: true }) }),
removeDomain: (siteId: string, domainId: string) => request<void>("/api/sites/" + siteId + "/domains/" + domainId, { method: "DELETE" }),
};
......@@ -29,3 +29,34 @@
@media(max-width:1100px){.create-layout{grid-template-columns:1fr;gap:48px;padding-top:40px}.create-intro{text-align:center}.create-intro>p,.create-intro h1{margin-left:auto;margin-right:auto}.intro-tag{margin:auto}.feature-row{max-width:620px;margin-left:auto;margin-right:auto}.create-card-wrap{justify-content:center}.workspace{grid-template-columns:58px 340px 1fr}.create-workspace{grid-template-columns:58px 280px 1fr}.workspace-create-content{padding:28px}}
@media(max-width:1250px) and (min-width:801px){.preview-toolbar{height:96px;grid-template-columns:minmax(160px,1fr) auto;grid-template-rows:42px 42px;grid-template-areas:"title actions" "modes modes";gap:0 10px;padding:6px 12px}.preview-modes{justify-content:center}.toolbar-actions .environment-link{width:31px;padding:0}.toolbar-actions .environment-link>span{display:none}}
@media(max-width:800px){.landing-header,.create-layout{width:min(100% - 30px,700px)}.landing-status{display:none}.feature-row{grid-template-columns:1fr}.create-card{padding:26px 20px}.form-grid{grid-template-columns:1fr}.field.wide{grid-column:auto}.workspace{grid-template-columns:52px 1fr}.control-panel{border-right:0}.preview-shell{display:none}.create-intro h1{font-size:48px}.create-layout{padding-bottom:35px}.create-workspace{grid-template-columns:52px 1fr}.create-workspace .create-control-panel{display:none}.workspace-create-content{padding:18px}.login-card{padding:30px 24px}}
.panel-tabs{grid-template-columns:repeat(3,1fr)}
.domain-side-panel{flex:1;min-height:0;overflow:auto;padding:30px 25px;background:linear-gradient(180deg,#fff,#faf9ff)}
.domain-side-icon{display:grid;place-items:center;width:42px;height:42px;color:var(--primary);background:var(--soft);border:1px solid #dfd6ff;border-radius:13px}
.domain-side-panel h2{margin:18px 0 8px;font-size:20px}.domain-side-panel>p{margin:0;color:var(--muted);font-size:10px;line-height:1.8}
.domain-side-stats{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:23px}.domain-side-stats>div{padding:13px;background:#fff;border:1px solid var(--border);border-radius:11px}.domain-side-stats strong,.domain-side-stats small{display:block}.domain-side-stats strong{font-size:19px}.domain-side-stats small{margin-top:4px;color:var(--muted);font-size:8px}
.domain-side-panel ul{display:flex;flex-direction:column;gap:12px;margin:24px 0;padding:0;list-style:none;color:var(--text-2);font-size:9px}.domain-side-panel li{display:flex;align-items:center;gap:7px}.domain-side-panel li svg{color:var(--primary)}
.domain-side-default{padding:13px;background:#f7f5ff;border:1px solid #e1d9ff;border-radius:11px}.domain-side-default span,.domain-side-default code,.domain-side-default small{display:block}.domain-side-default span{color:var(--primary);font-size:8px;font-weight:800}.domain-side-default code{margin-top:8px;overflow:hidden;font-size:10px;text-overflow:ellipsis}.domain-side-default small{margin-top:5px;color:var(--muted);font-size:7px}
.domain-workspace{min-width:0;min-height:0;display:flex;flex-direction:column;background:#f4f5f9}.domain-header{height:66px;flex:0 0 66px;display:flex;align-items:center;justify-content:space-between;padding:0 25px;background:#fff;border-bottom:1px solid var(--border)}.domain-header>div:first-child{display:flex;align-items:center;gap:11px}.domain-header>div:first-child>span{display:grid;place-items:center;width:35px;height:35px;color:var(--primary);background:var(--soft);border-radius:10px}.domain-header h1{margin:0;font-size:15px}.domain-header p{margin:4px 0 0;color:var(--muted);font-size:8px}.domain-header-status{display:flex;align-items:center;gap:6px;padding:7px 9px;color:#13765b;background:#e9f9f3;border-radius:8px;font-size:8px;font-weight:750}
.domain-scroll{flex:1;min-height:0;overflow:auto;padding:25px}.domain-scroll>section,.domain-notice{width:min(960px,100%);margin-left:auto;margin-right:auto}.domain-add-card{padding:22px;background:#fff;border:1px solid var(--border);border-radius:16px;box-shadow:0 10px 35px rgba(35,24,70,.05)}.domain-add-card h2,.domain-section-heading h2{margin:0;font-size:14px}.domain-add-card>div>p,.domain-section-heading p{margin:5px 0 0;color:var(--muted);font-size:9px}.domain-add-card code{font-size:9px}.domain-add-card form{display:grid;grid-template-columns:1fr auto;gap:8px;margin-top:17px}.domain-add-card form>div{height:42px;display:flex;align-items:center;gap:9px;padding:0 12px;background:#fafafd;border:1px solid var(--border);border-radius:10px}.domain-add-card form>div:focus-within{background:#fff;border-color:#b49cff;box-shadow:0 0 0 3px rgba(112,40,255,.07)}.domain-add-card form svg{color:var(--primary)}.domain-add-card input{width:100%;height:100%;padding:0;background:transparent;border:0;outline:0;font-size:12px}.domain-add-card form>button{height:42px;display:flex;align-items:center;gap:6px;padding:0 16px;color:#fff;background:var(--gradient);border:0;border-radius:10px;font-size:10px;font-weight:800}.domain-error,.domain-card-error{display:flex;align-items:flex-start;gap:6px;margin-top:11px;padding:9px 10px;color:#b42318;background:#fff0ee;border-radius:8px;font-size:9px;line-height:1.5}.domain-error svg,.domain-card-error svg{flex:0 0 auto}
.domain-notice{display:flex;align-items:flex-start;gap:10px;margin-top:12px;padding:12px 14px;color:#785000;background:#fff8e5;border:1px solid #f0dfb8;border-radius:11px}.domain-notice svg{flex:0 0 auto;margin-top:1px}.domain-notice strong,.domain-notice span{display:block}.domain-notice strong{font-size:9px}.domain-notice span{margin-top:4px;font-size:8px;line-height:1.5}
.domain-list-section,.provider-guides{margin-top:25px}.domain-section-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.domain-section-heading>span{display:grid;place-items:center;width:26px;height:26px;color:var(--primary);background:var(--soft);border-radius:8px;font-size:10px;font-weight:800}.domain-section-heading>svg{color:var(--primary)}.domain-empty{min-height:150px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;color:var(--muted);background:#fff;border:1px dashed #dcd8e8;border-radius:14px;font-size:9px}.domain-empty strong{color:var(--text);font-size:11px}
.domain-card-list{display:flex;flex-direction:column;gap:12px}.domain-card{padding:18px;background:#fff;border:1px solid var(--border);border-radius:15px;box-shadow:0 8px 28px rgba(35,24,70,.04)}.domain-card-head{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:12px}.domain-card-head>div:nth-child(2){display:flex;align-items:center;gap:7px;min-width:0}.domain-card-head strong{overflow:hidden;font-size:12px;white-space:nowrap;text-overflow:ellipsis}.domain-card-head em{padding:3px 6px;color:var(--primary);background:var(--soft);border-radius:5px;font-size:7px;font-style:normal;font-weight:800}.domain-state{display:flex;align-items:center;gap:5px;padding:5px 7px;border-radius:7px;font-size:8px;font-weight:750}.domain-state.pending{color:#8c6200;background:#fff7df}.domain-state.active{color:#13765b;background:#e7f8f2}.domain-state.failed{color:#b42318;background:#fff0ee}.domain-card-actions{display:flex;align-items:center;gap:5px}.domain-card-actions button{height:28px;padding:0 9px;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:7px;font-size:8px;font-weight:700}.domain-card-actions button.danger{width:28px;padding:0;display:grid;place-items:center;color:#b42318}
.domain-progress{display:flex;align-items:center;gap:7px;margin:16px 0 13px;color:#a0a2aa;font-size:8px}.domain-progress span.done{color:#168064;font-weight:750}.domain-progress i{height:1px;flex:1;background:#e6e6ec}.dns-records{display:grid;grid-template-columns:1fr 1fr;gap:9px}.dns-record{min-width:0;padding:12px;background:#fafafd;border:1px solid #ededf3;border-radius:11px}.dns-record>span{display:flex;align-items:center;justify-content:space-between}.dns-record>span strong{font-size:9px}.dns-record>span small{padding:3px 5px;color:var(--primary);background:var(--soft);border-radius:5px;font-size:7px;font-weight:800}.dns-record>div{display:grid;grid-template-columns:1fr 25px;gap:5px;margin-top:9px}.dns-record label{grid-column:1/-1;color:var(--muted);font-size:7px}.dns-record code{min-width:0;overflow:hidden;padding:6px 7px;background:#fff;border:1px solid var(--border);border-radius:6px;font-size:8px;white-space:nowrap;text-overflow:ellipsis}.dns-record button{display:grid;place-items:center;width:25px;color:var(--muted);background:#fff;border:1px solid var(--border);border-radius:6px}.dns-record button:hover{color:var(--primary);background:var(--soft)}
.domain-card-footer{display:flex;align-items:center;justify-content:space-between;margin-top:12px;color:var(--muted);font-size:8px}.domain-card-footer>div{display:flex;gap:6px}.domain-card-footer button,.domain-card-footer a{height:28px;display:flex;align-items:center;gap:5px;padding:0 9px;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:7px;font-size:8px;font-weight:750;text-decoration:none}.domain-card-footer a{color:#13765b}.domain-card-footer button:hover,.domain-card-footer a:hover{color:var(--primary);background:var(--soft)}
.provider-guide-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.provider-guide-grid article{padding:15px;background:#fff;border:1px solid var(--border);border-radius:13px}.provider-guide-grid article>div{display:flex;align-items:center;gap:8px}.provider-guide-grid article>div>span{display:grid;place-items:center;width:28px;height:28px;color:var(--primary);background:var(--soft);border-radius:8px}.provider-guide-grid article strong{font-size:10px}.provider-guide-grid ol{min-height:74px;margin:13px 0;padding-left:17px;color:var(--text-2);font-size:8px;line-height:1.7}.provider-guide-grid a{display:flex;align-items:center;gap:5px;color:var(--primary);font-size:8px;font-weight:750;text-decoration:none}.provider-tip{display:flex;align-items:center;gap:7px;margin-top:10px;padding:10px 12px;color:#6f4b00;background:#fff9e9;border-radius:9px;font-size:8px}
@media(max-width:1050px){.dns-records{grid-template-columns:1fr}.provider-guide-grid{grid-template-columns:1fr}.provider-guide-grid ol{min-height:0}}
@media(max-width:800px){.domain-workspace{display:none}.workspace.domains-mode .control-panel{display:none}.workspace.domains-mode .domain-workspace{display:flex;grid-column:2}.domain-header{padding:0 14px}.domain-header-status{display:none}.domain-scroll{padding:14px}.domain-add-card form{grid-template-columns:1fr}.domain-add-card form>button{justify-content:center}.domain-card-head{grid-template-columns:1fr auto}.domain-state{grid-column:1/-1;width:max-content}.dns-records{grid-template-columns:1fr}.domain-card-footer{align-items:flex-start;flex-direction:column;gap:8px}.provider-guide-grid{grid-template-columns:1fr}}
.domain-prerequisites,.domain-wizard,.domain-overview{padding:22px;background:#fff;border:1px solid var(--border);border-radius:16px;box-shadow:0 10px 35px rgba(35,24,70,.05)}
.domain-prerequisites .domain-section-heading>span{width:auto;padding:0 9px}.prerequisite-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:9px}.prerequisite-grid>div{min-width:0;padding:13px;background:#fafafd;border:1px solid #ededf3;border-radius:11px}.prerequisite-grid i{display:grid;place-items:center;width:25px;height:25px;margin-bottom:10px;border-radius:8px;font-style:normal}.prerequisite-grid i.ready{color:#13765b;background:#e7f8f2}.prerequisite-grid i.waiting{color:#986600;background:#fff5d8}.prerequisite-grid strong,.prerequisite-grid small{display:block}.prerequisite-grid strong{font-size:9px}.prerequisite-grid small{margin-top:5px;color:var(--muted);font-size:8px;line-height:1.55}.email-record-warning{display:flex;align-items:flex-start;gap:9px;margin-top:11px;padding:11px 12px;color:#6f4b00;background:#fff9e9;border-radius:9px}.email-record-warning svg{flex:0 0 auto}.email-record-warning strong,.email-record-warning span{display:block}.email-record-warning strong{font-size:9px}.email-record-warning span{margin-top:3px;font-size:8px;line-height:1.5}
.domain-wizard{margin-top:16px;padding:0;overflow:hidden}.wizard-heading{min-height:76px;display:flex;align-items:center;justify-content:space-between;gap:14px;padding:16px 20px;border-bottom:1px solid var(--divider)}.wizard-heading>div>span,.wizard-copy>span{color:var(--primary);font-size:8px;font-weight:850;letter-spacing:.09em}.wizard-heading h2{margin:5px 0 0;font-size:16px}.wizard-heading p{margin:4px 0 0;color:var(--muted);font-size:8px}.wizard-heading>button{height:31px;display:flex;align-items:center;gap:5px;padding:0 10px;color:var(--primary);background:var(--soft);border:1px solid #ddd3ff;border-radius:8px;font-size:8px;font-weight:750}
.wizard-steps{height:69px;display:grid;grid-template-columns:repeat(4,1fr);padding:0 24px;background:#fafafd;border-bottom:1px solid var(--divider)}.wizard-steps>div{position:relative;display:flex;align-items:center;justify-content:center;gap:7px;color:#a1a3ac;font-size:8px}.wizard-steps>div>span{position:relative;z-index:2;display:grid;place-items:center;width:24px;height:24px;background:#ececf1;border:3px solid #fafafd;border-radius:50%;font-size:8px;font-weight:800}.wizard-steps>div>i{position:absolute;z-index:1;top:34px;left:calc(50% + 23px);right:calc(-50% + 23px);height:1px;background:#dddde4}.wizard-steps>div.current{color:var(--primary)}.wizard-steps>div.current>span{color:white;background:var(--primary);box-shadow:0 0 0 4px rgba(112,40,255,.1)}.wizard-steps>div.done{color:#13765b}.wizard-steps>div.done>span{color:white;background:#20a77e}.wizard-steps>div.done>i{background:#8cd4bd}
.wizard-body{padding:22px}.wizard-copy h3{margin:7px 0 6px;font-size:15px}.wizard-copy p{margin:0;color:var(--muted);font-size:9px;line-height:1.7}.wizard-copy code,.wizard-help code{font-size:8px}.wizard-domain-entry form{display:grid;grid-template-columns:1fr auto;gap:8px;margin-top:18px}.wizard-domain-entry form>label{grid-column:1/-1;color:var(--text-2);font-size:9px;font-weight:750}.wizard-domain-entry form>div{height:43px;display:flex;align-items:center;gap:9px;padding:0 12px;background:#fafafd;border:1px solid var(--border);border-radius:10px}.wizard-domain-entry form>div:focus-within{background:#fff;border-color:#b49cff;box-shadow:0 0 0 3px rgba(112,40,255,.07)}.wizard-domain-entry form>div svg{color:var(--primary)}.wizard-domain-entry input{width:100%;height:100%;padding:0;background:transparent;border:0;outline:0;font-size:12px}.wizard-domain-entry form>button,.wizard-action>button{height:43px;display:flex;align-items:center;justify-content:center;gap:6px;padding:0 15px;color:white;background:var(--gradient);border:0;border-radius:10px;font-size:9px;font-weight:800}.wizard-help{display:flex;align-items:flex-start;gap:9px;margin-top:11px;padding:10px 11px;background:#f6f4ff;border-radius:9px;font-size:8px;line-height:1.55}.wizard-help strong{flex:0 0 auto;color:var(--primary)}
.instruction-list{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:17px 0 11px}.instruction-list>div{display:flex;align-items:flex-start;gap:9px;padding:12px;background:#fafafd;border:1px solid #ededf3;border-radius:10px}.instruction-list b{display:grid;place-items:center;width:22px;height:22px;flex:0 0 auto;color:var(--primary);background:var(--soft);border-radius:7px;font-size:8px}.instruction-list strong,.instruction-list small{display:block}.instruction-list strong{font-size:9px}.instruction-list small{margin-top:4px;color:var(--muted);font-size:8px;line-height:1.45}.wizard-action{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:13px;padding-top:13px;border-top:1px solid var(--divider)}.wizard-action strong,.wizard-action span{display:block}.wizard-action strong{font-size:9px}.wizard-action span{margin-top:4px;color:var(--muted);font-size:8px}.wizard-action>button{height:37px;flex:0 0 auto}.wizard-body>.domain-card-error{margin-top:12px}
.system-readiness{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:17px 0 12px}.system-readiness>div{display:flex;align-items:center;gap:9px;padding:12px;border-radius:10px}.system-readiness>div.ready{color:#13765b;background:#edf9f5;border:1px solid #caecdf}.system-readiness>div.waiting{color:#8b6200;background:#fff9e9;border:1px solid #f0dfb8}.system-readiness strong,.system-readiness small{display:block}.system-readiness strong{font-size:9px}.system-readiness small{margin-top:3px;font-size:8px}.wizard-record-group{margin-top:11px;padding:13px;background:#fafafd;border:1px solid #ededf3;border-radius:11px}.wizard-record-group>div:first-child>strong,.wizard-record-group>div:first-child>span{display:block}.wizard-record-group>div:first-child>strong{font-size:10px}.wizard-record-group>div:first-child>span{margin:4px 0 10px;color:var(--muted);font-size:8px}.wizard-record-group>.dns-record{background:white}.instruction-note{display:flex;align-items:flex-start;gap:8px;margin-top:10px;padding:10px 11px;color:#6f4b00;background:#fff9e9;border-radius:9px;font-size:8px;line-height:1.55}.instruction-note strong{flex:0 0 auto}.wizard-success{text-align:center;padding:36px 24px}.wizard-success>span{display:grid;place-items:center;width:58px;height:58px;margin:auto;color:#158164;background:#e8f8f2;border-radius:18px}.wizard-success h3{margin:17px 0 7px;font-size:18px}.wizard-success p{max-width:560px;margin:0 auto;color:var(--muted);font-size:10px;line-height:1.7}.wizard-success>div{display:flex;align-items:center;justify-content:center;gap:7px;margin-top:19px}.wizard-success a,.wizard-success button{height:36px;display:flex;align-items:center;gap:6px;padding:0 12px;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:9px;font-size:9px;font-weight:750;text-decoration:none}.wizard-success a{color:white;background:linear-gradient(135deg,#15936e,#20b486);border:0}.wizard-success>small{display:block;margin-top:13px;color:var(--muted);font-size:8px}
.dns-record>p{margin:8px 0 0;color:var(--muted);font-size:7px}.dns-record>p code{font-size:7px}.domain-overview{margin-top:16px}.domain-overview>div:last-child{display:flex;flex-direction:column;gap:6px}.domain-overview-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;border:1px solid var(--border);border-radius:11px;overflow:hidden}.domain-overview-row.selected{border-color:#cfc0ff;box-shadow:0 0 0 3px rgba(112,40,255,.06)}.domain-overview-main{min-width:0;height:58px;display:grid;grid-template-columns:auto minmax(0,1fr) auto auto auto;align-items:center;gap:9px;padding:0 11px;color:var(--text);text-align:left;background:#fff;border:0}.domain-overview-main>span:first-child{display:grid;place-items:center;width:31px;height:31px;color:var(--primary);background:var(--soft);border-radius:9px}.domain-overview-main>div{min-width:0}.domain-overview-main strong,.domain-overview-main small{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.domain-overview-main strong{font-size:10px}.domain-overview-main small{margin-top:4px;color:var(--muted);font-size:7px}.domain-overview-main>em{padding:3px 5px;color:var(--primary);background:var(--soft);border-radius:5px;font-size:7px;font-style:normal;font-weight:750}.domain-overview-main>b{display:flex;align-items:center;gap:3px;color:var(--primary);font-size:8px}.domain-overview-actions{display:flex;gap:5px;padding-right:10px}.domain-overview-actions button{height:28px;padding:0 8px;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:7px;font-size:8px}.domain-overview-actions button.danger{width:28px;padding:0;display:grid;place-items:center;color:#b42318}
.provider-guides{padding-top:7px}.provider-guides>.domain-section-heading p{max-width:650px}
@media(max-width:1100px){.prerequisite-grid{grid-template-columns:1fr 1fr}.instruction-list{grid-template-columns:1fr}.domain-overview-main{grid-template-columns:auto minmax(0,1fr) auto auto}.domain-overview-main>.domain-state{display:none}}
@media(max-width:800px){.prerequisite-grid{grid-template-columns:1fr}.wizard-heading{align-items:flex-start}.wizard-steps{padding:0 8px}.wizard-steps>div{flex-direction:column;gap:3px;text-align:center}.wizard-steps>div>i{top:25px}.wizard-domain-entry form{grid-template-columns:1fr}.wizard-domain-entry form>button{width:100%}.instruction-list,.system-readiness{grid-template-columns:1fr}.wizard-action{align-items:stretch;flex-direction:column}.wizard-action>button{width:100%}.wizard-success>div{flex-direction:column}.wizard-success a,.wizard-success button{width:100%;justify-content:center}.domain-overview-row{grid-template-columns:1fr}.domain-overview-actions{padding:0 10px 9px;justify-content:flex-end}.domain-overview-main{grid-template-columns:auto minmax(0,1fr) auto}.domain-overview-main>em,.domain-overview-main>.domain-state{display:none}}
.wizard-domain-entry form>button:disabled{color:#9a9ca5;background:#e9e9ef;opacity:1;box-shadow:none}
......@@ -72,3 +72,52 @@ export interface PreviewVersionResult {
previewUrl: string;
commit: string;
}
export type DomainOwnershipStatus = "pending" | "verified" | "failed";
export type DomainDnsStatus = "pending" | "valid" | "invalid";
export type DomainDeploymentStatus = "pending" | "deploying" | "active" | "failed";
export type DomainTlsStatus = "pending" | "active" | "managed" | "failed";
export type DomainStatus = "pending_verification" | "deploying" | "provisioning_tls" | "pending_dns" | "active" | "failed";
export interface DomainDnsRecord {
type: "TXT" | "CNAME";
name: string;
value: string;
purpose: "hostname" | "certificate";
}
export interface DomainBinding {
domainId: string;
siteId: string;
hostname: string;
isPrimary: boolean;
ownershipStatus: DomainOwnershipStatus;
dnsStatus: DomainDnsStatus;
deploymentStatus: DomainDeploymentStatus;
tlsStatus: DomainTlsStatus;
status: DomainStatus;
verificationHost: string;
verificationValue: string;
cnameTarget: string;
url: string;
deployedCommit?: string;
providerHostnameId?: string;
providerStatus?: string;
certificateRecords?: DomainDnsRecord[];
lastCheckedAt?: string;
lastError?: string;
lastDnsError?: string;
lastTlsError?: string;
lastDeploymentError?: string;
createdAt: string;
updatedAt: string;
}
export interface DomainListResult {
domains: DomainBinding[];
cnameTarget: string;
configured: boolean;
ipv4?: string;
ipv6?: string;
httpsMode: "external" | "caddy" | "cloudflare";
}
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