diff --git a/src/api/module/agent.ts b/src/api/module/agent.ts
index 579faac..8fdabb7 100644
--- a/src/api/module/agent.ts
+++ b/src/api/module/agent.ts
@@ -22,18 +22,31 @@ import type {
const AGENT = "/api/agent";
const BASE = "/api/base";
+/**
+ * 对话类接口的单请求超时(毫秒)。
+ *
+ *
为什么远大于普通接口:一次提问在服务端是「多轮 大模型 ⇄ 工具」循环
+ * ({@code agent.loop.max-rounds=6},单次大模型调用上限 60s),
+ * 再叠加本前端「浏览器代执行 → 回传续跑」的往返。给足 4 分钟,
+ * 既覆盖真实最坏情况,又保证**一定**会有终态 —— 不会出现界面永久卡住。
+ */
+const CHAT_TIMEOUT_MS = 240_000;
+
/** GET 请求 */
function get(url: string, params?: Record): Method {
return new Method("GET", alovaInstance, url, { params });
}
/** POST JSON body(与后端 @RequestBody 对齐) */
-function postJSON(url: string, data?: Record): Method {
+function postJSON(url: string, data?: Record, timeout?: number): Method {
return new Method(
"POST",
alovaInstance,
url,
- { headers: { "Content-Type": "application/json;charset=UTF-8" } },
+ {
+ headers: { "Content-Type": "application/json;charset=UTF-8" },
+ ...(timeout ? { timeout } : {}),
+ },
data ? JSON.stringify(data) : undefined
);
}
@@ -62,7 +75,7 @@ export class BaseApi {
export class AgentApi {
/** 一问一答:返回智能体回复 + 本轮执行过的工具轨迹(可能带补全表单) */
chat(data: AgentChatRequest) {
- return postJSON(`${AGENT}/chat`, data);
+ return postJSON(`${AGENT}/chat`, data, CHAT_TIMEOUT_MS);
}
/**
@@ -70,7 +83,7 @@ export class AgentApi {
* 服务端不再让模型二次决策,保证「填了什么就提交什么」。
*/
submitForm(sessionId: string | undefined, data: FormSubmit) {
- return postJSON(`${AGENT}/chat`, { sessionId, formSubmit: data });
+ return postJSON(`${AGENT}/chat`, { sessionId, formSubmit: data }, CHAT_TIMEOUT_MS);
}
/**
@@ -78,7 +91,7 @@ export class AgentApi {
* 把每个调用的原始响应带回,服务端解析并喂回模型生成最终回答。
*/
submitToolResults(sessionId: string | undefined, toolResult: ToolResultItem[]) {
- return postJSON(`${AGENT}/chat`, { sessionId, toolResult });
+ return postJSON(`${AGENT}/chat`, { sessionId, toolResult }, CHAT_TIMEOUT_MS);
}
/** 能力目录:按模块分组的可用工具,用于首屏快捷指令 */
diff --git a/src/components/FormCard.vue b/src/components/FormCard.vue
index af56a00..8e407ba 100644
--- a/src/components/FormCard.vue
+++ b/src/components/FormCard.vue
@@ -55,8 +55,14 @@ const summary = ref<{ label: string; text: string }[]>([]);
/**
* 「镜像字段」(如 deptName 镜像 deptId)不渲染输入控件:用户选好部门后,
* 提交时自动带上所选下选项的名称 —— 与模块页「字典反查部门名」的做法一致。
+ *
+ * 另:声明了 `hideWhenSingleOption` 的字段(模块页「班级」)在**选项恰好只有一个**时
+ * 自动填入并隐藏 —— 模块页就是 {@code v-if="myClasses.length > 1"}`。
+ * 只在真的取到唯一选项时才隐藏;选项没取到(302/超时)时照常显示,让用户能自己填。
*/
-const renderFields = computed(() => (props.form?.fields || []).filter(f => !f.mirrorOf));
+const renderFields = computed(() =>
+ (props.form?.fields || []).filter(f => !f.mirrorOf && !hiddenSingle[f.name])
+);
/* ==================== 附件上传(widget=file) ==================== */
/** 已上传附件的文件名(按字段名),仅用于界面展示 */
@@ -190,6 +196,83 @@ function approverNames(f: FormField, ids: any[]): string {
return ids.map(id => opts.find(o => String(o.value) === String(id))?.label || String(id)).join("、");
}
+/* ==================== 模块页同款呈现:自动选中 / 单选项隐藏 / 快捷时段 / 逐天时段 ==================== */
+
+/** widget=switch 的勾选态(真正提交的是 switchOn / switchOff 映射后的值) */
+const switchOn = reactive>({});
+/** 连续请假逐天时段(用户可逐天微调;提交时作为 continuousLeaveTime 显式发出) */
+const dailyRows = reactive>({});
+/** 选项只有一个、已自动填入且按模块页口径隐藏的字段 */
+const hiddenSingle = reactive>({});
+
+/** 日期加 n 天(不改原对象) */
+function addDays(d: Date, n: number): Date {
+ const c = new Date(d.getTime());
+ c.setDate(c.getDate() + n);
+ return c;
+}
+
+/**
+ * 按「基准起止时间 + 连续天数」生成逐天时段。
+ * 口径与模块页 {@code LeaveTimeSection.regenerate()} 一致:每天一段,时分沿用基准。
+ */
+function regenerateDays(f: FormField): { start: Date; end: Date }[] {
+ const spec = f.dailyExpand;
+ if (!spec) return [];
+ const s = toDate(values[spec.start]);
+ const e = toDate(values[spec.end]);
+ if (!s || !e) return [];
+ let n = Number(values[spec.count]);
+ if (!n || isNaN(n)) n = 1;
+ n = Math.max(1, Math.min(30, n));
+ const out: { start: Date; end: Date }[] = [];
+ for (let i = 0; i < n; i++) out.push({ start: addDays(s, i), end: addDays(e, i) });
+ return out;
+}
+
+/** 基准时间 / 天数 / 勾选态任一变化 → 重建逐天时段(用户手改过的行只在基准变了时才会被覆盖) */
+function syncDailyRows(f: FormField) {
+ if (!f.dailyExpand) return;
+ if (!switchOn[f.name]) {
+ delete dailyRows[f.name];
+ return;
+ }
+ dailyRows[f.name] = regenerateDays(f);
+}
+
+/**
+ * 本字段是否是某个「连续请假」的**天数**字段,且该连续请假处于勾选态 ——
+ * 是则返回逐天时段(逐天编辑器挂在「连续天数」下面,读起来才顺)。
+ */
+function daysFor(f: FormField): { start: Date; end: Date }[] | null {
+ const owner = (props.form?.fields || []).find(x => x.dailyExpand && x.dailyExpand.count === f.name);
+ if (!owner || !switchOn[owner.name]) return null;
+ return dailyRows[owner.name] || [];
+}
+
+/** 勾选框切换:把布尔态映射成真正提交的值,并展开/收起逐天时段 */
+function onSwitchToggle(f: FormField) {
+ const on = !!switchOn[f.name];
+ values[f.name] = normalize(f, on ? f.switchOn ?? "1" : f.switchOff ?? "0");
+ syncDailyRows(f);
+}
+
+/**
+ * 快捷时段(全天 / 上午 / 下午):沿用**当前已选的日期**,只换时分秒 ——
+ * 与模块页 {@code LeaveTimeSection.quickSet()} 完全一致(未选日期就按今天)。
+ */
+function applyQuickPeriod(f: FormField, item: { start: string; end: string }) {
+ const q = f.quickPeriods;
+ if (!q) return;
+ const baseStart = toDate(values[q.start]);
+ const baseEnd = toDate(values[q.end]);
+ const day = fmt(baseStart || baseEnd || new Date(), false);
+ values[q.start] = toDate(`${day} ${item.start}`);
+ values[q.end] = toDate(`${day} ${item.end}`);
+ // 连续请假已展开时,逐天时段跟着基准重建
+ for (const df of props.form?.fields || []) if (df.dailyExpand) syncDailyRows(df);
+}
+
/* ==================== 下拉选项:动态代取 + 联动 ==================== */
/** 动态取回的选项(按字段名);取到后覆盖字段自带的兜底选项 */
@@ -228,28 +311,39 @@ function depSignature(f: FormField) {
}
/**
- * 实际控件类型:动态取选项失败、且没有任何可选项时,退化成可手填的输入框
- * (填编号),而不是留一个点不开的空下拉把用户卡死。
+ * 实际控件类型。
+ *
+ * ★ 取不到选项时**绝不退化**成输入框:`班级 / 学生 / 请假类型 / 接收对象` 都是实体选择,
+ * 用户不可能知道「一年级(2)班」的内部编号是 7,退成数字框等于把表单搞坏
+ * (真实症状:用户看到「班级」变成让人填 id 的数字框)。
+ * 取不到就保留下拉形态 + 显示「重新加载」,由用户主动重试。
*/
function widgetOf(f: FormField) {
- const st = optState[f.name];
- // 审批流即使取不到教师名单也不退化 —— 它要渲染的是「步骤编辑器」,退成文本框毫无意义
- if (f.widget === "flow") return "flow";
- if (f.optionCall && st === "fail" && !baseOptions(f).length) {
- // 多选(含推送范围)退化成文本(用户可手填逗号串编号),单选退化成数字/文本
- if (f.widget === "multiselect" || f.widget === "scopes") return "text";
- return f.type === "integer" || f.type === "number" ? "number" : "text";
- }
return f.widget;
}
+/**
+ * 该字段是否独占整行。
+ *
+ * 表单是自适应多列网格,但下面这些控件「挤在半列里」会走形:
+ * 单选按钮组会被压到换行(模块页里它是独占一行的)、多行文本只剩两三行高、
+ * 附件与审批流更是必须整行。时间类字段保持并排(「开始 ~ 结束」与模块页一致)。
+ */
+function isWide(f: FormField) {
+ if (["flow", "radio", "switch", "textarea", "file"].includes(f.widget)) return true;
+ // 「连续天数」是「连续请假」按天展开的从属字段,跟着独占一行
+ return (props.form?.fields || []).some(x => x.dailyExpand?.count === f.name);
+}
+
/**
* 用用户自己的登录态去业务模块取本字段的下拉选项。
*
* 为什么不走后端:模块的字典接口只认浏览器 CAS 会话,服务端代取必然 `code=302`。
* 地址里的 `{{字段名}}` 由后端按 dependsOn 生成,这里替换成当前值(必须 URL 编码)。
+ *
+ * @param force 用户点了「重新加载」→ 预热阶段跳过 60s 限流,立刻再建一次模块会话
*/
-async function loadOptions(f: FormField) {
+async function loadOptions(f: FormField, force = false) {
const call = f.optionCall;
if (!call) return;
if (!depsReady(f)) {
@@ -262,23 +356,49 @@ async function loadOptions(f: FormField) {
for (const d of f.dependsOn || []) {
pathAndQuery = pathAndQuery.split(`{{${d}}}`).join(encodeURIComponent(String(values[d])));
}
- const res = await callModuleApi({ ...call, pathAndQuery });
+ const res = await callModuleApi({ ...call, pathAndQuery }, undefined, { forceWarm: force });
const items = res.error ? [] : parseOptionItems(res.body, f.valueKey, f.labelKey);
if (items.length) {
dynOptions[f.name] = items;
optState[f.name] = "ok";
- // 只有一个可选值时直接选上(如教师只教一个班),省掉一次无意义的点击
const cur = values[f.name];
- if ((cur === undefined || cur === null || String(cur).trim() === "") && items.length === 1) {
- values[f.name] = f.type === "integer" || f.type === "number"
- ? Number(items[0].value) : items[0].value;
+ const empty = cur === undefined || cur === null || String(cur).trim() === "";
+ // ★ 值一律按选项本身的类型(字符串)存:Radio/Select 都是 `选中态 === label` 的**严格**比较,
+ // 这里若擅自转成 Number,界面上就会出现「值是 3 但按钮没被选中」。
+ // 真正的类型转换在提交前由 normalize() 统一做(integer/number → Number)。
+ // ① 只有一个可选值时直接选上(如教师只教一个班),省掉一次无意义的点击
+ if (empty && items.length === 1) {
+ values[f.name] = items[0].value;
+ } else if (empty && f.autoPickFirst) {
+ // ② 模块页「请假类型」的默认行为:一进页面就已选中第一个类型。
+ // 注意只认**实时字典**的第一项 —— 若字典没取到(302/超时),宁可让用户自己选,
+ // 也不拿内置兜底的第一项替他决定(学校可能已经把那一项删了)。
+ values[f.name] = items[0].value;
+ }
+ // ③ 模块页「班级」:只有一个班时控件根本不渲染(值已在上面带上)
+ if (f.hideWhenSingleOption && items.length === 1) {
+ hiddenSingle[f.name] = true;
+ } else {
+ delete hiddenSingle[f.name];
}
} else {
dynOptions[f.name] = [];
optState[f.name] = "fail";
+ // 取不到选项就不能藏 —— 否则用户既看不到控件也没值,必填校验直接卡死
+ delete hiddenSingle[f.name];
}
}
+/**
+ * 用户点「重新加载」:强制再走一遍(含模块会话预热),用于上次 302 / 超时后手动补救。
+ * 用户能感知到「点一下就好了」,就不用去系统里绕一圈。
+ */
+async function reloadOptions(f: FormField) {
+ if (props.disabled || props.submitted) return;
+ await loadOptions(f, true);
+ if (optState[f.name] === "fail") Message.warning(`「${f.label}」还是没取到,请稍后再试或直接去系统里操作`);
+}
+
/** 依赖一变就重新拉从属选项;换班后旧的学生选择必须清掉 */
function syncOptions() {
for (const f of props.form?.fields || []) {
@@ -321,6 +441,9 @@ function initValues() {
Object.keys(uploadState).forEach(k => delete uploadState[k]);
Object.keys(flowEnabled).forEach(k => delete flowEnabled[k]);
Object.keys(flowSteps).forEach(k => delete flowSteps[k]);
+ Object.keys(switchOn).forEach(k => delete switchOn[k]);
+ Object.keys(dailyRows).forEach(k => delete dailyRows[k]);
+ Object.keys(hiddenSingle).forEach(k => delete hiddenSingle[k]);
resetOptionState();
// 「全字段表单」把已识别的参数直接预填进控件(不再只做只读回显)
const preset = props.form?.preset || {};
@@ -331,6 +454,13 @@ function initValues() {
} else if (f.widget === "flow") {
flowEnabled[f.name] = false;
flowSteps[f.name] = [{ vote: "any", ids: [] }];
+ } else if (f.widget === "switch") {
+ // 勾选框:布尔态 → 真正提交的值(模块页恒带 continuousLeave:0/1)
+ const onVal = f.switchOn ?? "1";
+ const offVal = f.switchOff ?? "0";
+ const isOn = seed !== undefined && seed !== null && String(seed) !== "" && String(seed) === String(onVal);
+ switchOn[f.name] = isOn;
+ values[f.name] = normalize(f, isOn ? onVal : offVal);
} else if (f.widget === "datetime" || f.widget === "date") {
values[f.name] = toDate(seed) ?? "";
} else if (f.widget === "multiselect" || f.widget === "scopes") {
@@ -339,7 +469,7 @@ function initValues() {
values[f.name] = arr.length ? arr : f.allToken ? [f.allToken] : [];
} else if (f.widget === "file") {
values[f.name] = Array.isArray(seed) ? [...seed] : [];
- } else if (f.widget === "select" || f.widget === "number") {
+ } else if (f.widget === "select" || f.widget === "number" || f.widget === "radio") {
values[f.name] = seed ?? undefined;
} else {
values[f.name] = seed ?? "";
@@ -347,6 +477,8 @@ function initValues() {
}
// 初值就位后立刻拉一次动态选项(含只有一层依赖的情况)
syncOptions();
+ // 预填里可能已经勾了「连续请假」(如模型已识别)→ 立刻展开逐天时段
+ for (const f of props.form?.fields || []) if (f.dailyExpand) syncDailyRows(f);
}
initValues();
@@ -361,6 +493,31 @@ watch(
() => syncOptions()
);
+/**
+ * 「连续请假」的逐天时段:基准起止时间 / 天数 / 勾选态任一变化就重建。
+ *
签名只由这三个基准组成 —— 用户逐天微调过的行不会被自己的编辑动作覆盖。
+ */
+watch(
+ () =>
+ (props.form?.fields || [])
+ .filter(f => f.dailyExpand)
+ .map(f => {
+ const s = f.dailyExpand!;
+ // 用时间戳做指纹:Date 直接进模板串会带上时区名,冗余但也能用;时间戳更稳
+ return [
+ f.name,
+ switchOn[f.name] ? 1 : 0,
+ toDate(values[s.start])?.getTime() || 0,
+ toDate(values[s.end])?.getTime() || 0,
+ values[s.count] ?? "",
+ ].join(":");
+ })
+ .join("|"),
+ () => {
+ for (const f of props.form?.fields || []) if (f.dailyExpand) syncDailyRows(f);
+ }
+);
+
/** 只读回显:已从对话里识别出的参数(优先用后端翻译好的可读文本) */
const presetList = computed(() => {
const f = props.form;
@@ -437,7 +594,7 @@ function optHint(f: FormField): string {
return `请先选择「${deps}」,这里会自动列出可选项`;
}
if (st === "fail") {
- return `「${f.label}」的可选项暂时没取到,可以先直接填对应编号,或稍后重试。`;
+ return `「${f.label}」的可选项没取到(首次访问该业务模块时较常见),点「重新加载」再试一次。`;
}
return "";
}
@@ -490,6 +647,22 @@ function onSubmit() {
}
continue;
}
+ // 连续请假:前端已把逐天时段算好(且用户可逐天微调)→ 显式发出 continuousLeaveTime。
+ // 后端检测到显式数组就不再自行展开(见 ToolExecutor.expandDailyArgs),
+ // 这样「基准时间 + 天数」与模块页 LeaveTimeSection.payload() 的口径完全一致。
+ if (f.dailyExpand && switchOn[f.name]) {
+ const days = dailyRows[f.name] || [];
+ if (days.length) {
+ args["continuousLeaveTime"] = days.map(r => ({
+ startDate: fmt(r.start, true),
+ endDate: fmt(r.end, true),
+ }));
+ rows.push({
+ label: f.label,
+ text: `连续 ${days.length} 天:${fmt(days[0].start, true)} ~ ${fmt(days[0].end, true)}`,
+ });
+ }
+ }
const v = values[f.name];
// 数组类字段即使是空数组也要提交(模块页就是恒带 fileIds:"" / attachments:[]),
// 否则「没传」与「传空」在 wire 上不一致,后端拿到的可能是 null 而不是空集合。
@@ -576,19 +749,39 @@ function reset() {
v-for="f in renderFields"
:key="f.name"
class="field"
- :class="{ 'field--wide': f.widget === 'flow' }"
+ :class="{ 'field--wide': isWide(f) }"
>
-