diff --git a/src/api/module/fill.ts b/src/api/module/fill.ts index 818a477..ae6bc99 100644 --- a/src/api/module/fill.ts +++ b/src/api/module/fill.ts @@ -5,7 +5,7 @@ * 与后端 FormResolveController / FormAnswerController 对齐。 */ import { alovaInstance } from "@/utils/alova/alova-iview"; -import type { ResolvedForm, SubmitRequest, SubmitView, MyAnswerItem } from "@/types/answer"; +import type { HostedForm, ResolvedForm, SubmitRequest, SubmitView, MyAnswerItem } from "@/types/answer"; import type { AnswerDetail, Capabilities, FillableForm, ManagedForm, MyAnswerViewItem } from "@/types/data"; /** 解析当前发布版本(契约 §1:入口寻址) */ @@ -20,6 +20,19 @@ export function submitAnswer(body: SubmitRequest) { return alovaInstance.Post("/api/form/submit", body).send(); } +/** + * hosted 取址(answer-delivery-v2 §6.3,TASK_2 A.3):按上下文令牌拿**它钉住那一版**的 schema。 + * 与 resolveForm 的区别就是这条链没有"当前发布版"概念——升版窗口里在途的办件必须按当时那版填。 + */ +export function resolveHostedForm(contextToken: string) { + return alovaInstance.Get("/api/form/hosted/schema", { params: { contextToken } }).send(); +} + +/** hosted 提交(同一份管线,多带 contextToken;后端在答卷事务内关联意图) */ +export function submitHostedAnswer(body: SubmitRequest) { + return alovaInstance.Post("/api/form/hosted/submit", body).send(); +} + /** 我的答卷列表(按 submitter 过滤,"我的提交"段) */ export function listMyAnswers(formCode: string, appCode?: string) { const params: Record = { formCode }; diff --git a/src/api/module/formAccess.ts b/src/api/module/formAccess.ts new file mode 100644 index 0000000..f6c5e60 --- /dev/null +++ b/src/api/module/formAccess.ts @@ -0,0 +1,74 @@ +/** + * 消费应用授权管理面(v3/TASK_1,挂应用设置 · 表单授权 tab)。 + * + * 后端 = `FormAccessController`(守卫=校管理员 ∪ 超管,与快应用注册同一把 C4③)。 + * 三条口径写在这里,免得下一个人"顺手补一个": + * 1. **这里的 appKey 是平台身份,不是 app_code**。快应用那个 slug(`apps.ts` 的 appCode) + * 只是资源筛选项,不能拿来授权;填进去的值必须是 UserBase 应用注册分配的 appKey, + * 后端会查平台密钥注册表核验存在性(不存在直接拒),**响应里永不出现密钥**。 + * 2. **默认拒绝**:读与订阅是两把独立的开关,没有隐式全集;owner 天然能读,但订阅要另开一行。 + * 所以新建授权时两把开关都不勾是被后端拒的,不在前端预先塞一个"全给"。 + * 3. **撤权不删行**:撤销是 `enabled=0` + `grantVersion` 进位,清单里仍看得见(含已撤销行), + * 重申会再进位。`grantVersion` 是给事件投递侧判旧用的,页面只展示、不改它。 + * + * `schId` 只有超管切目标域(含 0=平台级表单)时才带,校级传别的值会被 `FormAdminGuard` 拒。 + */ +import { alovaInstance } from "@/utils/alova/alova-iview"; + +const BASE = "/api/form/admin/form-access"; + +/** 资源拥有者绑定(未绑定时后端回 null) */ +export interface OwnershipView { + formUuid: string; + formSchoolId: number; + ownerAppKey: string; +} + +/** 消费授权行(含已撤销行) */ +export interface GrantView { + formUuid: string; + consumerSchoolId: number; + consumerAppKey: string; + readAllowed: boolean; + subscribeAllowed: boolean; + enabled: boolean; + grantVersion: number; +} + +/** 目标域参数:undefined 一律剔除,避免 query 里出现空 schId */ +function paramsOf(params: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === "") continue; + out[k] = v; + } + return out; +} + +/** 这张表的资源拥有者(null=还没绑) */ +export function getOwner(formUuid: string, schId?: number) { + return alovaInstance.Get(`${BASE}/owner`, { params: paramsOf({ formUuid, schId }) }); +} + +/** 设/换 owner 绑定(表单自己的 sch_id 由服务端落,前端不给归属校) */ +export function setOwner(formUuid: string, ownerAppKey: string, schId?: number) { + return alovaInstance.Put(`${BASE}/owner`, { ownerAppKey }, { params: paramsOf({ formUuid, schId }) }); +} + +/** grant 清单(含已撤销行:撤了也要看得见、能重申) */ +export function listGrants(formUuid: string, schId?: number) { + return alovaInstance.Get(`${BASE}/grants`, { params: paramsOf({ formUuid, schId }) }); +} + +/** 授权/重申(三元键 upsert;两把开关至少开一把,否则后端拒) */ +export function saveGrant( + body: { formUuid: string; consumerSchoolId: number; consumerAppKey: string; readAllowed: boolean; subscribeAllowed: boolean }, + schId?: number +) { + return alovaInstance.Post(`${BASE}/grants`, body, { params: paramsOf({ schId }) }); +} + +/** 撤销(enabled=0 + grantVersion 进位;走 POST 是为了带体,语义上它是写操作不是查询) */ +export function revokeGrant(body: { formUuid: string; consumerSchoolId: number; consumerAppKey: string }, schId?: number) { + return alovaInstance.Post(`${BASE}/grants/revoke`, body, { params: paramsOf({ schId }) }); +} diff --git a/src/api/module/formSync.ts b/src/api/module/formSync.ts new file mode 100644 index 0000000..7c80535 --- /dev/null +++ b/src/api/module/formSync.ts @@ -0,0 +1,197 @@ +/** + * 差异对账管理面(TASK_3,挂独立路由 /admin/sync,不动应用设置页)。 + * + * 三条与直觉不同的口径,前端必须照抄后端语义、不自作聪明: + * 1. **派发 2xx ≠ 对账完成**:任务列表里的 DISPATCHED 只代表"消费方收下了指令", + * 差异有没有修、修了几条,只看 `latestResult`(由 result 回报推进); + * 2. **COMPLETED 与 PARTIAL 是两种结论**:前者=完整扫描且无未决差异,后者=跑完了但还有 + * 未决项(只报告的多余行、暂停的修复、重试耗尽的条目),不能并列成"成功"; + * 3. **拒绝建任务也返回一行任务**(`state=FAILED` + `errorCode`/`errorDetail`), + * 超限与范围不符是留痕事实,不是屏幕上闪过的一行字。 + */ +import { alovaInstance } from "@/utils/alova/alova-iview"; + +const BASE = "/api/form/admin/sync"; + +/** 对账任务行(与 FC FormSyncModels.TaskView 同构) */ +export interface SyncTaskRow { + taskId: string; + consumerAppKey: string; + consumerSchoolId: number; + formUuid: string; + formVersion: number; + triggerType?: string; + operator?: string; + /** QUEUED / DISPATCHED / RUNNING / COMPLETED / PARTIAL / FAILED / CANCELLED */ + state: string; + /** PENDING / MATERIALIZING / COMPLETE / FAILED */ + manifestState: string; + manifestComplete?: boolean; + totalRows?: number; + totalPages?: number; + materializedRows?: number; + materializedPages?: number; + attempt?: number; + windowStart?: string; + windowEnd?: string; + dispatchedAt?: string; + finishedAt?: string; + cancelRequested?: boolean; + errorCode?: string; + errorDetail?: string; + latestResult?: SyncResultRow; +} + +/** 一次执行的差异与修复计数(一条 = 一个 attempt) */ +export interface SyncResultRow { + taskId: string; + attempt: number; + state: string; + localScanComplete?: boolean; + manifestComplete?: boolean; + scannedRows?: number; + pagesProcessed?: number; + missingRepaired?: number; + staleRepaired?: number; + hashConflicts?: number; + tombstonesRepaired?: number; + localAhead?: number; + deferred?: number; + extraReported?: number; + extraRetired?: number; + errorCount?: number; + errors?: SyncErrorRow[]; + resumed?: boolean; + reportedAt?: string; +} + +/** 错误明细:每条都能定位 consumer / task / range / answer,永不携带答卷值 */ +export interface SyncErrorRow { + code: string; + taskId?: string; + consumerAppKey?: string; + consumerSchoolId?: number; + formUuid?: string; + answerUuid?: string; + revision?: number; + detail?: string; +} + +/** Spring Page 的极简形态(列表只取这三项) */ +export interface SyncTaskPage { + content: SyncTaskRow[]; + totalElements: number; + totalPages: number; +} + +/** 投递失败/死信行(TASK_2 failedPage 的转发,不代表对账结论) */ +export interface FailedDeliveryRow { + eventId: string; + answerUuid: string; + answerRevision: number; + consumerAppKey: string; + consumerSchoolId: number; + state: string; + retryCount?: number; + lastError?: string; + nextRetryAt?: string; + brokerAcceptedAt?: string; + consumerAppliedAt?: string; +} + +export interface FailedDeliveryPage { + total: number; + rows: FailedDeliveryRow[]; +} + +/** 重放结论(稳定码:QUEUED / ALREADY_DELIVERED / GRANT_REVOKED / REVISION_MISMATCH / NO_DELIVERY_ROW) */ +export interface ReplayResult { + state: string; + eventId?: string; + answerRevision?: number; + message?: string; +} + +function withSch(params: Record, schId?: number) { + if (schId !== undefined && schId !== null) params.schId = schId; + return params; +} + +/** 建任务:scope=ANSWER / FORM / ALL(ALL 按生效订阅裂变成多行,受后端上限约束) */ +export function createSyncTasks(body: { + scope: string; + consumerAppKey: string; + consumerSchoolId: number; + formUuid?: string; + answerUuid?: string; + idempotencyKey?: string; +}, schId?: number) { + const params = withSch({}, schId); + return alovaInstance.Post(BASE + "/tasks", body, { params }).send(); +} + +/** 任务列表(状态与进度) */ +export function listSyncTasks(query: { + consumerAppKey?: string; + consumerSchoolId?: number; + state?: string; + page?: number; + size?: number; +}, schId?: number) { + const params: Record = {}; + if (query.consumerAppKey) params.consumerAppKey = query.consumerAppKey; + if (query.consumerSchoolId !== undefined && query.consumerSchoolId !== null) { + params.consumerSchoolId = query.consumerSchoolId; + } + if (query.state) params.state = query.state; + params.page = query.page || 1; + params.size = query.size || 20; + return alovaInstance.Get(BASE + "/tasks", { params: withSch(params, schId) }).send(); +} + +/** 任务详情(含最近一次回报摘要) */ +export function getSyncTask(taskId: string, schId?: number) { + return alovaInstance.Get(`${BASE}/tasks/${encodeURIComponent(taskId)}`, + { params: withSch({}, schId) }).send(); +} + +/** 逐 attempt 的结果与失败明细 */ +export function listSyncResults(taskId: string, schId?: number) { + return alovaInstance.Get(`${BASE}/tasks/${encodeURIComponent(taskId)}/results`, + { params: withSch({}, schId) }).send(); +} + +/** 取消任务:剩余投递与修复一律停(幂等) */ +export function cancelSyncTask(taskId: string, schId?: number) { + return alovaInstance.Post(`${BASE}/tasks/${encodeURIComponent(taskId)}/cancel`, undefined, + { params: withSch({}, schId) }).send(); +} + +/** 单答卷重放(先重新过 ACL 再执行;旧 outbox 日志不是新答卷,历史版本内容不可重建) */ +export function replaySyncAnswer(body: { + consumerAppKey: string; + consumerSchoolId: number; + answerUuid: string; + expectedRevision: number; + operationId: string; +}, schId?: number) { + const params = withSch({}, schId); + return alovaInstance.Post(BASE + "/replay", body, { params }).send(); +} + +/** 投递失败/死信分页(TASK_2 的投递面查看,与对账结论互不作证) */ +export function listFailedDeliveries(query: { + consumerAppKey: string; + consumerSchoolId: number; + page?: number; + size?: number; +}, schId?: number) { + const params: Record = { + consumerAppKey: query.consumerAppKey, + consumerSchoolId: query.consumerSchoolId, + page: query.page || 1, + size: query.size || 20, + }; + return alovaInstance.Get(BASE + "/deliveries/failed", + { params: withSch(params, schId) }).send(); +} diff --git a/src/components/admin/FormAccessPanel.vue b/src/components/admin/FormAccessPanel.vue new file mode 100644 index 0000000..3057b9c --- /dev/null +++ b/src/components/admin/FormAccessPanel.vue @@ -0,0 +1,385 @@ + + + + + diff --git a/src/router/routes.ts b/src/router/routes.ts index ed93c35..eb48a42 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -69,6 +69,16 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/admin/AppSettings.vue"), meta: { title: "应用设置" }, }, + { + // 差异对账(TASK_3 独占页):建任务/进度/结果/取消/单答卷重放。 + // 刻意不做成应用设置的第六个 tab——那一页由 TASK_1 独占(授权 tab),本轮不碰它。 + // UserBase 侧还没登记这条模块行(本任务禁改 UserBase/**),所以它暂时不进左导航, + // 直达地址 #/admin/sync 可用;登记模块行时 path 必须与本行逐字一致(约束①)。 + path: "/admin/sync", + name: "fc_admin_sync", + component: () => import("@/views/admin/SyncManage.vue"), + meta: { title: "差异对账" }, + }, { // 旧字典管理地址(六行版模块行时代的 path):只保留重定向,不再单独成页(T10 方案 §6.1)。 // 外部残留链接落到这里=进应用设置的字典 tab,避免出现"两条都能进、只有一条有守卫"的口径。 diff --git a/src/types/answer.ts b/src/types/answer.ts index e00a4cc..30284a7 100644 --- a/src/types/answer.ts +++ b/src/types/answer.ts @@ -34,9 +34,40 @@ export interface SubmitRequest { appCode?: string; idempotencyKey: string; subjectId?: number; + /** + * 可信提交上下文令牌(answer-delivery-v2 §6.3,TASK_2 A.3)。 + * 只有被业务应用(办事大厅)嵌进来的填报页才带它:后端据此按**签发时钉死的 + * formUuid+version** 取 schema(不回落到当前发布版)、复核会话用户/学校, + * 并在答卷事务里把这份提交原子关联回那次业务意图。 + * 它不是身份凭据——schId/submitterId 仍只由会话推导(V17 不变)。 + */ + contextToken?: string; values: Record; } +/** + * hosted 填报页元数据({@code GET /api/form/hosted/schema},TASK_2 A.3)。 + * 与 {@link ResolvedForm} 的字段大致同形,但刻意不继承它:这条链上没有"当前发布版" + * (version 由上下文令牌钉死),也没有状态定义编辑入口(statusDefs 恒不给,页面按默认态渲染), + * 用 extends 会把"后端其实没返回的字段"写成必填,是类型上的假话。 + */ +export interface HostedForm { + formUuid: string; + code: string; + appCode: string; + name: string; + version: number; + audience: string[]; + fillLimit: number; + deadline: string | null; + deadlineReached: boolean; + groups: any; + /** 上下文过期时刻(到点必须回办事页重新发起,不给旧页面继续提交的机会) */ + expiresAt: string | null; + /** 服务端锁定的父页面精确 origin:被嵌页 postMessage 的唯一允许目标 */ + embedOrigin: string; +} + /** 提交响应(契约 §5:{answerId, answerNo};幂等命中时 replayed=true) */ export interface SubmitView { answerId: string; diff --git a/src/utils/embed.ts b/src/utils/embed.ts index a070b89..9653fc3 100644 --- a/src/utils/embed.ts +++ b/src/utils/embed.ts @@ -111,3 +111,45 @@ export function listenFromParent(handler: (message: EmbedMessage, origin: string window.addEventListener("message", listener); return () => window.removeEventListener("message", listener); } + +/** + * hosted 通道(answer-delivery-v2 §6.4,TASK_2 A.4):向**服务端锁定的那一个精确 origin** 上报。 + * + *

与上面 {@link postToParent} 的区别是刻意的,也是这条链的安全边界所在:

+ *
    + *
  • 目标 origin 必须由后端(签发上下文时从注册信息取)给,不回落 document.referrer—— + * referrer 是页面可影响的输入,拿它当"能收到我提交回执的人"等于把成功消息投递给任意父页;
  • + *
  • 比较是整串 origin 相等(scheme+host+port 逐位),不走"同 hostname 即可"的宽松分支: + * 同域名不同端口/不同 scheme 在浏览器眼里是两个可Origin,把 http 的消息交给 https 的父页 + * 或反过来,都是真实存在的部署形态(本地跨端口、生产灰度站点);
  • + *
  • {@code proto/v} 由本函数强制带上,接收方逐字核对(父页侧同样核对 event.source)。
  • + *
+ * 不满足前两条时一律**不发**并在控制台点名原因:宁可父页收不到"可确认"信号(用户回到办事页 + * 由服务端收据确认,那才是权威),也不能把提交回执发错人。 + */ +export function postToHostedParent(message: Omit, expectedOrigin: string): boolean { + if (window.self === window.top) { + return false; + } + const target = (expectedOrigin || "").trim(); + if (!target) { + console.warn("[embed] hosted 通道缺少服务端锁定的 embedOrigin,已拒发"); + return false; + } + let normalized: string; + try { + const parsed = new URL(target); + normalized = parsed.origin; + } catch { + console.warn("[embed] embedOrigin 不是合法绝对 origin,已拒发:", target); + return false; + } + if (normalized !== target.replace(/\/+$/, "")) { + // 只接受"就是 origin 本身"的写法(带 path/query 的 URL 说明配置被拼接过,不是可信坐标) + console.warn("[embed] embedOrigin 必须是精确 origin(scheme+host+port),已拒发:", target); + return false; + } + const fullMessage: EmbedMessage = { proto: "fc", v: 1, type: message.type, ...message }; + window.parent.postMessage(fullMessage, normalized); + return true; +} diff --git a/src/views/Fill.vue b/src/views/Fill.vue index 36bb5a6..6a82542 100644 --- a/src/views/Fill.vue +++ b/src/views/Fill.vue @@ -17,12 +17,12 @@ import { useRoute } from "vue-router"; import { fieldWidget } from "@/components/fields"; import type { DesignField } from "@/types/formSchema"; import type { ResolvedForm, MyAnswerItem, SubmitRequest } from "@/types/answer"; -import { resolveForm, submitAnswer, listMyAnswers, deleteMyAnswer, editMyAnswer, getMyAnswerDetail } from "@/api/module/fill"; +import { resolveForm, resolveHostedForm, submitAnswer, submitHostedAnswer, listMyAnswers, deleteMyAnswer, editMyAnswer, getMyAnswerDetail } from "@/api/module/fill"; import { probeSession, probeAnonForm, submitAnonAnswer, anonUploadPath } from "@/api/module/anon"; import { baseApi } from "@/api/module/base"; import UserCheckModal from "@/components/UserCheckModal.vue"; import { CENTER_APP_CODE, managesForm, useDefaultStore } from "@/store"; -import { startHeightReporting, reportSubmitResult, reportNeedLogin } from "@/utils/embed"; +import { startHeightReporting, reportSubmitResult, reportNeedLogin, postToHostedParent } from "@/utils/embed"; import Utils from "@/utils"; import { Message, Modal } from "view-ui-plus"; @@ -55,9 +55,17 @@ const loading = ref(true); const error = ref(null); /** 本次填报走的通道(null=还没判出来);提交、上传、"我的提交"段都按它分派 */ -type FillChannel = "session" | "anon"; +type FillChannel = "session" | "anon" | "hosted"; const channel = ref(null); +/** + * 可信提交上下文令牌(?ctx=,只从地址栏读一次;answer-delivery-v2 §6.3,TASK_2 A.3)。 + * 带它时:schema 按令牌钉住的那一版取(不回落当前发布版)、提交走 hosted 端点、 + * 成功回执只允许发给服务端锁定的 embedOrigin。它不是身份凭据——本人身份仍由 CAS 会话判。 + */ +const hostedToken = computed(() => String(route.query.ctx ?? "").trim()); +const hostedEmbedOrigin = ref(""); + /** * 附件控件的上传端点(upload/image 控件 inject 它,缺省=登录面那条)。 * 匿名必须走 `/anon/form/upload?code=`:后端只有表单能定 {@code form_file.sch_id}=表单归属校, @@ -81,6 +89,22 @@ async function loadForm() { error.value = null; const queryAppCode = appCode.value || undefined; try { + // hosted 分支排在双探针之前:这条链只有"登录本人"一种形态(办事大厅首批口径)。 + // 匿名与普通提交仍是中心自用的两条既有通道,不因托管入口而改变; + // 匿名完成的消息也永远进不了本人办件(后端 submitterId 逐字比对)。 + if (hostedToken.value) { + channel.value = "hosted"; + if (!(await probeSession()).logged) { + guideToLogin(); + return; + } + const hosted = await resolveHostedForm(hostedToken.value); + hostedEmbedOrigin.value = hosted.embedOrigin; + // 托管态没有"我的提交"段:这一页只为这一次办件服务,摆历史只会诱导重复提交 + form.value = { ...hosted, answerNoEnabled: 0, statusDefs: [] } as any; + myAnswers.value = []; + return; + } if ((await probeSession()).logged) { channel.value = "session"; form.value = await resolveForm(code.value, queryAppCode); @@ -113,6 +137,13 @@ async function loadForm() { * "无会话 ∧ 这又不是匿名表单"时被调用:embed 发 postMessage,page 整页跳 CAS。 */ function guideToLogin() { + if (channel.value === "hosted") { + error.value = "请先登录后填写"; + // 托管态未登录时**不发**任何 postMessage:此刻还没取到服务端锁定的 embedOrigin + // (它跟着 hosted schema 一起回),而"随便找个父页发一句"正是 A.4 禁止的形态。 + // 用户在办事页重新登录后再进入口即可;父页的确认调用会拿到明确的未登录错误。 + return; + } if (isEmbedded.value) { error.value = "请先登录后填写"; reportNeedLogin(); @@ -200,6 +231,8 @@ function validateAll(): boolean { // ==================== 提交 ==================== const submitting = ref(false); +// Reuse the request identity if the server accepted but the response was lost. +const submitIdempotencyKey = ref(Utils.uuid()); const submitted = ref(false); const submitResult = ref<{ answerId: string; answerNo: string | null } | null>(null); @@ -215,7 +248,7 @@ async function handleSubmit() { } submitting.value = true; try { - const idempotencyKey = Utils.uuid(); + const idempotencyKey = submitIdempotencyKey.value; const payload: SubmitRequest = { formCode: code.value, appCode: appCode.value || undefined, @@ -223,13 +256,34 @@ async function handleSubmit() { // 代录:subject=被记录人(后端按该表单 ALL 档把关,非档者带这个字段就是 403); // 没选人就不带——"以我自己为被记录人"与手工填报同一条路径 subjectId: canProxy.value ? subject.value?.id : undefined, + // 托管态带令牌:后端在答卷事务内兑换它并原子关联那次业务意图(身份仍取会话) + contextToken: channel.value === "hosted" ? hostedToken.value : undefined, values: formValues.value }; - // 两条通道同一条请求形状(匿名体里没有 subjectId,服务端也不读身份字段——C8/V17) - const result = channel.value === "anon" ? await submitAnonAnswer(payload) : await submitAnswer(payload); + // 三条通道同一条请求形状(匿名体里没有 subjectId,服务端也不读身份字段——C8/V17) + const result = + channel.value === "anon" + ? await submitAnonAnswer(payload) + : channel.value === "hosted" + ? await submitHostedAnswer(payload) + : await submitAnswer(payload); submitResult.value = { answerId: result.answerId, answerNo: result.answerNo }; submitted.value = true; - if (isEmbedded.value) { + if (channel.value === "hosted") { + /** + * 托管回执(A.4):只报"可以去确认了",不报答案 id 也不报版本号—— + * 父页(办事大厅)拿到这个信号后必须回服务端读签名收据,浏览器这句话 + * 在整个建件链里没有任何证据力。目标 origin 用服务端锁定的那一个, + * 精确到 scheme+host+port,不回落 referrer、不同域名降级。 + */ + const posted = postToHostedParent( + { type: "success", success: true, hosted: true }, + hostedEmbedOrigin.value + ); + if (!posted) { + error.value = "提交已完成,但未能通知办事页;请回到办事页手动刷新确认"; + } + } else if (isEmbedded.value) { reportSubmitResult({ success: true, answerId: result.answerId, answerNo: result.answerNo || undefined }); } else { const redirectUrl = String(route.query.redirect ?? ""); @@ -243,7 +297,9 @@ async function handleSubmit() { } catch (e: any) { const msg = e?.message || "提交失败"; Message.error(msg); - if (isEmbedded.value) { + if (channel.value === "hosted") { + postToHostedParent({ type: "error", success: false, error: msg, hosted: true }, hostedEmbedOrigin.value); + } else if (isEmbedded.value) { reportSubmitResult({ success: false, error: msg }); } } finally { @@ -355,7 +411,8 @@ onUnmounted(() => { if (stopHeightReporting) stopHeightReporting(); }); -watch(code, () => { +watch([code, appCode, hostedToken], () => { + submitIdempotencyKey.value = Utils.uuid(); loadForm(); }); diff --git a/src/views/admin/AppSettings.vue b/src/views/admin/AppSettings.vue index 7cbefd9..2cb68e0 100644 --- a/src/views/admin/AppSettings.vue +++ b/src/views/admin/AppSettings.vue @@ -1,11 +1,15 @@ + + + +