本页由 4 个真实组件拼成;提示词与源码都可直接复制带走。
复现「新建发布」整页场景(/showcase/demo/create):
- 页面级规则按 /design#pages 的对应小节执行,不在此复述
- 本页由这些真实组件拼成(手写等价物不算):
- 玻璃输入框(Floating Label Input):标签在框上方,不做浮动标签动画|/showcase/component/input/glass-apple
- 玻璃标签输入(Tag Input (chips)):回车 / 逗号 / 失焦提交;粘贴逗号分隔的多个值会拆开并去重|/showcase/component/tags/glass-apple
- 玻璃多行输入(Autosizing Textarea):label 在框上方;校验契约与单行输入一致(aria-describedby + aria-invalid + role=alert)|/showcase/component/textarea/glass-apple
- 玻璃上传(Dropzone):三个入口:拖放、点选择文件、键盘聚焦按钮后 Enter 打开选择器|/showcase/component/upload/glass-apple
- 骨架:视口高度固定,页头不滚,正文在容器内滚;分区页的页头与正文共用同一条内容列
清单是扫 src/pages/showcase/demo/create.astro 的 import 闭包得出的,
手写等价物不会出现在这里。
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import DemoChrome from '../../../components/DemoChrome.astro';
import PageHeader from '../../../components/PageHeader.astro';
import PageShell from '../../../components/PageShell.astro';
import SceneDocLink from '../../../components/SceneDocLink.astro';
import CreateWizard from './CreateWizard.tsx';
---
<BaseLayout title="UI Echo · 新建向导复现">
<DemoChrome />
<PageShell demo col colWidth="48rem">
<PageHeader
slot="header"
title="新建发布"
description="三步向导:基本信息 → 账号 → 确认。提交失败时一次列出所有问题。"
size="sm"
>
<SceneDocLink id="create" slot="actions" />
</PageHeader>
<!-- 单任务页:标题留在页头,步骤条与表单两列,整列在正文区垂直居中 -->
<div class="my-auto">
<CreateWizard client:visible />
</div>
</PageShell>
</BaseLayout>
import { useEffect, useRef, useState } from 'react';
import { GlassAppleInput } from '../../../components/input/glass-apple/Input';
import { GlassAppleTextarea } from '../../../components/textarea/glass-apple/Textarea';
import { GlassAppleTags } from '../../../components/tags/glass-apple/Tags';
import { GlassAppleUpload } from '../../../components/upload/glass-apple/Upload';
const STEPS = ['基本信息', '账号', '确认'];
const EMAIL = /^\S+@\S+\.\S+$/;
interface Form {
name: string;
site: string;
desc: string;
tags: string[];
cover: File[];
email: string;
password: string;
}
export default function CreateWizard() {
const [step, setStep] = useState(0);
const [done, setDone] = useState(false);
const [form, setForm] = useState<Form>({ name: '', site: '', desc: '', tags: [], cover: [], email: '', password: '' });
const [showErrors, setShowErrors] = useState(false);
const [attempt, setAttempt] = useState(0);
const summaryRef = useRef<HTMLDivElement>(null);
const set =
(key: keyof Form) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm((prev) => ({ ...prev, [key]: e.target.value }));
const nameError = form.name.trim().length < 2 ? '名称至少 2 个字符' : '';
const emailError = !EMAIL.test(form.email) ? '邮箱格式不正确,例如 name@example.com' : '';
const passwordError = form.password.length < 8 ? '密码至少 8 位,含数字与字母' : '';
const stepErrors = [nameError, emailError || passwordError, ''];
const canAdvance = !stepErrors[step];
// 摘要文案与行内错误逐字一致;点一条直接聚焦到字段(见 docs/standards/pages.md §6)
const stepIssues =
step === 0
? [{ id: 'create-name', message: nameError }]
: step === 1
? [
{ id: 'create-email', message: emailError },
{ id: 'create-password', message: passwordError },
]
: [];
const issues = showErrors ? stepIssues.filter((i) => i.message) : [];
// 只在提交失败时抢焦点;打字过程中错误条数变化不抢回焦点
useEffect(() => {
if (attempt > 0) summaryRef.current?.focus();
}, [attempt]);
const focusField = (id: string) => {
const el = document.getElementById(id);
if (!el) return;
el.focus();
el.scrollIntoView({
block: 'center',
behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
});
};
const next = () => {
if (!canAdvance) {
setShowErrors(true);
setAttempt((n) => n + 1);
return;
}
setShowErrors(false);
setStep((s) => Math.min(STEPS.length - 1, s + 1));
};
if (done) {
return (
<div className="w-full rounded-2xl bg-[var(--color-surface)] border border-[var(--glass-border)] shadow-[var(--shadow-sm)] p-8 text-center"> <p className="text-sm font-medium mb-1">已提交</p>
<p className="text-xs text-[var(--color-text-muted)] mb-5">
「{form.name}」会在 1 个工作日内进入审核。
</p>
<button
type="button"
onClick={() => {
setDone(false);
setStep(0);
setForm({ name: '', site: '', desc: '', tags: [], cover: [], email: '', password: '' });
}}
className="press-feedback px-4 py-2 rounded-lg text-xs border border-[var(--glass-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors cursor-pointer"
>
再建一个
</button>
</div>
);
}
return (
<div className="w-full lg:grid lg:grid-cols-[11rem_minmax(0,1fr)] lg:gap-8 lg:items-start">
<nav aria-label="发布步骤">
<ol className="flex items-center gap-5 mb-6 lg:mb-0 lg:flex-col lg:items-start lg:gap-4 text-xs">
{STEPS.map((label, i) => (
<li
key={label}
aria-current={i === step ? 'step' : undefined}
className={`flex items-center gap-2 ${i === step ? 'text-[var(--color-text)]' : 'text-[var(--color-text-subtle)]'}`}
>
<span
className={`
size-5 rounded-full grid place-items-center font-mono text-[length:var(--text-2xs)]
${i < step
? 'bg-[var(--color-accent)] text-white'
: i === step
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)]'
: 'bg-[var(--color-bg)] text-[var(--color-text-subtle)]'}
`}
>
{i + 1}
</span>
{label}
</li>
))}
</ol>
</nav>
<div className="min-w-0">
{issues.length > 0 && (
<div
ref={summaryRef}
tabIndex={-1}
aria-labelledby="create-error-title"
className="mb-4 rounded-xl border border-[var(--color-warning-bd)] bg-[var(--color-warning-bg)] p-4"
>
<p id="create-error-title" className="text-sm font-medium text-[var(--color-warning-fg)] mb-2">
有 {issues.length} 个问题需要处理
</p>
<ul className="list-disc pl-4 space-y-1">
{issues.map((issue) => (
<li key={issue.id}>
<a
href={`#${issue.id}`}
onClick={(e) => {
e.preventDefault();
focusField(issue.id);
}}
className="text-xs text-[var(--color-warning-fg)] underline underline-offset-2"
>
{issue.message}
</a>
</li>
))}
</ul>
</div>
)}
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
if (step < STEPS.length - 1) next();
else setDone(true);
}}
className="rounded-2xl bg-[var(--color-surface)] border border-[var(--glass-border)] shadow-[var(--shadow-sm)] p-6 space-y-5"
>
{step === 0 && (
<>
<GlassAppleInput
id="create-name"
label="名称"
required
maxLength={20}
showCount
clearable
placeholder="给要发布的东西起个名字"
value={form.name}
onChange={set('name')}
errorMessage={showErrors ? nameError : undefined}
hint="2–20 个字符"
/>
<GlassAppleInput
label="网站"
prefix="https://"
suffix=".com"
placeholder="your-project"
value={form.site}
onChange={set('site')}
hint="可留空,稍后再填"
/>
<GlassAppleTextarea
label="发布说明"
placeholder="这次改了什么?审核会先看这里"
value={form.desc}
onChange={set('desc')}
maxLength={200}
showCount
autosize
maxRows={5}
hint="可留空;换行会保留"
/>
<GlassAppleTags
label="标签"
hint="回车或逗号添加,最多 6 个"
value={form.tags}
onChange={(tags) => setForm((prev) => ({ ...prev, tags }))}
max={6}
/>
<GlassAppleUpload
label="封面图"
accept="image/*"
maxSizeMb={5}
files={form.cover}
onChange={(cover) => setForm((prev) => ({ ...prev, cover }))}
hint="拖进来或点按钮选择;超过 5 MB 会被拒绝"
/>
</>
)}
{step === 1 && (
<>
<GlassAppleInput
id="create-email"
label="邮箱"
type="email"
required
autoComplete="email"
placeholder="you@example.com"
value={form.email}
onChange={set('email')}
errorMessage={showErrors ? emailError : undefined}
hint="用于接收审核结果"
/>
<GlassAppleInput
id="create-password"
label="密码"
type="password"
required
reveal
autoComplete="new-password"
placeholder="至少 8 位"
value={form.password}
onChange={set('password')}
errorMessage={showErrors ? passwordError : undefined}
/>
</>
)}
{step === 2 && (
<dl className="divide-y divide-[var(--glass-border)]">
{[
['名称', form.name],
['网站', form.site ? `https://${form.site}.com` : '未填写'],
['说明', form.desc.trim() ? `${form.desc.length} 字` : '未填写'],
['标签', form.tags.length ? form.tags.join('、') : '未填写'],
['封面', form.cover[0]?.name ?? '未填写'],
['邮箱', form.email],
['密码', '•'.repeat(form.password.length)],
].map(([k, v]) => (
<div key={k} className="flex items-center justify-between gap-4 py-3 first:pt-0 last:pb-0">
<dt className="text-xs text-[var(--color-text-muted)]">{k}</dt>
<dd className="text-sm truncate">{v}</dd>
</div>
))}
</dl>
)}
<div className="flex items-center justify-between pt-1">
<button
type="button"
onClick={() => {
setShowErrors(false);
setStep((s) => Math.max(0, s - 1));
}}
disabled={step === 0}
className="press-feedback px-4 py-2 rounded-lg text-xs border border-[var(--glass-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
上一步
</button>
<button
type="submit"
className="press-feedback press-hover px-5 py-2 rounded-lg text-xs font-medium bg-[var(--color-accent)] text-white cursor-pointer"
>
{step === STEPS.length - 1 ? '提交' : '下一步'}
</button>
</div>
</form>
<p aria-live="polite" className="sr-only">
{showErrors ? stepErrors[step] || '请检查表单' : ''}
</p>
</div>
</div>
);
}
页面级规则(页头、工具栏、表格、设置、详情、流程、状态)只写在 页面模式 一处,这里不复述——复述就会漂移。