配合后端把 5 个模块「新建/发布」页的字段完整搬进助手表单:
- FormCard 新增审批流控件(widget=flow):勾「走审批流程」后按步配置,
每步可选或签/会签并多选审批人(来自教师组织树),提交时拼
flow:[{name,vote,approverTeaIds}];发布方式选了「提交审批」却没配审批人则当场拦下
- 镜像字段(deptName)不渲染控件,提交时按源字段选中项反查显示名一起带上,
与模块页「字典 id + 反查名称」的提交口径一致
- 附件支持对象数组形态(作业 attachments:[{fileId,fileUrl,fileType,fileName,sort}]);
数组类字段在为空时也照常提交(模块页就是恒带 fileIds:"" / attachments:[])
- module-session:callModuleApi 支持 followUp —— 首步业务成功后用返回的资源 id 续跑第二步,
并带上「第一步成功但没拿回编号」的如实报错
- types:BrowserToolCall 增加 followUp;widget 联合类型补 scopes / flow
验证:npm run build 通过;headless Chrome 真机跑「表单 → 浏览器代执行 → 模块接口」,
公告(镜像 + 审批流 + pushScopes)/ 作业(Create→Publish、Create→Schedule)/
宿舍(镜像 + 全部宿舍哨兵)/ 活动(分类存名称)共 50 项断言全绿
This commit is contained in:
+196
-10
@@ -52,9 +52,17 @@ const errors = reactive<Record<string, string>>({});
|
|||||||
/** 提交后的摘要(用于只读回显) */
|
/** 提交后的摘要(用于只读回显) */
|
||||||
const summary = ref<{ label: string; text: string }[]>([]);
|
const summary = ref<{ label: string; text: string }[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「镜像字段」(如 deptName 镜像 deptId)不渲染输入控件:用户选好部门后,
|
||||||
|
* 提交时自动带上所选下选项的名称 —— 与模块页「字典反查部门名」的做法一致。
|
||||||
|
*/
|
||||||
|
const renderFields = computed(() => (props.form?.fields || []).filter(f => !f.mirrorOf));
|
||||||
|
|
||||||
/* ==================== 附件上传(widget=file) ==================== */
|
/* ==================== 附件上传(widget=file) ==================== */
|
||||||
/** 已上传附件的文件名(按字段名),仅用于界面展示 */
|
/** 已上传附件的文件名(按字段名),仅用于界面展示 */
|
||||||
const uploadNames = reactive<Record<string, string[]>>({});
|
const uploadNames = reactive<Record<string, string[]>>({});
|
||||||
|
/** 已上传附件的原始信息(按字段名):作业模块要按它组装 attachments 对象数组 */
|
||||||
|
const uploadMeta = reactive<Record<string, { id: number; name: string; type: string }[]>>({});
|
||||||
/** 上传状态:idle / uploading */
|
/** 上传状态:idle / uploading */
|
||||||
const uploadState = reactive<Record<string, "idle" | "uploading">>({});
|
const uploadState = reactive<Record<string, "idle" | "uploading">>({});
|
||||||
|
|
||||||
@@ -64,7 +72,7 @@ const uploadState = reactive<Record<string, "idle" | "uploading">>({});
|
|||||||
* 为什么不走后端:模块的用户态接口只认浏览器 CAS 会话,服务端代调必 302。
|
* 为什么不走后端:模块的用户态接口只认浏览器 CAS 会话,服务端代调必 302。
|
||||||
* 与下拉代取同理 —— 先直接发,遇到登录跳转就先预热模块会话再重试一次。
|
* 与下拉代取同理 —— 先直接发,遇到登录跳转就先预热模块会话再重试一次。
|
||||||
*/
|
*/
|
||||||
async function uploadOne(path: string, file: File): Promise<number | null> {
|
async function uploadOne(path: string, file: File): Promise<{ id: number; name: string; type: string } | null> {
|
||||||
const doSend = async () => {
|
const doSend = async () => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
@@ -84,8 +92,11 @@ async function uploadOne(path: string, file: File): Promise<number | null> {
|
|||||||
try {
|
try {
|
||||||
const j = JSON.parse(r.body);
|
const j = JSON.parse(r.body);
|
||||||
if (Number(j.code) === 0 && j.data) {
|
if (Number(j.code) === 0 && j.data) {
|
||||||
const id = j.data.fileid ?? j.data.fileId ?? j.data.id;
|
const d = j.data;
|
||||||
if (id !== undefined && id !== null) return Number(id);
|
const id = d.fileid ?? d.fileId ?? d.id;
|
||||||
|
if (id !== undefined && id !== null) {
|
||||||
|
return { id: Number(id), name: d.name || d.fileName || file.name, type: String(d.type || "") };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Message.error(j.msg || `「${file.name}」上传失败`);
|
Message.error(j.msg || `「${file.name}」上传失败`);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -106,16 +117,19 @@ async function onPickFiles(f: FormField, e: Event) {
|
|||||||
uploadState[f.name] = "uploading";
|
uploadState[f.name] = "uploading";
|
||||||
const ids: any[] = Array.isArray(values[f.name]) ? [...values[f.name]] : [];
|
const ids: any[] = Array.isArray(values[f.name]) ? [...values[f.name]] : [];
|
||||||
const names: string[] = [...(uploadNames[f.name] || [])];
|
const names: string[] = [...(uploadNames[f.name] || [])];
|
||||||
|
const metas = [...(uploadMeta[f.name] || [])];
|
||||||
try {
|
try {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const id = await uploadOne(f.uploadPath, file);
|
const up = await uploadOne(f.uploadPath, file);
|
||||||
if (id !== null) {
|
if (up !== null) {
|
||||||
ids.push(id);
|
ids.push(up.id);
|
||||||
names.push(file.name);
|
names.push(up.name);
|
||||||
|
metas.push(up);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
values[f.name] = ids;
|
values[f.name] = ids;
|
||||||
uploadNames[f.name] = names;
|
uploadNames[f.name] = names;
|
||||||
|
uploadMeta[f.name] = metas;
|
||||||
delete errors[f.name];
|
delete errors[f.name];
|
||||||
} finally {
|
} finally {
|
||||||
uploadState[f.name] = "idle";
|
uploadState[f.name] = "idle";
|
||||||
@@ -126,10 +140,54 @@ async function onPickFiles(f: FormField, e: Event) {
|
|||||||
function removeFile(f: FormField, idx: number) {
|
function removeFile(f: FormField, idx: number) {
|
||||||
const ids = Array.isArray(values[f.name]) ? [...values[f.name]] : [];
|
const ids = Array.isArray(values[f.name]) ? [...values[f.name]] : [];
|
||||||
const names = [...(uploadNames[f.name] || [])];
|
const names = [...(uploadNames[f.name] || [])];
|
||||||
|
const metas = [...(uploadMeta[f.name] || [])];
|
||||||
ids.splice(idx, 1);
|
ids.splice(idx, 1);
|
||||||
names.splice(idx, 1);
|
names.splice(idx, 1);
|
||||||
|
metas.splice(idx, 1);
|
||||||
values[f.name] = ids;
|
values[f.name] = ids;
|
||||||
uploadNames[f.name] = names;
|
uploadNames[f.name] = names;
|
||||||
|
uploadMeta[f.name] = metas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 附件按模块要求组装成对象数组(作业模块 attachments:[{fileId,fileUrl,fileType,fileName,sort}]) */
|
||||||
|
function attachmentObjects(f: FormField) {
|
||||||
|
return (uploadMeta[f.name] || []).map((m, i) => ({
|
||||||
|
fileId: m.id,
|
||||||
|
fileUrl: `/api/file/Preview?id=${m.id}`,
|
||||||
|
fileType: /^image/i.test(m.type) || /\.(jpe?g|gif|png|bmp|webp)$/i.test(m.name) ? "image" : "file",
|
||||||
|
fileName: m.name,
|
||||||
|
sort: i,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 审批流(widget=flow) ==================== */
|
||||||
|
/** 是否走审批流程(按字段名) */
|
||||||
|
const flowEnabled = reactive<Record<string, boolean>>({});
|
||||||
|
/** 审批步骤:每步一个表决方式 + 审批人列表(按字段名) */
|
||||||
|
const flowSteps = reactive<Record<string, { vote: "any" | "all"; ids: any[] }[]>>({});
|
||||||
|
|
||||||
|
function addFlowStep(f: FormField) {
|
||||||
|
(flowSteps[f.name] ||= []).push({ vote: "any", ids: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFlowStep(f: FormField, i: number) {
|
||||||
|
const list = flowSteps[f.name] || [];
|
||||||
|
if (list.length <= 1) return;
|
||||||
|
list.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组装 flow 参数:[{name,vote,approverTeaIds}](与模块页 ApprovalFlowEditor 的提交口径一致) */
|
||||||
|
function buildFlow(f: FormField) {
|
||||||
|
if (!flowEnabled[f.name]) return undefined;
|
||||||
|
const steps = (flowSteps[f.name] || []).filter(s => (s.ids || []).length);
|
||||||
|
if (!steps.length) return undefined;
|
||||||
|
return steps.map((s, i) => ({ name: `第${i + 1}步`, vote: s.vote, approverTeaIds: s.ids.map(x => Number(x)) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批人下拉的显示名(把 teaId 翻成姓名,用于提交后的只读摘要) */
|
||||||
|
function approverNames(f: FormField, ids: any[]): string {
|
||||||
|
const opts = fieldOptions(f);
|
||||||
|
return ids.map(id => opts.find(o => String(o.value) === String(id))?.label || String(id)).join("、");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================== 下拉选项:动态代取 + 联动 ==================== */
|
/* ==================== 下拉选项:动态代取 + 联动 ==================== */
|
||||||
@@ -175,6 +233,8 @@ function depSignature(f: FormField) {
|
|||||||
*/
|
*/
|
||||||
function widgetOf(f: FormField) {
|
function widgetOf(f: FormField) {
|
||||||
const st = optState[f.name];
|
const st = optState[f.name];
|
||||||
|
// 审批流即使取不到教师名单也不退化 —— 它要渲染的是「步骤编辑器」,退成文本框毫无意义
|
||||||
|
if (f.widget === "flow") return "flow";
|
||||||
if (f.optionCall && st === "fail" && !baseOptions(f).length) {
|
if (f.optionCall && st === "fail" && !baseOptions(f).length) {
|
||||||
// 多选(含推送范围)退化成文本(用户可手填逗号串编号),单选退化成数字/文本
|
// 多选(含推送范围)退化成文本(用户可手填逗号串编号),单选退化成数字/文本
|
||||||
if (f.widget === "multiselect" || f.widget === "scopes") return "text";
|
if (f.widget === "multiselect" || f.widget === "scopes") return "text";
|
||||||
@@ -257,13 +317,21 @@ function initValues() {
|
|||||||
Object.keys(values).forEach(k => delete values[k]);
|
Object.keys(values).forEach(k => delete values[k]);
|
||||||
Object.keys(errors).forEach(k => delete errors[k]);
|
Object.keys(errors).forEach(k => delete errors[k]);
|
||||||
Object.keys(uploadNames).forEach(k => delete uploadNames[k]);
|
Object.keys(uploadNames).forEach(k => delete uploadNames[k]);
|
||||||
|
Object.keys(uploadMeta).forEach(k => delete uploadMeta[k]);
|
||||||
Object.keys(uploadState).forEach(k => delete uploadState[k]);
|
Object.keys(uploadState).forEach(k => delete uploadState[k]);
|
||||||
|
Object.keys(flowEnabled).forEach(k => delete flowEnabled[k]);
|
||||||
|
Object.keys(flowSteps).forEach(k => delete flowSteps[k]);
|
||||||
resetOptionState();
|
resetOptionState();
|
||||||
// 「全字段表单」把已识别的参数直接预填进控件(不再只做只读回显)
|
// 「全字段表单」把已识别的参数直接预填进控件(不再只做只读回显)
|
||||||
const preset = props.form?.preset || {};
|
const preset = props.form?.preset || {};
|
||||||
for (const f of props.form?.fields || []) {
|
for (const f of props.form?.fields || []) {
|
||||||
const seed = preset[f.name] !== undefined && preset[f.name] !== null ? preset[f.name] : f.defaultValue;
|
const seed = preset[f.name] !== undefined && preset[f.name] !== null ? preset[f.name] : f.defaultValue;
|
||||||
if (f.widget === "datetime" || f.widget === "date") {
|
if (f.mirrorOf) {
|
||||||
|
continue;
|
||||||
|
} else if (f.widget === "flow") {
|
||||||
|
flowEnabled[f.name] = false;
|
||||||
|
flowSteps[f.name] = [{ vote: "any", ids: [] }];
|
||||||
|
} else if (f.widget === "datetime" || f.widget === "date") {
|
||||||
values[f.name] = toDate(seed) ?? "";
|
values[f.name] = toDate(seed) ?? "";
|
||||||
} else if (f.widget === "multiselect" || f.widget === "scopes") {
|
} else if (f.widget === "multiselect" || f.widget === "scopes") {
|
||||||
const arr = Array.isArray(seed) ? [...seed] : seed ? [seed] : [];
|
const arr = Array.isArray(seed) ? [...seed] : seed ? [seed] : [];
|
||||||
@@ -401,8 +469,35 @@ function onSubmit() {
|
|||||||
// 2) 用户填写的字段
|
// 2) 用户填写的字段
|
||||||
const rows: { label: string; text: string }[] = [];
|
const rows: { label: string; text: string }[] = [];
|
||||||
for (const f of props.form?.fields || []) {
|
for (const f of props.form?.fields || []) {
|
||||||
|
// 镜像字段(deptName 等):不带输入控件,取源字段「选中项的显示名」一起提交,与模块页一致
|
||||||
|
if (f.mirrorOf) {
|
||||||
|
const src = values[f.mirrorOf];
|
||||||
|
if (src === undefined || src === null || String(src).trim() === "") continue;
|
||||||
|
const srcField = (props.form?.fields || []).find(x => x.name === f.mirrorOf);
|
||||||
|
const opt = srcField ? fieldOptions(srcField).find(o => String(o.value) === String(src)) : undefined;
|
||||||
|
args[f.name] = opt ? opt.label : String(src);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 审批流:拼成 [{name,vote,approverTeaIds}](只勾了「走审批」但一个人都没选 → 不提交该字段)
|
||||||
|
if (f.widget === "flow") {
|
||||||
|
const flow = buildFlow(f);
|
||||||
|
if (flow && flow.length) {
|
||||||
|
args[f.name] = flow;
|
||||||
|
rows.push({
|
||||||
|
label: f.label,
|
||||||
|
text: flow.map((s, i) => `第${i + 1}步(${s.vote === "all" ? "会签" : "或签"}):${approverNames(f, (flowSteps[f.name][i] || {}).ids || [])}`).join(";"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const v = values[f.name];
|
const v = values[f.name];
|
||||||
if (v === undefined || v === null || String(v).trim() === "") continue;
|
// 数组类字段即使是空数组也要提交(模块页就是恒带 fileIds:"" / attachments:[]),
|
||||||
|
// 否则「没传」与「传空」在 wire 上不一致,后端拿到的可能是 null 而不是空集合。
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
// 落到下面的分支自行拼装
|
||||||
|
} else if (v === undefined || v === null || String(v).trim() === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// 推送范围(校园公告 pushScopes):把多选结果拼成 [{scopeType,scopeId,scopeName}];
|
// 推送范围(校园公告 pushScopes):把多选结果拼成 [{scopeType,scopeId,scopeName}];
|
||||||
// 只勾「全部」→ [{scopeType:1,scopeId:0,scopeName:"全部班级"}]
|
// 只勾「全部」→ [{scopeType:1,scopeId:0,scopeName:"全部班级"}]
|
||||||
if (f.widget === "scopes") {
|
if (f.widget === "scopes") {
|
||||||
@@ -418,11 +513,23 @@ function onSubmit() {
|
|||||||
rows.push({ label: f.label, text: payload.map(p => p.scopeName).join("、") });
|
rows.push({ label: f.label, text: payload.map(p => p.scopeName).join("、") });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// 附件:作业模块要对象数组(attachments),其余模块是 fileId 数组(后端再按 join 拼串)
|
||||||
|
if (f.widget === "file" && f.fileAsObject) {
|
||||||
|
const objs = attachmentObjects(f);
|
||||||
|
args[f.name] = objs;
|
||||||
|
rows.push({ label: f.label, text: objs.map(o => o.fileName).join("、") || `${objs.length} 个附件` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const normalized = normalize(f, v);
|
const normalized = normalize(f, v);
|
||||||
args[f.name] = normalized;
|
args[f.name] = normalized;
|
||||||
const opt = fieldOptions(f).find(o => String(o.value) === String(normalized));
|
const opt = fieldOptions(f).find(o => String(o.value) === String(normalized));
|
||||||
rows.push({ label: f.label, text: opt ? opt.label : String(normalized) });
|
rows.push({ label: f.label, text: opt ? opt.label : String(normalized) });
|
||||||
}
|
}
|
||||||
|
// 选了「提交审批」却没配审批人 → 与模块页一致地拦下来(后端也会拒)
|
||||||
|
if (args.publishType === "approve" && !(Array.isArray(args.flow) && args.flow.length)) {
|
||||||
|
Message.warning("发布方式选了「提交审批」,请先在「审批流程」里勾选并选择审批人");
|
||||||
|
return;
|
||||||
|
}
|
||||||
summary.value = rows;
|
summary.value = rows;
|
||||||
emit("submit", { tool: props.form.tool, args });
|
emit("submit", { tool: props.form.tool, args });
|
||||||
}
|
}
|
||||||
@@ -465,7 +572,12 @@ function reset() {
|
|||||||
|
|
||||||
<!-- 填写区 -->
|
<!-- 填写区 -->
|
||||||
<div v-if="!submitted" class="form-card__fields">
|
<div v-if="!submitted" class="form-card__fields">
|
||||||
<div v-for="f in form.fields" :key="f.name" class="field">
|
<div
|
||||||
|
v-for="f in renderFields"
|
||||||
|
:key="f.name"
|
||||||
|
class="field"
|
||||||
|
:class="{ 'field--wide': f.widget === 'flow' }"
|
||||||
|
>
|
||||||
<label class="field__label">
|
<label class="field__label">
|
||||||
<span v-if="f.required" class="field__star">*</span>{{ f.label }}
|
<span v-if="f.required" class="field__star">*</span>{{ f.label }}
|
||||||
</label>
|
</label>
|
||||||
@@ -512,6 +624,38 @@ function reset() {
|
|||||||
<span v-else class="file-tip">可选,支持图片 / 文档 / 压缩包</span>
|
<span v-else class="file-tip">可选,支持图片 / 文档 / 压缩包</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="widgetOf(f) === 'flow'" class="field__flow">
|
||||||
|
<Checkbox v-model="flowEnabled[f.name]" :disabled="disabled">
|
||||||
|
选择审批人后走审批流程(不勾选 = 直接发布)
|
||||||
|
</Checkbox>
|
||||||
|
<template v-if="flowEnabled[f.name]">
|
||||||
|
<div v-for="(st, i) in flowSteps[f.name] || []" :key="i" class="flow-step">
|
||||||
|
<span class="flow-step__no">第 {{ i + 1 }} 步</span>
|
||||||
|
<Select v-model="st.vote" size="small" :disabled="disabled" style="width: 92px; flex: none">
|
||||||
|
<Option value="any">或签</Option>
|
||||||
|
<Option value="all">会签</Option>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
v-model="st.ids"
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
:placeholder="optState[f.name] === 'loading' ? '正在读取审批人…' : '选择审批人(可多选)'"
|
||||||
|
:disabled="disabled"
|
||||||
|
:loading="optState[f.name] === 'loading'"
|
||||||
|
style="flex: 1; min-width: 150px"
|
||||||
|
>
|
||||||
|
<Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option>
|
||||||
|
</Select>
|
||||||
|
<a v-if="(flowSteps[f.name] || []).length > 1" class="flow-step__del" @click="removeFlowStep(f, i)">删除</a>
|
||||||
|
</div>
|
||||||
|
<a class="flow-add" @click="addFlowStep(f)">+ 再加一步(多步依次审批)</a>
|
||||||
|
<p class="field__tip">按顺序流转:上一步通过后进入下一步,任一步驳回则退回发布人。</p>
|
||||||
|
<p v-if="optState[f.name] === 'fail'" class="field__warn">
|
||||||
|
审批人名单暂时没取到(教师树接口未通),可以先不勾选「走审批流程」直接发布,或稍后重试。
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DatePicker
|
<DatePicker
|
||||||
v-else-if="widgetOf(f) === 'datetime'"
|
v-else-if="widgetOf(f) === 'datetime'"
|
||||||
v-model="values[f.name]"
|
v-model="values[f.name]"
|
||||||
@@ -728,6 +872,48 @@ function reset() {
|
|||||||
color: var(--agent-danger);
|
color: var(--agent-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------ 审批流(widget=flow) ------------------------------ */
|
||||||
|
.field--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__flow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 1px dashed var(--agent-border-strong);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--agent-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step__no {
|
||||||
|
flex: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--agent-text-mute);
|
||||||
|
width: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step__del {
|
||||||
|
flex: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--agent-danger);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-add {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--agent-primary-dark);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------ 附件上传 ------------------------------ */
|
/* ------------------------------ 附件上传 ------------------------------ */
|
||||||
.file-btn {
|
.file-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
+27
-1
@@ -36,7 +36,7 @@ export interface FormField {
|
|||||||
/** 中文标签,如「开始时间」 */
|
/** 中文标签,如「开始时间」 */
|
||||||
label: string;
|
label: string;
|
||||||
/** 控件类型 */
|
/** 控件类型 */
|
||||||
widget: "text" | "textarea" | "number" | "date" | "datetime" | "select" | "multiselect" | "file";
|
widget: "text" | "textarea" | "number" | "date" | "datetime" | "select" | "multiselect" | "file" | "scopes" | "flow";
|
||||||
type?: string;
|
type?: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
/** 字段说明(含义与格式) */
|
/** 字段说明(含义与格式) */
|
||||||
@@ -59,6 +59,18 @@ export interface FormField {
|
|||||||
allTokenLabel?: string;
|
allTokenLabel?: string;
|
||||||
/** widget=scopes(校园公告 pushScopes):节点类型 1 全校 / 2 学部 / 3 年级 / 4 班级 / 5 训练队 / 6 学员 */
|
/** widget=scopes(校园公告 pushScopes):节点类型 1 全校 / 2 学部 / 3 年级 / 4 班级 / 5 训练队 / 6 学员 */
|
||||||
scopeType?: number;
|
scopeType?: number;
|
||||||
|
/**
|
||||||
|
* 「镜像字段」:本字段不渲染输入控件,提交时自动填入同一表单中该字段**选中项的显示名**。
|
||||||
|
* 例:deptName 镜像 deptId —— 模块页会「选部门 → 用字典反查名称 → 一起提交」。
|
||||||
|
*/
|
||||||
|
mirrorOf?: string;
|
||||||
|
/**
|
||||||
|
* 「连续请假」展开说明:{ count, start, end }。
|
||||||
|
* 本字段取 1 时由后端按天数把起止时间复制成逐天时间段(continuousLeaveTime)。
|
||||||
|
*/
|
||||||
|
dailyExpand?: { count: string; start: string; end: string };
|
||||||
|
/** widget=file 时提交**附件对象数组**(作业模块 attachments),而非 fileId 数组 */
|
||||||
|
fileAsObject?: boolean;
|
||||||
/** 下拉选项 */
|
/** 下拉选项 */
|
||||||
options?: { value: string; label: string }[];
|
options?: { value: string; label: string }[];
|
||||||
/**
|
/**
|
||||||
@@ -139,6 +151,20 @@ export interface BrowserToolCall {
|
|||||||
pathAndQuery: string;
|
pathAndQuery: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
|
/**
|
||||||
|
* 非空 = 「两步提交」的第二步(首步成功后自动续跑)。
|
||||||
|
* 模块里有些动作没有一步到位的接口 —— 作业发布就是 `Create`(落草稿拿 id)
|
||||||
|
* → `Publish`/`Schedule`。路径里的 `{data}` 由首步响应 data 节点替换。
|
||||||
|
*/
|
||||||
|
followUp?: FollowUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 两步提交的第二步描述 */
|
||||||
|
export interface FollowUp {
|
||||||
|
method: "GET" | "POST" | "POST_JSON" | "POST_FORM";
|
||||||
|
pathAndQuery: string;
|
||||||
|
body?: string;
|
||||||
|
contentType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 代执行结果回传项(与 BrowserToolCall 按 name 对应) */
|
/** 代执行结果回传项(与 BrowserToolCall 按 name 对应) */
|
||||||
|
|||||||
+99
-10
@@ -74,26 +74,80 @@ export function warmModuleSession(pathAndQuery: string, onStart?: () => void, ti
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 一次模块接口请求的描述(首步用 BrowserToolCall,第二步用它的 followUp) */
|
||||||
|
interface ModuleRequest {
|
||||||
|
method: string;
|
||||||
|
pathAndQuery: string;
|
||||||
|
body?: string;
|
||||||
|
contentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务是否成功(模块统一包装 `{code,msg,data}`,`code=0` 才算成功)。
|
||||||
|
* 无包装(老接口直接回数组/对象)也当成功 —— 与服务端 {@code parseResponse} 口径一致。
|
||||||
|
*/
|
||||||
|
function isBizSuccess(status: number, body?: string): boolean {
|
||||||
|
if (status < 200 || status >= 300) return false;
|
||||||
|
const s = (body || "").trim();
|
||||||
|
if (!s) return true;
|
||||||
|
if (s.startsWith("<")) return false; // 拿回网页 = 没落到接口上
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(s);
|
||||||
|
if (j && typeof j === "object" && "code" in j) return Number((j as any).code) === 0;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从首步响应里取「资料编号」用于第二步路径(`{data}`)。
|
||||||
|
*
|
||||||
|
* 两种常见形态:直接回 id(作业创建 `data=123`),或回一个对象(`data={id:123,...}`)——
|
||||||
|
* 对象必须取 `id` 再填路径,直接 JSON 序列化会拼出 `/homeworks/{"id":123}/publish`。
|
||||||
|
*/
|
||||||
|
function extractDataId(body?: string): string | null {
|
||||||
|
const s = (body || "").trim();
|
||||||
|
if (!s) return null;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(s);
|
||||||
|
const d = j && typeof j === "object" ? (j as any).data : undefined;
|
||||||
|
if (d === undefined || d === null) return null;
|
||||||
|
if (typeof d === "object") {
|
||||||
|
const v = (d as any).id;
|
||||||
|
return v === undefined || v === null || String(v) === "" ? null : String(v);
|
||||||
|
}
|
||||||
|
const v = String(d);
|
||||||
|
return v === "" || v === "null" ? null : v;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用用户自己的登录态调用一次模块接口;遇到 `code=302` 会先预热会话再重试一次。
|
* 用用户自己的登录态调用一次模块接口;遇到 `code=302` 会先预热会话再重试一次。
|
||||||
*
|
*
|
||||||
|
* 若该调用声明了 `followUp`(两步提交,如作业「先落草稿 → 再发布/定时」),
|
||||||
|
* 首步**业务成功**后自动用返回的资源 id 续跑第二步,回传的是第二步的结果。
|
||||||
|
*
|
||||||
* @param call 接口描述(`pathAndQuery` 相对当前站点根,同站部署 ⇒ 同源)
|
* @param call 接口描述(`pathAndQuery` 相对当前站点根,同站部署 ⇒ 同源)
|
||||||
* @param onWarm 识别出「需要建会话」时的回调(用于更新界面文案)
|
* @param onWarm 识别出「需要建会话」时的回调(用于更新界面文案)
|
||||||
*/
|
*/
|
||||||
export async function callModuleApi(call: BrowserToolCall, onWarm?: () => void): Promise<ToolResultItem> {
|
export async function callModuleApi(call: BrowserToolCall, onWarm?: () => void): Promise<ToolResultItem> {
|
||||||
const base = { name: call.name, arguments: call.arguments };
|
const base = { name: call.name, arguments: call.arguments };
|
||||||
const send = async () => {
|
|
||||||
|
const send = async (req: ModuleRequest) => {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), 15000);
|
const timer = setTimeout(() => ctrl.abort(), 15000);
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { yschapirequest: "1" };
|
const headers: Record<string, string> = { yschapirequest: "1" };
|
||||||
let body: string | undefined;
|
let body: string | undefined;
|
||||||
if (call.method !== "GET") {
|
if (req.method !== "GET") {
|
||||||
headers["Content-Type"] = call.contentType || "application/json;charset=UTF-8";
|
headers["Content-Type"] = req.contentType || "application/json;charset=UTF-8";
|
||||||
body = call.body;
|
body = req.body;
|
||||||
}
|
}
|
||||||
const res = await fetch(`${location.origin}${call.pathAndQuery}`, {
|
const res = await fetch(`${location.origin}${req.pathAndQuery}`, {
|
||||||
method: call.method === "GET" ? "GET" : "POST",
|
method: req.method === "GET" ? "GET" : "POST",
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
@@ -104,13 +158,48 @@ export async function callModuleApi(call: BrowserToolCall, onWarm?: () => void):
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
try {
|
|
||||||
let r = await send();
|
/** 发一次请求,遇 `code=302` 先预热模块会话再重试 */
|
||||||
|
const sendWithWarm = async (req: ModuleRequest) => {
|
||||||
|
let r = await send(req);
|
||||||
if (isLoginRedirect(r.status, r.body)) {
|
if (isLoginRedirect(r.status, r.body)) {
|
||||||
const warmed = await warmModuleSession(call.pathAndQuery, onWarm);
|
const warmed = await warmModuleSession(req.pathAndQuery, onWarm);
|
||||||
if (warmed) r = await send();
|
if (warmed) r = await send(req);
|
||||||
}
|
}
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fu = (call as any).followUp as ModuleRequest | undefined;
|
||||||
|
const r = await sendWithWarm({
|
||||||
|
method: call.method,
|
||||||
|
pathAndQuery: call.pathAndQuery,
|
||||||
|
body: call.body,
|
||||||
|
contentType: call.contentType,
|
||||||
|
});
|
||||||
|
if (!fu || !fu.pathAndQuery) {
|
||||||
return { ...base, status: r.status, body: r.body };
|
return { ...base, status: r.status, body: r.body };
|
||||||
|
}
|
||||||
|
// 首步没成功(HTTP 错 / code≠0 / 拿回网页)就不发第二步,如实回传首步结果
|
||||||
|
if (!isBizSuccess(r.status, r.body)) {
|
||||||
|
return { ...base, status: r.status, body: r.body };
|
||||||
|
}
|
||||||
|
const id = extractDataId(r.body);
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: r.status,
|
||||||
|
body: r.body,
|
||||||
|
error: "第一步已提交,但没拿回资料编号,无法继续第二步(发布 / 定时发布)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const r2 = await sendWithWarm({
|
||||||
|
method: fu.method || "POST",
|
||||||
|
pathAndQuery: fu.pathAndQuery.replace("{data}", encodeURIComponent(id)),
|
||||||
|
body: fu.body,
|
||||||
|
contentType: fu.contentType,
|
||||||
|
});
|
||||||
|
return { ...base, status: r2.status, body: r2.body };
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const reason = e?.name === "AbortError" ? "请求超时(15 秒)" : e?.message || "网络请求失败";
|
const reason = e?.name === "AbortError" ? "请求超时(15 秒)" : e?.message || "网络请求失败";
|
||||||
return { ...base, error: reason };
|
return { ...base, error: reason };
|
||||||
|
|||||||
+3
-1
@@ -197,7 +197,9 @@ async function runBrowserTool(placeholder: ChatItem, pending: any) {
|
|||||||
placeholder.text = pending?.reply || pending?.browserTool?.notice || "正在用你的登录态查询业务模块…";
|
placeholder.text = pending?.reply || pending?.browserTool?.notice || "正在用你的登录态查询业务模块…";
|
||||||
const results: ToolResultItem[] = [];
|
const results: ToolResultItem[] = [];
|
||||||
for (const call of calls) {
|
for (const call of calls) {
|
||||||
placeholder.stage = `正在用你的登录态${call.write ? "提交" : "查询"}「${call.title}」…`;
|
placeholder.stage = call.followUp
|
||||||
|
? `正在用你的登录态提交并完成「${call.title}」…`
|
||||||
|
: `正在用你的登录态${call.write ? "提交" : "查询"}「${call.title}」…`;
|
||||||
results.push(await execBrowserCall(call, () => {
|
results.push(await execBrowserCall(call, () => {
|
||||||
const ctx = call.pathAndQuery.split("/").filter(Boolean)[0] || "该模块";
|
const ctx = call.pathAndQuery.split("/").filter(Boolean)[0] || "该模块";
|
||||||
placeholder.stage = `「${ctx}」模块还没建立登录会话,正在为你自动登录…`;
|
placeholder.stage = `「${ctx}」模块还没建立登录会话,正在为你自动登录…`;
|
||||||
|
|||||||
Reference in New Issue
Block a user