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 复用。
- 例句与提示改为「我要请假」「帮学生登记请假」。
674 lines
22 KiB
Vue
674 lines
22 KiB
Vue
<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>
|