feat(leave): 裸意图直达表单 + 按身份分离请假表单 + 前端代取下拉
build / build (push) Successful in 1m52s

- 说「我要请假」这类只有意图的短句时,直接弹出表单不再过模型:
  后端 IntentShortcut 在编排器入口做「去空白标点 + 剥前后缀后正好等于标准说法
  且原句 ≤12 字」的窄匹配,命中即跳过 LLM 直接下发表单。
- 按身份分离:学生 → leave.submit(提交申请),教师/管理员 → leave.register
  (帮学生登记,必填 classId+stuId);RolePolicy 把两者分别归入 STUDENT_ONLY
  与 STAFF_ONLY,模型视野里天然只有一个,不可能选错。
- 表单字段由各自必填参数推导;下拉选项改为后端下发 optionCall(含 {{占位符}}),
  由前端带用户登录态代取后按 valueKey/labelKey 映射(模块 /api/* 服务端必然 302)。
  stuId 依赖 classId,选完班级才解锁学生名单。
- 抽出 utils/module-session.ts(会话预热 / 带登录态调模块 / 字典项解析),
  Chat.vue 与 FormCard.vue 复用。
- 例句与提示改为「我要请假」「帮学生登记请假」。
This commit is contained in:
lijing
2026-10-12 17:49:34 +08:00
parent 854550c019
commit abd97adf9a
4 changed files with 343 additions and 112 deletions
+148 -9
View File
@@ -4,7 +4,8 @@
*
* 用户说「我要请假」但信息不全时,后端会把缺失字段(中文标签 + 格式示例 + 下拉选项)
* 下发到这里渲染成表单 —— 比让模型口头追问精确得多:
* - 下拉框:选项由后端从业务字典接口现取(如请假类型、发文部门)
* - 下拉框:选项由后端从业务字典接口现取(如请假类型、班级),或由本组件带**用户登录态**代取
* - 联动下拉:如「学生」依赖先选好「班级」(后端在字段上标注 dependsOn + 占位符地址)
* - 日期时间:用选择器,避免用户手输格式出错
* - 已识别的信息以只读标签回显,用户可核对
* 提交后直接执行对应工具,不再让模型二次猜测。
@@ -14,6 +15,7 @@
*/
import { reactive, computed, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { callModuleApi, parseOptionItems } from "@/utils/module-session";
import type { FormField, FormRequest } from "@/types/agent";
const props = defineProps<{
@@ -50,6 +52,103 @@ const errors = reactive<Record<string, string>>({});
/** 提交后的摘要(用于只读回显) */
const summary = ref<{ label: string; text: string }[]>([]);
/* ==================== 下拉选项:动态代取 + 联动 ==================== */
/** 动态取回的选项(按字段名);取到后覆盖字段自带的兜底选项 */
const dynOptions = reactive<Record<string, { value: string; label: string }[]>>({});
/** 动态取选项的状态:idle(依赖未填/未开始) · loading · ok · fail */
const optState = reactive<Record<string, "idle" | "loading" | "ok" | "fail">>({});
/** 依赖字段值指纹:只有依赖真的变了才重新拉取,避免每次输入都打接口 */
const depSig = reactive<Record<string, string>>({});
/** 当前该显示的选项:动态取回的优先,否则用后端给的(可能是内置兜底) */
function fieldOptions(f: FormField) {
const dyn = dynOptions[f.name];
if (dyn && dyn.length) return dyn;
return f.options || [];
}
/** 依赖字段是否都已填好 —— 没填好就不该去拉从属选项,也不该让用户点开一个空下拉 */
function depsReady(f: FormField) {
return (f.dependsOn || []).every(d => {
const v = values[d];
return v !== undefined && v !== null && String(v).trim() !== "";
});
}
function depSignature(f: FormField) {
return (f.dependsOn || []).map(d => `${d}=${values[d] ?? ""}`).join("&");
}
/**
* 实际控件类型:动态取选项失败、且没有任何可选项时,退化成可手填的输入框
* (填编号),而不是留一个点不开的空下拉把用户卡死。
*/
function widgetOf(f: FormField) {
const st = optState[f.name];
if (f.optionCall && st === "fail" && !fieldOptions(f).length) {
return f.type === "integer" || f.type === "number" ? "number" : "text";
}
return f.widget;
}
/**
* 用用户自己的登录态去业务模块取本字段的下拉选项。
*
* 为什么不走后端:模块的字典接口只认浏览器 CAS 会话,服务端代取必然 `code=302`。
* 地址里的 `{{字段名}}` 由后端按 dependsOn 生成,这里替换成当前值(必须 URL 编码)。
*/
async function loadOptions(f: FormField) {
const call = f.optionCall;
if (!call) return;
if (!depsReady(f)) {
delete dynOptions[f.name];
optState[f.name] = "idle";
return;
}
optState[f.name] = "loading";
let pathAndQuery = String(call.pathAndQuery || "");
for (const d of f.dependsOn || []) {
pathAndQuery = pathAndQuery.split(`{{${d}}}`).join(encodeURIComponent(String(values[d])));
}
const res = await callModuleApi({ ...call, pathAndQuery });
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;
}
} else {
dynOptions[f.name] = [];
optState[f.name] = "fail";
}
}
/** 依赖一变就重新拉从属选项;换班后旧的学生选择必须清掉 */
function syncOptions() {
for (const f of props.form?.fields || []) {
if (!f.optionCall) continue;
const sig = depSignature(f);
if (depSig[f.name] === sig) continue;
const hadSig = depSig[f.name] !== undefined;
depSig[f.name] = sig;
if (hadSig && (f.dependsOn || []).length) {
values[f.name] = undefined;
}
loadOptions(f);
}
}
function resetOptionState() {
Object.keys(dynOptions).forEach(k => delete dynOptions[k]);
Object.keys(optState).forEach(k => delete optState[k]);
Object.keys(depSig).forEach(k => delete depSig[k]);
}
function toDate(v: any): Date | undefined {
if (!v) return undefined;
if (v instanceof Date) return v;
@@ -66,6 +165,7 @@ function fmt(d: Date, withTime: boolean): string {
function initValues() {
Object.keys(values).forEach(k => delete values[k]);
Object.keys(errors).forEach(k => delete errors[k]);
resetOptionState();
for (const f of props.form?.fields || []) {
if (f.widget === "datetime" || f.widget === "date") {
values[f.name] = toDate(f.defaultValue) ?? "";
@@ -75,11 +175,22 @@ function initValues() {
values[f.name] = f.defaultValue ?? "";
}
}
// 初值就位后立刻拉一次动态选项(含只有一层依赖的情况)
syncOptions();
}
initValues();
watch(() => props.form, initValues);
/**
* 依赖字段变化 → 重新拉从属选项。
* 依赖形如「字段名:班级=3」,因此选完班级才会去取学生名单,其它字段的输入不会触发请求。
*/
watch(
() => (props.form?.fields || []).map(f => `${f.name}:${depSignature(f)}`).join("|"),
() => syncOptions()
);
/** 只读回显:已从对话里识别出的参数(优先用后端翻译好的可读文本) */
const presetList = computed(() => {
const f = props.form;
@@ -117,6 +228,8 @@ function normalize(f: FormField, v: any) {
* 下拉框是可用的,只是可能不包含学校自定义的项 —— 用淡色说明,不阻塞填写。
*/
function dictNote(f: FormField): string {
// 有动态代取通道时由 optHint 统一说明,这里不重复
if (f.optionCall) return "";
return f.optionsFallback
? `「${f.label}」为系统内置标准类型;学校自定义项暂时没取到。`
: "";
@@ -127,12 +240,36 @@ function dictNote(f: FormField): string {
* 免得用户对着一个空输入框发呆。
*/
function dictWarn(f: FormField): string {
if (f.optionCall) return "";
if (f.optionsFallback) return "";
if (!f.optionsFrom) return "";
if (f.options && f.options.length) return "";
return `「${f.label}」的可选项暂时没取到(来源接口未通),可以先直接填对应编号,稍后再试。`;
}
function labelOfName(name: string): string {
const f = (props.form?.fields || []).find(x => x.name === name);
return f?.label || NAME_LABEL[name] || name;
}
/**
* 动态下拉的状态提示:等待上级字段 / 正在读取 / 取不到(可手填编号)。
* 有了它,用户不会对着一个空下拉框猜「为什么点不开」。
*/
function optHint(f: FormField): string {
if (!f.optionCall) return "";
const st = optState[f.name];
if (st === "loading") return "正在读取可选项…";
if (!depsReady(f)) {
const deps = (f.dependsOn || []).map(labelOfName).join("、");
return `请先选择「${deps}」,这里会自动列出可选项`;
}
if (st === "fail") {
return `「${f.label}」的可选项暂时没取到,可以先直接填对应编号,或稍后重试。`;
}
return "";
}
function onSubmit() {
if (props.submitted || props.disabled) return;
@@ -216,17 +353,18 @@ function reset() {
<div class="field__control">
<Select
v-if="f.widget === 'select'"
v-if="widgetOf(f) === 'select'"
v-model="values[f.name]"
:placeholder="f.example || '请选择'"
:disabled="disabled"
:placeholder="!depsReady(f) ? '请先选择上级项' : (f.example || '请选择')"
:disabled="disabled || !depsReady(f) || optState[f.name] === 'loading'"
:loading="optState[f.name] === 'loading'"
clearable
>
<Option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</Option>
<Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option>
</Select>
<DatePicker
v-else-if="f.widget === 'datetime'"
v-else-if="widgetOf(f) === 'datetime'"
v-model="values[f.name]"
type="datetime"
format="yyyy-MM-dd HH:mm:ss"
@@ -236,7 +374,7 @@ function reset() {
/>
<DatePicker
v-else-if="f.widget === 'date'"
v-else-if="widgetOf(f) === 'date'"
v-model="values[f.name]"
type="date"
format="yyyy-MM-dd"
@@ -246,7 +384,7 @@ function reset() {
/>
<InputNumber
v-else-if="f.widget === 'number'"
v-else-if="widgetOf(f) === 'number'"
v-model="values[f.name]"
:precision="f.type === 'integer' ? 0 : undefined"
:placeholder="f.example || f.description || '请输入数字'"
@@ -255,7 +393,7 @@ function reset() {
/>
<Input
v-else-if="f.widget === 'textarea'"
v-else-if="widgetOf(f) === 'textarea'"
v-model="values[f.name]"
type="textarea"
:rows="3"
@@ -271,6 +409,7 @@ function reset() {
/>
</div>
<p v-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>