diff --git a/src/api/module/svc.ts b/src/api/module/svc.ts index 0ce1d30..1a147ce 100644 --- a/src/api/module/svc.ts +++ b/src/api/module/svc.ts @@ -4,6 +4,9 @@ import config from "@/assets/config/host.config"; import type { ApplySubmitRequest, ApplyVo, + FcIntentConfirmView, + FcIntentCreateRequest, + FcIntentView, FlowProgressVo, HallFilters, HallItem, @@ -109,6 +112,20 @@ class ApplyApi { item(siteCode: string, itemCode: string) { return get>(`${APPLY}/item`, { siteCode, itemCode }); } + /** + * 创建可信建件意图(TASK_2 A.1,绑定表单中心确切版本的事项走这条)。 + * 入参只有事项坐标 + 客户端幂等键;返回的被嵌页地址与令牌都由服务端签发,前端不参与拼装。 + */ + createIntent(data: FcIntentCreateRequest) { + return postJSON(`${APPLY}/intents`, data); + } + /** + * 确认(TASK_2 A.4):被嵌页那句"提交好了"只能触发这一步—— + * 后端去读表单中心的签名收据,答卷坐标/提交者一律以服务端返回为准,浏览器说的不算。 + */ + confirmIntent(intentId: number) { + return postJSON(`${APPLY}/intents/${intentId}/confirm`); + } detail(id: number) { return get(`${APPLY}/${id}`); } diff --git a/src/types/svc.ts b/src/types/svc.ts index f1082aa..8aa5180 100644 --- a/src/types/svc.ts +++ b/src/types/svc.ts @@ -59,6 +59,50 @@ export interface ApplySubmitRequest { attachments?: ApplyAttachmentItem[]; } +/** + * 可信建件意图(TASK_2 A.1/A.2;POST /api/apply/intents 的出入参)。 + * + * 浏览器能提交的只有事项坐标与自己的幂等键:提交者/学校/流程线一律由后端从 CAS 会话与 + * 事项行取,答卷坐标只能从表单中心的服务端收据来({@code FcIntentConfirmView})。 + * 因此这个类型里没有 answerId/bizId/submitterId 字段位——不是"我们不建议传",是传了也没有。 + */ +export interface FcIntentCreateRequest { + siteCode: string; + itemCode: string; + /** 客户端幂等键(uuid):重复 POST 回到同一意图,不会生成第二个业务意图 */ + clientKey: string; +} + +export interface FcIntentView { + intentId: number; + fcIntentId: string; + state: string; + siteCode: string; + itemCode: string; + itemTitle: string; + fcFormUuid: string; + fcFormVersion: number; + flowGuid?: string; + /** 被嵌填报页地址(服务端按配置基址拼好,前端只负责塞进 iframe.src) */ + fillPageUrl: string; + contextToken: string; + expiresAt?: string; + reused?: boolean; +} + +export interface FcIntentConfirmView { + intentId: number; + state: string; + answerUuid?: string; + answerRevision?: number; + contentHash?: string; + applyId?: number; + flowStatus?: number; + flowEnabled?: boolean; + /** 收据还没有答卷时的原因(不是错误:填报尚未完成) */ + message?: string; +} + /** 事项大厅列表行(后端复用 oapi ItemRow,见 HallController) */ export interface HallItem { id: number; diff --git a/src/views/Apply.vue b/src/views/Apply.vue index d8e464a..0ecbad3 100644 --- a/src/views/Apply.vue +++ b/src/views/Apply.vue @@ -10,10 +10,11 @@ * flowEnabled=true → 排队中:轮询详情等状态翻转(10=审批中),翻转即报成功; * 超时未翻转才告知"已保存为待提交,可在我的办事重试"(此时多半真失败了)。 */ -import { computed, onMounted, reactive, ref } from "vue"; +import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue"; import { useRoute, useRouter } from "vue-router"; import { applyApi } from "@/api/module/svc"; -import type { ApplyAttachmentItem } from "@/types/svc"; +import type { ApplyAttachmentItem, FcIntentConfirmView, FcIntentView } from "@/types/svc"; +import Utils from "@/utils"; import { useSite } from "@/utils/site"; import DynamicForm from "@/components/DynamicForm.vue"; const route = useRoute(); @@ -147,6 +148,138 @@ function toDate(s?: string | null): Date | null { return Number.isNaN(d.getTime()) ? null : d; } +// ==================== 可信建件(表单中心托管填报,TASK_2 A.1/A.4) ==================== + +/** 事项绑了表单中心的确切版本 ⇒ 走托管通道:本页不自己渲染表单、也不自己收表单数据 */ +const hosted = computed(() => item.value?.hosted === true); +const intent = ref(null); +const intentError = ref(""); +const starting = ref(false); +const confirming = ref(false); +const iframeEl = ref(null); +const hostedTip = ref(""); +let detachListener: (() => void) | null = null; + +/** 精确 origin(scheme+host+port);A.4 明令不许按同 hostname 降级比较 */ +function exactOriginOf(url: string): string { + try { + return new URL(url).origin; + } catch { + return ""; + } +} + +/** 发起意图:入参只有事项坐标 + 客户端幂等键,被嵌页地址与令牌都由服务端给 */ +async function startHosted() { + if (!item.value) return; + intentError.value = ""; + starting.value = true; + try { + intent.value = await applyApi + .createIntent({ + siteCode: siteCode.value, + itemCode: String(route.query.itemCode), + clientKey: Utils.uuid(), + }) + .send(); + hostedTip.value = "请在下方页面完成填报,提交后本页会自动确认。"; + attachHostedListener(); + } catch (e: any) { + intentError.value = String(e?.message || e || "发起失败"); + } finally { + starting.value = false; + } +} + +/** + * 消息来源三重校验(TASK_2 A.4,三条缺一即忽略): + * ① {@code event.origin} 逐字等于被嵌页 origin; + * ② {@code event.source} 必须是当前这个 iframe 的 contentWindow + * (同源的另一个窗口——比如被嵌页自己开的新标签——也不行); + * ③ {@code proto/v/type} 闭集。 + * 校验通过也只得到一个"可以去确认"的信号:答卷坐标、提交者一律由后端读表单中心的签名收据决定, + * 浏览器说的话在整个建件链里没有任何证据力(连 answerId 都不看)。 + */ +function attachHostedListener() { + if (detachListener) return; + const expected = exactOriginOf(intent.value?.fillPageUrl || ""); + const handler = (event: MessageEvent) => { + if (!expected || event.origin !== expected) return; + const frameWindow = iframeEl.value?.contentWindow; + if (!frameWindow || event.source !== frameWindow) return; + const data: any = event.data; + if (!data || data.proto !== "fc" || data.v !== 1) return; + if (data.type === "success" && data.hosted === true) confirmHosted(); + else if (data.type === "error") hostedTip.value = "填报未成功:" + (data.error || "请重试"); + else if (data.type === "needLogin") hostedTip.value = "填报页需要登录态,请重新登录后再试。"; + }; + window.addEventListener("message", handler); + detachListener = () => window.removeEventListener("message", handler); +} + +/** 确认:后端读服务端收据 → 复核 → 建件;未提交时只回原因,不报错也不建空件 */ +async function confirmHosted() { + const current = intent.value; + if (!current || confirming.value) return; + confirming.value = true; + try { + const result: FcIntentConfirmView = await applyApi.confirmIntent(current.intentId).send(); + if (result.applyId) { + hostedTip.value = ""; + await finishFromApply(result.applyId); + } else { + hostedTip.value = result.message || "中心尚未收到答卷,填报完成后再点一次确认"; + } + } catch (e: any) { + hostedTip.value = String(e?.message || e || "确认失败,请稍后重试"); + } finally { + confirming.value = false; + } +} + +/** 托管建件完成后复用既有的"排队中/未接入"分流与成功页(与本地表单那条同一套判据) */ +async function finishFromApply(applyId: number) { + let latest: any = null; + try { + latest = await applyApi.detail(applyId).send(); + } catch { + latest = null; + } + if (latest && latest.flowStatus === 0 && latest.flowEnabled !== false) { + latest = (await awaitFlowStart(applyId, latest)) || latest; + } + const itemTitle = String(item.value?.title || intent.value?.itemTitle || ""); + const enabled = latest?.flowEnabled !== false && intent.value?.flowGuid != null; + const headline = !enabled + ? "申请已保存为待提交" + : latest && latest.flowStatus !== 0 + ? "已提交,进入审批流程" + : "申请已保存为待提交"; + const tip = !enabled + ? "流程中心未接入,请联系管理员开通后在「我的办事」重试提交" + : latest && latest.flowStatus !== 0 + ? "已进入审批流程,可在办件详情查看进度" + : "流程中心响应较慢,可在「我的办事」中重试提交"; + const promise = String(item.value?.promiseTimeLimit || ""); + const days = parsePromiseDays(promise); + const submittedAt = toDate(latest?.submitTime || latest?.createTime) || new Date(); + done.value = { + applyId, + itemTitle, + headline, + tip, + tone: headline.startsWith("已提交") ? "success" : "warning", + currentNode: headline.startsWith("已提交") ? await currentNodeName(applyId) : "", + promise, + eta: days ? addDays(submittedAt, days) : promise, + }; + if (detachListener) { + detachListener(); + detachListener = null; + } + window.scrollTo(0, 0); +} + /** 当前节点:进度接口里 active 节点的名称;进度不可见时留空(该行整体不显示) */ async function currentNodeName(applyId: number): Promise { try { @@ -218,6 +351,12 @@ async function submit() { } onMounted(load); +onBeforeUnmount(() => { + if (detachListener) { + detachListener(); + detachListener = null; + } +});