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) }" > -

- @@ -621,7 +814,7 @@ function reset() { 移除 - 可选,支持图片 / 文档 / 压缩包 + {{ f.placeholder || "可选,支持图片 / 文档 / 压缩包" }}
@@ -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() { />
-

{{ optHint(f) }}

+ +
+ 快捷时段 + + {{ f.quickPeriods.items[0].start.slice(0, 5) }} 起,日期用你上面选的 +
+ + +
+ 逐天时段(可逐天微调) +
+ 第 {{ i + 1 }} 天 + + ~ + +
+
+ + +

+ {{ optHint(f) }} + 重新加载 +

+

{{ optHint(f) }}

{{ dictNote(f) }}

{{ dictWarn(f) }}

{{ f.description }}

@@ -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); +} diff --git a/src/components/MessageBubble.vue b/src/components/MessageBubble.vue index fbd8fc3..4c25280 100644 --- a/src/components/MessageBubble.vue +++ b/src/components/MessageBubble.vue @@ -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 } }): void; @@ -41,17 +41,23 @@ function paragraphs(text: string): string[] { 校园智能助手
- -
+ +
+

{{ p }}

+
+ + +
{{ item.stage || "思考中…" }} -
- - -
-

{{ p }}

+ 已等待 {{ Math.floor(elapsed) }}s
@@ -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; diff --git a/src/types/agent.ts b/src/types/agent.ts index d9b89d2..fb95139 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -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 }[]; /** diff --git a/src/utils/alova/alova-core.ts b/src/utils/alova/alova-core.ts index 8ec2396..36f85a9 100644 --- a/src/utils/alova/alova-core.ts +++ b/src/utils/alova/alova-core.ts @@ -35,6 +35,18 @@ const defHooks: Required = { }; /* ============= 错误处理(原 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(res: BschResponse, status: number): T { // http 层错误 if (status !== 200) { @@ -77,11 +89,15 @@ function errorHandle(res: BschResponse, status: number): T { } } +/** 默认单请求超时(毫秒)。慢接口可在创建 Method 时用 `timeout` 单独放宽。 */ +export const DEFAULT_TIMEOUT_MS = 30_000; + /* ============= 工厂函数 ============= */ export function createBschAlova( baseURL: string, hooks: RequestHooks = {}, statesHook: StatesHook = 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 }, }); diff --git a/src/utils/module-session.ts b/src/utils/module-session.ts index 4c886a4..3f9521e 100644 --- a/src/utils/module-session.ts +++ b/src/utils/module-session.ts @@ -14,6 +14,17 @@ import type { BrowserToolCall, ToolResultItem } from "@/types/agent"; /** 每个模块最近一次「会话预热」的时间戳(避免失败后每条请求都白等一轮) */ const warmTriedAt = new Map(); const WARM_RETRY_MS = 60_000; +/** + * 预热等待上限。 + * + *

6s 太短:导航链是「模块 302 → SSO 签票 → 回跳模块 → 模块校验票据落会话」, + * 内网慢一点、SSO 首次签票冷启动,很容易超过 6s。原先到点就放弃且**不重试**, + * 于是那次请求原样把 `code=302` 回传,助手只回一句「需要你的登录态」—— + * 这正是「时不时出不来」的来源之一。 + */ +const WARM_TIMEOUT_MS = 12_000; +/** 同一模块并发的预热请求合流(多个下拉同时要建会话时,不要开一堆 iframe 互相打架) */ +const warmInflight = new Map>(); /** * 判断模块响应是否为「未登录」信号。 @@ -42,36 +53,59 @@ export function isLoginRedirect(status: number, body?: string): boolean { * 平台即回真 302 → SSO 见已有 TGT 直接签票回跳 → 模块校验票据后落会话),随后重试原请求。 * 整个过程用户无感;只在首次访问某模块时发生一次。 * - * @param pathAndQuery 刚失败的接口相对路径(`/homeleave/api/...`),首段即模块上下文 - * @returns 是否完成了这轮预热尝试(false = 60s 内已试过,或拿不到模块段) + *

返回值语义(调用方据此决定要不要重试): + *

    + *
  • {@code true} —— 本轮**真的发起过**导航(不保证成功,要看后续请求的结果);
  • + *
  • {@code false} —— 近期刚试过(60s 限流),本轮没等、没导航。
  • + *
+ * + *

注意:**不要**把返回值当「会话已就绪」。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 { +export function warmModuleSession( + pathAndQuery: string, + onStart?: () => void, + timeoutMs = WARM_TIMEOUT_MS, + force = false +): Promise { 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(resolve => { + const task = new Promise(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 { +export async function callModuleApi( + call: BrowserToolCall, + onWarm?: () => void, + opts?: { forceWarm?: boolean } +): Promise { 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(resolve => window.setTimeout(resolve, 900)); + return await send(req); }; try { diff --git a/src/views/Chat.vue b/src/views/Chat.vue index 1a39556..50e4eb1 100644 --- a/src/views/Chat.vue +++ b/src/views/Chat.vue @@ -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([]); const input = ref(""); const sending = ref(false); const stage = ref(""); +/** 本轮已等待秒数(心跳,随阶段轮播递增;用于提示「还在跑,不是卡住了」) */ +const elapsed = ref(0); const sessionId = ref(undefined); const listEl = ref(null); const inputEl = ref(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() { } } +/** + * 请求失败时给用户看的文案。 + * + *

超时单独说清楚:它不是「坏了」,而是「服务端这次太慢」, + * 并且**写操作可能已经在后台完成** —— 所以特别提醒先去模块页确认再重试, + * 避免用户重复提交(重复请假 / 重复发通知)。 + */ +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" />