fix(form): 下拉取不到时不再退化成数字框,并让请假表单与模块页对齐
build / build (push) Successful in 2m58s

用户反馈「班级变成数字框了」的直接原因:选项加载失败(模块会话未建立时
接口回 302)后 widgetOf 把下拉降级成了数字输入框 —— 用户不可能知道
「一年级(2)班」的内部编号是 7,等于把表单搞坏。

- widgetOf 恒返回后端下发的控件形态,取不到选项时保留下拉 + 显示「重新加载」
- 请假表单对齐 HomeLeave 模块页:
  * 请假类型 = 单选按钮组 + 默认选中第一项(radio / autoPickFirst)
  * 请假时间 = 起止 datetime + 快捷时段(全天/上午/下午)+ 连续请假勾选框
    + 连续天数 + 逐天可微调时段(switch / quickPeriods / dailyExpand)
  * 事由 placeholder「请输入请假原因.....」、附件提示「可选,大小不超过 200MB」
  * 单选组 / 勾选框 / 多行文本 / 附件 / 审批流独占整行,避免挤在半列走形
- 会话预热:上限 6s→12s,且失败后总是重试两次(原「到点即弃且不重试」是
  「时不时出不来」的来源之一);callModuleApi 支持 forceWarm 供「重新加载」绕过 60s 限流
- alova 的 timeout 原先被写进 headers(fetch 不认),改为真正的客户端超时
- 处理中的转圈常驻并显示已等待秒数,不再因为气泡已有文案就静默
This commit is contained in:
lijing
2026-10-13 16:15:42 +08:00
parent 97cd25b0e0
commit d1d76c6232
7 changed files with 590 additions and 66 deletions
+18 -5
View File
@@ -22,18 +22,31 @@ import type {
const AGENT = "/api/agent";
const BASE = "/api/base";
/**
* 对话类接口的单请求超时(毫秒)。
*
* <p>为什么远大于普通接口:一次提问在服务端是「多轮 大模型 ⇄ 工具」循环
* ({@code agent.loop.max-rounds=6},单次大模型调用上限 60s),
* 再叠加本前端「浏览器代执行 → 回传续跑」的往返。给足 4 分钟,
* 既覆盖真实最坏情况,又保证**一定**会有终态 —— 不会出现界面永久卡住。
*/
const CHAT_TIMEOUT_MS = 240_000;
/** GET 请求 */
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
return new Method<T>("GET", alovaInstance, url, { params });
}
/** POST JSON body(与后端 @RequestBody 对齐) */
function postJSON<T = any>(url: string, data?: Record<string, any>): Method<T> {
function postJSON<T = any>(url: string, data?: Record<string, any>, timeout?: number): Method<T> {
return new Method<T>(
"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<AgentChatResponse>(`${AGENT}/chat`, data);
return postJSON<AgentChatResponse>(`${AGENT}/chat`, data, CHAT_TIMEOUT_MS);
}
/**
@@ -70,7 +83,7 @@ export class AgentApi {
* 服务端不再让模型二次决策,保证「填了什么就提交什么」。
*/
submitForm(sessionId: string | undefined, data: FormSubmit) {
return postJSON<AgentChatResponse>(`${AGENT}/chat`, { sessionId, formSubmit: data });
return postJSON<AgentChatResponse>(`${AGENT}/chat`, { sessionId, formSubmit: data }, CHAT_TIMEOUT_MS);
}
/**
@@ -78,7 +91,7 @@ export class AgentApi {
* 把每个调用的原始响应带回,服务端解析并喂回模型生成最终回答。
*/
submitToolResults(sessionId: string | undefined, toolResult: ToolResultItem[]) {
return postJSON<AgentChatResponse>(`${AGENT}/chat`, { sessionId, toolResult });
return postJSON<AgentChatResponse>(`${AGENT}/chat`, { sessionId, toolResult }, CHAT_TIMEOUT_MS);
}
/** 能力目录:按模块分组的可用工具,用于首屏快捷指令 */
+371 -28
View File
@@ -55,8 +55,14 @@ const summary = ref<{ label: string; text: string }[]>([]);
/**
* 「镜像字段」(如 deptName 镜像 deptId)不渲染输入控件:用户选好部门后,
* 提交时自动带上所选下选项的名称 —— 与模块页「字典反查部门名」的做法一致。
*
* <p>另:声明了 `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<Record<string, boolean>>({});
/** 连续请假逐天时段(用户可逐天微调;提交时作为 continuousLeaveTime 显式发出) */
const dailyRows = reactive<Record<string, { start: Date; end: Date }[]>>({});
/** 选项只有一个、已自动填入且按模块页口径隐藏的字段 */
const hiddenSingle = reactive<Record<string, boolean>>({});
/** 日期加 n 天(不改原对象) */
function addDays(d: Date, n: number): Date {
const c = new Date(d.getTime());
c.setDate(c.getDate() + n);
return c;
}
/**
* 按「基准起止时间 + 连续天数」生成逐天时段。
* <p>口径与模块页 {@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()
);
/**
* 「连续请假」的逐天时段:基准起止时间 / 天数 / 勾选态任一变化就重建。
* <p>签名只由这三个基准组成 —— 用户逐天微调过的行不会被自己的编辑动作覆盖。
*/
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) }"
>
<label class="field__label">
<label v-if="widgetOf(f) !== 'switch'" class="field__label">
<span v-if="f.required" class="field__star">*</span>{{ f.label }}
</label>
<div class="field__control">
<Select
v-if="widgetOf(f) === 'select'"
<!-- 单选按钮组:对齐模块页 RadioGroup(如「请假类型」,且默认已选中第一个) -->
<RadioGroup
v-if="widgetOf(f) === 'radio'"
v-model="values[f.name]"
:placeholder="!depsReady(f) ? '请先选择上级项' : (f.example || '请选择')"
:disabled="disabled || optState[f.name] === 'loading'"
>
<Radio v-for="o in fieldOptions(f)" :key="o.value" :label="o.value" border>{{ o.label }}</Radio>
</RadioGroup>
<!-- 勾选框:对齐模块页 Checkbox(如「连续请假」,勾上才展开天数与逐天时段) -->
<Checkbox
v-else-if="widgetOf(f) === 'switch'"
v-model="switchOn[f.name]"
:disabled="disabled"
@on-change="onSwitchToggle(f)"
>
{{ f.label }}
</Checkbox>
<Select
v-else-if="widgetOf(f) === 'select'"
v-model="values[f.name]"
:placeholder="!depsReady(f) ? '请先选择上级项' : (f.placeholder || f.example || '请选择')"
:disabled="disabled || !depsReady(f) || optState[f.name] === 'loading'"
:loading="optState[f.name] === 'loading'"
:filterable="(f.dependsOn || []).length > 0"
clearable
>
<Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option>
@@ -621,7 +814,7 @@ function reset() {
<a v-if="!disabled" @click="removeFile(f, i)">移除</a>
</li>
</ul>
<span v-else class="file-tip">可选,支持图片 / 文档 / 压缩包</span>
<span v-else class="file-tip">{{ f.placeholder || "可选,支持图片 / 文档 / 压缩包" }}</span>
</div>
<div v-else-if="widgetOf(f) === 'flow'" class="field__flow">
@@ -689,8 +882,10 @@ function reset() {
v-else-if="widgetOf(f) === 'textarea'"
v-model="values[f.name]"
type="textarea"
:rows="3"
:placeholder="f.example || f.description || '请输入'"
:rows="f.rows || 3"
:maxlength="f.maxLength"
:show-word-limit="!!f.maxLength"
:placeholder="f.placeholder || f.example || f.description || '请输入'"
:disabled="disabled"
/>
@@ -702,7 +897,53 @@ function reset() {
/>
</div>
<p v-if="optHint(f)" class="field__tip">{{ optHint(f) }}</p>
<!-- 快捷时段:对齐模块页 LeaveTimeSection 的「全天 / 上午 / 下午」按钮 -->
<div v-if="f.quickPeriods" class="field__quick">
<span class="field__quick-label">快捷时段</span>
<button
v-for="q in f.quickPeriods.items"
:key="q.label"
type="button"
class="quick-btn"
:disabled="disabled"
@click="applyQuickPeriod(f, q)"
>
{{ q.label }}
</button>
<span class="field__quick-tip">{{ f.quickPeriods.items[0].start.slice(0, 5) }} 起,日期用你上面选的</span>
</div>
<!-- 逐天时段:勾了「连续请假」后逐天列出,可单天微调(对齐模块页) -->
<div v-if="daysFor(f)" class="field__days">
<span class="field__days-title">逐天时段(可逐天微调)</span>
<div v-for="(d, i) in daysFor(f) || []" :key="i" class="day-row">
<span class="day-row__no">第 {{ i + 1 }} 天</span>
<DatePicker
v-model="d.start"
type="datetime"
format="yyyy-MM-dd HH:mm:ss"
size="small"
:disabled="disabled"
style="width: 186px"
/>
<span class="day-row__sep">~</span>
<DatePicker
v-model="d.end"
type="datetime"
format="yyyy-MM-dd HH:mm:ss"
size="small"
:disabled="disabled"
style="width: 186px"
/>
</div>
</div>
<!-- 选项没取到:给出显式「重新加载」,而不是把下拉换成输入框让用户填编号 -->
<p v-if="optState[f.name] === 'fail' && f.optionCall" class="field__retry">
<span class="field__retry-text">{{ optHint(f) }}</span>
<a class="field__retry-btn" @click="reloadOptions(f)">重新加载</a>
</p>
<p v-else-if="optHint(f)" class="field__tip">{{ optHint(f) }}</p>
<p v-if="dictNote(f)" class="field__tip">{{ dictNote(f) }}</p>
<p v-if="dictWarn(f)" class="field__warn">{{ dictWarn(f) }}</p>
<p v-if="f.description && f.example" class="field__tip">{{ f.description }}</p>
@@ -859,6 +1100,26 @@ function reset() {
color: var(--agent-text-mute);
}
/* 选项没取到:提示 + 紧跟其后的「重新加载」链接(不要被推到行尾) */
.field__retry {
margin: 5px 0 0;
font-size: 11px;
line-height: 1.55;
color: var(--agent-text-mute);
}
.field__retry-btn {
margin-left: 6px;
color: var(--agent-primary);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.field__retry-btn:hover {
opacity: 0.8;
}
.field__warn {
margin: 5px 0 0;
font-size: 11px;
@@ -1066,4 +1327,86 @@ function reset() {
cursor: not-allowed;
transform: none;
}
/* ------------------------------ 快捷时段(对齐模块页全天/上午/下午) ------------------------------ */
.field__quick {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.field__quick-label {
font-size: 12px;
color: var(--agent-text-mute);
}
.quick-btn {
height: 26px;
padding: 0 11px;
border: 1px solid var(--agent-border-strong);
border-radius: 8px;
background: var(--agent-surface);
color: var(--agent-text-sub);
font-family: inherit;
font-size: 12px;
cursor: pointer;
transition: all 0.16s ease;
}
.quick-btn:hover:not(:disabled) {
border-color: var(--agent-primary);
color: var(--agent-primary-dark);
background: var(--agent-primary-soft);
}
.quick-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.field__quick-tip {
font-size: 11.5px;
color: var(--agent-text-mute);
}
/* ------------------------------ 逐天时段(连续请假) ------------------------------ */
.field__days {
margin-top: 8px;
padding: 10px 12px;
border: 1px dashed var(--agent-border-strong);
border-radius: 10px;
background: var(--agent-surface-2);
}
.field__days-title {
display: block;
margin-bottom: 8px;
font-size: 12px;
color: var(--agent-text-sub);
}
.day-row {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 6px;
}
.day-row:last-child {
margin-bottom: 0;
}
.day-row__no {
flex: none;
width: 52px;
font-size: 12px;
color: var(--agent-text-mute);
}
.day-row__sep {
flex: none;
color: var(--agent-text-mute);
}
</style>
+31 -8
View File
@@ -8,7 +8,7 @@ import ToolTraceCard from "./ToolTraceCard.vue";
import FormCard from "./FormCard.vue";
import type { ChatItem } from "@/types/agent";
const props = defineProps<{ item: ChatItem; pending?: boolean }>();
const props = defineProps<{ item: ChatItem; pending?: boolean; elapsed?: number }>();
const emit = defineEmits<{
(e: "form-submit", payload: { item: ChatItem; form: { tool: string; args: Record<string, any> } }): void;
@@ -41,17 +41,23 @@ function paragraphs(text: string): string[] {
<span v-if="item.role === 'assistant'" class="msg__name">校园智能助手</span>
<div class="msg__stack">
<!-- 助手等待中:阶段提示 -->
<div v-if="pending" class="msg__stage">
<!-- 正文 -->
<div v-if="item.text" class="msg__bubble">
<p v-for="(p, i) in paragraphs(item.text)" :key="i">{{ p }}</p>
</div>
<!--
处理中:**始终**显示(哪怕气泡里已经有「正在通过你的浏览器执行…」这类文案)。
历史缺陷:原来只在气泡无文案时才显示,于是服务端一下发 notice 文案,
转圈提示就消失了 —— 后续若再等十几秒,界面看着就是「卡死没下文」。
这里补上已等待秒数,让用户确知后台还在跑。
-->
<div v-if="pending" class="msg__stage" :class="{ 'msg__stage--compact': !!item.text }">
<span class="dot" />
<span class="dot" />
<span class="dot" />
<span class="msg__stage-text">{{ item.stage || "思考中…" }}</span>
</div>
<!-- 正文 -->
<div v-if="item.text" class="msg__bubble">
<p v-for="(p, i) in paragraphs(item.text)" :key="i">{{ p }}</p>
<span v-if="elapsed >= 3" class="msg__stage-elapsed">已等待 {{ Math.floor(elapsed) }}s</span>
</div>
<!-- 工具轨迹 -->
@@ -200,6 +206,23 @@ function paragraphs(text: string): string[] {
margin-left: 5px;
}
/* 气泡已有文案时的紧凑版:更淡、更小,一行挂在气泡下方,只做「还在跑」的提示 */
.msg__stage--compact {
margin-top: 8px;
padding: 7px 12px;
font-size: 12.5px;
background: transparent;
border-style: dashed;
}
.msg__stage-elapsed {
margin-left: 6px;
padding-left: 8px;
border-left: 1px solid var(--agent-border-strong);
color: var(--agent-text-mute);
font-variant-numeric: tabular-nums;
}
.dot {
width: 5px;
height: 5px;
+39 -1
View File
@@ -36,13 +36,29 @@ export interface FormField {
/** 中文标签,如「开始时间」 */
label: string;
/** 控件类型 */
widget: "text" | "textarea" | "number" | "date" | "datetime" | "select" | "multiselect" | "file" | "scopes" | "flow";
widget:
| "text"
| "textarea"
| "number"
| "date"
| "datetime"
| "select"
| "multiselect"
| "file"
| "scopes"
| "flow"
/** 单选按钮组(平铺,对齐模块页的 RadioGroup,如「请假类型」) */
| "radio"
/** 勾选框(对齐模块页的 Checkbox,如「连续请假」) */
| "switch";
type?: string;
required: boolean;
/** 字段说明(含义与格式) */
description?: string;
/** 格式示例,用作 placeholder */
example?: string;
/** 显式占位提示(优先于 example),照搬业务模块页的原文案 */
placeholder?: string;
defaultValue?: string;
/**
* 数组值的序列化分隔符(后端会在发请求前把数组拼成模块要的逗号串,前端原样提交数组即可)。
@@ -71,6 +87,28 @@ export interface FormField {
dailyExpand?: { count: string; start: string; end: string };
/** widget=file 时提交**附件对象数组**(作业模块 attachments),而非 fileId 数组 */
fileAsObject?: boolean;
/** widget=radio:选项就绪后自动选中第一项(模块页「请假类型」的默认行为) */
autoPickFirst?: boolean;
/** 选项只有一个时自动填入并隐藏本字段(模块页「班级」:单班时不显示) */
hideWhenSingleOption?: boolean;
/**
* 快捷时段按钮组:`{ start, end, items: [{label,start,end}] }`。
* `start/end` 是**时刻**(HH:mm:ss),点按钮时套用到当前已选日期上。
* 对齐模块页 LeaveTimeSection 的「全天 / 上午 / 下午」。
*/
quickPeriods?: {
start: string;
end: string;
items: { label: string; start: string; end: string }[];
};
/** widget=switch:勾选时提交的值 */
switchOn?: string;
/** widget=switch:未勾选时提交的值 */
switchOff?: string;
/** 多行文本框显示行数 */
rows?: number;
/** 文本最大长度(显示字数统计) */
maxLength?: number;
/** 下拉选项 */
options?: { value: string; label: string }[];
/**
+39 -4
View File
@@ -35,6 +35,18 @@ const defHooks: Required<RequestHooks> = {
};
/* ============= 错误处理(原 axios 逻辑平移) ============= */
/**
* 是否是「超时」类错误。
*
* alova 的 fetch 适配器超时抛 {@code Error("fetchError: network timeout")},
* 浏览器原生 abort 抛 {@code AbortError} —— 两类都要认出来单独给文案,
* 否则用户只会看到一句看不懂的英文。
*/
export function isTimeoutMessage(msg?: string): boolean {
const s = (msg || "").toLowerCase();
return s.includes("timeout") || s.includes("abort");
}
function errorHandle<T>(res: BschResponse<T>, status: number): T {
// http 层错误
if (status !== 200) {
@@ -77,11 +89,15 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
}
}
/** 默认单请求超时(毫秒)。慢接口可在创建 Method 时用 `timeout` 单独放宽。 */
export const DEFAULT_TIMEOUT_MS = 30_000;
/* ============= 工厂函数 ============= */
export function createBschAlova(
baseURL: string,
hooks: RequestHooks = {},
statesHook: StatesHook<any> = VueHook,
defaultTimeoutMs: number = DEFAULT_TIMEOUT_MS,
) {
const { injectHeaders, onLoadingStart, onLoadingEnd, onBizError, onHttpError } = {
...defHooks,
@@ -103,9 +119,14 @@ export function createBschAlova(
// 2. 通用头
method.config.headers = Object.assign(method.config.headers || {}, {
yschapirequest: "1",
timeout: 20000,
yschreferer: Utils.strToHexCharCode(window.location.href),
});
// 3. ★ 真正的请求超时。
// 历史上这里写的是 headers 里塞一个 timeout —— fetch 不认请求头超时,
// 等于全站没有客户端超时:后端一旦卡住,Promise 既不 resolve 也不 reject,
// 界面就永久停在「正在通过你的浏览器执行…」,表现为「时不时出不来」。
// alova 的 timeout 会经 fetch 适配器转成 AbortController,到点必 reject。
if (method.config.timeout == null) method.config.timeout = defaultTimeoutMs;
}
/* ---- 响应拦截 ---- */
@@ -115,12 +136,24 @@ export function createBschAlova(
clearTimeout(timer);
onLoadingEnd();
}
const res: BschResponse = await response.json();
let res: BschResponse;
try {
res = await response.json();
} catch {
// 反向代理/网关直接回了一页 HTML(502/504 之类),解析不出 JSON。
// 不在这里兜住的话,用户看到的是一句 "Unexpected token '<'" 的英文报错。
const msg = response.ok
? "服务返回的不是有效数据,请稍后重试"
: `服务暂时不可用(HTTP ${response.status}),请稍后重试`;
onHttpError(msg);
throw new Error(msg);
}
try {
return errorHandle(res, response.status);
} catch (e: any) {
// 注意:code=302(跳登录)与登出流程都不走这里 —— errorHandle 返回的是挂起 Promise,
// 不抛异常(见上)。所以能落到这里的都是真正的业务/HTTP 错误,照常提示即可。
console.log(e);
onBizError(e.message);
return Promise.reject(e);
}
@@ -135,7 +168,7 @@ export function createBschAlova(
}
const msg = err?.message || "网络请求出错";
onHttpError(msg);
onHttpError(isTimeoutMessage(msg) ? "请求超时,请稍后重试" : msg);
return Promise.reject(err);
}
@@ -154,6 +187,8 @@ export function createBschAlova(
requestAdapter: fetchAdapter(),
baseURL,
cacheFor: null,
// 全局兜底超时;个别慢接口在创建 Method 时用 config.timeout 覆盖(见 beforeRequest 注释)
timeout: defaultTimeoutMs,
beforeRequest,
responded: { onSuccess, onError, onComplete },
});
+65 -17
View File
@@ -14,6 +14,17 @@ import type { BrowserToolCall, ToolResultItem } from "@/types/agent";
/** 每个模块最近一次「会话预热」的时间戳(避免失败后每条请求都白等一轮) */
const warmTriedAt = new Map<string, number>();
const WARM_RETRY_MS = 60_000;
/**
* 预热等待上限。
*
* <p>6s 太短:导航链是「模块 302 → SSO 签票 → 回跳模块 → 模块校验票据落会话」,
* 内网慢一点、SSO 首次签票冷启动,很容易超过 6s。原先到点就放弃且**不重试**,
* 于是那次请求原样把 `code=302` 回传,助手只回一句「需要你的登录态」——
* 这正是「时不时出不来」的来源之一。
*/
const WARM_TIMEOUT_MS = 12_000;
/** 同一模块并发的预热请求合流(多个下拉同时要建会话时,不要开一堆 iframe 互相打架) */
const warmInflight = new Map<string, Promise<boolean>>();
/**
* 判断模块响应是否为「未登录」信号。
@@ -42,36 +53,59 @@ export function isLoginRedirect(status: number, body?: string): boolean {
* 平台即回真 302 → SSO 见已有 TGT 直接签票回跳 → 模块校验票据后落会话),随后重试原请求。
* 整个过程用户无感;只在首次访问某模块时发生一次。
*
* @param pathAndQuery 刚失败的接口相对路径(`/homeleave/api/...`),首段即模块上下文
* @returns 是否完成了这轮预热尝试(false = 60s 内已试过,或拿不到模块段)
* <p>返回值语义(调用方据此决定要不要重试):
* <ul>
* <li>{@code true} —— 本轮**真的发起过**导航(不保证成功,要看后续请求的结果);</li>
* <li>{@code false} —— 近期刚试过(60s 限流),本轮没等、没导航。</li>
* </ul>
*
* <p>注意:**不要**把返回值当「会话已就绪」。iframe 的 load 事件不等于 CAS 会话一定落好
* (回跳可能比 load 稍慢),所以真正的判据永远是「重试一次请求看还 302 不 302」。
*
* @param pathAndQuery 刚失败的接口相对路径(`/home-leave/api/...`),首段即模块上下文
* @param force 用户主动点了「重新加载」时传 true —— 绕过 60s 限流,立刻再导航一次
* @returns 本轮是否发起过预热导航
*/
export function warmModuleSession(pathAndQuery: string, onStart?: () => void, timeoutMs = 6000): Promise<boolean> {
export function warmModuleSession(
pathAndQuery: string,
onStart?: () => void,
timeoutMs = WARM_TIMEOUT_MS,
force = false
): Promise<boolean> {
const ctx = pathAndQuery.split("/").filter(Boolean)[0] || "";
if (!ctx) return Promise.resolve(false);
// 已有同模块预热在跑 → 合流,别重复开 iframe
const running = warmInflight.get(ctx);
if (running) return running;
const now = Date.now();
if (now - (warmTriedAt.get(ctx) || 0) < WARM_RETRY_MS) return Promise.resolve(false);
if (!force && now - (warmTriedAt.get(ctx) || 0) < WARM_RETRY_MS) return Promise.resolve(false);
warmTriedAt.set(ctx, now);
onStart?.();
const url = `${location.origin}${pathAndQuery}`;
return new Promise<boolean>(resolve => {
const task = new Promise<boolean>(resolve => {
const frame = document.createElement("iframe");
frame.setAttribute("aria-hidden", "true");
frame.style.cssText = "position:fixed;left:-9999px;top:0;width:1px;height:1px;border:0;";
let settled = false;
const done = (ok: boolean) => {
const done = () => {
if (settled) return;
settled = true;
window.clearTimeout(cap);
window.setTimeout(() => frame.remove(), 0);
resolve(ok);
resolve(true);
};
const cap = window.setTimeout(() => done(false), timeoutMs);
// 导航链(模块 302 → SSO 签票 → 回跳模块)走完才会触发 load,再留一点沉降时间
frame.onload = () => window.setTimeout(() => done(true), 700);
const cap = window.setTimeout(done, timeoutMs);
// 导航链(模块 302 → SSO 签票 → 回跳模块)走完才会触发 load,再留一点沉降时间;
// 超时也走同一个 done() —— 关键区别是**调用方仍会重试请求**,而不是原地放弃。
frame.onload = () => window.setTimeout(done, 700);
frame.onerror = () => window.setTimeout(done, 700);
frame.src = url;
document.body.appendChild(frame);
});
warmInflight.set(ctx, task);
task.finally(() => warmInflight.delete(ctx));
return task;
}
/** 一次模块接口请求的描述(首步用 BrowserToolCall,第二步用它的 followUp) */
@@ -132,8 +166,13 @@ function extractDataId(body?: string): string | null {
*
* @param call 接口描述(`pathAndQuery` 相对当前站点根,同站部署 ⇒ 同源)
* @param onWarm 识别出「需要建会话」时的回调(用于更新界面文案)
* @param opts `{ forceWarm: true }` —— 用户主动重试,预热阶段跳过 60s 限流
*/
export async function callModuleApi(call: BrowserToolCall, onWarm?: () => void): Promise<ToolResultItem> {
export async function callModuleApi(
call: BrowserToolCall,
onWarm?: () => void,
opts?: { forceWarm?: boolean }
): Promise<ToolResultItem> {
const base = { name: call.name, arguments: call.arguments };
const send = async (req: ModuleRequest) => {
@@ -159,14 +198,23 @@ export async function callModuleApi(call: BrowserToolCall, onWarm?: () => void):
}
};
/** 发一次请求,遇 `code=302` 先预热模块会话再重试 */
/** 发一次请求,遇 `code=302` 先预热模块会话,再用**真实请求**验证会话是否就绪 */
const sendWithWarm = async (req: ModuleRequest) => {
let r = await send(req);
if (isLoginRedirect(r.status, r.body)) {
const warmed = await warmModuleSession(req.pathAndQuery, onWarm);
if (warmed) r = await send(req);
}
return r;
if (!isLoginRedirect(r.status, r.body)) return r;
// ① 预热(iframe 真导航,把模块 CAS 会话建起来)。
// ★ 不再用它的返回值决定要不要重试 —— iframe 的 load 事件只是「导航结束」,
// 不等于「CAS 会话已落好」;真正的判据永远是再打一次接口看还 302 不 302。
await warmModuleSession(req.pathAndQuery, onWarm, undefined, opts?.forceWarm);
// ② 立刻验证一次
r = await send(req);
if (!isLoginRedirect(r.status, r.body)) return r;
// ③ 再给一次机会:SSO 签票回跳偶尔比 iframe 的 load 稍慢,隔一拍再试
await new Promise<void>(resolve => window.setTimeout(resolve, 900));
return await send(req);
};
try {
+27 -3
View File
@@ -13,6 +13,7 @@
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { agentApi } from "@/api/module/agent";
import { isTimeoutMessage } from "@/utils/alova/alova-core";
import QuickActions from "@/components/QuickActions.vue";
import MessageBubble from "@/components/MessageBubble.vue";
import { callModuleApi } from "@/utils/module-session";
@@ -24,6 +25,8 @@ const items = ref<ChatItem[]>([]);
const input = ref("");
const sending = ref(false);
const stage = ref("");
/** 本轮已等待秒数(心跳,随阶段轮播递增;用于提示「还在跑,不是卡住了」) */
const elapsed = ref(0);
const sessionId = ref<string | undefined>(undefined);
const listEl = ref<HTMLElement | null>(null);
const inputEl = ref<HTMLTextAreaElement | null>(null);
@@ -127,7 +130,11 @@ watch(input, () => nextTick(autoGrow));
function startStage() {
let i = 0;
stage.value = STAGES[0];
elapsed.value = 0;
stageTimer = setInterval(() => {
// 每 1.6s 一跳,兼作「已等待秒数」的心跳 —— 用户能看到时间在走,
// 就不会误以为「卡住了」。(浏览器代执行 + 多轮大模型,一次十几秒很正常)
elapsed.value += 1.6;
i = Math.min(i + 1, STAGES.length - 1);
stage.value = STAGES[i];
const last = items.value[items.value.length - 1];
@@ -142,6 +149,22 @@ function stopStage() {
}
}
/**
* 请求失败时给用户看的文案。
*
* <p>超时单独说清楚:它不是「坏了」,而是「服务端这次太慢」,
* 并且**写操作可能已经在后台完成** —— 所以特别提醒先去模块页确认再重试,
* 避免用户重复提交(重复请假 / 重复发通知)。
*/
function errorText(e: any, writeHint = false): string {
if (isTimeoutMessage(e?.message)) {
return writeHint
? "这次处理超时了(超过 4 分钟)。如果是提交类操作,它可能已在后台完成 —— 请先到对应模块页面确认一下,没有的话再重试。"
: "这次处理超时了(超过 4 分钟)。可以再说一次,或点下方「去系统里自己查看」用你的登录态直接打开该模块。";
}
return e?.message || "请求失败,请稍后重试";
}
/** 新建一条等待中的助手消息 */
function pushPending(): ChatItem {
const placeholder: ChatItem = {
@@ -230,7 +253,7 @@ async function send(text?: string) {
}
} catch (e: any) {
if (e?.message === "redirect") return;
placeholder.error = e?.message || "请求失败,请稍后重试";
placeholder.error = errorText(e, placeholder.wroteSomething);
} finally {
stopStage();
placeholder.stage = "";
@@ -256,7 +279,7 @@ async function onFormSubmit(payload: { item: ChatItem; form: { tool: string; arg
}
} catch (e: any) {
if (e?.message === "redirect") return;
placeholder.error = e?.message || "提交失败,请稍后重试";
placeholder.error = errorText(e, true); // 表单提交都是写操作 → 提示先确认再重试
item.formSubmitted = false; // 失败则允许用户改完再提交
} finally {
stopStage();
@@ -303,7 +326,8 @@ defineExpose({ newSession, pickSample });
v-for="(it, i) in items"
:key="it.id"
:item="it"
:pending="sending && i === items.length - 1 && !it.text"
:pending="sending && i === items.length - 1"
:elapsed="elapsed"
@form-submit="onFormSubmit"
/>
</div>