Files
yunhai-agent-front/src/components/FormCard.vue
T
lijing abd97adf9a
build / build (push) Successful in 1m52s
feat(leave): 裸意图直达表单 + 按身份分离请假表单 + 前端代取下拉
- 说「我要请假」这类只有意图的短句时,直接弹出表单不再过模型:
  后端 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 复用。
- 例句与提示改为「我要请假」「帮学生登记请假」。
2026-10-12 17:49:34 +08:00

674 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 补全表单卡片。
*
* 用户说「我要请假」但信息不全时,后端会把缺失字段(中文标签 + 格式示例 + 下拉选项)
* 下发到这里渲染成表单 —— 比让模型口头追问精确得多:
* - 下拉框:选项由后端从业务字典接口现取(如请假类型、班级),或由本组件带**用户登录态**代取
* - 联动下拉:如「学生」依赖先选好「班级」(后端在字段上标注 dependsOn + 占位符地址)
* - 日期时间:用选择器,避免用户手输格式出错
* - 已识别的信息以只读标签回显,用户可核对
* 提交后直接执行对应工具,不再让模型二次猜测。
*
* 注意:view-ui-plus 组件在模板里必须用 PascalCase(Select / DatePicker / Input / Option),
* 写成 i-select / i-date-picker 不会被解析。
*/
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<{
form: FormRequest;
submitted?: boolean;
disabled?: boolean;
}>();
const emit = defineEmits<{
(e: "submit", payload: { tool: string; args: Record<string, any> }): void;
}>();
/** 常见参数名的中文兜底(后端已给 label 的以 label 为准) */
const NAME_LABEL: Record<string, string> = {
leaveType: "请假类型",
startDate: "开始时间",
endDate: "结束时间",
remarks: "请假事由",
classId: "班级",
stuId: "学生",
serid: "记录",
deptId: "发文部门",
moduleId: "所属栏目",
opinion: "审批意见",
remark: "备注",
joinCount: "参加人数",
guardianName: "监护人姓名",
guardianPhone: "监护人电话",
relation: "与学生关系",
};
const values = reactive<Record<string, any>>({});
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;
const d = new Date(String(v).replace(/-/g, "/"));
return isNaN(d.getTime()) ? undefined : d;
}
function fmt(d: Date, withTime: boolean): string {
const p = (n: number) => String(n).padStart(2, "0");
const day = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
return withTime ? `${day} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` : day;
}
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) ?? "";
} else if (f.widget === "select" || f.widget === "number") {
values[f.name] = f.defaultValue ?? undefined;
} else {
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;
if (f?.recognized?.length) {
return f.recognized.map(r => ({ name: r.name, label: r.label, text: r.text }));
}
const preset = f?.preset || {};
return Object.entries(preset).map(([k, v]) => ({
name: k,
label: labelOf(k),
text: String(v),
}));
});
/** 还差几项必填没填(页脚给出明确预期) */
const missingCount = computed(() => (props.form?.fields || []).filter(f => f.required).length);
function labelOf(name: string): string {
const f = (props.form?.fields || []).find(x => x.name === name);
return f?.label || NAME_LABEL[name] || name;
}
function normalize(f: FormField, v: any) {
const t = (f.type || "string").toLowerCase();
if (t === "integer" || t === "number") {
const n = Number(v);
return isNaN(n) ? v : n;
}
if (v instanceof Date) return f.widget === "date" ? fmt(v, false) : fmt(v, true);
return v;
}
/**
* 选项来自后端内置兜底(学校自定义字典没取到)时的轻提示。
* 下拉框是可用的,只是可能不包含学校自定义的项 —— 用淡色说明,不阻塞填写。
*/
function dictNote(f: FormField): string {
// 有动态代取通道时由 optHint 统一说明,这里不重复
if (f.optionCall) return "";
return f.optionsFallback
? `「${f.label}」为系统内置标准类型;学校自定义项暂时没取到。`
: "";
}
/**
* 选项完全没取到(来源接口 302 / 超时,控件退化成输入框)时给一句人话提示,
* 免得用户对着一个空输入框发呆。
*/
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;
let ok = true;
for (const f of props.form?.fields || []) {
const v = values[f.name];
const empty = v === undefined || v === null || String(v).trim() === "";
if (f.required && empty) {
errors[f.name] = `请填写「${f.label}」`;
ok = false;
} else {
delete errors[f.name];
}
}
if (!ok) {
Message.warning("还有必填项没填完,请检查标红的字段");
return;
}
const args: Record<string, any> = {};
// 1) 已识别的参数原样带上
Object.entries(props.form?.preset || {}).forEach(([k, v]) => {
args[k] = v;
});
// 2) 用户填写的字段
const rows: { label: string; text: string }[] = [];
for (const f of props.form?.fields || []) {
const v = values[f.name];
if (v === undefined || v === null || String(v).trim() === "") continue;
const normalized = normalize(f, v);
args[f.name] = normalized;
const opt = f.options?.find(o => String(o.value) === String(normalized));
rows.push({ label: f.label, text: opt ? opt.label : String(normalized) });
}
summary.value = rows;
emit("submit", { tool: props.form.tool, args });
}
function reset() {
initValues();
Message.info("已重置为初始值");
}
</script>
<template>
<div class="form-card" :class="{ 'form-card--done': submitted }">
<!-- 头部 -->
<div class="form-card__head">
<span class="form-card__badge" :class="form.write ? 'form-card__badge--write' : 'form-card__badge--read'">
{{ form.write ? "需要你确认" : "需要补充信息" }}
</span>
<span class="form-card__title">{{ form.title }}</span>
<span v-if="submitted" class="form-card__state">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12.5l5 5 11-11" />
</svg>
已提交
</span>
</div>
<div class="form-card__body">
<p v-if="form.description && !submitted" class="form-card__desc">{{ form.description }}</p>
<!-- 已识别信息:只读回显,便于核对 -->
<div v-if="presetList.length && !submitted" class="form-card__preset">
<span class="form-card__preset-label">已识别</span>
<span v-for="p in presetList" :key="p.name" class="chip">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12.5l5 5 11-11" />
</svg>
{{ p.label }}:{{ p.text }}
</span>
</div>
<!-- 填写区 -->
<div v-if="!submitted" class="form-card__fields">
<div v-for="f in form.fields" :key="f.name" class="field">
<label 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'"
v-model="values[f.name]"
:placeholder="!depsReady(f) ? '请先选择上级项' : (f.example || '请选择')"
:disabled="disabled || !depsReady(f) || optState[f.name] === 'loading'"
:loading="optState[f.name] === 'loading'"
clearable
>
<Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option>
</Select>
<DatePicker
v-else-if="widgetOf(f) === 'datetime'"
v-model="values[f.name]"
type="datetime"
format="yyyy-MM-dd HH:mm:ss"
:placeholder="f.example || '选择日期与时刻'"
:disabled="disabled"
style="width: 100%"
/>
<DatePicker
v-else-if="widgetOf(f) === 'date'"
v-model="values[f.name]"
type="date"
format="yyyy-MM-dd"
:placeholder="f.example || '选择日期'"
:disabled="disabled"
style="width: 100%"
/>
<InputNumber
v-else-if="widgetOf(f) === 'number'"
v-model="values[f.name]"
:precision="f.type === 'integer' ? 0 : undefined"
:placeholder="f.example || f.description || '请输入数字'"
:disabled="disabled"
style="width: 100%"
/>
<Input
v-else-if="widgetOf(f) === 'textarea'"
v-model="values[f.name]"
type="textarea"
:rows="3"
:placeholder="f.example || f.description || '请输入'"
:disabled="disabled"
/>
<Input
v-else
v-model="values[f.name]"
:placeholder="f.example || f.description || '请输入'"
:disabled="disabled"
/>
</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>
<p v-if="errors[f.name]" class="field__error">{{ errors[f.name] }}</p>
</div>
</div>
<!-- 提交后:只读摘要 -->
<div v-else class="form-card__summary">
<div v-for="row in summary" :key="row.label" class="summary-row">
<span class="summary-row__label">{{ row.label }}</span>
<span class="summary-row__value">{{ row.text }}</span>
</div>
</div>
</div>
<!-- 页脚 -->
<div v-if="!submitted" class="form-card__footer">
<span class="form-card__hint">
{{ form.hint || (missingCount ? `带 * 为必填,共 ${missingCount} 项` : "确认无误后提交") }}
</span>
<div class="form-card__btns">
<button class="fc-btn fc-btn--ghost" type="button" :disabled="disabled" @click="reset">重置</button>
<button class="fc-btn fc-btn--primary" type="button" :disabled="disabled" @click="onSubmit">
{{ form.write ? "确认提交" : "查询" }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.form-card {
margin-top: 11px;
border: 1px solid var(--agent-border);
border-radius: var(--agent-radius-lg);
background: var(--agent-surface);
overflow: hidden;
box-shadow: var(--agent-shadow-sm);
}
.form-card--done {
box-shadow: var(--agent-shadow-xs);
}
/* ------------------------------ 头部 ------------------------------ */
.form-card__head {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 16px;
background: linear-gradient(96deg, rgba(37, 99, 235, 0.07), rgba(124, 58, 237, 0.03) 60%, transparent);
border-bottom: 1px solid var(--agent-border);
}
.form-card__badge {
flex: none;
padding: 3px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.3px;
color: #fff;
}
.form-card__badge--write {
background: linear-gradient(135deg, #2563eb, #4f46e5);
}
.form-card__badge--read {
background: linear-gradient(135deg, #0d9488, #0891b2);
}
.form-card__title {
font-size: 13.5px;
font-weight: 600;
color: var(--agent-text);
}
.form-card__state {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11.5px;
color: var(--agent-success);
}
/* ------------------------------ 主体 ------------------------------ */
.form-card__body {
padding: 13px 16px 3px;
}
.form-card__desc {
margin: 0 0 10px;
font-size: 12.5px;
line-height: 1.7;
color: var(--agent-text-sub);
}
.form-card__preset {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
margin-bottom: 13px;
padding: 9px 11px;
border-radius: 10px;
background: var(--agent-surface-2);
border: 1px dashed var(--agent-border-strong);
}
.form-card__preset-label {
font-size: 11px;
color: var(--agent-text-mute);
letter-spacing: 0.5px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
color: var(--agent-primary-dark);
background: var(--agent-primary-soft);
border: 1px solid var(--agent-primary-line);
}
.form-card__fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 14px 18px;
padding-bottom: 4px;
}
.field__label {
display: block;
margin-bottom: 6px;
font-size: 12.5px;
color: var(--agent-text);
}
.field__star {
color: var(--agent-danger);
margin-right: 3px;
}
.field__tip {
margin: 5px 0 0;
font-size: 11px;
line-height: 1.55;
color: var(--agent-text-mute);
}
.field__warn {
margin: 5px 0 0;
font-size: 11px;
line-height: 1.55;
color: var(--agent-warn);
}
.field__error {
margin: 5px 0 0;
font-size: 11px;
color: var(--agent-danger);
}
/* ------------------------------ 提交后摘要 ------------------------------ */
.form-card__summary {
padding: 2px 0 6px;
}
.summary-row {
display: flex;
gap: 12px;
padding: 5px 0;
font-size: 12.5px;
border-bottom: 1px dashed var(--agent-border);
}
.summary-row:last-child {
border-bottom: none;
}
.summary-row__label {
flex: none;
width: 80px;
color: var(--agent-text-mute);
}
.summary-row__value {
color: var(--agent-text);
word-break: break-all;
}
/* ------------------------------ 页脚 ------------------------------ */
.form-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
padding: 11px 16px;
border-top: 1px solid var(--agent-border);
background: var(--agent-surface-2);
}
.form-card__hint {
font-size: 11.5px;
line-height: 1.6;
color: var(--agent-text-mute);
}
.form-card__btns {
flex: none;
display: flex;
gap: 8px;
}
.fc-btn {
height: 32px;
padding: 0 16px;
border-radius: 9px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: all 0.16s ease;
}
.fc-btn--ghost {
background: var(--agent-surface);
border-color: var(--agent-border-strong);
color: var(--agent-text-sub);
}
.fc-btn--ghost:hover:not(:disabled) {
border-color: var(--agent-text-mute);
color: var(--agent-text);
}
.fc-btn--primary {
background: linear-gradient(135deg, #2563eb, #4f46e5);
color: #fff;
box-shadow: 0 10px 20px -12px rgba(37, 99, 235, 0.9);
}
.fc-btn--primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 14px 24px -12px rgba(37, 99, 235, 0.95);
}
.fc-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
</style>