feat: home menu link, button restyle with brand icon, CMS zh labels/i18n and logout button
This commit is contained in:
parent
8e74bc71e9
commit
49a916bbf4
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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.0x(V1 已验证范围)</div></div><div><span className="field-label">格式</span><div className="field-input text-muted">WAV(V1 已验证格式)</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.0x(V1 已验证范围)</div></div><div><span className="field-label">格式</span><div className="field-input text-muted">WAV(V1 已验证格式)</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>;
|
||||||
|
|||||||
@ -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" },
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
66
apps/cms/src/app/(payload)/api/cms-logout/route.ts
Normal file
66
apps/cms/src/app/(payload)/api/cms-logout/route.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 }],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 描述" } },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 描述" } },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 }],
|
||||||
};
|
};
|
||||||
|
|||||||
72
apps/cms/src/components/CmsLogoutButton.tsx
Normal file
72
apps/cms/src/components/CmsLogoutButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
6
components/brand-icon.tsx
Normal file
6
components/brand-icon.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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" },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user