用户反馈「班级变成数字框了」的直接原因:选项加载失败(模块会话未建立时
接口回 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:
+18
-5
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 }[];
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user