来源：https://ui.docs.xihanfun.com/examples/form-page

# 表单页

一张新建工单的表：九个字段分成两组，值全在同一张表里，校验、错误摘要与重置由[表单](../components/form)统一管。

```vue
<script setup lang="ts">
import type { RadioGroupNode, SelectNode } from "@xihan-ui/headless";
import {
  XhAlertContent,
  XhAlertDescription,
  XhAlertRoot,
  XhAlertTitle,
  XhCheckbox,
  XhFieldControl,
  XhFieldDescription,
  XhFieldErrorText,
  XhFieldLabel,
  XhFieldRoot,
  XhFieldsetLegend,
  XhFieldsetRoot,
  XhFormErrorSummary,
  XhFormErrorSummaryItem,
  XhFormFieldGroup,
  XhFormResetTrigger,
  XhFormRoot,
  XhFormSubmitTrigger,
  XhNumberFieldControl,
  XhNumberFieldDecrementTrigger,
  XhNumberFieldIncrementTrigger,
  XhNumberFieldInput,
  XhNumberFieldLabel,
  XhNumberFieldRoot,
  XhPageHeaderDescription,
  XhPageHeaderRoot,
  XhPageHeaderTitle,
  XhRadioGroupRoot,
  XhSelectRoot,
  XhSliderControl,
  XhSliderLabel,
  XhSliderRange,
  XhSliderRoot,
  XhSliderThumb,
  XhSliderTrack,
  XhSwitch,
  XhTagsInputControl,
  XhTagsInputInput,
  XhTagsInputItem,
  XhTagsInputItemDeleteTrigger,
  XhTagsInputItemPreview,
  XhTagsInputItemText,
  XhTagsInputLabel,
  XhTagsInputRoot,
  XhTextFieldControl,
  XhTextFieldInput,
  XhTextFieldRoot,
} from "@xihan-ui/vue";
import { ref } from "vue";

const priorities: SelectNode[] = [
  { value: "p0", label: "P0 · 阻断" },
  { value: "p1", label: "P1 · 严重" },
  { value: "p2", label: "P2 · 一般" },
];

const kinds: RadioGroupNode[] = [
  { value: "bug", label: "缺陷" },
  { value: "feature", label: "需求" },
  { value: "ask", label: "咨询" },
];

const defaults = {
  title: "",
  detail: "",
  priority: ["p2"],
  kind: "bug",
  amount: "1",
  labels: [] as string[],
  impact: [40],
  notify: true,
  agree: false,
};

const submitted = ref("");

// 整表跑一遍，返回「字段名 → 错误文案」；空串表示这条没错
function validate(values: Record<string, unknown>) {
  const title = String(values.title ?? "").trim();
  const detail = String(values.detail ?? "").trim();
  return {
    title: title ? "" : "标题不能为空",
    detail: detail.length >= 8 ? "" : "把现象说满 8 个字，值班的人才接得住",
    agree: values.agree === true ? "" : "要先确认这条工单可以对内公开",
  };
}

function onSubmit(details: { values: Record<string, unknown> }) {
  submitted.value = JSON.stringify(details.values);
}
</script>

<template>
  <div class="form-page">
    <XhPageHeaderRoot>
      <XhPageHeaderTitle>新建工单</XhPageHeaderTitle>
      <XhPageHeaderDescription>提交后进入值班队列，重复问题会被合并</XhPageHeaderDescription>
    </XhPageHeaderRoot>

    <XhAlertRoot tone="info">
      <XhAlertContent>
        <XhAlertTitle>先看一眼已知问题</XhAlertTitle>
        <XhAlertDescription>近三天同类工单 12 条，其中 9 条已定位到同一处配置。</XhAlertDescription>
      </XhAlertContent>
    </XhAlertRoot>

    <XhFormRoot
      class="form-page__form"
      :default-values="defaults"
      :validate="validate"
      @submit="onSubmit"
    >
      <!-- 摘要只在提交失败后显形，条目一次全写上 -->
      <XhFormErrorSummary v-slot="{ errorCount }">
        <span>共 {{ errorCount }} 处需要修改</span>
        <XhFormErrorSummaryItem v-slot="{ error }" name="title">{{ error }}</XhFormErrorSummaryItem>
        <XhFormErrorSummaryItem v-slot="{ error }" name="detail">{{ error }}</XhFormErrorSummaryItem>
        <XhFormErrorSummaryItem v-slot="{ error }" name="agree">{{ error }}</XhFormErrorSummaryItem>
      </XhFormErrorSummary>

      <XhFieldsetRoot class="form-page__group">
        <XhFieldsetLegend>问题本身</XhFieldsetLegend>

        <XhFormFieldGroup v-slot="{ value, error, invalid, setValue }" name="title">
          <XhFieldRoot :invalid="invalid" required>
            <XhFieldLabel>标题</XhFieldLabel>
            <XhFieldControl>
              <input
                :value="value"
                placeholder="一句话说清现象"
                @input="setValue(($event.target as HTMLInputElement).value)"
              >
            </XhFieldControl>
            <XhFieldErrorText>{{ error }}</XhFieldErrorText>
          </XhFieldRoot>
        </XhFormFieldGroup>

        <XhFormFieldGroup v-slot="{ value, error, invalid, setValue }" name="detail">
          <XhFieldRoot :invalid="invalid" required>
            <XhFieldLabel>详细描述</XhFieldLabel>
            <XhFieldControl>
              <XhTextFieldRoot
                :value="String(value ?? '')"
                :invalid="invalid"
                placeholder="复现步骤、期望结果、实际结果"
                @update:value="setValue($event)"
              >
                <XhTextFieldControl>
                  <XhTextFieldInput as="textarea" rows="3" />
                </XhTextFieldControl>
              </XhTextFieldRoot>
            </XhFieldControl>
            <XhFieldDescription>写清复现步骤，值班的人不必再来回问</XhFieldDescription>
            <XhFieldErrorText>{{ error }}</XhFieldErrorText>
          </XhFieldRoot>
        </XhFormFieldGroup>

        <div class="form-page__row">
          <XhFormFieldGroup v-slot="{ value, setValue }" name="priority">
            <XhSelectRoot
              :collection="priorities"
              :value="(value as string[])"
              label="优先级"
              placeholder="请选择"
              @update:value="setValue($event)"
            />
          </XhFormFieldGroup>

          <XhFormFieldGroup v-slot="{ value, setValue }" name="amount">
            <XhNumberFieldRoot
              :value="String(value ?? '')"
              :min="1"
              :max="99"
              @update:value="setValue($event)"
            >
              <XhNumberFieldLabel>受影响实例</XhNumberFieldLabel>
              <XhNumberFieldControl>
                <XhNumberFieldDecrementTrigger />
                <XhNumberFieldInput />
                <XhNumberFieldIncrementTrigger />
              </XhNumberFieldControl>
            </XhNumberFieldRoot>
          </XhFormFieldGroup>
        </div>

        <XhFormFieldGroup v-slot="{ value, setValue }" name="kind">
          <XhRadioGroupRoot
            :collection="kinds"
            :value="(value as string)"
            label="工单类型"
            @update:value="setValue($event)"
          />
        </XhFormFieldGroup>

        <XhFormFieldGroup v-slot="{ value, setValue }" name="labels">
          <XhTagsInputRoot
            v-slot="{ value: tags }"
            :value="(value as string[])"
            placeholder="回车落一个"
            @update:value="setValue($event)"
          >
            <XhTagsInputLabel>关联模块</XhTagsInputLabel>
            <XhTagsInputControl>
              <XhTagsInputItem v-for="tag in tags" :key="tag" :value="tag">
                <XhTagsInputItemPreview>
                  <XhTagsInputItemText>{{ tag }}</XhTagsInputItemText>
                  <XhTagsInputItemDeleteTrigger />
                </XhTagsInputItemPreview>
              </XhTagsInputItem>
              <XhTagsInputInput />
            </XhTagsInputControl>
          </XhTagsInputRoot>
        </XhFormFieldGroup>

        <XhFormFieldGroup v-slot="{ value, setValue }" name="impact">
          <XhSliderRoot
            v-slot="{ value: impact }"
            :value="(value as number[])"
            :min="0"
            :max="100"
            :step="5"
            :large-step="20"
            @update:value="setValue($event)"
          >
            <XhSliderLabel>影响面估计：{{ impact[0] }}%</XhSliderLabel>
            <XhSliderControl>
              <XhSliderTrack>
                <XhSliderRange />
              </XhSliderTrack>
              <XhSliderThumb />
            </XhSliderControl>
          </XhSliderRoot>
        </XhFormFieldGroup>
      </XhFieldsetRoot>

      <XhFieldsetRoot class="form-page__group">
        <XhFieldsetLegend>提交之前</XhFieldsetLegend>

        <XhFormFieldGroup v-slot="{ value, setValue }" name="notify">
          <label class="form-page__check">
            <XhSwitch :checked="value === true" @update:checked="setValue($event)" />
            有进展就通知我
          </label>
        </XhFormFieldGroup>

        <XhFormFieldGroup v-slot="{ value, error, invalid, setValue }" name="agree">
          <XhFieldRoot :invalid="invalid" required>
            <label class="form-page__check">
              <XhCheckbox
                :checked="value === true"
                :invalid="invalid"
                @update:checked="setValue($event)"
              />
              这条工单可以对内公开
            </label>
            <XhFieldErrorText>{{ error }}</XhFieldErrorText>
          </XhFieldRoot>
        </XhFormFieldGroup>
      </XhFieldsetRoot>

      <div class="form-page__actions">
        <XhFormSubmitTrigger>提交工单</XhFormSubmitTrigger>
        <XhFormResetTrigger>重置</XhFormResetTrigger>
      </div>

      <p v-if="submitted" class="form-page__result">已提交：{{ submitted }}</p>
    </XhFormRoot>
  </div>
</template>

<style scoped>
.form-page {
  display: flex;
  flex-direction: column;
  gap: var(--xh-space-4);
  inline-size: 100%;
  max-inline-size: 560px;
}

.form-page__form {
  display: flex;
  flex-direction: column;
  gap: var(--xh-space-4);
}

.form-page__group {
  display: flex;
  flex-direction: column;
  gap: var(--xh-space-4);
}

/* 两栏并排，窄屏落回一栏 */
.form-page__row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: var(--xh-space-4);
  align-items: end;
}

.form-page__check {
  display: flex;
  align-items: center;
  gap: var(--xh-space-2);
  font-size: var(--xh-font-size-md);
}

.form-page__actions {
  display: flex;
  gap: var(--xh-space-2);
}

.form-page__result {
  margin: 0;
  color: var(--xh-fg-muted);
  font-size: var(--xh-font-size-sm);
  word-break: break-all;
}
</style>
```

```html
<style>
  #form-page {
    display: flex;
    flex-direction: column;
    gap: var(--xh-space-4);
    inline-size: 100%;
    max-inline-size: 560px;
  }

  #form-page [data-form="body"] {
    display: flex;
    flex-direction: column;
    gap: var(--xh-space-4);
  }

  /* 两栏并排，窄屏落回一栏 */
  #form-page [data-form="row"] {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
    gap: var(--xh-space-4);
    align-items: end;
  }

  #form-page [data-form="check"] {
    display: flex;
    align-items: center;
    gap: var(--xh-space-2);
    font-size: var(--xh-font-size-md);
  }

  #form-page [data-form="actions"] {
    display: flex;
    gap: var(--xh-space-2);
  }

  #form-page [data-form="hint"] {
    margin: 0;
    color: var(--xh-fg-muted);
    font-size: var(--xh-font-size-sm);
  }

  #form-page [data-form="error"] {
    margin: 0;
    color: var(--xh-fg-danger);
    font-size: var(--xh-font-size-sm);
  }

  #form-page [data-form="result"] {
    margin: 0;
    color: var(--xh-fg-muted);
    font-size: var(--xh-font-size-sm);
    word-break: break-all;
  }
</style>

<div id="form-page">
  <xh-page-header>
    <div data-xh-part="root">
      <div data-xh-part="title">新建工单</div>
      <div data-xh-part="description">提交后进入值班队列，重复问题会被合并</div>
    </div>
  </xh-page-header>

  <xh-alert tone="info">
    <div data-xh-part="root">
      <div data-xh-part="content">
        <div data-xh-part="title">先看一眼已知问题</div>
        <div data-xh-part="description">近三天同类工单 12 条，其中 9 条已定位到同一处配置。</div>
      </div>
    </div>
  </xh-alert>

  <xh-form id="form-page-form">
    <form data-xh-part="root" data-form="body">
      <!-- 摘要只在提交失败后显形，条目一次全写上 -->
      <div data-xh-part="error-summary">
        <span id="form-page-count">共 0 处需要修改</span>
        <a data-xh-part="error-summary-item" name="title"></a>
        <a data-xh-part="error-summary-item" name="detail"></a>
        <a data-xh-part="error-summary-item" name="agree"></a>
      </div>

      <div data-xh-part="field-group" name="title">
        <xh-field>
          <div data-xh-part="root">
            <label data-xh-part="label">标题</label>
            <input data-xh-part="control" placeholder="一句话说清现象" />
            <p data-xh-part="error-text"></p>
          </div>
        </xh-field>
      </div>

      <div data-xh-part="field-group" name="detail">
        <xh-text-field id="form-page-detail" placeholder="复现步骤、期望结果、实际结果">
          <div data-xh-part="root">
            <label data-xh-part="label">详细描述</label>
            <div data-xh-part="control">
              <textarea data-xh-part="input" rows="3"></textarea>
            </div>
          </div>
        </xh-text-field>
        <p data-form="hint">写清复现步骤，值班的人不必再来回问</p>
        <p data-form="error" data-error="detail" hidden></p>
      </div>

      <div data-form="row">
        <div data-xh-part="field-group" name="priority">
          <xh-select id="form-page-priority" placeholder="请选择">
            <div data-xh-part="root">
              <span data-xh-part="label">优先级</span>
              <div data-xh-part="control">
                <button data-xh-part="trigger">
                  <span data-xh-part="value-text"></span>
                  <span data-xh-part="indicator"></span>
                </button>
              </div>
              <div data-xh-part="positioner">
                <div data-xh-part="content">
                  <div data-xh-part="list">
                    <div data-xh-part="item" value="p0">
                      <span data-xh-part="item-text">P0 · 阻断</span>
                      <span data-xh-part="item-indicator"></span>
                    </div>
                    <div data-xh-part="item" value="p1">
                      <span data-xh-part="item-text">P1 · 严重</span>
                      <span data-xh-part="item-indicator"></span>
                    </div>
                    <div data-xh-part="item" value="p2">
                      <span data-xh-part="item-text">P2 · 一般</span>
                      <span data-xh-part="item-indicator"></span>
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </xh-select>
        </div>

        <div data-xh-part="field-group" name="amount">
          <xh-number-field id="form-page-amount" min="1" max="99">
            <div data-xh-part="root">
              <label data-xh-part="label">受影响实例</label>
              <div data-xh-part="control">
                <button data-xh-part="decrement-trigger"></button>
                <input data-xh-part="input" />
                <button data-xh-part="increment-trigger"></button>
              </div>
            </div>
          </xh-number-field>
        </div>
      </div>

      <div data-xh-part="field-group" name="kind">
        <xh-radio-group id="form-page-kind" orientation="horizontal">
          <div data-xh-part="root">
            <span data-xh-part="label">工单类型</span>
            <div data-xh-part="item" value="bug">
              <input data-xh-part="hidden-input" />
              <span data-xh-part="indicator"></span>
              <span data-xh-part="item-text">缺陷</span>
            </div>
            <div data-xh-part="item" value="feature">
              <input data-xh-part="hidden-input" />
              <span data-xh-part="indicator"></span>
              <span data-xh-part="item-text">需求</span>
            </div>
            <div data-xh-part="item" value="ask">
              <input data-xh-part="hidden-input" />
              <span data-xh-part="indicator"></span>
              <span data-xh-part="item-text">咨询</span>
            </div>
          </div>
        </xh-radio-group>
      </div>

      <div data-xh-part="field-group" name="labels">
        <xh-tags-input id="form-page-labels" placeholder="回车落一个">
          <div data-xh-part="root">
            <label data-xh-part="label">关联模块</label>
            <div data-xh-part="control">
              <input data-xh-part="input" />
            </div>
          </div>
        </xh-tags-input>
      </div>

      <div data-xh-part="field-group" name="impact">
        <xh-slider id="form-page-impact" min="0" max="100" step="5" large-step="20">
          <div data-xh-part="root">
            <label data-xh-part="label">
              影响面估计：<span id="form-page-impact-value">40</span>%
            </label>
            <div data-xh-part="control">
              <div data-xh-part="track">
                <div data-xh-part="range"></div>
              </div>
              <div data-xh-part="thumb">
                <input data-xh-part="hidden-input" />
              </div>
            </div>
          </div>
        </xh-slider>
      </div>

      <div data-xh-part="field-group" name="notify">
        <label data-form="check">
          <xh-switch id="form-page-notify">
            <button data-xh-part="root">
              <span data-xh-part="thumb"></span>
            </button>
          </xh-switch>
          有进展就通知我
        </label>
      </div>

      <div data-xh-part="field-group" name="agree">
        <label data-form="check">
          <xh-checkbox id="form-page-agree">
            <button data-xh-part="root">
              <span data-xh-part="indicator"></span>
            </button>
          </xh-checkbox>
          这条工单可以对内公开
        </label>
        <p data-form="error" data-error="agree" hidden></p>
      </div>

      <div data-form="actions">
        <button data-xh-part="submit-trigger">提交工单</button>
        <button data-xh-part="reset-trigger">重置</button>
      </div>

      <p id="form-page-submitted" data-form="result" hidden></p>
    </form>
  </xh-form>
</div>

<script type="module">
  const host = document.getElementById("form-page-form");
  const count = document.getElementById("form-page-count");
  const submitted = document.getElementById("form-page-submitted");
  const impactReadout = document.getElementById("form-page-impact-value");

  const defaults = {
    title: "",
    detail: "",
    priority: ["p2"],
    kind: "bug",
    amount: "1",
    labels: [],
    impact: [40],
    notify: true,
    agree: false,
  };

  let values = { ...defaults };

  // 整表跑一遍：required 拦空值，长度与「必须勾上」各由一条规则收
  host.rules = {
    title: { required: true, message: "标题不能为空" },
    detail: { required: true, min: 8, message: "把现象说满 8 个字，值班的人才接得住" },
    agree: {
      required: true,
      validator: (value) => (value === true ? undefined : "要先确认这条工单可以对内公开"),
    },
  };
  host.defaultValues = defaults;
  host.values = values;

  const title = host.querySelector('[data-xh-part="field-group"][name="title"] [data-xh-part="control"]');
  const detail = document.getElementById("form-page-detail");
  const priority = document.getElementById("form-page-priority");
  const amount = document.getElementById("form-page-amount");
  const kind = document.getElementById("form-page-kind");
  const labels = document.getElementById("form-page-labels");
  const tagControl = labels.querySelector('[data-xh-part="control"]');
  const tagInput = tagControl.querySelector('[data-xh-part="input"]');
  const impact = document.getElementById("form-page-impact");
  const notify = document.getElementById("form-page-notify");
  const agree = document.getElementById("form-page-agree");

  // 九个控件各自把改动写回同一张值表
  title.addEventListener("input", () => host.setFieldValue("title", title.value));
  detail.addEventListener("value-change", (e) => host.setFieldValue("detail", e.detail.value));
  priority.addEventListener("value-change", (e) => host.setFieldValue("priority", e.detail.value));
  amount.addEventListener("value-change", (e) => host.setFieldValue("amount", e.detail.value));
  kind.addEventListener("value-change", (e) => host.setFieldValue("kind", e.detail.value));
  labels.addEventListener("value-change", (e) => host.setFieldValue("labels", e.detail.value));
  impact.addEventListener("value-change", (e) => host.setFieldValue("impact", e.detail.value));
  notify.addEventListener("checked-change", (e) => host.setFieldValue("notify", e.detail.checked));
  agree.addEventListener("checked-change", (e) => host.setFieldValue("agree", e.detail.checked));

  // 一个标签一个节点：外壳带 value 标识身份，里面是文本与删除按钮
  function createTag(value) {
    const item = document.createElement("div");
    item.dataset.xhPart = "item";
    item.setAttribute("value", value);
    const preview = document.createElement("div");
    preview.dataset.xhPart = "item-preview";
    const text = document.createElement("span");
    text.dataset.xhPart = "item-text";
    text.textContent = value;
    const remove = document.createElement("button");
    remove.dataset.xhPart = "item-delete-trigger";
    preview.append(text, remove);
    item.append(preview);
    return item;
  }

  // 按当前值增删标签节点，已经在的那份原地留着
  function renderTags(next) {
    const alive = new Map();
    for (const el of tagControl.querySelectorAll('[data-xh-part="item"]')) {
      alive.set(el.getAttribute("value"), el);
    }
    for (const [value, el] of alive) {
      if (!next.includes(value)) el.remove();
    }
    for (const value of next) {
      if (!alive.has(value)) tagControl.insertBefore(createTag(value), tagInput);
    }
  }

  // 值表是唯一事实源：变了就刷回每个控件
  function syncControls() {
    const next = String(values.title ?? "");
    if (title.value !== next) title.value = next;
    detail.value = String(values.detail ?? "");
    amount.value = String(values.amount ?? "");
    kind.value = String(values.kind ?? "");
    priority.value = values.priority ?? [];
    labels.value = values.labels ?? [];
    impact.value = values.impact ?? [0];
    notify.checked = values.notify === true;
    agree.checked = values.agree === true;
    impactReadout.textContent = impact.value[0];
    renderTags(labels.value);
  }

  // 错误文案由作者自己写进字段、控件旁那一行与摘要条目
  function paintErrors(errors) {
    host.querySelector('[data-xh-part="field-group"][name="title"] [data-xh-part="error-text"]')
      .textContent = errors.title ?? "";
    for (const line of host.querySelectorAll("[data-error]")) {
      const message = errors[line.dataset.error] ?? "";
      line.textContent = message;
      line.hidden = message === "";
    }
    detail.invalid = errors.detail !== undefined;
    agree.invalid = errors.agree !== undefined;
    for (const item of host.querySelectorAll('[data-xh-part="error-summary-item"]'))
      item.textContent = errors[item.getAttribute("value")] ?? "";
    count.textContent = `共 ${Object.keys(errors).length} 处需要修改`;
  }

  // 值给了即受控，写值只发通知，改动由宿主自己写回
  host.addEventListener("values-change", (event) => {
    values = event.detail.values;
    host.values = values;
    syncControls();
  });
  host.addEventListener("errors-change", (event) => paintErrors(event.detail.errors));
  host.addEventListener("submit", (event) => {
    submitted.hidden = false;
    submitted.textContent = `已提交：${JSON.stringify(event.detail.values)}`;
  });

  syncControls();
</script>
```

## 这一屏定了什么

- **值只有一份。** 每个控件外面套一层 `XhFormFieldGroup`，拿到的是这张表里属于自己的那一格与写回它的动作；控件自己不再另存一份值，也就不存在两边不同步。
- **校验在表这一层，不在控件里。** `validate` 拿到整张值表跑一遍，返回「字段名 → 错误文案」；跨字段的规则天然写得下，控件一个字都不必改。
- **错误摘要是一处，不是九处。** 提交没过时摘要显形并把焦点送到第一个出错的字段，条目一次全写上，谁露面由当下的错误表决定——[无障碍](../guide/a11y)那一册讲的就是这条通路。
- **控件形态不一，控件外壳一致。** 文本框、下拉、数字输入、标签输入四种控件的高度、内边距、描边与聚焦环取的是同一组控件令牌，摆在一起才对得齐。
- **两栏在窄屏落回一栏。** 栅格写的是 `minmax` 而不是固定断点，[响应式](../guide/styling)由内容宽度决定，不由设备决定。

## 换成你的项目

- 复合控件参与原生提交与重置要给 `name`，规则见[表单参与与重置](../guide/forms)。
- 异步校验直接在 `validate` 里返回 Promise，提交时表单会等它回来再放行或拦下。
- 分组用[字段组](../components/fieldset)：`legend` 是这一组的名字，说明文案会自动接进 `aria-describedby`。
