feat: 提交校园智能助手前端工程源码(Vue3 + Vite + view-ui-plus)
build / build (push) Successful in 3m14s

This commit is contained in:
lijing
2026-10-12 14:45:00 +08:00
parent 96bb37c89f
commit 37d292c5d2
51 changed files with 13430 additions and 2 deletions
+534
View File
@@ -0,0 +1,534 @@
<script setup lang="ts">
/**
* 补全表单卡片。
*
* 用户说「我要请假」但信息不全时,后端会把缺失字段(中文标签 + 格式示例 + 下拉选项)
* 下发到这里渲染成表单 —— 比让模型口头追问精确得多:
* - 下拉框:选项由后端从业务字典接口现取(如请假类型、发文部门)
* - 日期时间:用选择器,避免用户手输格式出错
* - 已识别的信息以只读标签回显,用户可核对
* 提交后直接执行对应工具,不再让模型二次猜测。
*
* 注意: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 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 }[]>([]);
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]);
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 ?? "";
}
}
}
initValues();
watch(() => props.form, initValues);
/** 只读回显:已从对话里识别出的参数(优先用后端翻译好的可读文本) */
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 {
return f.optionsFallback
? `「${f.label}」为系统内置标准类型;学校自定义项暂时没取到。`
: "";
}
/**
* 选项完全没取到(来源接口 302 / 超时,控件退化成输入框)时给一句人话提示,
* 免得用户对着一个空输入框发呆。
*/
function dictWarn(f: FormField): string {
if (f.optionsFallback) return "";
if (!f.optionsFrom) return "";
if (f.options && f.options.length) return "";
return `「${f.label}」的可选项暂时没取到(来源接口未通),可以先直接填对应编号,稍后再试。`;
}
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="f.widget === 'select'"
v-model="values[f.name]"
:placeholder="f.example || '请选择'"
:disabled="disabled"
clearable
>
<Option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</Option>
</Select>
<DatePicker
v-else-if="f.widget === '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="f.widget === 'date'"
v-model="values[f.name]"
type="date"
format="yyyy-MM-dd"
:placeholder="f.example || '选择日期'"
:disabled="disabled"
style="width: 100%"
/>
<InputNumber
v-else-if="f.widget === '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="f.widget === '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="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>