参照 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:
+339
-14
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 表单提交体 */
|
/** 表单提交体 */
|
||||||
|
|||||||
@@ -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: "./",
|
||||||
|
|||||||
Reference in New Issue
Block a user