diff --git a/src/components/FormCard.vue b/src/components/FormCard.vue index 8e407ba..55c85b2 100644 --- a/src/components/FormCard.vue +++ b/src/components/FormCard.vue @@ -60,9 +60,223 @@ const summary = ref<{ label: string; text: string }[]>([]); * 自动填入并隐藏 —— 模块页就是 {@code v-if="myClasses.length > 1"}`。 * 只在真的取到唯一选项时才隐藏;选项没取到(302/超时)时照常显示,让用户能自己填。 */ -const renderFields = computed(() => - (props.form?.fields || []).filter(f => !f.mirrorOf && !hiddenSingle[f.name]) -); +/* ==================== 栏目动态字段(业务模块按所选栏目下发) ==================== */ +/** + * 「栏目动态字段」——由栏目配置决定、后端构建期**枚举不出来**的一组字段。 + * + *

典型场景:教师专业发展的每个栏目(教学设计 / 课题研究…)可以挂管理员自定义字段, + * 模块页在「发布」表单里把它们渲染在正文之前,提交时打包成 `contents` + * (`JSON.stringify([{fieldId, value}])`)。要真正对齐就必须在**选好栏目之后** + * 把这组定义取回来 —— 与下拉字典同源:模块接口只认浏览器 CAS 会话, + * 所以后端只下发「去哪儿取」,这里带用户登录态取回后就地生成控件。 + * + *

字段名统一加 {@link EXTRA_PREFIX} 前缀:既不与工具自己的参数撞名, + * 也让提交前能把它们**摘出来**单独打包(它们不能混进 args 直接发给后端)。 + */ +const EXTRA_PREFIX = "__x_"; + +/** 当前栏目下的动态字段(本地生成,不在后端下发的字段列表里) */ +const extraFields = ref([]); +/** idle=未选栏目 / loading / ok / fail(取不到只是少填自定义字段,不阻塞发布) */ +const extraState = ref<"idle" | "loading" | "ok" | "fail">("idle"); +/** 级联字段的区划树缓存(按层级:2 省市 / 3 省市区),同模块页 areaTreeCache */ +const areaTree = reactive>({}); + +function isExtraField(f?: FormField) { + return !!f && f.name.startsWith(EXTRA_PREFIX); +} + +/** 栏目字段 id(打包 contents 时要用回原 id) */ +function extraFieldId(name: string) { + return Number(name.slice(EXTRA_PREFIX.length)); +} + +/** + * 渲染哪几个字段。 + * + *

栏目动态字段要插在**正文之前**(模块页的顺序是「标题 / 年级段学科 / 是否公开 → + * 栏目动态字段 → 正文 → 附件」),所以按后端给的锚点({@code extraFields.before})插入, + * 锚点不存在时退化为追加到末尾。 + */ +const renderFields = computed(() => { + const base = (props.form?.fields || []).filter(f => !f.mirrorOf && !hiddenSingle[f.name]); + const ext = extraFields.value.filter(f => !hiddenSingle[f.name]); + if (!ext.length) return base; + const anchor = props.form?.extraFields?.before; + const idx = anchor ? base.findIndex(f => f.name === anchor) : -1; + if (idx < 0) return [...base, ...ext]; + return [...base.slice(0, idx), ...ext, ...base.slice(idx)]; +}); + +/** 栏目字段的选项形如 [{fleid, Name}](模块侧自有命名),转成通用 {value,label} */ +function extraOptions(raw: any): { value: string; label: string }[] { + let arr: any[] = []; + try { + arr = raw?.extText ? JSON.parse(raw.extText) : []; + } catch { + arr = []; + } + if (!Array.isArray(arr)) return []; + return arr + .filter(o => o && o.fleid !== undefined && o.fleid !== null) + .map(o => ({ value: String(o.fleid), label: o.Name ?? String(o.fleid) })); +} + +/** 级联字段的展开层级(2=省市 / 3=省市区,容错默认 3) */ +function extraAreaLevel(raw: any): 2 | 3 { + try { + const cfg = raw?.extText ? JSON.parse(raw.extText) : {}; + return Number(cfg?.level) === 2 ? 2 : 3; + } catch { + return 3; + } +} + +/** 栏目字段定义 → 表单控件(与模块页 RecordEdit.vue 的 fieldType 分支一一对应) */ +function mapExtraField(raw: any): FormField | null { + const id = Number(raw?.id); + if (!id) return null; + const base: Partial = { + name: `${EXTRA_PREFIX}${id}`, + label: raw.fieldName || `字段${id}`, + required: !!raw.ifRequired, + type: "string", + }; + switch (String(raw.fieldType || "text")) { + case "text": { + const rows = Number(raw.rowNum || 1); + return { + ...base, + widget: rows > 1 ? "textarea" : "text", + rows: rows > 1 ? rows : undefined, + maxLength: Number(raw.maxLength) || undefined, + placeholder: raw.defaultValue || "", + } as FormField; + } + case "number": + return { ...base, widget: "number", type: "number" } as FormField; + case "date": + return { ...base, widget: "date" } as FormField; + case "radio": + return { ...base, widget: "radio", options: extraOptions(raw) } as FormField; + case "checkbox": + // 模块页是 Checkbox 组,值本身是数组 —— 用多选下拉呈现同一语义,提交时仍是数组 + return { ...base, widget: "multiselect", options: extraOptions(raw) } as FormField; + case "select": + return { ...base, widget: "select", options: extraOptions(raw) } as FormField; + case "province_city": + return { ...base, widget: "cascader", areaLevel: extraAreaLevel(raw) } as FormField; + default: + // 模块端新加了未知类型:退成文本框,至少不丢数据 + return { ...base, widget: "text" } as FormField; + } +} + +/** + * 把地址里的 {@code {{占位符}}} 换成对应表单字段的当前值。 + *

占位符名与字段名**并不相同**(模块侧的栏目字段接口查询参数叫 SerID, + * 表单字段叫 moduleId),所以按后端给的 dependsOn 映射替换,而不是同名替换。 + */ +function fillExtraPlaceholders(tpl: string): string { + let out = String(tpl || ""); + const map = props.form?.extraFields?.dependsOn || {}; + Object.entries(map).forEach(([ph, field]) => { + out = out.split(`{{${ph}}}`).join(encodeURIComponent(String(values[field] ?? ""))); + }); + return out; +} + +/** 依赖字段(如「所属栏目」)是否都已填好 —— 没选栏目就不该去拉它的字段定义 */ +function extraDepsReady(): boolean { + const deps = Object.values(props.form?.extraFields?.dependsOn || {}); + return deps.every(d => { + const v = values[d]; + return v !== undefined && v !== null && String(v).trim() !== ""; + }); +} + +/** 换栏目时把上一栏目的字段与取值一起清掉(否则会带着旧栏目的值提交) */ +function clearExtra() { + extraFields.value.forEach(f => { + delete values[f.name]; + delete errors[f.name]; + }); + extraFields.value = []; +} + +/** 取回「当前栏目」的动态字段定义并生成控件 */ +async function loadExtraFields(force = false) { + const spec = props.form?.extraFields; + if (!spec) { + extraState.value = "idle"; + return; + } + if (!extraDepsReady()) { + clearExtra(); + extraState.value = "idle"; + return; + } + clearExtra(); + extraState.value = "loading"; + const call = { ...spec.call, pathAndQuery: fillExtraPlaceholders(spec.call.pathAndQuery) }; + const res = await callModuleApi(call, undefined, { forceWarm: force }); + let payload: any = null; + try { + payload = res.error ? null : JSON.parse(res.body || ""); + } catch { + payload = null; + } + if (payload && typeof payload === "object" && "code" in payload) { + payload = Number(payload.code) === 0 ? payload.data : null; + } + const raw = payload ? payload[spec.fieldsKey || "fields"] : null; + if (!Array.isArray(raw)) { + extraState.value = "fail"; + return; + } + const mapped = raw.map(mapExtraField).filter((f): f is FormField => !!f); + mapped.forEach(f => { + values[f.name] = f.widget === "multiselect" ? [] : ""; + }); + extraFields.value = mapped; + extraState.value = "ok"; + // 级联字段:按层级懒加载区划树(多个同级字段共用一份) + const levels = Array.from(new Set(mapped.filter(f => f.widget === "cascader").map(f => f.areaLevel || 3))); + for (const lv of levels) await ensureAreaTree(lv); +} + +/** 按层级取一次区划树(模块页 areaTreeCache 的同款做法,一次请求多字段共用) */ +async function ensureAreaTree(level: number) { + const areaCall = props.form?.extraFields?.areaCall; + if (!areaCall || (areaTree[level] && areaTree[level].length)) return; + const call = { + ...areaCall, + pathAndQuery: String(areaCall.pathAndQuery || "").split("{{level}}").join(String(level)), + }; + const res = await callModuleApi(call); + let payload: any = null; + try { + payload = res.error ? null : JSON.parse(res.body || ""); + } catch { + payload = null; + } + if (payload && typeof payload === "object" && "code" in payload) { + payload = Number(payload.code) === 0 ? payload.data : null; + } + if (Array.isArray(payload) && payload.length) areaTree[level] = payload; +} + +/** 级联选择:v-model 存编码数组,与模块页 contents 的 value:string[] 契约一致 */ +function onExtraAreaChange(f: FormField, v: any) { + values[f.name] = Array.isArray(v) ? v : v ? [v] : []; +} + +/** 用户点「重新加载」:强制再走一遍(含模块会话预热) */ +async function reloadExtraFields() { + if (props.disabled || props.submitted) return; + await loadExtraFields(true); + if (extraState.value === "fail") Message.warning("该栏目的自定义字段还是没取到,可稍后重试,或先不填这几项"); +} /* ==================== 附件上传(widget=file) ==================== */ /** 已上传附件的文件名(按字段名),仅用于界面展示 */ @@ -444,6 +658,10 @@ function initValues() { Object.keys(switchOn).forEach(k => delete switchOn[k]); Object.keys(dailyRows).forEach(k => delete dailyRows[k]); Object.keys(hiddenSingle).forEach(k => delete hiddenSingle[k]); + // 栏目动态字段:换表单就整体丢弃(取值、区划树缓存都不跨表单复用) + extraFields.value = []; + extraState.value = "idle"; + Object.keys(areaTree).forEach(k => delete areaTree[Number(k)]); resetOptionState(); // 「全字段表单」把已识别的参数直接预填进控件(不再只做只读回显) const preset = props.form?.preset || {}; @@ -479,6 +697,8 @@ function initValues() { syncOptions(); // 预填里可能已经勾了「连续请假」(如模型已识别)→ 立刻展开逐天时段 for (const f of props.form?.fields || []) if (f.dailyExpand) syncDailyRows(f); + // 栏目动态字段:只有「所属栏目」已经有值(模型识别出来了)才立刻去取 + loadExtraFields(); } initValues(); @@ -493,6 +713,18 @@ watch( () => syncOptions() ); +/** + * 依赖字段(「所属栏目」)一变就重新取栏目动态字段。 + * 签名只由依赖字段组成 —— 用户在其它字段上打字不会触发模块请求。 + */ +watch( + () => + Object.values(props.form?.extraFields?.dependsOn || {}) + .map(d => `${d}=${values[d] ?? ""}`) + .join("&"), + () => loadExtraFields() +); + /** * 「连续请假」的逐天时段:基准起止时间 / 天数 / 勾选态任一变化就重建。 *

签名只由这三个基准组成 —— 用户逐天微调过的行不会被自己的编辑动作覆盖。 @@ -603,7 +835,8 @@ function onSubmit() { if (props.submitted || props.disabled) return; let ok = true; - for (const f of props.form?.fields || []) { + // 必填校验要连栏目动态字段一起看(它们是模块侧标了 ifRequired 的字段) + for (const f of [...(props.form?.fields || []), ...extraFields.value]) { const v = values[f.name]; const empty = v === undefined || v === null || String(v).trim() === ""; if (f.required && empty) { @@ -703,6 +936,40 @@ function onSubmit() { Message.warning("发布方式选了「提交审批」,请先在「审批流程」里勾选并选择审批人"); return; } + // 栏目动态字段:单独打包成 contents(不是普通参数,绝不能混进 args 直接发) + const extraSpec = props.form?.extraFields; + if (extraSpec?.param) { + const packed: { fieldId: number; value: any }[] = []; + for (const f of extraFields.value) { + if (hiddenSingle[f.name]) continue; + const v = values[f.name]; + if (Array.isArray(v)) { + // 模块页的 checkbox 恒带数组(未勾选也是 []),这里保持一致 + packed.push({ fieldId: extraFieldId(f.name), value: v }); + } else if (v === undefined || v === null || String(v).trim() === "") { + // 文本/日期/单选没填就不带这一项(与模块页「只提交碰过的字段」同口径) + continue; + } else { + packed.push({ fieldId: extraFieldId(f.name), value: normalize(f, v) }); + } + } + args[extraSpec.param] = JSON.stringify(packed); + if (packed.length) { + rows.push({ + label: extraSpec.title || "栏目动态字段", + text: packed + .map(p => { + const f = extraFields.value.find(x => extraFieldId(x.name) === p.fieldId); + const opt = f ? (f.options || []).find(o => String(o.value) === String(p.value)) : undefined; + const shown = Array.isArray(p.value) + ? p.value.map(t => (f?.options || []).find(o => String(o.value) === String(t))?.label || t).join("、") || "(空)" + : opt?.label || String(p.value); + return `${f?.label || p.fieldId}:${shown}`; + }) + .join(";"), + }); + } + } summary.value = rows; emit("submit", { tool: props.form.tool, args }); } @@ -745,15 +1012,16 @@ function reset() {

-
- + + +

+ 这个栏目的自定义字段暂时没取到(模块接口未通或需要重新登录)。 + 重新加载 + 也可以先不填这几项直接发布,或到系统里补齐。 +

@@ -1081,6 +1370,42 @@ function reset() { padding-bottom: 4px; } +/* ------------------------------ 栏目动态字段(业务模块按栏目下发) ------------------------------ */ +/* 分区标题要横跨整行(网格是 auto-fill 多列,否则会挤在某一格里) */ +.fields-divider { + grid-column: 1 / -1; + display: flex; + align-items: baseline; + gap: 8px; + margin-top: 2px; + padding-top: 10px; + border-top: 1px dashed var(--agent-border); +} + +.fields-divider__text { + font-size: 12.5px; + font-weight: 600; + color: var(--agent-text); +} + +.fields-divider__hint { + font-size: 11px; + color: var(--agent-text-mute); +} + +.field__warn--extra { + grid-column: 1 / -1; +} + +.field__warn-sub { + margin-left: 6px; + color: var(--agent-text-mute); +} + +.field__cascader { + width: 100%; +} + .field__label { display: block; margin-bottom: 6px; diff --git a/src/types/agent.ts b/src/types/agent.ts index fb95139..2acef21 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -50,7 +50,9 @@ export interface FormField { /** 单选按钮组(平铺,对齐模块页的 RadioGroup,如「请假类型」) */ | "radio" /** 勾选框(对齐模块页的 Checkbox,如「连续请假」) */ - | "switch"; + | "switch" + /** 级联选择(栏目动态字段的 province_city:省市区,值存编码数组) */ + | "cascader"; type?: string; required: boolean; /** 字段说明(含义与格式) */ @@ -109,6 +111,8 @@ export interface FormField { rows?: number; /** 文本最大长度(显示字数统计) */ maxLength?: number; + /** widget=cascader:区划树展开层级(2 省市 / 3 省市区),来自栏目字段配置 */ + areaLevel?: number; /** 下拉选项 */ options?: { value: string; label: string }[]; /** @@ -161,6 +165,41 @@ export interface FormRequest { /** 缺失字段的中文名 */ missing?: string[]; hint?: string; + /** + * 「栏目动态字段」——由业务模块的**栏目配置**决定、后端构建期无法枚举的一组字段。 + * + *

典型场景:教师专业发展每个栏目(教学设计/课题研究…)可以挂管理员自定义字段, + * 模块页在「发布」表单里把它们渲染在正文之前,提交时打包成 `contents` + * (`JSON.stringify([{fieldId, value}])`)。助手要真正对齐,就必须在选好栏目后 + * 把这组字段取回来渲染 —— 步骤与「下拉字典代取」一致。 + */ + extraFields?: ExtraFieldsSpec; +} + +/** + * 「栏目动态字段」的代取描述。 + * + *

与下拉字典同源:模块的字典接口只认浏览器 CAS 会话,服务端取不到, + * 所以后端只下发「去哪儿取」,前端带用户登录态取回后就地渲染。 + */ +export interface ExtraFieldsSpec { + /** 取字段定义的调用;地址里含 `{{占位符}}`,依赖有值后替换再发 */ + call: BrowserToolCall; + /** + * 占位符 → 表单字段名(如 `SerID → moduleId`)。 + * 模块侧的查询参数习惯用老式命名(`SerID`),与表单字段名(`moduleId`)并不一致。 + */ + dependsOn?: Record; + /** 渲染结果打包后写进哪个提交参数(专业发展是 contents) */ + param: string; + /** 区块标题(默认「栏目动态字段」) */ + title?: string; + /** 渲染在哪个字段**之前**(对齐模块页:栏目字段在正文与附件之上) */ + before?: string; + /** 响应里字段数组所在的键(默认 fields,即 data.fields) */ + fieldsKey?: string; + /** 「省市区」级联字段的区划树调用(地址里含 `{{level}}`,按字段配置取 2 或 3 级) */ + areaCall?: BrowserToolCall; } /** 表单提交体 */ diff --git a/vite.config.ts b/vite.config.ts index 6dc6bad..d611e29 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -101,6 +101,9 @@ export default defineConfig((): UserConfig => { "/dormnotice": { target: "http://localhost:8093", changeOrigin: true }, "/familyactivity": { target: "http://localhost:8093", changeOrigin: true }, "/coursehomework": { target: "http://localhost:8093", changeOrigin: true }, + // 教师专业发展:前缀与模块 key 同名(profession),但按同一条铁律显式列出, + // 免得以后改了 key 又出现「本地测不出、上线 404」。 + "/profession": { target: "http://localhost:8093", changeOrigin: true }, }, }, base: "./",