/** 插件表单区块的状态机(三个 hook,无 JSX)。 * * 拆出来的理由有两条,都是硬约束: * ① 「函数 ≤80 行」(AGENTS §10)—— 表单组件把「值同步 + 候选加载 + 提交」塞进一个函数 * 会到 170 行以上; * ② 「快照每 30s 重取一次也不能把用户正在输入的内容重置掉」这条行为只有在**依赖键** * 集中的一处才守得住,散在组件里迟早有人漏一个。 */ import { useCallback, useEffect, useMemo, useState } from "react"; import type { SchemaField } from "../schema/fieldTypes"; import { loadFormValues, planSubmit } from "../schema/values"; import { actionFailure, resolveOptionsAction } from "./actions"; import { runPluginAction } from "./panelsApi"; import { decodeOptions, type PanelFieldDto, type PanelOptionDto } from "./types"; export interface FormFlash { tone: "ok" | "err"; text: string; code?: string; } export interface FormValuesState { /** 控件里的当前值(一律字符串,与渠道配置表单同口径)。 */ values: Record; /** 打开表单时的值:判断「用户把盘上已有值清空了」必须知道它原本是什么。 */ initial: Record; /** 盘上已有值的键(含只有掩码可看的秘密)。 */ configured: Set; /** 显式勾选清除的键(掩码秘密唯一能清除的方式)。 */ cleared: Set; /** 本次提交被拦下时点名的必填字段。 */ missing: Set; setValue: (key: string, next: string) => void; toggleClear: (key: string, next: boolean) => void; markMissing: (keys: readonly string[]) => void; /** 提交成功后清掉「显式清除」勾选(否则开关会一直亮着、输入框一直禁用)。 */ resetCleared: () => void; } /** 表单值状态:外部值(快照 / 行内动作带回)变了才重置,其余时候保持用户输入。 * * 用**渲染期派生 state** 而不是 useEffect 做首次装载:初次渲染(含服务端渲染)就该是 * 「打开即显示现状」,而 effect 只在客户端跑,会让首帧先画一个空表单再跳成有值的。 * `fieldsKey` / `externalKey` 用 JSON 串是因为快照每 30s 重取一次、对象身份每次都变。 */ export function useFormValues( fields: readonly SchemaField[], fieldsKey: string, external: Record, ): FormValuesState { const externalKey = JSON.stringify(external); const loadedKey = `${fieldsKey}|${externalKey}`; const loaded = useMemo(() => loadFormValues(fields, external), [loadedKey]); const [values, setValues] = useState>(loaded.values); const [initial, setInitial] = useState>(loaded.values); const [configured, setConfigured] = useState(loaded.configured); const [cleared, setCleared] = useState([]); const [missing, setMissing] = useState([]); const [syncedKey, setSyncedKey] = useState(loadedKey); if (syncedKey !== loadedKey) { setSyncedKey(loadedKey); setValues(loaded.values); setInitial(loaded.values); setConfigured(loaded.configured); setCleared([]); setMissing([]); } const setValue = useCallback((key: string, next: string) => { setValues((prev) => ({ ...prev, [key]: next })); }, []); const toggleClear = useCallback((key: string, next: boolean) => { setCleared((prev) => (next ? [...new Set([...prev, key])] : prev.filter((k) => k !== key))); }, []); const markMissing = useCallback((keys: readonly string[]) => { setMissing([...keys]); }, []); const resetCleared = useCallback(() => setCleared([]), []); return { values, initial, configured: useMemo(() => new Set(configured), [configured]), cleared: useMemo(() => new Set(cleared), [cleared]), missing: useMemo(() => new Set(missing), [missing]), setValue, toggleClear, markMissing, resetCleared, }; } export interface FieldOptionsState { options: Record; /** `optionsFrom` → 失败原因(动作名 + 错误码)。取不到不是致命错误,但**要说出来**。 */ optionErrors: Record; } /** 下拉候选:按 `optionsFrom` 逐条取一次(取不到就降级成文本框,由调用方在说明里写明)。 * * 两条口径: * - **形状兼容**由 `types.decodeOptions` 兜住:候选项可以是**裸字符串**(老声明 / 别的 * 插件还在用的老形状)或 `{value,label}` 对象,两种都归一成 `{value,label}`。控件显示 * `label`、**只有 `value` 会进表单值** —— label 一旦落盘就是把配置文件写坏。 * - **面板级只调一次**(`PluginPanelView`):表单拿它渲染下拉,表格拿它把行里的裸值翻成 * 中文显示名(`tableState.optionLabelIndex` 查表)。分两处各拉一次就是同一份候选发两次 * 请求,且表格那份会因「本区块没有字段」而拉不到来源。 */ export function useFieldOptions(pluginId: string, declared: readonly PanelFieldDto[]): FieldOptionsState { const [options, setOptions] = useState>({}); const [optionErrors, setOptionErrors] = useState>({}); const fromKeys = Array.from(new Set(declared.map((f) => f.optionsFrom ?? "").filter((k) => k !== ""))).join("|"); useEffect(() => { let cancelled = false; const keys = fromKeys === "" ? [] : fromKeys.split("|"); for (const key of keys) { const action = resolveOptionsAction(key); if (action === "") continue; runPluginAction(pluginId, action, {}) .then((raw) => { if (cancelled) return; setOptions((prev) => ({ ...prev, [key]: decodeOptions(raw) })); setOptionErrors((prev) => ({ ...prev, [key]: "" })); }) .catch((err: unknown) => { if (cancelled) return; setOptionErrors((prev) => ({ ...prev, [key]: `${action}:${actionFailure(err).code}` })); }); } return () => { cancelled = true; }; }, [pluginId, fromKeys]); return { options, optionErrors }; } export interface FormSubmitState { submit: () => void; busy: boolean; flash: FormFlash | null; } export interface FormSubmitArgs { pluginId: string; action: string; fields: readonly SchemaField[]; state: FormValuesState; onReload: () => void; log: (what: string, result: "ok" | "error") => void; } /** 提交:本地先跑必填与类型校验(拦下「填了却起不来」),失败**如实**报错码与文案。 */ export function useFormSubmit({ pluginId, action, fields, state, onReload, log }: FormSubmitArgs): FormSubmitState { const [busy, setBusy] = useState(false); const [flash, setFlash] = useState(null); const { values, initial, cleared, configured, markMissing, resetCleared } = state; const submit = useCallback(() => { if (action === "") return; const plan = planSubmit(fields, values, initial, [...cleared], [...configured]); if (!plan.ok) { markMissing(plan.kind === "missing" ? plan.missing.map((f) => f.key) : []); setFlash({ tone: "err", text: plan.message, code: plan.kind === "missing" ? "missing_required" : "invalid_value", }); return; } markMissing([]); setBusy(true); setFlash(null); runPluginAction(pluginId, action, { values: plan.patch }) .then(() => { log(`plugin.${pluginId}/${action}`, "ok"); setFlash({ tone: "ok", text: "已提交,正在刷新页面数据…" }); resetCleared(); onReload(); }) .catch((err: unknown) => { const failure = actionFailure(err); log(`plugin.${pluginId}/${action}`, "error"); setFlash({ tone: "err", text: failure.message, code: failure.code }); }) .finally(() => setBusy(false)); }, [action, pluginId, fields, values, initial, cleared, configured, markMissing, resetCleared, onReload, log]); return { submit, busy, flash }; }