This commit is contained in:
@@ -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<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) {
|
||||
return get<ApplyVo>(`${APPLY}/${id}`);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,50 @@ export interface ApplySubmitRequest {
|
||||
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) */
|
||||
export interface HallItem {
|
||||
id: number;
|
||||
|
||||
+208
-12
@@ -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<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 节点的名称;进度不可见时留空(该行整体不显示) */
|
||||
async function currentNodeName(applyId: number): Promise<string> {
|
||||
try {
|
||||
@@ -218,6 +351,12 @@ async function submit() {
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
onBeforeUnmount(() => {
|
||||
if (detachListener) {
|
||||
detachListener();
|
||||
detachListener = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -271,16 +410,45 @@ onMounted(load);
|
||||
<span class="sh-tag sh-tag-success" v-if="runText">{{ runText }}</span>
|
||||
<span class="sh-tag" v-if="item.handleTimeLimit">办理时限 {{ item.handleTimeLimit }}</span>
|
||||
</div>
|
||||
<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>
|
||||
<!-- 可信建件通道(TASK_2 A.1/A.4):表单在中心,本页只承载被嵌填报页与"确认"这一个动作 -->
|
||||
<template v-if="hosted">
|
||||
<Divider orientation="left">在线填报</Divider>
|
||||
<div v-if="!intent" class="hosted-start">
|
||||
<div class="sh-muted">
|
||||
该事项的申请表单由表单中心统一维护。点击下方按钮在本页打开填报页;
|
||||
提交完成后本页会向服务端确认,办件号以确认结果为准。
|
||||
</div>
|
||||
<div class="actions">
|
||||
<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 :bordered="false" dis-hover v-else>
|
||||
<div class="state-tip">
|
||||
@@ -388,4 +556,32 @@ onMounted(load);
|
||||
padding: 40px;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user