feat(profession): 发布专业发展记录表单对齐模块页,支持栏目动态字段
build / build (push) Successful in 1m53s

参照 ProfessionView/src/views/profession/RecordEdit.vue(权威契约)逐字段重写:

- 正文字段名由 content 改为 remark;按模块页补齐「年级段 / 学科 / 是否公开 / 栏目动态字段 / 添加附件」
- 新增「栏目动态字段」机制:这组字段由**栏目的后台配置**决定,后端构建期无法枚举 ⇒
  后端只下发「去哪儿取」(含 {{占位符}} 依赖映射),前端带用户登录态取回后就地渲染;
  提交时按模块页口径打包成 contents = JSON.stringify([{fieldId,value}])
- 支持七种栏目字段类型:text(多行转 textarea) / number / date / radio / checkbox / select / province_city(级联)
- 分区渲染在「正文内容」之前,与模块页一致;取不到字段定义时保留下拉并给「重新加载」
- 单行文本控件补 maxlength 与 placeholder(原先只有 textarea 认 placeholder)
- vite dev 补 /profession 代理,让「路径拼错」在本地就暴露
This commit is contained in:
lijing
2026-10-13 18:11:03 +08:00
parent e00f0db729
commit 4e6725aa1b
3 changed files with 382 additions and 15 deletions
+339 -14
View File
@@ -60,9 +60,223 @@ const summary = ref<{ label: string; text: string }[]>([]);
* 自动填入并隐藏 —— 模块页就是 {@code v-if="myClasses.length > 1"}`。 * 自动填入并隐藏 —— 模块页就是 {@code v-if="myClasses.length > 1"}`。
* 只在真的取到唯一选项时才隐藏;选项没取到(302/超时)时照常显示,让用户能自己填。 * 只在真的取到唯一选项时才隐藏;选项没取到(302/超时)时照常显示,让用户能自己填。
*/ */
const renderFields = computed(() => /* ==================== 栏目动态字段(业务模块按所选栏目下发) ==================== */
(props.form?.fields || []).filter(f => !f.mirrorOf && !hiddenSingle[f.name]) /**
); * 「栏目动态字段」——由栏目配置决定、后端构建期**枚举不出来**的一组字段。
*
* <p>典型场景:教师专业发展的每个栏目(教学设计 / 课题研究…)可以挂管理员自定义字段,
* 模块页在「发布」表单里把它们渲染在正文之前,提交时打包成 `contents`
* (`JSON.stringify([{fieldId, value}])`)。要真正对齐就必须在**选好栏目之后**
* 把这组定义取回来 —— 与下拉字典同源:模块接口只认浏览器 CAS 会话,
* 所以后端只下发「去哪儿取」,这里带用户登录态取回后就地生成控件。
*
* <p>字段名统一加 {@link EXTRA_PREFIX} 前缀:既不与工具自己的参数撞名,
* 也让提交前能把它们**摘出来**单独打包(它们不能混进 args 直接发给后端)。
*/
const EXTRA_PREFIX = "__x_";
/** 当前栏目下的动态字段(本地生成,不在后端下发的字段列表里) */
const extraFields = ref<FormField[]>([]);
/** idle=未选栏目 / loading / ok / fail(取不到只是少填自定义字段,不阻塞发布) */
const extraState = ref<"idle" | "loading" | "ok" | "fail">("idle");
/** 级联字段的区划树缓存(按层级:2 省市 / 3 省市区),同模块页 areaTreeCache */
const areaTree = reactive<Record<number, any[]>>({});
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));
}
/**
* 渲染哪几个字段。
*
* <p>栏目动态字段要插在**正文之前**(模块页的顺序是「标题 / 年级段学科 / 是否公开 →
* 栏目动态字段 → 正文 → 附件」),所以按后端给的锚点({@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<FormField> = {
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 {{占位符}}} 换成对应表单字段的当前值。
* <p>占位符名与字段名**并不相同**(模块侧的栏目字段接口查询参数叫 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) ==================== */ /* ==================== 附件上传(widget=file) ==================== */
/** 已上传附件的文件名(按字段名),仅用于界面展示 */ /** 已上传附件的文件名(按字段名),仅用于界面展示 */
@@ -444,6 +658,10 @@ function initValues() {
Object.keys(switchOn).forEach(k => delete switchOn[k]); Object.keys(switchOn).forEach(k => delete switchOn[k]);
Object.keys(dailyRows).forEach(k => delete dailyRows[k]); Object.keys(dailyRows).forEach(k => delete dailyRows[k]);
Object.keys(hiddenSingle).forEach(k => delete hiddenSingle[k]); Object.keys(hiddenSingle).forEach(k => delete hiddenSingle[k]);
// 栏目动态字段:换表单就整体丢弃(取值、区划树缓存都不跨表单复用)
extraFields.value = [];
extraState.value = "idle";
Object.keys(areaTree).forEach(k => delete areaTree[Number(k)]);
resetOptionState(); resetOptionState();
// 「全字段表单」把已识别的参数直接预填进控件(不再只做只读回显) // 「全字段表单」把已识别的参数直接预填进控件(不再只做只读回显)
const preset = props.form?.preset || {}; const preset = props.form?.preset || {};
@@ -479,6 +697,8 @@ function initValues() {
syncOptions(); syncOptions();
// 预填里可能已经勾了「连续请假」(如模型已识别)→ 立刻展开逐天时段 // 预填里可能已经勾了「连续请假」(如模型已识别)→ 立刻展开逐天时段
for (const f of props.form?.fields || []) if (f.dailyExpand) syncDailyRows(f); for (const f of props.form?.fields || []) if (f.dailyExpand) syncDailyRows(f);
// 栏目动态字段:只有「所属栏目」已经有值(模型识别出来了)才立刻去取
loadExtraFields();
} }
initValues(); initValues();
@@ -493,6 +713,18 @@ watch(
() => syncOptions() () => syncOptions()
); );
/**
* 依赖字段(「所属栏目」)一变就重新取栏目动态字段。
* 签名只由依赖字段组成 —— 用户在其它字段上打字不会触发模块请求。
*/
watch(
() =>
Object.values(props.form?.extraFields?.dependsOn || {})
.map(d => `${d}=${values[d] ?? ""}`)
.join("&"),
() => loadExtraFields()
);
/** /**
* 「连续请假」的逐天时段:基准起止时间 / 天数 / 勾选态任一变化就重建。 * 「连续请假」的逐天时段:基准起止时间 / 天数 / 勾选态任一变化就重建。
* <p>签名只由这三个基准组成 —— 用户逐天微调过的行不会被自己的编辑动作覆盖。 * <p>签名只由这三个基准组成 —— 用户逐天微调过的行不会被自己的编辑动作覆盖。
@@ -603,7 +835,8 @@ function onSubmit() {
if (props.submitted || props.disabled) return; if (props.submitted || props.disabled) return;
let ok = true; 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 v = values[f.name];
const empty = v === undefined || v === null || String(v).trim() === ""; const empty = v === undefined || v === null || String(v).trim() === "";
if (f.required && empty) { if (f.required && empty) {
@@ -703,6 +936,40 @@ function onSubmit() {
Message.warning("发布方式选了「提交审批」,请先在「审批流程」里勾选并选择审批人"); Message.warning("发布方式选了「提交审批」,请先在「审批流程」里勾选并选择审批人");
return; 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; summary.value = rows;
emit("submit", { tool: props.form.tool, args }); emit("submit", { tool: props.form.tool, args });
} }
@@ -745,15 +1012,16 @@ function reset() {
<!-- 填写区 --> <!-- 填写区 -->
<div v-if="!submitted" class="form-card__fields"> <div v-if="!submitted" class="form-card__fields">
<div <template v-for="(f, fi) in renderFields" :key="f.name">
v-for="f in renderFields" <!-- 栏目动态字段的分区标题:只在「第一张栏目字段」之前出现一次 -->
:key="f.name" <div v-if="isExtraField(f) && !isExtraField(renderFields[fi - 1])" class="fields-divider">
class="field" <span class="fields-divider__text">{{ form.extraFields?.title || "栏目动态字段" }}</span>
:class="{ 'field--wide': isWide(f) }" <span class="fields-divider__hint">由「所属栏目」的配置决定</span>
> </div>
<label v-if="widgetOf(f) !== 'switch'" class="field__label"> <div class="field" :class="{ 'field--wide': isWide(f) }">
<span v-if="f.required" class="field__star">*</span>{{ f.label }} <label v-if="widgetOf(f) !== 'switch'" class="field__label">
</label> <span v-if="f.required" class="field__star">*</span>{{ f.label }}
</label>
<div class="field__control"> <div class="field__control">
<!-- 单选按钮组:对齐模块页 RadioGroup(如「请假类型」,且默认已选中第一个) --> <!-- 单选按钮组:对齐模块页 RadioGroup(如「请假类型」,且默认已选中第一个) -->
@@ -798,6 +1066,19 @@ function reset() {
<Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option> <Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option>
</Select> </Select>
<div v-else-if="widgetOf(f) === 'cascader'" class="field__cascader">
<Cascader
:data="areaTree[f.areaLevel || 3] || []"
:model-value="values[f.name]"
:change-on-select="true"
:placeholder="f.placeholder || '请选择省市区'"
:disabled="disabled"
transfer
style="width: 100%"
@on-change="(v: any) => onExtraAreaChange(f, v)"
/>
</div>
<div v-else-if="widgetOf(f) === 'file'" class="field__file"> <div v-else-if="widgetOf(f) === 'file'" class="field__file">
<label class="file-btn" :class="{ 'file-btn--busy': uploadState[f.name] === 'uploading' }"> <label class="file-btn" :class="{ 'file-btn--busy': uploadState[f.name] === 'uploading' }">
<input <input
@@ -892,7 +1173,8 @@ function reset() {
<Input <Input
v-else v-else
v-model="values[f.name]" v-model="values[f.name]"
:placeholder="f.example || f.description || '请输入'" :maxlength="f.maxLength"
:placeholder="f.placeholder || f.example || f.description || '请输入'"
:disabled="disabled" :disabled="disabled"
/> />
</div> </div>
@@ -949,6 +1231,13 @@ function reset() {
<p v-if="f.description && f.example" class="field__tip">{{ f.description }}</p> <p v-if="f.description && f.example" class="field__tip">{{ f.description }}</p>
<p v-if="errors[f.name]" class="field__error">{{ errors[f.name] }}</p> <p v-if="errors[f.name]" class="field__error">{{ errors[f.name] }}</p>
</div> </div>
</template>
<!-- 栏目自定义字段取不到:停在原地给「重新加载」,不静默少填 -->
<p v-if="extraState === 'fail'" class="field__warn field__warn--extra">
这个栏目的自定义字段暂时没取到(模块接口未通或需要重新登录)。
<a class="field__retry-btn" @click="reloadExtraFields()">重新加载</a>
<span class="field__warn-sub">也可以先不填这几项直接发布,或到系统里补齐。</span>
</p>
</div> </div>
<!-- 提交后:只读摘要 --> <!-- 提交后:只读摘要 -->
@@ -1081,6 +1370,42 @@ function reset() {
padding-bottom: 4px; 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 { .field__label {
display: block; display: block;
margin-bottom: 6px; margin-bottom: 6px;
+40 -1
View File
@@ -50,7 +50,9 @@ export interface FormField {
/** 单选按钮组(平铺,对齐模块页的 RadioGroup,如「请假类型」) */ /** 单选按钮组(平铺,对齐模块页的 RadioGroup,如「请假类型」) */
| "radio" | "radio"
/** 勾选框(对齐模块页的 Checkbox,如「连续请假」) */ /** 勾选框(对齐模块页的 Checkbox,如「连续请假」) */
| "switch"; | "switch"
/** 级联选择(栏目动态字段的 province_city:省市区,值存编码数组) */
| "cascader";
type?: string; type?: string;
required: boolean; required: boolean;
/** 字段说明(含义与格式) */ /** 字段说明(含义与格式) */
@@ -109,6 +111,8 @@ export interface FormField {
rows?: number; rows?: number;
/** 文本最大长度(显示字数统计) */ /** 文本最大长度(显示字数统计) */
maxLength?: number; maxLength?: number;
/** widget=cascader:区划树展开层级(2 省市 / 3 省市区),来自栏目字段配置 */
areaLevel?: number;
/** 下拉选项 */ /** 下拉选项 */
options?: { value: string; label: string }[]; options?: { value: string; label: string }[];
/** /**
@@ -161,6 +165,41 @@ export interface FormRequest {
/** 缺失字段的中文名 */ /** 缺失字段的中文名 */
missing?: string[]; missing?: string[];
hint?: string; hint?: string;
/**
* 「栏目动态字段」——由业务模块的**栏目配置**决定、后端构建期无法枚举的一组字段。
*
* <p>典型场景:教师专业发展每个栏目(教学设计/课题研究…)可以挂管理员自定义字段,
* 模块页在「发布」表单里把它们渲染在正文之前,提交时打包成 `contents`
* (`JSON.stringify([{fieldId, value}])`)。助手要真正对齐,就必须在选好栏目后
* 把这组字段取回来渲染 —— 步骤与「下拉字典代取」一致。
*/
extraFields?: ExtraFieldsSpec;
}
/**
* 「栏目动态字段」的代取描述。
*
* <p>与下拉字典同源:模块的字典接口只认浏览器 CAS 会话,服务端取不到,
* 所以后端只下发「去哪儿取」,前端带用户登录态取回后就地渲染。
*/
export interface ExtraFieldsSpec {
/** 取字段定义的调用;地址里含 `{{占位符}}`,依赖有值后替换再发 */
call: BrowserToolCall;
/**
* 占位符 → 表单字段名(如 `SerID → moduleId`)。
* 模块侧的查询参数习惯用老式命名(`SerID`),与表单字段名(`moduleId`)并不一致。
*/
dependsOn?: Record<string, string>;
/** 渲染结果打包后写进哪个提交参数(专业发展是 contents) */
param: string;
/** 区块标题(默认「栏目动态字段」) */
title?: string;
/** 渲染在哪个字段**之前**(对齐模块页:栏目字段在正文与附件之上) */
before?: string;
/** 响应里字段数组所在的键(默认 fields,即 data.fields) */
fieldsKey?: string;
/** 「省市区」级联字段的区划树调用(地址里含 `{{level}}`,按字段配置取 2 或 3 级) */
areaCall?: BrowserToolCall;
} }
/** 表单提交体 */ /** 表单提交体 */
+3
View File
@@ -101,6 +101,9 @@ export default defineConfig((): UserConfig => {
"/dormnotice": { target: "http://localhost:8093", changeOrigin: true }, "/dormnotice": { target: "http://localhost:8093", changeOrigin: true },
"/familyactivity": { target: "http://localhost:8093", changeOrigin: true }, "/familyactivity": { target: "http://localhost:8093", changeOrigin: true },
"/coursehomework": { 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: "./", base: "./",