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 栏目动态字段要插在**正文之前**(模块页的顺序是「标题 / 年级段学科 / 是否公开 →
+ * 栏目动态字段 → 正文 → 附件」),所以按后端给的锚点({@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 占位符名与字段名**并不相同**(模块侧的栏目字段接口查询参数叫 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() {
{{ f.description }} {{ errors[f.name] }}
+ 这个栏目的自定义字段暂时没取到(模块接口未通或需要重新登录)。
+ 重新加载
+ 也可以先不填这几项直接发布,或到系统里补齐。
+ 典型场景:教师专业发展每个栏目(教学设计/课题研究…)可以挂管理员自定义字段,
+ * 模块页在「发布」表单里把它们渲染在正文之前,提交时打包成 `contents`
+ * (`JSON.stringify([{fieldId, value}])`)。助手要真正对齐,就必须在选好栏目后
+ * 把这组字段取回来渲染 —— 步骤与「下拉字典代取」一致。
+ */
+ extraFields?: ExtraFieldsSpec;
+}
+
+/**
+ * 「栏目动态字段」的代取描述。
+ *
+ * 与下拉字典同源:模块的字典接口只认浏览器 CAS 会话,服务端取不到,
+ * 所以后端只下发「去哪儿取」,前端带用户登录态取回后就地渲染。
+ */
+export interface ExtraFieldsSpec {
+ /** 取字段定义的调用;地址里含 `{{占位符}}`,依赖有值后替换再发 */
+ call: BrowserToolCall;
+ /**
+ * 占位符 → 表单字段名(如 `SerID → moduleId`)。
+ * 模块侧的查询参数习惯用老式命名(`SerID`),与表单字段名(`moduleId`)并不一致。
+ */
+ dependsOn?: Record