feat: home menu link, button restyle with brand icon, CMS zh labels/i18n and logout button

This commit is contained in:
flym 2026-09-12 03:00:43 +08:00
parent 8e74bc71e9
commit 49a916bbf4
23 changed files with 271 additions and 68 deletions

View File

@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { ArrowRight } from "@/components/icons"; import { ArrowRight } from "@/components/icons";
import { BrandIcon } from "@/components/brand-icon";
import { PreviewNotice, SectionHeading, StatusCard } from "@/components/ui"; import { PreviewNotice, SectionHeading, StatusCard } from "@/components/ui";
export const metadata = { title: "联系我们" }; export const metadata = { title: "联系我们" };
@ -17,7 +18,7 @@ export default function ContactPage() {
<div className="mt-12 rounded-2xl border border-line bg-panel/50 p-6 sm:p-8"> <div className="mt-12 rounded-2xl border border-line bg-panel/50 p-6 sm:p-8">
<p className="text-sm font-semibold text-copy"></p> <p className="text-sm font-semibold text-copy"></p>
<p className="mt-2 text-sm leading-7 text-muted"> AI Lab TTS </p> <p className="mt-2 text-sm leading-7 text-muted"> AI Lab TTS </p>
<div className="mt-6 flex flex-wrap gap-3"><Link className="button-secondary" href="/products"> <ArrowRight /></Link><Link className="button-ghost" href="/tts"> AI Lab</Link></div> <div className="mt-6 flex flex-wrap gap-3"><Link className="button-secondary" href="/products"><BrandIcon /> <ArrowRight /></Link><Link className="button-ghost" href="/tts"><BrandIcon /> AI Lab</Link></div>
</div> </div>
</div> </div>
); );

View File

@ -178,18 +178,19 @@ select:focus-visible {
} }
.button-primary { .button-primary {
background: #ff6a00; background: transparent;
color: #0d0f12; color: #ff812b;
} }
.button-primary:hover { .button-primary:hover {
background: #ff812b; background: #ff6a00;
color: #0d0f12;
transform: translateY(-2px); transform: translateY(-2px);
} }
.button-secondary { .button-secondary {
border: 1px solid #1677ff; border: 1px solid #1677ff;
background: rgba(22, 119, 255, 0.08); background: transparent;
color: #dbeafe; color: #dbeafe;
} }
@ -200,10 +201,12 @@ select:focus-visible {
.button-ghost { .button-ghost {
border: 1px solid #2a2f36; border: 1px solid #2a2f36;
background: transparent;
color: #d1d5db; color: #d1d5db;
} }
.button-ghost:hover { .button-ghost:hover {
background: rgba(0, 209, 230, 0.1);
border-color: #00d1e6; border-color: #00d1e6;
color: #f5f7fa; color: #f5f7fa;
} }

View File

@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { BrandIcon } from "@/components/brand-icon";
export default function NotFound() { export default function NotFound() {
return <div className="container-shell flex min-h-[560px] flex-col items-center justify-center py-20 text-center"><p className="eyebrow">404 / Not found</p><h1 className="mt-6 text-5xl font-semibold tracking-[-0.06em]"></h1><p className="mt-5 max-w-md text-sm leading-7 text-muted"></p><Link className="button-primary mt-8" href="/"></Link></div>; return <div className="container-shell flex min-h-[560px] flex-col items-center justify-center py-20 text-center"><p className="eyebrow">404 / Not found</p><h1 className="mt-6 text-5xl font-semibold tracking-[-0.06em]"></h1><p className="mt-5 max-w-md text-sm leading-7 text-muted"></p><Link className="button-primary mt-8" href="/"><BrandIcon /></Link></div>;
} }

View File

@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { ArrowRight, BluetoothIcon, ChipIcon, SparkIcon, WaveIcon } from "@/components/icons"; import { ArrowRight, BluetoothIcon, ChipIcon, SparkIcon, WaveIcon } from "@/components/icons";
import { BrandIcon } from "@/components/brand-icon";
import { ArrowLink, DevPlaceholder, ProductVisual, SectionHeading } from "@/components/ui"; import { ArrowLink, DevPlaceholder, ProductVisual, SectionHeading } from "@/components/ui";
import { getProducts } from "@/lib/content"; import { getProducts } from "@/lib/content";
@ -24,7 +25,7 @@ export default async function HomePage() {
<h1 className="display-heading text-balance"></h1> <h1 className="display-heading text-balance"></h1>
<p className="body-copy mt-8 max-w-md text-lg"></p> <p className="body-copy mt-8 max-w-md text-lg"></p>
<div className="mt-9 flex flex-wrap items-center gap-4"> <div className="mt-9 flex flex-wrap items-center gap-4">
<Link className="button-primary" href="/products"> <ArrowRight /></Link> <Link className="button-primary" href="/products"><BrandIcon /> <ArrowRight /></Link>
<Link className="text-sm font-semibold text-muted hover:text-copy" href="#technology"> <span className="ml-2"></span></Link> <Link className="text-sm font-semibold text-muted hover:text-copy" href="#technology"> <span className="ml-2"></span></Link>
</div> </div>
</div> </div>
@ -83,7 +84,7 @@ export default async function HomePage() {
<p className="eyebrow eyebrow-cyan">AI Lab</p> <p className="eyebrow eyebrow-cyan">AI Lab</p>
<h2 className="text-balance mt-5 max-w-lg text-4xl font-semibold tracking-[-0.05em] text-copy sm:text-6xl"></h2> <h2 className="text-balance mt-5 max-w-lg text-4xl font-semibold tracking-[-0.05em] text-copy sm:text-6xl"></h2>
<p className="body-copy mt-6 max-w-md"></p> <p className="body-copy mt-6 max-w-md"></p>
<Link className="button-secondary mt-8" href="/tts"> TTS <ArrowRight /></Link> <Link className="button-secondary mt-8" href="/tts"><BrandIcon /> TTS <ArrowRight /></Link>
</div> </div>
<div className="relative flex min-h-64 items-center justify-center overflow-hidden rounded-2xl border border-line bg-panel/50 p-8"> <div className="relative flex min-h-64 items-center justify-center overflow-hidden rounded-2xl border border-line bg-panel/50 p-8">
<div className="absolute inset-x-12 h-px bg-cyan/30" /> <div className="absolute inset-x-12 h-px bg-cyan/30" />

View File

@ -1,5 +1,6 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ArrowRight } from "@/components/icons"; import { ArrowRight } from "@/components/icons";
import { BrandIcon } from "@/components/brand-icon";
import { DevPlaceholder, FeatureList, PreviewNotice, ProductVisual } from "@/components/ui"; import { DevPlaceholder, FeatureList, PreviewNotice, ProductVisual } from "@/components/ui";
import { getProducts } from "@/lib/content"; import { getProducts } from "@/lib/content";
@ -29,7 +30,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
<p className="mt-6 text-xs font-semibold uppercase tracking-[0.2em] text-subtle">{product.category}</p> <p className="mt-6 text-xs font-semibold uppercase tracking-[0.2em] text-subtle">{product.category}</p>
<h1 className="mt-4 max-w-xl text-5xl font-semibold tracking-[-0.06em] sm:text-7xl">{product.name}</h1> <h1 className="mt-4 max-w-xl text-5xl font-semibold tracking-[-0.06em] sm:text-7xl">{product.name}</h1>
<p className="body-copy mt-7 max-w-lg text-lg">{product.summary}</p> <p className="body-copy mt-7 max-w-lg text-lg">{product.summary}</p>
<div className="mt-8 flex flex-wrap gap-3"><a className="button-primary" href="#details"> <ArrowRight /></a><a className="button-ghost" href="/contact"></a></div> <div className="mt-8 flex flex-wrap gap-3"><a className="button-primary" href="#details"><BrandIcon /> <ArrowRight /></a><a className="button-ghost" href="/contact"><BrandIcon /></a></div>
</div> </div>
<ProductVisual accent={product.accent} label={product.category.toUpperCase()} /> <ProductVisual accent={product.accent} label={product.category.toUpperCase()} />
</div> </div>

View File

@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ArrowRight, WaveIcon } from "@/components/icons"; import { ArrowRight, WaveIcon } from "@/components/icons";
import { BrandIcon } from "@/components/brand-icon";
import { PreviewNotice, SectionHeading, StatusCard } from "@/components/ui"; import { PreviewNotice, SectionHeading, StatusCard } from "@/components/ui";
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1"; const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
@ -93,13 +94,13 @@ export default function TtsPage() {
return <div className="container-shell py-16 sm:py-24"> return <div className="container-shell py-16 sm:py-24">
<div className="flex flex-col justify-between gap-8 lg:flex-row lg:items-end"><SectionHeading eyebrow="AI Lab / Text to Speech" title="把文字变成声音。" description="TTS 任务现在由业务 API 持久化并归属当前用户;生成、历史、回放和下载使用真实服务状态。" tone="cyan" /><span className="rounded-full border border-cyan/30 bg-cyan/8 px-3 py-1 text-xs font-semibold text-cyan">Phase 3</span></div> <div className="flex flex-col justify-between gap-8 lg:flex-row lg:items-end"><SectionHeading eyebrow="AI Lab / Text to Speech" title="把文字变成声音。" description="TTS 任务现在由业务 API 持久化并归属当前用户;生成、历史、回放和下载使用真实服务状态。" tone="cyan" /><span className="rounded-full border border-cyan/30 bg-cyan/8 px-3 py-1 text-xs font-semibold text-cyan">Phase 3</span></div>
<div className="mt-10"><PreviewNotice>{loggedIn ? "当前使用测试数据库和真实任务 Worker。上游未配置时任务会明确失败并释放额度。" : "生成、历史、回放和下载需要登录。"}</PreviewNotice></div> <div className="mt-10"><PreviewNotice>{loggedIn ? "当前使用测试数据库和真实任务 Worker。上游未配置时任务会明确失败并释放额度。" : "生成、历史、回放和下载需要登录。"}</PreviewNotice></div>
{!loggedIn ? <div className="mt-8"><StatusCard title="请先登录" description="登录后可使用真实任务、额度和历史功能。"><Link className="button-primary mt-5" href="/login"> <ArrowRight /></Link></StatusCard></div> : <> {!loggedIn ? <div className="mt-8"><StatusCard title="请先登录" description="登录后可使用真实任务、额度和历史功能。"><Link className="button-primary mt-5" href="/login"><BrandIcon /> <ArrowRight /></Link></StatusCard></div> : <>
<div className="mt-8 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]"> <div className="mt-8 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<section className="rounded-2xl border border-line bg-panel/60 p-5 sm:p-7" aria-labelledby="tts-input-title"><div className="flex items-center justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">01 / Input</p><h2 className="mt-2 text-xl font-semibold" id="tts-input-title"></h2></div><span className="text-xs text-subtle">{normalizeText(text).length} / </span></div><textarea className="field-input mt-6 min-h-64 resize-y leading-7" onChange={(event) => setText(event.target.value)} placeholder="输入需要转换的文本。" value={text} /><div className="mt-5 flex flex-col gap-4 border-t border-line pt-5 sm:flex-row sm:items-center sm:justify-between"><p className="text-sm text-muted">{usage?.available.toLocaleString() ?? "加载中"}</p><button className="button-primary" disabled={busy || !csrf} onClick={createTask} type="button">{busy ? "生成中…" : "生成语音"} <ArrowRight /></button></div></section> <section className="rounded-2xl border border-line bg-panel/60 p-5 sm:p-7" aria-labelledby="tts-input-title"><div className="flex items-center justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">01 / Input</p><h2 className="mt-2 text-xl font-semibold" id="tts-input-title"></h2></div><span className="text-xs text-subtle">{normalizeText(text).length} / </span></div><textarea className="field-input mt-6 min-h-64 resize-y leading-7" onChange={(event) => setText(event.target.value)} placeholder="输入需要转换的文本。" value={text} /><div className="mt-5 flex flex-col gap-4 border-t border-line pt-5 sm:flex-row sm:items-center sm:justify-between"><p className="text-sm text-muted">{usage?.available.toLocaleString() ?? "加载中"}</p><button className="button-primary" disabled={busy || !csrf} onClick={createTask} type="button"><BrandIcon />{busy ? "生成中…" : "生成语音"} <ArrowRight /></button></div></section>
<aside className="space-y-6"><section className="rounded-2xl border border-line bg-panel/60 p-5 sm:p-7" aria-labelledby="tts-settings-title"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">02 / Parameters</p><h2 className="mt-2 text-xl font-semibold" id="tts-settings-title"></h2><div className="mt-6 space-y-5"><div><label className="field-label" htmlFor="voice"></label><select className="field-input" id="voice" onChange={(event) => setVoice(event.target.value)} value={voice}><option value=""></option>{voices.map((item) => <option key={item.id} value={item.provider_voice_id}>{item.name}{item.language ? ` · ${item.language}` : ""}</option>)}</select></div><div><span className="field-label"></span><div className="field-input text-muted">1.0xV1 </div></div><div><span className="field-label"></span><div className="field-input text-muted">WAVV1 </div></div></div></section><StatusCard title="额度" description={`${usage?.plan?.toUpperCase() ?? ""} · 已用 ${usage?.used.toLocaleString() ?? "-"} · 冻结 ${usage?.reserved.toLocaleString() ?? "-"}`} tone="info" /></aside> <aside className="space-y-6"><section className="rounded-2xl border border-line bg-panel/60 p-5 sm:p-7" aria-labelledby="tts-settings-title"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">02 / Parameters</p><h2 className="mt-2 text-xl font-semibold" id="tts-settings-title"></h2><div className="mt-6 space-y-5"><div><label className="field-label" htmlFor="voice"></label><select className="field-input" id="voice" onChange={(event) => setVoice(event.target.value)} value={voice}><option value=""></option>{voices.map((item) => <option key={item.id} value={item.provider_voice_id}>{item.name}{item.language ? ` · ${item.language}` : ""}</option>)}</select></div><div><span className="field-label"></span><div className="field-input text-muted">1.0xV1 </div></div><div><span className="field-label"></span><div className="field-input text-muted">WAVV1 </div></div></div></section><StatusCard title="额度" description={`${usage?.plan?.toUpperCase() ?? ""} · 已用 ${usage?.used.toLocaleString() ?? "-"} · 冻结 ${usage?.reserved.toLocaleString() ?? "-"}`} tone="info" /></aside>
</div> </div>
{error ? <p className="mt-6 rounded-xl border border-danger/30 bg-danger/10 px-4 py-3 text-sm leading-6 text-danger" role="alert">{error}</p> : null} {error ? <p className="mt-6 rounded-xl border border-danger/30 bg-danger/10 px-4 py-3 text-sm leading-6 text-danger" role="alert">{error}</p> : null}
{activeTask?.status === "succeeded" ? <section className="mt-8 rounded-2xl border border-success/30 bg-success/5 p-5 sm:p-7"><div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-success">03 / Result</p><h2 className="mt-2 text-xl font-semibold"></h2><p className="mt-2 text-sm text-muted"> {activeTask.id}</p></div><a className="button-secondary" download href={`${apiBase}/tts/tasks/${activeTask.id}/download`}> {format.toUpperCase()}</a></div><audio className="mt-6 w-full" controls src={`${apiBase}/tts/tasks/${activeTask.id}/audio`} /></section> : null} {activeTask?.status === "succeeded" ? <section className="mt-8 rounded-2xl border border-success/30 bg-success/5 p-5 sm:p-7"><div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-success">03 / Result</p><h2 className="mt-2 text-xl font-semibold"></h2><p className="mt-2 text-sm text-muted"> {activeTask.id}</p></div><a className="button-secondary" download href={`${apiBase}/tts/tasks/${activeTask.id}/download`}><BrandIcon /> {format.toUpperCase()}</a></div><audio className="mt-6 w-full" controls src={`${apiBase}/tts/tasks/${activeTask.id}/audio`} /></section> : null}
<section className="mt-8 rounded-2xl border border-line bg-panel/40 p-5 sm:p-7" aria-labelledby="tts-history-title"><div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">04 / History</p><h2 className="mt-2 text-xl font-semibold" id="tts-history-title"></h2></div><span className="text-sm text-muted">{tasks.length} </span></div>{tasks.length ? <div className="mt-6 divide-y divide-line">{tasks.map((task) => <div className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between" key={task.id}><div><p className="font-semibold text-copy">{task.status === "succeeded" ? "生成完成" : task.status === "failed" ? "生成失败" : "处理中"}</p><p className="mt-1 text-sm text-muted">{task.voice_id} · {task.text_length} · {new Date(task.created_at).toLocaleString()}</p></div>{task.audio_available ? <div className="flex gap-3"><a className="text-sm text-cyan hover:text-copy" href={`${apiBase}/tts/tasks/${task.id}/audio`}></a><a className="text-sm text-cyan hover:text-copy" download href={`${apiBase}/tts/tasks/${task.id}/download`}></a></div> : null}</div>)}</div> : <div className="mt-6 flex flex-col items-center justify-center rounded-xl border border-dashed border-line py-14 text-center"><span className="text-cyan"><WaveIcon className="h-9 w-9" /></span><p className="mt-4 font-semibold"></p><p className="mt-2 max-w-sm text-sm leading-6 text-muted">访</p></div>}</section> <section className="mt-8 rounded-2xl border border-line bg-panel/40 p-5 sm:p-7" aria-labelledby="tts-history-title"><div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">04 / History</p><h2 className="mt-2 text-xl font-semibold" id="tts-history-title"></h2></div><span className="text-sm text-muted">{tasks.length} </span></div>{tasks.length ? <div className="mt-6 divide-y divide-line">{tasks.map((task) => <div className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between" key={task.id}><div><p className="font-semibold text-copy">{task.status === "succeeded" ? "生成完成" : task.status === "failed" ? "生成失败" : "处理中"}</p><p className="mt-1 text-sm text-muted">{task.voice_id} · {task.text_length} · {new Date(task.created_at).toLocaleString()}</p></div>{task.audio_available ? <div className="flex gap-3"><a className="text-sm text-cyan hover:text-copy" href={`${apiBase}/tts/tasks/${task.id}/audio`}></a><a className="text-sm text-cyan hover:text-copy" download href={`${apiBase}/tts/tasks/${task.id}/download`}></a></div> : null}</div>)}</div> : <div className="mt-6 flex flex-col items-center justify-center rounded-xl border border-dashed border-line py-14 text-center"><span className="text-cyan"><WaveIcon className="h-9 w-9" /></span><p className="mt-4 font-semibold"></p><p className="mt-2 max-w-sm text-sm leading-6 text-muted">访</p></div>}</section>
</>} </>}
</div>; </div>;

View File

@ -1,16 +1,33 @@
import { buildConfig } from "payload"; import { buildConfig } from "payload";
import { postgresAdapter } from "@payloadcms/db-postgres"; import { postgresAdapter } from "@payloadcms/db-postgres";
import { translations } from "@payloadcms/translations/all";
import { HomeContent } from "./src/collections/HomeContent.ts"; import { HomeContent } from "./src/collections/HomeContent.ts";
import { Media } from "./src/collections/Media.ts"; import { Media } from "./src/collections/Media.ts";
import { Products } from "./src/collections/Products.ts"; import { Products } from "./src/collections/Products.ts";
import { SiteSettings } from "./src/collections/SiteSettings.ts"; import { SiteSettings } from "./src/collections/SiteSettings.ts";
import { Users } from "./src/collections/Users.ts"; import { Users } from "./src/collections/Users.ts";
const serverURL = new URL(process.env.PAYLOAD_PUBLIC_SERVER_URL || "https://www.kaotings.com").origin;
export default buildConfig({ export default buildConfig({
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || "https://www.kaotings.com/cms", serverURL,
admin: { user: Users.slug }, admin: {
user: Users.slug,
components: {
logout: {
Button: "/src/components/CmsLogoutButton.tsx#CmsLogoutButton",
},
},
},
collections: [Users, Products, Media], collections: [Users, Products, Media],
globals: [SiteSettings, HomeContent], globals: [SiteSettings, HomeContent],
i18n: {
fallbackLanguage: "zh",
supportedLanguages: {
en: translations.en,
zh: translations.zh,
},
},
secret: process.env.PAYLOAD_SECRET || "development-only-change-me", secret: process.env.PAYLOAD_SECRET || "development-only-change-me",
db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URI || "" } }), db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URI || "" } }),
typescript: { outputFile: "./src/payload-types.ts" }, typescript: { outputFile: "./src/payload-types.ts" },

View File

@ -1,6 +1,8 @@
import { CmsLogoutButton as CmsLogoutButton_238a8e87baa2055e479e2f034d1495c1 } from '../../../../src/components/CmsLogoutButton.tsx'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */ /** @type import('payload').ImportMap */
export const importMap = { export const importMap = {
"/src/components/CmsLogoutButton.tsx#CmsLogoutButton": CmsLogoutButton_238a8e87baa2055e479e2f034d1495c1,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
} }

View File

@ -0,0 +1,66 @@
import config from "@payload-config";
import { headers as getHeaders } from "next/headers";
import { NextResponse } from "next/server";
import { createLocalReq, getPayload, logoutOperation } from "payload";
const COOKIE_PATHS = ["/", "/cms", "/cms/admin", "/cms/api"];
const EXPIRED_DATE = "Thu, 01 Jan 1970 00:00:00 GMT";
function appendExpiredCookie(response: NextResponse, name: string, path: string, domain?: string) {
const domainPart = domain ? `; Domain=${domain}` : "";
response.headers.append(
"Set-Cookie",
`${name}=; Path=${path}${domainPart}; Expires=${EXPIRED_DATE}; Max-Age=0; HttpOnly; SameSite=Lax`,
);
}
function getDomainCandidates(host: string | null) {
if (!host) {
return [undefined];
}
const hostname = host.split(":")[0]?.toLowerCase();
if (!hostname || hostname === "localhost" || /^[\d.]+$/.test(hostname)) {
return [undefined];
}
const parentDomain = hostname.startsWith("www.") ? hostname.slice(4) : undefined;
return Array.from(new Set([undefined, hostname, parentDomain ? `.${parentDomain}` : undefined]));
}
export async function POST() {
const requestHeaders = await getHeaders();
const response = NextResponse.json({ success: true }, { headers: { "Cache-Control": "no-store" } });
try {
const payload = await getPayload({ config, cron: true });
const authResult = await payload.auth({ headers: requestHeaders });
const user = authResult.user;
if (user) {
const req = await createLocalReq({ user }, payload);
const collection = payload.collections[user.collection];
if (collection) {
await logoutOperation({ allSessions: false, collection, req });
}
}
} catch (error) {
console.error("CMS logout operation failed", error);
}
const configuredPrefix = (config as { cookiePrefix?: string }).cookiePrefix || "payload";
const cookieNames = new Set(["payload-token", `${configuredPrefix}-token`]);
const domains = getDomainCandidates(requestHeaders.get("host"));
for (const name of cookieNames) {
for (const path of COOKIE_PATHS) {
for (const domain of domains) {
appendExpiredCookie(response, name, path, domain);
}
}
}
return response;
}

View File

@ -2,12 +2,13 @@ import type { GlobalConfig } from "payload";
export const HomeContent: GlobalConfig = { export const HomeContent: GlobalConfig = {
slug: "home-content", slug: "home-content",
label: { en: "Home content", zh: "首页内容" },
access: { read: () => true, update: ({ req: { user } }) => Boolean(user) }, access: { read: () => true, update: ({ req: { user } }) => Boolean(user) },
fields: [ fields: [
{ name: "heroClaim", type: "text" }, { name: "heroClaim", type: "text", label: { en: "Hero claim", zh: "首页主张" } },
{ name: "heroDescription", type: "textarea" }, { name: "heroDescription", type: "textarea", label: { en: "Hero description", zh: "首页描述" } },
{ name: "technologyDescription", type: "textarea" }, { name: "technologyDescription", type: "textarea", label: { en: "Technology description", zh: "技术描述" } },
{ name: "aiLabDescription", type: "textarea" }, { name: "aiLabDescription", type: "textarea", label: { en: "AI lab description", zh: "AI 实验室描述" } },
{ name: "featuredProducts", type: "relationship", relationTo: "products", hasMany: true }, { name: "featuredProducts", type: "relationship", label: { en: "Featured products", zh: "推荐产品" }, relationTo: "products", hasMany: true },
], ],
}; };

View File

@ -2,6 +2,10 @@ import type { CollectionConfig } from "payload";
export const Media: CollectionConfig = { export const Media: CollectionConfig = {
slug: "media", slug: "media",
labels: {
singular: { en: "Media", zh: "媒体" },
plural: { en: "Media", zh: "媒体" },
},
upload: { staticDir: "media", mimeTypes: ["image/*", "video/*"] }, upload: { staticDir: "media", mimeTypes: ["image/*", "video/*"] },
access: { access: {
read: () => true, read: () => true,
@ -9,5 +13,5 @@ export const Media: CollectionConfig = {
update: ({ req: { user } }) => Boolean(user), update: ({ req: { user } }) => Boolean(user),
delete: ({ req: { user } }) => Boolean(user), delete: ({ req: { user } }) => Boolean(user),
}, },
fields: [{ name: "alt", type: "text", required: true }], fields: [{ name: "alt", type: "text", label: { en: "Alt text", zh: "替代文本" }, required: true }],
}; };

View File

@ -2,6 +2,10 @@ import type { CollectionConfig } from "payload";
export const Products: CollectionConfig = { export const Products: CollectionConfig = {
slug: "products", slug: "products",
labels: {
singular: { en: "Product", zh: "产品" },
plural: { en: "Products", zh: "产品" },
},
access: { access: {
read: ({ req: { user } }) => user ? true : { status: { equals: "published" } }, read: ({ req: { user } }) => user ? true : { status: { equals: "published" } },
create: ({ req: { user } }) => Boolean(user), create: ({ req: { user } }) => Boolean(user),
@ -10,19 +14,30 @@ export const Products: CollectionConfig = {
}, },
admin: { useAsTitle: "name", defaultColumns: ["name", "status", "updatedAt"] }, admin: { useAsTitle: "name", defaultColumns: ["name", "status", "updatedAt"] },
fields: [ fields: [
{ name: "name", type: "text", required: true }, { name: "name", type: "text", label: { en: "Name", zh: "名称" }, required: true },
{ name: "slug", type: "text", required: true, unique: true, index: true }, { name: "slug", type: "text", label: { en: "Slug", zh: "链接标识" }, required: true, unique: true, index: true },
{ name: "category", type: "text", required: true }, { name: "category", type: "text", label: { en: "Category", zh: "分类" }, required: true },
{ name: "summary", type: "textarea", required: true }, { name: "summary", type: "textarea", label: { en: "Summary", zh: "摘要" }, required: true },
{ name: "description", type: "textarea", required: true }, { name: "description", type: "textarea", label: { en: "Description", zh: "描述" }, required: true },
{ name: "cover", type: "upload", relationTo: "media" }, { name: "cover", type: "upload", label: { en: "Cover", zh: "封面" }, relationTo: "media" },
{ name: "features", type: "array", fields: [{ name: "value", type: "text" }] }, { name: "features", type: "array", label: { en: "Features", zh: "功能亮点" }, fields: [{ name: "value", type: "text", label: { en: "Value", zh: "内容" } }] },
{ name: "specs", type: "json" }, { name: "specs", type: "json", label: { en: "Specs", zh: "规格参数" } },
{ name: "status", type: "select", defaultValue: "draft", options: ["draft", "published", "unpublished"], required: true }, {
{ name: "featured", type: "checkbox", defaultValue: false }, name: "status",
{ name: "sortOrder", type: "number", defaultValue: 0 }, type: "select",
{ name: "publishedAt", type: "date" }, label: { en: "Status", zh: "状态" },
{ name: "seoTitle", type: "text" }, defaultValue: "draft",
{ name: "seoDescription", type: "textarea" }, options: [
{ label: { en: "Draft", zh: "草稿" }, value: "draft" },
{ label: { en: "Published", zh: "已发布" }, value: "published" },
{ label: { en: "Unpublished", zh: "未发布" }, value: "unpublished" },
],
required: true,
},
{ name: "featured", type: "checkbox", label: { en: "Featured", zh: "推荐展示" }, defaultValue: false },
{ name: "sortOrder", type: "number", label: { en: "Sort order", zh: "排序" }, defaultValue: 0 },
{ name: "publishedAt", type: "date", label: { en: "Published at", zh: "发布时间" } },
{ name: "seoTitle", type: "text", label: { en: "SEO title", zh: "SEO 标题" } },
{ name: "seoDescription", type: "textarea", label: { en: "SEO description", zh: "SEO 描述" } },
], ],
}; };

View File

@ -2,12 +2,13 @@ import type { GlobalConfig } from "payload";
export const SiteSettings: GlobalConfig = { export const SiteSettings: GlobalConfig = {
slug: "site-settings", slug: "site-settings",
label: { en: "Site settings", zh: "站点设置" },
access: { read: () => true, update: ({ req: { user } }) => Boolean(user) }, access: { read: () => true, update: ({ req: { user } }) => Boolean(user) },
fields: [ fields: [
{ name: "brandName", type: "text", defaultValue: "考町科技 / KAOTINGS" }, { name: "brandName", type: "text", label: { en: "Brand name", zh: "品牌名称" }, defaultValue: "考町科技 / KAOTINGS" },
{ name: "contactNotice", type: "textarea" }, { name: "contactNotice", type: "textarea", label: { en: "Contact notice", zh: "联系提示" } },
{ name: "footerDescription", type: "textarea" }, { name: "footerDescription", type: "textarea", label: { en: "Footer description", zh: "页脚描述" } },
{ name: "seoTitle", type: "text" }, { name: "seoTitle", type: "text", label: { en: "SEO title", zh: "SEO 标题" } },
{ name: "seoDescription", type: "textarea" }, { name: "seoDescription", type: "textarea", label: { en: "SEO description", zh: "SEO 描述" } },
], ],
}; };

View File

@ -2,6 +2,10 @@ import type { CollectionConfig } from "payload";
export const Users: CollectionConfig = { export const Users: CollectionConfig = {
slug: "users", slug: "users",
labels: {
singular: { en: "User", zh: "用户" },
plural: { en: "Users", zh: "用户" },
},
auth: true, auth: true,
access: { access: {
admin: ({ req: { user } }) => Boolean(user), admin: ({ req: { user } }) => Boolean(user),
@ -10,5 +14,5 @@ export const Users: CollectionConfig = {
update: ({ req: { user } }) => Boolean(user), update: ({ req: { user } }) => Boolean(user),
delete: ({ req: { user } }) => Boolean(user), delete: ({ req: { user } }) => Boolean(user),
}, },
fields: [{ name: "displayName", type: "text", required: true }], fields: [{ name: "displayName", type: "text", label: { en: "Display name", zh: "显示名称" }, required: true }],
}; };

View File

@ -0,0 +1,72 @@
"use client";
import React, { useState } from "react";
import { LogOutIcon, toast, useAuth, useConfig, useTranslation } from "@payloadcms/ui";
import { formatAdminURL } from "payload/shared";
type Props = {
tabIndex?: number;
};
function withCurrentBasePath(path: string) {
if (typeof window === "undefined") {
return path;
}
const basePath = window.location.pathname === "/cms" || window.location.pathname.startsWith("/cms/") ? "/cms" : "";
if (!basePath || path === basePath || path.startsWith(`${basePath}/`)) {
return path;
}
return `${basePath}${path.startsWith("/") ? path : `/${path}`}`;
}
export function CmsLogoutButton({ tabIndex = 0 }: Props) {
const [busy, setBusy] = useState(false);
const { logOut } = useAuth();
const { config } = useConfig();
const { t } = useTranslation();
async function handleLogout() {
if (busy) {
return;
}
setBusy(true);
try {
await logOut();
} catch (error) {
console.error("Payload logout failed", error);
}
try {
await fetch(withCurrentBasePath(formatAdminURL({ apiRoute: config.routes.api, path: "/cms-logout" })), {
cache: "no-store",
credentials: "include",
method: "POST",
});
} catch (error) {
console.error("CMS cookie cleanup failed", error);
} finally {
toast.success(t("authentication:loggedOutSuccessfully"));
window.location.replace(withCurrentBasePath(formatAdminURL({ adminRoute: config.routes.admin, path: "/login" })));
}
}
return (
<button
aria-label={t("authentication:logOut")}
className="nav__log-out"
disabled={busy}
onClick={() => void handleLogout()}
style={{ appearance: "none", background: "transparent", border: 0, cursor: busy ? "wait" : "pointer", padding: 0 }}
tabIndex={tabIndex}
title={t("authentication:logOut")}
type="button"
>
<LogOutIcon />
</button>
);
}

View File

@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { ArrowRight } from "@/components/icons"; import { ArrowRight } from "@/components/icons";
import { BrandIcon } from "@/components/brand-icon";
import { PreviewNotice, StatusCard } from "@/components/ui"; import { PreviewNotice, StatusCard } from "@/components/ui";
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1"; const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
@ -63,8 +64,8 @@ export function AccountPanel() {
return <> return <>
<div className="mt-8 grid gap-5 md:grid-cols-2 lg:grid-cols-4"><StatusCard title="账户状态" description={user.status === "active" ? "正常" : "已停用"} tone="success" /><StatusCard title="会员计划" description={usage.plan.toUpperCase()} /><StatusCard title="可用额度" description={`${usage.available.toLocaleString()} 字符`} tone="info" /><StatusCard title="验证状态" description={`邮箱 ${user.email_verified ? "已验证" : "未验证"} · 手机 ${user.phone_verified ? "已验证" : "未验证"}`} tone="warning" /></div> <div className="mt-8 grid gap-5 md:grid-cols-2 lg:grid-cols-4"><StatusCard title="账户状态" description={user.status === "active" ? "正常" : "已停用"} tone="success" /><StatusCard title="会员计划" description={usage.plan.toUpperCase()} /><StatusCard title="可用额度" description={`${usage.available.toLocaleString()} 字符`} tone="info" /><StatusCard title="验证状态" description={`邮箱 ${user.email_verified ? "已验证" : "未验证"} · 手机 ${user.phone_verified ? "已验证" : "未验证"}`} tone="warning" /></div>
<div className="mt-8 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]"> <div className="mt-8 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
<section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Profile</p><h2 className="mt-3 text-xl font-semibold"></h2><form className="mt-6 space-y-4" onSubmit={updateProfile}><div><label className="field-label" htmlFor="profile-username"></label><input className="field-input" id="profile-username" name="username" required defaultValue={user.username} /></div><div><label className="field-label" htmlFor="profile-email"></label><input className="field-input" id="profile-email" name="email" type="email" defaultValue={user.email ?? ""} /></div><div><label className="field-label" htmlFor="profile-phone"></label><input className="field-input" id="profile-phone" name="phone" defaultValue={user.phone ?? ""} /></div>{profileMessage ? <p className="text-sm text-muted" role="status">{profileMessage}</p> : null}<button className="button-secondary w-full" type="submit"></button></form>{user.role === "admin" ? <div className="mt-6 space-y-4 border-t border-line pt-5"><div className="flex justify-between gap-4 text-sm"><dt className="text-muted"></dt><dd><Link className="text-cyan hover:text-copy" href="/admin"></Link></dd></div><div className="flex justify-between gap-4 text-sm"><dt className="text-muted"></dt><dd><a className="text-cyan hover:text-copy" href="/cms/admin"> CMS</a></dd></div></div> : null}<button className="button-ghost mt-7 w-full" onClick={logout} type="button">退 <ArrowRight /></button></section> <section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Profile</p><h2 className="mt-3 text-xl font-semibold"></h2><form className="mt-6 space-y-4" onSubmit={updateProfile}><div><label className="field-label" htmlFor="profile-username"></label><input className="field-input" id="profile-username" name="username" required defaultValue={user.username} /></div><div><label className="field-label" htmlFor="profile-email"></label><input className="field-input" id="profile-email" name="email" type="email" defaultValue={user.email ?? ""} /></div><div><label className="field-label" htmlFor="profile-phone"></label><input className="field-input" id="profile-phone" name="phone" defaultValue={user.phone ?? ""} /></div>{profileMessage ? <p className="text-sm text-muted" role="status">{profileMessage}</p> : null}<button className="button-secondary w-full" type="submit"><BrandIcon /></button></form>{user.role === "admin" ? <div className="mt-6 space-y-4 border-t border-line pt-5"><div className="flex justify-between gap-4 text-sm"><dt className="text-muted"></dt><dd><Link className="text-cyan hover:text-copy" href="/admin"></Link></dd></div><div className="flex justify-between gap-4 text-sm"><dt className="text-muted"></dt><dd><a className="text-cyan hover:text-copy" href="/cms/admin"> CMS</a></dd></div></div> : null}<button className="button-ghost mt-7 w-full" onClick={logout} type="button"><BrandIcon />退 <ArrowRight /></button></section>
<div className="space-y-6"><section className="rounded-2xl border border-line bg-panel/60 p-6"><div className="flex items-center justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Usage</p><h2 className="mt-3 text-xl font-semibold"></h2></div><Link className="text-sm text-cyan hover:text-copy" href="/tts"></Link></div><div className="mt-6 grid gap-4 sm:grid-cols-3"><StatusCard title="周期上限" description={usage.limit.toLocaleString()} /><StatusCard title="已使用" description={usage.used.toLocaleString()} /><StatusCard title="冻结" description={usage.reserved.toLocaleString()} /></div></section><section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Security</p><h2 className="mt-3 text-xl font-semibold"></h2><form className="mt-5 grid gap-4 sm:grid-cols-2" onSubmit={changePassword}><div><label className="field-label" htmlFor="current-password"></label><input className="field-input" id="current-password" name="current_password" required type="password" /></div><div><label className="field-label" htmlFor="new-password"></label><input className="field-input" id="new-password" minLength={8} name="new_password" required type="password" /></div>{passwordMessage ? <p className="sm:col-span-2 text-sm text-muted" role="status">{passwordMessage}</p> : null}<button className="button-secondary sm:col-span-2" type="submit"></button></form></section></div> <div className="space-y-6"><section className="rounded-2xl border border-line bg-panel/60 p-6"><div className="flex items-center justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Usage</p><h2 className="mt-3 text-xl font-semibold"></h2></div><Link className="text-sm text-cyan hover:text-copy" href="/tts"></Link></div><div className="mt-6 grid gap-4 sm:grid-cols-3"><StatusCard title="周期上限" description={usage.limit.toLocaleString()} /><StatusCard title="已使用" description={usage.used.toLocaleString()} /><StatusCard title="冻结" description={usage.reserved.toLocaleString()} /></div></section><section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Security</p><h2 className="mt-3 text-xl font-semibold"></h2><form className="mt-5 grid gap-4 sm:grid-cols-2" onSubmit={changePassword}><div><label className="field-label" htmlFor="current-password"></label><input className="field-input" id="current-password" name="current_password" required type="password" /></div><div><label className="field-label" htmlFor="new-password"></label><input className="field-input" id="new-password" minLength={8} name="new_password" required type="password" /></div>{passwordMessage ? <p className="sm:col-span-2 text-sm text-muted" role="status">{passwordMessage}</p> : null}<button className="button-secondary sm:col-span-2" type="submit"><BrandIcon /></button></form></section></div>
</div> </div>
</>; </>;
} }

View File

@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getMe } from "@/lib/admin"; import { getMe } from "@/lib/admin";
import { BrandIcon } from "@/components/brand-icon";
import { PreviewNotice, StatusCard } from "@/components/ui"; import { PreviewNotice, StatusCard } from "@/components/ui";
type Me = { email: string; role: string; plan: string; status: string }; type Me = { email: string; role: string; plan: string; status: string };
@ -43,7 +44,7 @@ export function AdminGate({ children }: { children: React.ReactNode }) {
<div className="mt-8"> <div className="mt-8">
<StatusCard title="无权访问" description="当前会话不是管理员,或未登录。前往账户页登录后,只有 role=admin 的账户才能看到管理功能。" tone="warning" /> <StatusCard title="无权访问" description="当前会话不是管理员,或未登录。前往账户页登录后,只有 role=admin 的账户才能看到管理功能。" tone="warning" />
<Link className="button-ghost mt-6 inline-flex" href="/account"> <Link className="button-ghost mt-6 inline-flex" href="/account">
<BrandIcon />
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getUsageSummary, listAuditLogs, listTtsTasks } from "@/lib/admin"; import { getUsageSummary, listAuditLogs, listTtsTasks } from "@/lib/admin";
import { BrandIcon } from "@/components/brand-icon";
import { StatusCard } from "@/components/ui"; import { StatusCard } from "@/components/ui";
type Task = { type Task = {
@ -137,7 +138,7 @@ export function TtsAuditPanel() {
<option value="succeeded"></option> <option value="succeeded"></option>
<option value="failed"></option> <option value="failed"></option>
</select> </select>
<button className="button-secondary" onClick={() => void loadTasks(1)} type="button"></button> <button className="button-secondary" onClick={() => void loadTasks(1)} type="button"><BrandIcon /></button>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
@ -176,8 +177,8 @@ export function TtsAuditPanel() {
<div className="flex items-center justify-between gap-3 border-t border-line p-4 text-sm"> <div className="flex items-center justify-between gap-3 border-t border-line p-4 text-sm">
<span className="text-subtle"> {taskPage} / {taskPages} </span> <span className="text-subtle"> {taskPage} / {taskPages} </span>
<div className="flex gap-2"> <div className="flex gap-2">
<button className="button-ghost disabled:opacity-50" disabled={taskPage <= 1} onClick={() => void loadTasks(Math.max(1, taskPage - 1))} type="button"></button> <button className="button-ghost disabled:opacity-50" disabled={taskPage <= 1} onClick={() => void loadTasks(Math.max(1, taskPage - 1))} type="button"><BrandIcon /></button>
<button className="button-ghost disabled:opacity-50" disabled={taskPage >= taskPages} onClick={() => void loadTasks(taskPage + 1)} type="button"></button> <button className="button-ghost disabled:opacity-50" disabled={taskPage >= taskPages} onClick={() => void loadTasks(taskPage + 1)} type="button"><BrandIcon /></button>
</div> </div>
</div> </div>
</div> </div>
@ -215,8 +216,8 @@ export function TtsAuditPanel() {
<div className="flex items-center justify-between gap-3 border-t border-line p-4 text-sm"> <div className="flex items-center justify-between gap-3 border-t border-line p-4 text-sm">
<span className="text-subtle"> {auditPage} / {auditPages} </span> <span className="text-subtle"> {auditPage} / {auditPages} </span>
<div className="flex gap-2"> <div className="flex gap-2">
<button className="button-ghost disabled:opacity-50" disabled={auditPage <= 1} onClick={() => void loadAudit(Math.max(1, auditPage - 1))} type="button"></button> <button className="button-ghost disabled:opacity-50" disabled={auditPage <= 1} onClick={() => void loadAudit(Math.max(1, auditPage - 1))} type="button"><BrandIcon /></button>
<button className="button-ghost disabled:opacity-50" disabled={auditPage >= auditPages} onClick={() => void loadAudit(auditPage + 1)} type="button"></button> <button className="button-ghost disabled:opacity-50" disabled={auditPage >= auditPages} onClick={() => void loadAudit(auditPage + 1)} type="button"><BrandIcon /></button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -16,6 +16,7 @@ import {
type AdminUser, type AdminUser,
type AdminUserDetail, type AdminUserDetail,
} from "@/lib/admin"; } from "@/lib/admin";
import { BrandIcon } from "@/components/brand-icon";
import { StatusCard } from "@/components/ui"; import { StatusCard } from "@/components/ui";
type UserRow = AdminUser; type UserRow = AdminUser;
@ -73,7 +74,7 @@ export function UsersPanel() {
<StatusCard title="总页数" description={`${pages}`} /> <StatusCard title="总页数" description={`${pages}`} />
</div> </div>
<button className="button-secondary" onClick={() => setShowCreate((value) => !value)} type="button">{showCreate ? "取消添加" : "手动添加用户"}</button> <button className="button-secondary" onClick={() => setShowCreate((value) => !value)} type="button"><BrandIcon />{showCreate ? "取消添加" : "手动添加用户"}</button>
{showCreate ? <CreateUserPanel onCreated={() => { setShowCreate(false); setNotice("用户已创建"); void load(1); }} /> : null} {showCreate ? <CreateUserPanel onCreated={() => { setShowCreate(false); setNotice("用户已创建"); void load(1); }} /> : null}
<div className="rounded-2xl border border-line bg-panel/50"> <div className="rounded-2xl border border-line bg-panel/50">
@ -86,10 +87,10 @@ export function UsersPanel() {
<option value="disabled"></option> <option value="disabled"></option>
</select> </select>
<button className="button-secondary" onClick={() => void load(1)} type="button"> <button className="button-secondary" onClick={() => void load(1)} type="button">
<BrandIcon />
</button> </button>
<button className="button-ghost" onClick={() => { setEmail(""); setPhone(""); setStatus(""); void load(1); }} type="button"> <button className="button-ghost" onClick={() => { setEmail(""); setPhone(""); setStatus(""); void load(1); }} type="button">
<BrandIcon />
</button> </button>
</div> </div>
@ -141,10 +142,10 @@ export function UsersPanel() {
<span className="text-subtle"> {page} / {pages} </span> <span className="text-subtle"> {page} / {pages} </span>
<div className="flex gap-2"> <div className="flex gap-2">
<button className="button-ghost disabled:opacity-50" disabled={page <= 1} onClick={() => void load(Math.max(1, page - 1))} type="button"> <button className="button-ghost disabled:opacity-50" disabled={page <= 1} onClick={() => void load(Math.max(1, page - 1))} type="button">
<BrandIcon />
</button> </button>
<button className="button-ghost disabled:opacity-50" disabled={page >= pages} onClick={() => void load(page + 1)} type="button"> <button className="button-ghost disabled:opacity-50" disabled={page >= pages} onClick={() => void load(page + 1)} type="button">
<BrandIcon />
</button> </button>
</div> </div>
</div> </div>
@ -229,7 +230,7 @@ function UserDetailPanel({ detail, onRefresh, setNotice, onClose }: { detail: Ad
<h3 className="text-xl font-semibold">{detail.user.username}</h3> <h3 className="text-xl font-semibold">{detail.user.username}</h3>
<p className="mt-1 text-sm text-subtle"> Plan{detail.effective_plan.toUpperCase()} · {detail.user.status === "active" ? "正常" : "已禁用"}</p> <p className="mt-1 text-sm text-subtle"> Plan{detail.effective_plan.toUpperCase()} · {detail.user.status === "active" ? "正常" : "已禁用"}</p>
</div> </div>
<button className="button-ghost" onClick={onClose} type="button"></button> <button className="button-ghost" onClick={onClose} type="button"><BrandIcon /></button>
</div> </div>
<div className="mt-6 grid gap-5 md:grid-cols-3"> <div className="mt-6 grid gap-5 md:grid-cols-3">
@ -287,7 +288,7 @@ function UserDetailPanel({ detail, onRefresh, setNotice, onClose }: { detail: Ad
<label className="flex items-center gap-2"><input checked={emailVerified} onChange={(e) => setEmailVerified(e.target.checked)} type="checkbox" /></label> <label className="flex items-center gap-2"><input checked={emailVerified} onChange={(e) => setEmailVerified(e.target.checked)} type="checkbox" /></label>
<label className="flex items-center gap-2"><input checked={phoneVerified} onChange={(e) => setPhoneVerified(e.target.checked)} type="checkbox" /></label> <label className="flex items-center gap-2"><input checked={phoneVerified} onChange={(e) => setPhoneVerified(e.target.checked)} type="checkbox" /></label>
</div> </div>
<button className="button-secondary mt-4 disabled:opacity-50" disabled={busy || !csrf} onClick={() => void saveProfile()} type="button"></button> <button className="button-secondary mt-4 disabled:opacity-50" disabled={busy || !csrf} onClick={() => void saveProfile()} type="button"><BrandIcon /></button>
</div> </div>
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
@ -295,17 +296,17 @@ function UserDetailPanel({ detail, onRefresh, setNotice, onClose }: { detail: Ad
{message ? <p className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning" role="status">{message}</p> : null} {message ? <p className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning" role="status">{message}</p> : null}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void toggleStatus()} type="button"> <button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void toggleStatus()} type="button">
{detail.user.status === "active" ? "禁用用户" : "恢复用户"} <BrandIcon />{detail.user.status === "active" ? "禁用用户" : "恢复用户"}
</button> </button>
<div className="flex gap-2"> <div className="flex gap-2">
<input aria-label="VIP 天数" className="field-input w-20" onChange={(e) => setVipDays(e.target.value)} value={vipDays} /> <input aria-label="VIP 天数" className="field-input w-20" onChange={(e) => setVipDays(e.target.value)} value={vipDays} />
<button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doGrant()} type="button"> VIP</button> <button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doGrant()} type="button"><BrandIcon /> VIP</button>
</div> </div>
<button className="button-ghost disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doRevoke()} type="button"> VIP</button> <button className="button-ghost disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doRevoke()} type="button"><BrandIcon /> VIP</button>
<div className="flex gap-2"> <div className="flex gap-2">
<input aria-label="额度调整量" className="field-input w-24" onChange={(e) => setAmount(e.target.value)} value={amount} /> <input aria-label="额度调整量" className="field-input w-24" onChange={(e) => setAmount(e.target.value)} value={amount} />
<button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doAdjust()} type="button"></button> <button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doAdjust()} type="button"><BrandIcon /></button>
<button className="button-ghost text-danger disabled:opacity-50" disabled={busy || !csrf} onClick={() => void removeUser()} type="button"></button> <button className="button-ghost text-danger disabled:opacity-50" disabled={busy || !csrf} onClick={() => void removeUser()} type="button"><BrandIcon /></button>
</div> </div>
</div> </div>
<p className="text-xs text-subtle">使</p> <p className="text-xs text-subtle">使</p>
@ -330,5 +331,5 @@ function CreateUserPanel({ onCreated }: { onCreated: () => void }) {
} catch (e) { setMessage(e instanceof Error ? e.message : "创建失败"); } finally { setBusy(false); } } catch (e) { setMessage(e instanceof Error ? e.message : "创建失败"); } finally { setBusy(false); }
} }
return <form className="rounded-2xl border border-line bg-panel/50 p-5" onSubmit={submit}><p className="font-semibold"></p><div className="mt-4 grid gap-4 md:grid-cols-3"><input className="field-input" name="username" placeholder="用户名" required /><input className="field-input" name="email" placeholder="邮箱(可选)" type="email" /><input className="field-input" name="phone" placeholder="手机号(可选)" /><input className="field-input" minLength={8} name="password" placeholder="初始密码" required type="password" /><input className="field-input" name="reason" placeholder="创建原因(必填)" required /></div><div className="mt-4 flex flex-wrap gap-5 text-sm"><label className="flex items-center gap-2"><input name="email_verified" type="checkbox" /></label><label className="flex items-center gap-2"><input name="phone_verified" type="checkbox" /></label></div>{message ? <p className="mt-3 text-sm text-danger" role="status">{message}</p> : null}<button className="button-secondary mt-4 disabled:opacity-50" disabled={busy || !csrf} type="submit"></button></form>; return <form className="rounded-2xl border border-line bg-panel/50 p-5" onSubmit={submit}><p className="font-semibold"></p><div className="mt-4 grid gap-4 md:grid-cols-3"><input className="field-input" name="username" placeholder="用户名" required /><input className="field-input" name="email" placeholder="邮箱(可选)" type="email" /><input className="field-input" name="phone" placeholder="手机号(可选)" /><input className="field-input" minLength={8} name="password" placeholder="初始密码" required type="password" /><input className="field-input" name="reason" placeholder="创建原因(必填)" required /></div><div className="mt-4 flex flex-wrap gap-5 text-sm"><label className="flex items-center gap-2"><input name="email_verified" type="checkbox" /></label><label className="flex items-center gap-2"><input name="phone_verified" type="checkbox" /></label></div>{message ? <p className="mt-3 text-sm text-danger" role="status">{message}</p> : null}<button className="button-secondary mt-4 disabled:opacity-50" disabled={busy || !csrf} type="submit"><BrandIcon /></button></form>;
} }

View File

@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { PreviewNotice } from "@/components/ui"; import { PreviewNotice } from "@/components/ui";
import { BrandIcon } from "@/components/brand-icon";
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1"; const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
@ -113,7 +114,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
{verificationId ? <form className="mt-8 space-y-5" onSubmit={confirmVerification}> {verificationId ? <form className="mt-8 space-y-5" onSubmit={confirmVerification}>
<div><label className="field-label" htmlFor="verification-code"></label><input className="field-input" id="verification-code" inputMode="numeric" maxLength={6} onChange={(event) => setVerificationCode(event.target.value)} placeholder="输入 6 位验证码" required value={verificationCode} /></div> <div><label className="field-label" htmlFor="verification-code"></label><input className="field-input" id="verification-code" inputMode="numeric" maxLength={6} onChange={(event) => setVerificationCode(event.target.value)} placeholder="输入 6 位验证码" required value={verificationCode} /></div>
{error ? <p className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm leading-6 text-danger" role="alert">{error}</p> : null} {error ? <p className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm leading-6 text-danger" role="alert">{error}</p> : null}
<button className="button-primary w-full" disabled={busy || !csrf} type="submit">{busy ? "验证中…" : "验证邮箱"}</button> <button className="button-primary w-full" disabled={busy || !csrf} type="submit"><BrandIcon />{busy ? "验证中…" : "验证邮箱"}</button>
<button className="w-full text-sm text-muted hover:text-copy" disabled={busy} onClick={resendVerification} type="button"></button> <button className="w-full text-sm text-muted hover:text-copy" disabled={busy} onClick={resendVerification} type="button"></button>
</form> : <form className="mt-8 space-y-5" onSubmit={submit}> </form> : <form className="mt-8 space-y-5" onSubmit={submit}>
{isLogin ? <div><label className="field-label" htmlFor="email"></label><input className="field-input" id="email" onChange={(event) => setEmail(event.target.value)} placeholder="name@example.com / 13800138000" required value={email} /></div> : <> {isLogin ? <div><label className="field-label" htmlFor="email"></label><input className="field-input" id="email" onChange={(event) => setEmail(event.target.value)} placeholder="name@example.com / 13800138000" required value={email} /></div> : <>
@ -128,7 +129,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
{!isLogin ? <div><label className="field-label" htmlFor="password-confirm"></label><input className="field-input" id="password-confirm" minLength={8} onChange={(event) => setConfirmation(event.target.value)} placeholder="再次输入密码" required type="password" value={confirmation} /></div> : null} {!isLogin ? <div><label className="field-label" htmlFor="password-confirm"></label><input className="field-input" id="password-confirm" minLength={8} onChange={(event) => setConfirmation(event.target.value)} placeholder="再次输入密码" required type="password" value={confirmation} /></div> : null}
{error ? <p className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm leading-6 text-danger" role="alert">{error}</p> : null} {error ? <p className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm leading-6 text-danger" role="alert">{error}</p> : null}
{message ? <p className="rounded-lg border border-success/30 bg-success/10 px-3 py-2 text-sm leading-6 text-success" role="status">{message}</p> : null} {message ? <p className="rounded-lg border border-success/30 bg-success/10 px-3 py-2 text-sm leading-6 text-success" role="status">{message}</p> : null}
<button className="button-primary w-full" disabled={busy || !csrf} type="submit">{busy ? "处理中…" : isLogin ? "登录" : "注册"}</button> <button className="button-primary w-full" disabled={busy || !csrf} type="submit"><BrandIcon />{busy ? "处理中…" : isLogin ? "登录" : "注册"}</button>
</form>} </form>}
<p className="mt-6 text-center text-sm text-muted">{isLogin ? "还没有账户?" : "已有账户?"}{" "}<Link className="text-cyan hover:text-copy" href={isLogin ? "/register" : "/login"}>{isLogin ? "去注册" : "去登录"}</Link></p> <p className="mt-6 text-center text-sm text-muted">{isLogin ? "还没有账户?" : "已有账户?"}{" "}<Link className="text-cyan hover:text-copy" href={isLogin ? "/register" : "/login"}>{isLogin ? "去注册" : "去登录"}</Link></p>
</div> </div>

View File

@ -0,0 +1,6 @@
import Image from "next/image";
import logo from "@/assets/brand/logo.png";
export function BrandIcon({ className = "h-4 w-4" }: { className?: string }) {
return <Image alt="" aria-hidden className={`shrink-0 rounded-sm ${className}`} height={16} src={logo} width={16} />;
}

View File

@ -5,6 +5,7 @@ import { useState } from "react";
import { CloseIcon, MenuIcon } from "@/components/icons"; import { CloseIcon, MenuIcon } from "@/components/icons";
import { navigation } from "@/lib/content"; import { navigation } from "@/lib/content";
import { BrandMark } from "@/components/brand-mark"; import { BrandMark } from "@/components/brand-mark";
import { BrandIcon } from "@/components/brand-icon";
export function SiteHeader() { export function SiteHeader() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -22,12 +23,12 @@ export function SiteHeader() {
</Link> </Link>
))} ))}
</nav> </nav>
<div className="desktop-nav items-center gap-3"> <div className="desktop-nav items-center gap-6">
<Link className="text-sm text-muted transition-colors hover:text-copy" href="/login"> <Link className="button-ghost min-h-10 px-4 py-2 text-sm" href="/login">
<BrandIcon />
</Link> </Link>
<Link className="button-primary min-h-10 px-4 py-2" href="/contact"> <Link className="button-primary min-h-10 px-4 py-2 text-sm" href="/contact">
<BrandIcon />
</Link> </Link>
</div> </div>
<button aria-expanded={open} aria-label={open ? "关闭导航" : "打开导航"} className="button-ghost mobile-nav-trigger min-h-10 p-2" onClick={() => setOpen((current) => !current)} type="button"> <button aria-expanded={open} aria-label={open ? "关闭导航" : "打开导航"} className="button-ghost mobile-nav-trigger min-h-10 p-2" onClick={() => setOpen((current) => !current)} type="button">

View File

@ -30,6 +30,7 @@ export const products: Product[] = [
]; ];
export const navigation = [ export const navigation = [
{ label: "主页", href: "/" },
{ label: "产品", href: "/products" }, { label: "产品", href: "/products" },
{ label: "技术", href: "/#technology" }, { label: "技术", href: "/#technology" },
{ label: "AI Lab", href: "/tts" }, { label: "AI Lab", href: "/tts" },