t2: 办事页接表单中心可信提交(Apply.vue 托管填报告知)
build / build (push) Successful in 1m39s

This commit is contained in:
chenxing
2026-10-22 10:03:23 +08:00
parent ce876760af
commit 0ae8c6a911
3 changed files with 269 additions and 12 deletions
+17
View File
@@ -4,6 +4,9 @@ import config from "@/assets/config/host.config";
import type { import type {
ApplySubmitRequest, ApplySubmitRequest,
ApplyVo, ApplyVo,
FcIntentConfirmView,
FcIntentCreateRequest,
FcIntentView,
FlowProgressVo, FlowProgressVo,
HallFilters, HallFilters,
HallItem, HallItem,
@@ -109,6 +112,20 @@ class ApplyApi {
item(siteCode: string, itemCode: string) { item(siteCode: string, itemCode: string) {
return get<Record<string, any>>(`${APPLY}/item`, { siteCode, itemCode }); return get<Record<string, any>>(`${APPLY}/item`, { siteCode, itemCode });
} }
/**
* 创建可信建件意图(TASK_2 A.1,绑定表单中心确切版本的事项走这条)。
* 入参只有事项坐标 + 客户端幂等键;返回的被嵌页地址与令牌都由服务端签发,前端不参与拼装。
*/
createIntent(data: FcIntentCreateRequest) {
return postJSON<FcIntentView>(`${APPLY}/intents`, data);
}
/**
* 确认(TASK_2 A.4):被嵌页那句"提交好了"只能触发这一步——
* 后端去读表单中心的签名收据,答卷坐标/提交者一律以服务端返回为准,浏览器说的不算。
*/
confirmIntent(intentId: number) {
return postJSON<FcIntentConfirmView>(`${APPLY}/intents/${intentId}/confirm`);
}
detail(id: number) { detail(id: number) {
return get<ApplyVo>(`${APPLY}/${id}`); return get<ApplyVo>(`${APPLY}/${id}`);
} }
+44
View File
@@ -59,6 +59,50 @@ export interface ApplySubmitRequest {
attachments?: ApplyAttachmentItem[]; attachments?: ApplyAttachmentItem[];
} }
/**
* 可信建件意图(TASK_2 A.1/A.2;POST /api/apply/intents 的出入参)。
*
* 浏览器能提交的<b>只有事项坐标与自己的幂等键</b>:提交者/学校/流程线一律由后端从 CAS 会话与
* 事项行取,答卷坐标只能从表单中心的服务端收据来({@code FcIntentConfirmView})。
* 因此这个类型里<b>没有</b> 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) */ /** 事项大厅列表行(后端复用 oapi ItemRow,见 HallController) */
export interface HallItem { export interface HallItem {
id: number; id: number;
+208 -12
View File
@@ -10,10 +10,11 @@
* flowEnabled=true → 排队中:轮询详情等状态翻转(10=审批中),翻转即报成功; * 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 { useRoute, useRouter } from "vue-router";
import { applyApi } from "@/api/module/svc"; 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 { useSite } from "@/utils/site";
import DynamicForm from "@/components/DynamicForm.vue"; import DynamicForm from "@/components/DynamicForm.vue";
const route = useRoute(); const route = useRoute();
@@ -147,6 +148,138 @@ function toDate(s?: string | null): Date | null {
return Number.isNaN(d.getTime()) ? null : d; return Number.isNaN(d.getTime()) ? null : d;
} }
// ==================== 可信建件(表单中心托管填报,TASK_2 A.1/A.4) ====================
/** 事项绑了表单中心的确切版本 ⇒ 走托管通道:本页不自己渲染表单、也不自己收表单数据 */
const hosted = computed(() => item.value?.hosted === true);
const intent = ref<FcIntentView | null>(null);
const intentError = ref("");
const starting = ref(false);
const confirming = ref(false);
const iframeEl = ref<HTMLIFrameElement | null>(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 节点的名称;进度不可见时留空(该行整体不显示) */ /** 当前节点:进度接口里 active 节点的名称;进度不可见时留空(该行整体不显示) */
async function currentNodeName(applyId: number): Promise<string> { async function currentNodeName(applyId: number): Promise<string> {
try { try {
@@ -218,6 +351,12 @@ async function submit() {
} }
onMounted(load); onMounted(load);
onBeforeUnmount(() => {
if (detachListener) {
detachListener();
detachListener = null;
}
});
</script> </script>
<template> <template>
@@ -271,16 +410,45 @@ onMounted(load);
<span class="sh-tag sh-tag-success" v-if="runText">{{ runText }}</span> <span class="sh-tag sh-tag-success" v-if="runText">{{ runText }}</span>
<span class="sh-tag" v-if="item.handleTimeLimit">办理时限 {{ item.handleTimeLimit }}</span> <span class="sh-tag" v-if="item.handleTimeLimit">办理时限 {{ item.handleTimeLimit }}</span>
</div> </div>
<Divider v-if="schemaJson" orientation="left">申请表单</Divider> <!-- 可信建件通道(TASK_2 A.1/A.4):表单在中心,本页只承载被嵌填报页与"确认"这一个动作 -->
<DynamicForm v-if="schemaJson" ref="dynFormRef" :schema-json="schemaJson" :form-data="formData" /> <template v-if="hosted">
<div v-else class="no-form">该事项无需填写表单,提交后由办理部门联系确认。</div> <Divider orientation="left">在线填报</Divider>
<div class="actions"> <div v-if="!intent" class="hosted-start">
<Button class="sh-btn-primary" type="primary" :loading="submitting" @click="submit"> <div class="sh-muted">
提交申请 该事项的申请表单由表单中心统一维护。点击下方按钮在本页打开填报页;
</Button> 提交完成后本页会向服务端确认,办件号以确认结果为准。
<Button @click="router.push('/my')">我的办事</Button> </div>
<Button @click="router.push('/hall')">返回大厅</Button> <div class="actions">
</div> <Button class="sh-btn-primary" type="primary" :loading="starting"
@click="startHosted">开始在线填报</Button>
<Button @click="router.push('/my')">我的办事</Button>
<Button @click="router.push('/hall')">返回大厅</Button>
</div>
<div class="err" v-if="intentError">发起失败:{{ intentError }}</div>
</div>
<div v-else class="hosted-frame-wrap">
<iframe ref="iframeEl" :src="intent.fillPageUrl" class="hosted-frame" frameborder="0"
title="申请表单" />
<div class="hosted-foot">
<span class="sh-muted">{{ hostedTip }}</span>
<Button type="primary" :loading="confirming" @click="confirmHosted">
我已完成填报(确认)
</Button>
</div>
</div>
</template>
<template v-else>
<Divider v-if="schemaJson" orientation="left">申请表单</Divider>
<DynamicForm v-if="schemaJson" ref="dynFormRef" :schema-json="schemaJson" :form-data="formData" />
<div v-else class="no-form">该事项无需填写表单,提交后由办理部门联系确认。</div>
<div class="actions">
<Button class="sh-btn-primary" type="primary" :loading="submitting" @click="submit">
提交申请
</Button>
<Button @click="router.push('/my')">我的办事</Button>
<Button @click="router.push('/hall')">返回大厅</Button>
</div>
</template>
</Card> </Card>
<Card :bordered="false" dis-hover v-else> <Card :bordered="false" dis-hover v-else>
<div class="state-tip"> <div class="state-tip">
@@ -388,4 +556,32 @@ onMounted(load);
padding: 40px; padding: 40px;
color: var(--sh-text-weak); color: var(--sh-text-weak);
} }
/* 可信建件(被嵌填报页):iframe 高度给足,填报页自己上报高度前不能出现内部滚动条套娃 */
.hosted-start {
display: flex;
flex-direction: column;
gap: 12px;
}
.hosted-frame-wrap {
display: flex;
flex-direction: column;
gap: 10px;
}
.hosted-frame {
width: 100%;
min-height: 640px;
border: 1px solid var(--sh-border);
border-radius: calc(var(--sh-radius) - 4px);
background: #fff;
}
.hosted-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.err {
color: var(--sh-accent);
}
</style> </style>