新建发布 · 场景文档

本页由 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 闭包得出的, 手写等价物不会出现在这里。

一条链接装走整页

/r/scene--create.json /llms/scenes/create.md

create.astro src/pages/showcase/demo/create.astro

---
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>

CreateWizard.tsx src/pages/showcase/demo/CreateWizard.tsx

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>
  );
}

页面级规则(页头、工具栏、表格、设置、详情、流程、状态)只写在 页面模式 一处,这里不复述——复述就会漂移。