Compare commits

...
5 Commits
Author SHA1 Message Date
chenxing 0ae8c6a911 t2: 办事页接表单中心可信提交(Apply.vue 托管填报告知)
build / build (push) Successful in 1m39s
2026-10-22 10:03:23 +08:00
ops ce876760af fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:29 +00:00
ops 864038d1b1 ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:37:00 +00:00
chenxing 8ce8082e89 feat(hall): 新增事项指南页(先看指南再办理)
build / build (push) Failing after 2m2s
- /#/item?siteCode=&itemCode= 新页面:元数据 + 五 tab + 底部固定操作条
- HallApi.guide() 接匿名接口 /api/hall/item(契约 §3.2)
- HallItem 补 10 个承诺字段、新增 ItemGuideVo/ItemGuideAttachment(契约 §4)
- 状态角标三态与办理分流按契约 §5
- .gitignore 忽略 vite.config.ts.timestamp-* 临时文件
2026-10-20 18:01:16 +08:00
chenxing dda1a71cf5 feat(hall): 大厅卡片信息密度 + 我的办事五分区 + 提交后状态告知
- Hall.vue:承诺时限/跑动次数、四态角标、卡片点击进指南
- ApplyMy.vue:待我审批/我发起的/已办/抄送/收藏 五分区
- Apply.vue:提交成功页(办件号 + 预计完成)
- ApplyDetail.vue:进度条 + 已历时 + 催办占位 + 评价占位
2026-10-20 15:50:04 +08:00
11 changed files with 1494 additions and 91 deletions
+6 -1
View File
@@ -20,7 +20,10 @@ jobs:
options: --network infra-infra-net options: --network infra-infra-net
steps: steps:
- name: 安装 git / curl / zip(alpine) - name: 安装 git / curl / zip(alpine)
run: apk add --no-cache git curl zip run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git curl zip
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库 # 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库) - name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -52,6 +55,8 @@ jobs:
set -e set -e
cd /build cd /build
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
npm config set registry https://registry.npmmirror.com
npm ci npm ci
npm run build npm run build
' '
+3
View File
@@ -27,3 +27,6 @@ yarn.lock
# scratch # scratch
.temp .temp
# Vite 配置热重载产生的临时文件(构建时自动生成,勿入库)
vite.config.ts.timestamp-*
+22
View File
@@ -4,9 +4,13 @@ import config from "@/assets/config/host.config";
import type { import type {
ApplySubmitRequest, ApplySubmitRequest,
ApplyVo, ApplyVo,
FcIntentConfirmView,
FcIntentCreateRequest,
FcIntentView,
FlowProgressVo, FlowProgressVo,
HallFilters, HallFilters,
HallItem, HallItem,
ItemGuideVo,
PageResult, PageResult,
} from "@/types/svc"; } from "@/types/svc";
@@ -68,6 +72,10 @@ class HallApi {
site(siteCode: string) { site(siteCode: string) {
return get(`${HALL}/site`, { siteCode }); return get(`${HALL}/site`, { siteCode });
} }
/** 事项指南(匿名;游客可读) */
guide(siteCode: string, itemCode: string) {
return get<ItemGuideVo>(`${HALL}/item`, { siteCode, itemCode });
}
} }
class ApplyApi { class ApplyApi {
@@ -104,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}`);
} }
+8 -1
View File
@@ -2,7 +2,7 @@ import type { RouteRecordRaw } from "vue-router";
/** /**
* 公共办事大厅路由(用户面;后台管理路由在 svc-hall/front,两仓互不引用)。 * 公共办事大厅路由(用户面;后台管理路由在 svc-hall/front,两仓互不引用)。
* 五个页面:事项大厅(入口)/ 在线办理 / 我的办件 / 我的审批 / 办件详情。 * 六个页面:事项大厅(入口)/ 事项指南 / 在线办理 / 我的办件 / 我的审批 / 办件详情。
* 全部走 hash 路由(部署于 /{contextPath}/hall/,hash 免服务端 rewrite)。 * 全部走 hash 路由(部署于 /{contextPath}/hall/,hash 免服务端 rewrite)。
* 参数页用 query 传参(与 CMS 指南页"我要办理"外链形态一致: * 参数页用 query 传参(与 CMS 指南页"我要办理"外链形态一致:
* {svc_hall_base}/#/apply?siteCode=&itemCode=&from=<指南页绝对URL>; * {svc_hall_base}/#/apply?siteCode=&itemCode=&from=<指南页绝对URL>;
@@ -16,6 +16,13 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/Hall.vue"), component: () => import("@/views/Hall.vue"),
meta: { title: "办事大厅" }, meta: { title: "办事大厅" },
}, },
// 事项指南(P0-1「先看指南再办理」):{base}/#/item?siteCode=&itemCode=,匿名可读
{
path: "/item",
name: "item_guide",
component: () => import("@/views/ItemGuide.vue"),
meta: { title: "事项指南" },
},
{ {
path: "/apply", path: "/apply",
name: "apply", name: "apply",
+103
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;
@@ -71,6 +115,65 @@ export interface HallItem {
handleMode?: number; handleMode?: number;
onlineFlag?: number; onlineFlag?: number;
publishTime?: string; publishTime?: string;
// ===== 《svc-hall一期接口契约》§2.2/§3.1 追加字段(全部可空,旧事项为 null)=====
/** 外链办理地址:非空即"外链最高优先"(§5 三态判定第一态) */
applyUrl?: string | null;
/** 承诺办结时限(面向用户的口径,展示优先于 handleTimeLimit) */
promiseTimeLimit?: string | null;
handleTimeLimit?: string | null;
/** 跑现场次数:0=零跑动 */
runCount?: number | null;
/** 是否快递送达:0 否 / 1 是(到付)/ 2 是(学校承担) */
expressFlag?: number | null;
/** 协办部门(顿号分隔文本快照) */
coopDepts?: string | null;
handlerName?: string | null;
/** 办理时间(办公时段,区别于办理时限) */
handleTimeDesc?: string | null;
feeDesc?: string | null;
feeBasis?: string | null;
}
/** 事项指南附件(《契约》§3.2 attachments) */
export interface ItemGuideAttachment {
fileName: string;
fileUrl: string;
fileSize?: number;
}
/** 事项指南(GET /api/hall/item 出参,匿名;《契约》§3.2/§4) */
export interface ItemGuideVo {
id: number;
itemCode: string;
title: string;
summary?: string;
/** 服务对象字典 key(中文名需查 /api/hall/filters,接口不返回 objectName) */
objectKey?: string;
categoryName?: string;
deptName?: string;
/** 办理方式:1 线上 / 2 线下 / 3 线上线下结合 */
handleMode?: number;
onlineFlag?: number;
applyUrl?: string | null;
handlePlace?: string;
handleTimeLimit?: string;
promiseTimeLimit?: string;
contactPhone?: string;
supervisePhone?: string;
coverImage?: string;
viewCount?: number;
coopDepts?: string;
handlerName?: string;
handleTimeDesc?: string;
runCount?: number;
expressFlag?: number;
feeDesc?: string;
feeBasis?: string;
/** 以下三段为入库时已 jsoup 白名单清洗的富文本,前端原样 v-html(《契约》§3.2 规则) */
processHtml?: string;
materialHtml?: string;
noticeHtml?: string;
attachments?: ItemGuideAttachment[];
} }
export interface HallFilterOption { export interface HallFilterOption {
+399 -15
View File
@@ -8,13 +8,13 @@
* 于是明明提交成功也弹失败提示。现在按 flowEnabled 分流 + 短轮询收敛: * 于是明明提交成功也弹失败提示。现在按 flowEnabled 分流 + 短轮询收敛:
* flowEnabled=false → 流程中心未接入,真的不会翻转:如实告知"已保存为待提交"; * flowEnabled=false → 流程中心未接入,真的不会翻转:如实告知"已保存为待提交";
* 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 { Message } from "view-ui-plus";
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();
@@ -33,8 +33,49 @@ const siteCode = ref("");
const formData = reactive<Record<string, any>>({}); const formData = reactive<Record<string, any>>({});
const dynFormRef = ref<InstanceType<typeof DynamicForm> | null>(null); // DynamicForm.validate() const dynFormRef = ref<InstanceType<typeof DynamicForm> | null>(null); // DynamicForm.validate()
/** 提交后的状态告知(P0-5):成功页区块取代一次性 Message,用户看得到"下一步与预计完成" */
const done = ref<{
applyId: number;
itemTitle: string;
headline: string;
tip: string;
tone: "success" | "warning";
currentNode: string;
promise: string;
eta: string;
} | null>(null);
const schemaJson = computed(() => (item.value?.schemaJson as string) || ""); const schemaJson = computed(() => (item.value?.schemaJson as string) || "");
/** 需跑现场次数(0=零跑动):/api/apply/item 未下发该字段时整段隐藏,不编造 */
const runText = computed(() => {
const n = item.value?.runCount;
if (n === null || n === undefined || n === "") return "";
const num = Number(n);
if (Number.isNaN(num)) return "";
return num === 0 ? "零跑动" : `需跑 ${num} 次`;
});
/**
* 承诺时限 → 天数:取文案里最大的数字("1-3个工作日" 按上限 3 估,不把承诺说短)。
* 解析不出("当日办结""次周内")返回 null,由调用方回落为原文展示。
*/
function parsePromiseDays(text: any): number | null {
const nums = String(text || "").match(/\d+/g);
if (!nums || !nums.length) return null;
const n = Math.max(...nums.map(Number));
return n > 0 && n <= 365 ? n : null;
}
const pad2 = (n: number) => String(n).padStart(2, "0");
/** 提交日 + N 天(按自然日加,工作日口径待后端确认,见交付说明) */
function addDays(base: Date, n: number): string {
const d = new Date(base.getTime());
d.setDate(d.getDate() + n);
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
async function load() { async function load() {
siteCode.value = resolveSite(route.query.siteCode); siteCode.value = resolveSite(route.query.siteCode);
// CMS 指南页来路(T4):校验通过即记入 session,App.vue 顶栏渲染"返回门户" // CMS 指南页来路(T4):校验通过即记入 session,App.vue 顶栏渲染"返回门户"
@@ -100,6 +141,159 @@ async function awaitFlowStart(applyId: number, first: any) {
return latest; return latest;
} }
/** 后端时间为 `yyyy-MM-dd HH:mm:ss` / ISO 两种形态,统一转 Date;解析失败返回 null */
function toDate(s?: string | null): Date | null {
if (!s) return null;
const d = new Date(String(s).trim().replace(" ", "T"));
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 {
const p: any = await applyApi.progress(applyId).send();
const names = (p?.steps || [])
.filter((st: any) => st.active)
.map((st: any) => st.nodeName || st.label)
.filter(Boolean);
return Array.from(new Set(names)).join("、");
} catch {
return "";
}
}
async function submit() { async function submit() {
if (!item.value) return; if (!item.value) return;
// 前端先按 schema 校验(字段下方内联红字 + 滚到第一个错误),不再把"必填项未填写" // 前端先按 schema 校验(字段下方内联红字 + 滚到第一个错误),不再把"必填项未填写"
@@ -116,32 +310,89 @@ async function submit() {
}) })
.send(); .send();
// 分流语义与提示文案保持不变(BP-R4-1),只是把结果落成成功页区块而非一闪而过的 Message
let latest = apply;
let headline = "已提交,进入审批流程";
let tip = "已进入审批流程,可在办件详情查看进度";
let tone: "success" | "warning" = "success";
// ① 流程中心未接入:不会翻转,如实告知(不说"暂不可用",因为压根没接) // ① 流程中心未接入:不会翻转,如实告知(不说"暂不可用",因为压根没接)
if (apply.flowEnabled === false) { if (apply.flowEnabled === false) {
Message.warning("流程中心未接入,申请已保存为待提交;请联系管理员开通后重试"); headline = "申请已保存为待提交";
tip = "流程中心未接入,请联系管理员开通后在「我的办事」重试提交";
tone = "warning";
} else if (apply.flowStatus === 0) { } else if (apply.flowStatus === 0) {
// ② 排队中:等状态翻转(成功即成功提示,不再误报) // ② 排队中:等状态翻转(成功即成功提示,不再误报)
const latest = await awaitFlowStart(apply.id, apply); latest = (await awaitFlowStart(apply.id, apply)) || apply;
if (latest && latest.flowStatus && latest.flowStatus !== 0) { if (!(latest && latest.flowStatus && latest.flowStatus !== 0)) {
Message.success("已提交,进入审批流程"); headline = "申请已保存为待提交";
} else { tip = "流程中心响应较慢,可在「我的办事」中重试提交";
Message.warning("申请已保存为待提交:流程中心响应较慢,可在「我的办件」中重试"); tone = "warning";
} }
} else {
Message.success("已提交,进入审批流程");
} }
router.replace(`/apply-detail?id=${apply.id}`);
const promise = String(item.value?.promiseTimeLimit || "");
const days = parsePromiseDays(promise);
const submittedAt = toDate(latest?.submitTime || latest?.createTime) || new Date();
done.value = {
applyId: Number(apply.id),
itemTitle: String(item.value?.title || ""),
headline,
tip,
tone,
currentNode: tone === "success" ? await currentNodeName(apply.id) : "",
promise,
// 解析不出数字时不猜日期:该行直接显示承诺时限原文
eta: days ? addDays(submittedAt, days) : promise,
};
window.scrollTo(0, 0);
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
} }
onMounted(load); onMounted(load);
onBeforeUnmount(() => {
if (detachListener) {
detachListener();
detachListener = null;
}
});
</script> </script>
<template> <template>
<div class="page"> <div class="page">
<Card :bordered="false" dis-hover v-if="item"> <!-- 提交后的状态告知(P0-5):办件号 + 当前节点 + 承诺时限 + 预计完成,替代一闪而过的 Message -->
<Card :bordered="false" dis-hover v-if="done">
<div class="done">
<Icon :type="done?.tone === 'success' ? 'md-checkmark-circle' : 'md-alert-circle'" :size="46"
:class="['done-ico', done?.tone]" />
<div class="done-headline">{{ done?.headline }}</div>
<div class="done-tip sh-muted">{{ done?.tip }}</div>
<div class="done-kv">
<div class="dk" v-if="done?.itemTitle">
<span class="k">办理事项</span><span class="v">{{ done?.itemTitle }}</span>
</div>
<div class="dk">
<span class="k">办件号</span><span class="v b">{{ done?.applyId }}</span>
</div>
<div class="dk" v-if="done?.currentNode">
<span class="k">当前节点</span><span class="v">{{ done?.currentNode }}</span>
</div>
<div class="dk" v-if="done?.promise">
<span class="k">承诺时限</span><span class="v">{{ done?.promise }}</span>
</div>
<div class="dk" v-if="done?.eta">
<span class="k">预计完成</span><span class="v b">{{ done?.eta }}</span>
</div>
</div>
<div class="done-acts">
<Button class="sh-btn-primary" type="primary"
@click="router.push(`/apply-detail?id=${done?.applyId}`)">查看办件详情</Button>
<Button @click="router.push('/hall')">再办一件</Button>
</div>
</div>
</Card>
<Card :bordered="false" dis-hover v-else-if="item">
<template #title>在线办理 · {{ item.title }}</template> <template #title>在线办理 · {{ item.title }}</template>
<div class="summary" v-if="item.summary || item.deptName || item.handleTimeLimit"> <div class="summary" v-if="item.summary || item.deptName || item.handleTimeLimit">
<div v-if="item.summary">{{ item.summary }}</div> <div v-if="item.summary">{{ item.summary }}</div>
@@ -151,6 +402,42 @@ onMounted(load);
<span v-if="item.contactPhone">咨询电话:{{ item.contactPhone }}</span> <span v-if="item.contactPhone">咨询电话:{{ item.contactPhone }}</span>
</div> </div>
</div> </div>
<!-- 事项上下文条:填表前先把"谁办 / 多久 / 跑几次"摆在表单上方(字段缺失即不渲染) -->
<div class="ctx-bar" v-if="item.deptName || item.promiseTimeLimit || runText">
<span class="ctx-title">{{ item.title }}</span>
<span class="sh-tag" v-if="item.deptName">{{ item.deptName }}</span>
<span class="sh-tag sh-tag-primary" v-if="item.promiseTimeLimit">承诺 {{ item.promiseTimeLimit }}</span>
<span class="sh-tag sh-tag-success" v-if="runText">{{ runText }}</span>
<span class="sh-tag" v-if="item.handleTimeLimit">办理时限 {{ item.handleTimeLimit }}</span>
</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> <Divider v-if="schemaJson" orientation="left">申请表单</Divider>
<DynamicForm v-if="schemaJson" ref="dynFormRef" :schema-json="schemaJson" :form-data="formData" /> <DynamicForm v-if="schemaJson" ref="dynFormRef" :schema-json="schemaJson" :form-data="formData" />
<div v-else class="no-form">该事项无需填写表单,提交后由办理部门联系确认。</div> <div v-else class="no-form">该事项无需填写表单,提交后由办理部门联系确认。</div>
@@ -158,9 +445,10 @@ onMounted(load);
<Button class="sh-btn-primary" type="primary" :loading="submitting" @click="submit"> <Button class="sh-btn-primary" type="primary" :loading="submitting" @click="submit">
提交申请 提交申请
</Button> </Button>
<Button @click="router.push('/my')">我的办件</Button> <Button @click="router.push('/my')">我的办事</Button>
<Button @click="router.push('/hall')">返回大厅</Button> <Button @click="router.push('/hall')">返回大厅</Button>
</div> </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">
@@ -178,6 +466,74 @@ onMounted(load);
.summary { .summary {
padding-bottom: 12px; padding-bottom: 12px;
} }
.ctx-bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 10px 12px;
margin-bottom: 6px;
border: 1px solid var(--sh-border);
border-left: 3px solid var(--sh-primary);
border-radius: calc(var(--sh-radius) - 4px);
background: color-mix(in srgb, var(--sh-primary) 4%, #fff);
}
.ctx-title {
font-weight: 600;
}
.done {
text-align: center;
padding: 24px 0 10px;
}
.done-ico {
line-height: 1;
}
.done-ico.success {
color: var(--sh-success);
}
.done-ico.warning {
color: var(--sh-accent);
}
.done-headline {
font-size: 18px;
font-weight: 600;
margin: 10px 0 4px;
}
.done-tip {
margin-bottom: 16px;
}
.done-kv {
display: inline-block;
text-align: left;
min-width: 340px;
max-width: 100%;
margin-bottom: 18px;
}
.dk {
display: flex;
gap: 12px;
padding: 6px 0;
border-bottom: 1px dashed var(--sh-border);
font-size: 14px;
}
.dk .k {
width: 88px;
color: var(--sh-text-weak);
flex-shrink: 0;
}
.dk .v {
flex: 1;
word-break: break-all;
}
.dk .v.b {
color: var(--sh-primary);
font-weight: 600;
}
.done-acts {
display: flex;
gap: 8px;
justify-content: center;
}
.meta { .meta {
margin-top: 8px; margin-top: 8px;
display: flex; display: flex;
@@ -200,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>
+206 -2
View File
@@ -124,6 +124,7 @@ const SNAPSHOT_LABEL: Record<string, string> = {
summary: "事项说明", summary: "事项说明",
deptName: "办理部门", deptName: "办理部门",
handleTimeLimit: "办理时限", handleTimeLimit: "办理时限",
promiseTimeLimit: "承诺时限",
contactPhone: "咨询电话", contactPhone: "咨询电话",
}; };
@@ -138,6 +139,78 @@ function parse(json?: string): Record<string, any> {
} }
} }
// ===== 头部进度条(P0-5):节点全部取自 progress.steps,有几步画几步,不硬凑五格 =====
const stepList = computed(() => progress.value?.steps || []);
const activeIdx = computed(() => stepList.value.findIndex(st => st.active));
function stepStateAt(i: number): "done" | "now" | "pending" {
const act = activeIdx.value;
if (act >= 0) return i < act ? "done" : i === act ? "now" : "pending";
// 无活动节点(终态或未推进):有办理时间的节点算已完成
return (stepList.value[i]?.assignees || []).some(a => !!a.handleTime) ? "done" : "pending";
}
const barSteps = computed(() =>
stepList.value.map((st, i) => ({ name: st.nodeName || st.label || "—", state: stepStateAt(i) }))
);
/** 后端时间两种形态(`yyyy-MM-dd HH:mm:ss` / ISO),解析失败返回 null(不显示该行,不猜) */
function toDate(s?: string | null): Date | null {
if (!s) return null;
const d = new Date(String(s).trim().replace(" ", "T"));
return Number.isNaN(d.getTime()) ? null : d;
}
/** 已历时天数:提交 → 办结(在途则 → 现在),自然日向下取整 */
const elapsedDays = computed<number | null>(() => {
const from = toDate(apply.value?.submitTime);
if (!from) return null;
const to = toDate(apply.value?.finishTime) || new Date();
return Math.max(0, Math.floor((to.getTime() - from.getTime()) / 86400000));
});
/** 承诺时限文案/天数(快照未带 promiseTimeLimit 时回落办理时限;解析不出数字就不算余量) */
const promiseText = computed(() =>
String(snapshot.value?.promiseTimeLimit || snapshot.value?.handleTimeLimit || "")
);
const promiseDays = computed<number | null>(() => {
const nums = promiseText.value.match(/\d+/g);
if (!nums || !nums.length) return null;
const n = Math.max(...nums.map(Number));
return n > 0 && n <= 365 ? n : null;
});
const remainDays = computed<number | null>(() => {
if (elapsedDays.value === null || promiseDays.value === null) return null;
return promiseDays.value - elapsedDays.value;
});
/** 一句话时长告知:"当前节点已历时 N 天,时限剩 M 天"(余量解析不出则只报已历时) */
const clockText = computed(() => {
const elapsed = elapsedDays.value;
if (elapsed === null) return "";
const base = `${activeIdx.value >= 0 ? "当前节点" : "本办件"}已历时 ${elapsed} 天`;
const rest = remainDays.value;
if (rest === null) return base;
return rest >= 0 ? `${base},时限剩 ${rest} 天` : `${base},已超承诺时限 ${-rest} 天`;
});
const clockOver = computed(() => remainDays.value !== null && remainDays.value < 0);
/** 催办(P1-3 本期占位):只确认 + 记提示,不接后端;限频文案先行告知 */
function urge() {
Modal.confirm({
title: "催办确认",
content: `确认催办「${snapshot.itemTitle || apply.value?.itemTitle || "该办件"}」?系统会提醒当前审批人(同一办件 10 分钟内只能催一次)。`,
onOk: () => {
Message.success("已记录催办");
},
});
}
/** 服务评价(P1-6 本期占位) */
function evaluate() {
Message.info("评价功能即将上线");
}
/** 附件/图片字段值 = /api/file/upload 返回体数组({fileName,fileUrl}),渲染为可点链接 */ /** 附件/图片字段值 = /api/file/upload 返回体数组({fileName,fileUrl}),渲染为可点链接 */
function fileList(v: any): { fileName: string; fileUrl: string }[] { function fileList(v: any): { fileName: string; fileUrl: string }[] {
if (!Array.isArray(v)) return []; if (!Array.isArray(v)) return [];
@@ -274,6 +347,25 @@ onMounted(load);
<span class="k">办结时间</span> <span class="k">办结时间</span>
<span class="v">{{ apply.finishTime }}</span> <span class="v">{{ apply.finishTime }}</span>
</div> </div>
<!-- 横向进度条:步骤与审批轨迹同源 -->
<div class="progress" v-if="barSteps.length">
<div v-for="(st, i) in barSteps" :key="i" class="pstep" :class="st.state">
<div class="dot">{{ st.state === "done" ? "✓" : i + 1 }}</div>
<div class="pstep-name">{{ st.name }}</div>
</div>
</div>
<div class="clock-row" v-if="clockText">
<div class="clock-text">
<span :class="{ over: clockOver }">{{ clockText }}</span>
<span class="sh-muted" v-if="promiseText">({{ promiseText }})</span>
</div>
<div class="clock-act" v-if="apply.flowStatus === 10">
<Button size="small" @click="urge">催办</Button>
<span class="sh-muted">同一办件 10 分钟内只能催一次</span>
</div>
</div>
</div> </div>
<div class="sh-card" v-if="apply.formDataJson"> <div class="sh-card" v-if="apply.formDataJson">
@@ -312,7 +404,7 @@ onMounted(load);
<div class="sh-card"> <div class="sh-card">
<div class="section-title">审批进度</div> <div class="section-title">审批进度</div>
<div v-if="!progress || !progress.steps || !progress.steps.length" class="sh-muted"> <div v-if="!progress || !progress.steps || !progress.steps.length" class="sh-muted">
{{ apply.flowInstanceId ? "加载中或暂无可展示的轨迹" : "尚未进入审批流程(可在「我的办件」重试提交)" }} {{ apply.flowInstanceId ? "加载中或暂无可展示的轨迹" : "尚未进入审批流程(可在「我的办事」重试提交)" }}
</div> </div>
<div class="timeline" v-else> <div class="timeline" v-else>
<div class="step" v-for="(step, idx) in progress.steps" :key="idx"> <div class="step" v-for="(step, idx) in progress.steps" :key="idx">
@@ -336,8 +428,17 @@ onMounted(load);
</div> </div>
</div> </div>
<!-- 服务评价(P1-6 本期占位,后端未接) -->
<div class="sh-card">
<div class="section-title">服务评价</div>
<p class="sh-muted">
办结后可对本次服务评价;评价进入管理端「差评回访」清单,差评 100% 回访。
</p>
<Button class="sh-btn-primary" type="primary" @click="evaluate">去评价</Button>
</div>
<div class="sh-card actions"> <div class="sh-card actions">
<Button @click="router.push('/my')">返回我的办件</Button> <Button @click="router.push('/my')">返回我的办事</Button>
<Button @click="router.push('/hall')">办事大厅</Button> <Button @click="router.push('/hall')">办事大厅</Button>
<Button v-if="apply.flowInstanceId" @click="openDiagram">查看流程图</Button> <Button v-if="apply.flowInstanceId" @click="openDiagram">查看流程图</Button>
<Button v-if="apply.flowStatus === 0" :loading="acting" @click="retry">重试提交</Button> <Button v-if="apply.flowStatus === 0" :loading="acting" @click="retry">重试提交</Button>
@@ -378,6 +479,109 @@ onMounted(load);
padding-left: 8px; padding-left: 8px;
border-left: 3px solid var(--sh-primary); border-left: 3px solid var(--sh-primary);
} }
/* 头部进度条:已完成绿 / 当前蓝 / 待办灰,连线随已完成段着色(配色全部由令牌混合) */
.progress {
display: flex;
align-items: flex-start;
margin: 18px 0 4px;
}
.pstep {
flex: 1;
min-width: 0;
position: relative;
text-align: center;
font-size: 12.5px;
color: var(--sh-text-weak);
}
.pstep .dot {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--sh-border);
color: var(--sh-text-weak);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 6px;
font-size: 11px;
font-weight: 700;
position: relative;
z-index: 2;
border: 2px solid #fff;
box-sizing: border-box;
}
.pstep-name {
padding: 0 4px;
word-break: break-all;
}
.pstep::before,
.pstep::after {
content: "";
position: absolute;
top: 11px;
height: 2px;
background: var(--sh-border);
}
.pstep::before {
left: 0;
right: 50%;
}
.pstep::after {
left: 50%;
right: 0;
}
.pstep:first-child::before {
display: none;
}
.pstep:last-child::after {
display: none;
}
.pstep.done {
color: var(--sh-success);
}
.pstep.done .dot {
background: var(--sh-success);
color: #fff;
}
.pstep.done::before,
.pstep.done::after,
.pstep.now::before {
background: var(--sh-success);
}
.pstep.now {
color: var(--sh-primary);
font-weight: 600;
}
.pstep.now .dot {
background: var(--sh-primary);
color: #fff;
box-shadow: 0 0 0 5px color-mix(in srgb, var(--sh-primary) 15%, transparent);
}
.clock-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 16px;
padding: 10px 12px;
border: 1px solid var(--sh-border);
border-radius: calc(var(--sh-radius) - 4px);
background: color-mix(in srgb, var(--sh-accent) 8%, #fff);
font-size: 13px;
}
.clock-text {
flex: 1;
min-width: 200px;
}
.clock-text .over {
color: var(--sh-danger);
font-weight: 600;
}
.clock-act {
display: flex;
align-items: center;
gap: 8px;
}
.group-title { .group-title {
font-weight: 600; font-weight: 600;
font-size: 13px; font-size: 13px;
+249 -17
View File
@@ -1,22 +1,55 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 我的办件:镜像状态列表 + 撤回/重试/重提入口。 * 我的办事(/my,P0-4 聚合页):待我审批 / 我发起的 / 已办 / 抄送我的 / 我的收藏 五分区一页。
* 撤回走本应用后端 /api/apply/{id}/withdraw(不再直调 flow-center:跨应用票据与 CORS 都不再需要, * 分区数量与列表同源:todo/done/cc 走流程中心代理(/api/apply/approval/{Todo,Done,Cc}),
* "谁能撤"由后端 + 流程中心按 submitterId 双重把关)。 * my 走本地办件(/api/apply/my);收藏本期未接后端,恒为 0。
* 三个审批分区直接复用 FlowApprovalList(不重写组件),宿主仍按 P1-09 负责换址代际与 bizId 校验。
*
* 我发起的 = 原"我的办件"能力,撤回/重试/重提语义未变:
* 撤回走本应用后端 /api/apply/{id}/withdraw("谁能撤"由后端 + 流程中心按 submitterId 双重把关)。
*/ */
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus"; import { Message, Modal } from "view-ui-plus";
import { applyApi } from "@/api/module/svc"; import { applyApi, approvalApi } from "@/api/module/svc";
import svcFlowApprovalApi from "@/api/module/approvalAdapter";
import FlowApprovalList from "@/components/flow-approval/FlowApprovalList.vue";
import FlowRecordDrawer from "@/components/flow-approval/FlowRecordDrawer.vue";
import FlowTaskEmbed from "@/components/flow-approval/FlowTaskEmbed.vue";
import type { ApplyVo } from "@/types/svc"; import type { ApplyVo } from "@/types/svc";
type Section = "todo" | "my" | "done" | "cc" | "fav";
type ApprovalSection = "todo" | "done" | "cc";
const SECTIONS: Array<{ key: Section; label: string }> = [
{ key: "todo", label: "待我审批" },
{ key: "my", label: "我发起的" },
{ key: "done", label: "已办" },
{ key: "cc", label: "抄送我的" },
{ key: "fav", label: "我的收藏" },
];
const route = useRoute();
const router = useRouter(); const router = useRouter();
/** 分区用 tab 态切换(不改路由;路由表由并行任务 B 维护),query 仅用于外部深链 */
const querySection = String(route.query.section || route.query.tab || "") as Section;
const section = ref<Section>(SECTIONS.some(s => s.key === querySection) ? querySection : "my");
const counts = ref<Record<Section, number>>({ todo: 0, my: 0, done: 0, cc: 0, fav: 0 });
const faTabs = computed<ApprovalSection[]>(() => {
const s = section.value;
return s === "todo" || s === "done" || s === "cc" ? [s] : [];
});
const loading = ref(false); const loading = ref(false);
const list = ref<ApplyVo[]>([]); const list = ref<ApplyVo[]>([]);
const total = ref(0); const total = ref(0);
const page = ref(1); const page = ref(1);
const flowStatus = ref<number | undefined>(undefined); const flowStatus = ref<number | undefined>(undefined);
const actingId = ref<number>(0); const actingId = ref<number>(0);
const approvalRef = ref<InstanceType<typeof FlowApprovalList> | null>(null);
const STATUS: Record<number, { text: string; cls: string }> = { const STATUS: Record<number, { text: string; cls: string }> = {
0: { text: "待提交", cls: "sh-tag" }, 0: { text: "待提交", cls: "sh-tag" },
@@ -26,6 +59,21 @@ const STATUS: Record<number, { text: string; cls: string }> = {
40: { text: "已撤回", cls: "sh-tag sh-tag-warning" }, 40: { text: "已撤回", cls: "sh-tag sh-tag-warning" },
}; };
/** 五个分区各取 total(pageSize=1 只为数数);未登录/后端拒绝时该分区记 0,不阻塞其他分区 */
async function loadCounts() {
const jobs: Array<[Section, () => Promise<any>]> = [
["todo", () => approvalApi.todo(1, 1).send()],
["my", () => applyApi.my({ page: 1, pageSize: 1 }).send()],
["done", () => approvalApi.done(1, 1).send()],
["cc", () => approvalApi.cc(1, 1).send()],
];
const settled = await Promise.allSettled(jobs.map(([, run]) => run()));
settled.forEach((r, i) => {
const next = r.status === "fulfilled" ? Number(r.value?.total) || 0 : 0;
counts.value[jobs[i][0]] = next;
});
}
async function load() { async function load() {
loading.value = true; loading.value = true;
try { try {
@@ -39,13 +87,32 @@ async function load() {
} }
} }
function switchSection(key: Section) {
if (section.value === key) return;
section.value = key;
if (key === "my") {
page.value = 1;
load();
}
}
/**
* 业务编号(原型 P0-4 行内展示)。本期后端未下发正式编号(P1-5),
* 先按 `SVC-提交日-办件号` 占位;提交时间缺失时退回显示"办件号 {id}",不编造日期。
*/
function bizNo(row: ApplyVo): string {
const day = String(row.submitTime || row.createTime || "").slice(0, 10).replace(/-/g, "");
if (!/^\d{8}$/.test(day)) return `办件号 ${row.id}`;
return `SVC-${day}-${String(row.id).padStart(6, "0")}`;
}
/** 待提交态重试(后端复用同一 bizId 再次代发起,流程中心 uk 幂等) */ /** 待提交态重试(后端复用同一 bizId 再次代发起,流程中心 uk 幂等) */
async function retry(row: ApplyVo) { async function retry(row: ApplyVo) {
actingId.value = row.id; actingId.value = row.id;
try { try {
await applyApi.retry(row.id).send(); await applyApi.retry(row.id).send();
Message.success("已重新提交"); Message.success("已重新提交");
load(); refresh();
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || "重试失败"); Message.error(e?.message || "重试失败");
} finally { } finally {
@@ -63,7 +130,7 @@ async function withdraw(row: ApplyVo) {
try { try {
await applyApi.withdraw(row.id).send(); await applyApi.withdraw(row.id).send();
Message.success("已撤回,可重新提交"); Message.success("已撤回,可重新提交");
load(); refresh();
} catch (e: any) { } catch (e: any) {
// 典型原因:"已有任务被办理,不能撤回" // 典型原因:"已有任务被办理,不能撤回"
Message.error(e?.message || "撤回失败"); Message.error(e?.message || "撤回失败");
@@ -84,7 +151,7 @@ async function resubmit(row: ApplyVo) {
try { try {
await applyApi.resubmit(row.id).send(); await applyApi.resubmit(row.id).send();
Message.success("已重新提交,进入审批流程"); Message.success("已重新提交,进入审批流程");
load(); refresh();
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || "重新提交失败"); Message.error(e?.message || "重新提交失败");
} finally { } finally {
@@ -94,25 +161,117 @@ async function resubmit(row: ApplyVo) {
}); });
} }
onMounted(load); /** 写动作后列表与分区计数一起收敛 */
function refresh() {
if (section.value === "my") load();
loadCounts();
}
// ===== FlowApprovalList 宿主接线(与 MyApprovals 同一纪律,不改组件本体) =====
const recordVisible = ref(false);
const recordBizId = ref("");
function openRecord(bizId: string) {
recordBizId.value = bizId;
recordVisible.value = true;
}
const taskVisible = ref(false);
const taskUrl = ref("");
const taskTaskId = ref<number | string>("");
const taskBizId = ref<string | number>("");
/** 换址请求代际号:关闭/换件时 +1,晚到响应按代际丢弃(P1-09) */
let taskReqEpoch = 0;
async function openTask(payload: { bizId: string; taskId: number }) {
const epoch = ++taskReqEpoch;
taskBizId.value = payload.bizId;
taskTaskId.value = payload.taskId;
taskUrl.value = "";
taskVisible.value = true;
try {
const res: any = await svcFlowApprovalApi.taskPageUrl(payload.bizId, payload.taskId);
if (epoch !== taskReqEpoch) return; // 已关闭/已换件:废弃
const url = res?.pageUrl || "";
if (!url) {
Message.error("任务页地址获取失败");
taskVisible.value = false;
return;
}
taskUrl.value = url;
} catch (e: any) {
if (epoch !== taskReqEpoch) return;
Message.error(e?.msg || e?.message || "任务页地址获取失败");
taskVisible.value = false;
}
}
function onTaskClose() {
taskReqEpoch++;
taskUrl.value = "";
taskTaskId.value = "";
taskBizId.value = "";
}
function onHandled() {
approvalRef.value?.load();
loadCounts();
}
/** open-biz:只用与当前办件上下文一致的 bizId 构造 /apply-detail?id=(P1-09) */
function onOpenBiz(payload: { bizId?: string | null }) {
const msgBizId = payload.bizId != null ? String(payload.bizId) : "";
const ctxBizId = String(taskBizId.value || "");
if (!msgBizId && !ctxBizId) return;
if (msgBizId && ctxBizId && msgBizId !== ctxBizId) return;
const applyId = Number(msgBizId || ctxBizId);
if (!Number.isInteger(applyId) || applyId <= 0) return;
taskVisible.value = false;
router.push(`/apply-detail?id=${applyId}`);
}
/** 查看详情(只用本行 id 构造路由) */
function openDetail(applyId: number | string) {
const id = Number(applyId);
if (!Number.isInteger(id) || id <= 0) return;
router.push(`/apply-detail?id=${id}`);
}
onMounted(async () => {
loadCounts();
if (section.value === "my") await load();
});
</script> </script>
<template> <template>
<div class="page"> <div class="page mine-page">
<div class="page-head">
<h2 class="page-title">我的办事</h2>
<span class="page-sub">「待我审批/已办/抄送我的」为流程中心数据(按本应用过滤);「我发起的」为本地办件</span>
<Button size="small" @click="router.push('/hall')">去办事大厅</Button>
</div>
<div class="mine-tabs">
<button v-for="s in SECTIONS" :key="s.key" type="button" class="mine-tab"
:class="{ on: section === s.key }" @click="switchSection(s.key)">
<div class="n">{{ counts[s.key] }}</div>
<div class="l">{{ s.label }}</div>
</button>
</div>
<!-- 我发起的:本地办件(状态筛选只作用于本分区) -->
<template v-if="section === 'my'">
<div class="sh-card bar"> <div class="sh-card bar">
<Select v-model="flowStatus" clearable placeholder="全部状态" style="width: 150px" <Select v-model="flowStatus" clearable placeholder="全部状态" style="width: 150px"
@on-change="() => { page = 1; load(); }"> @on-change="() => { page = 1; load(); }">
<Option v-for="(v, k) in STATUS" :key="k" :value="Number(k)">{{ v.text }}</Option> <Option v-for="(v, k) in STATUS" :key="k" :value="Number(k)">{{ v.text }}</Option>
</Select> </Select>
<span class="sh-muted">共 {{ total }} 条</span> <span class="sh-muted">共 {{ total }} 条</span>
<Button class="sh-btn-primary" type="primary" size="small" @click="router.push('/hall')">
去办事大厅
</Button>
</div> </div>
<div class="sh-card" v-for="row in list" :key="row.id"> <div class="sh-card" v-for="row in list" :key="row.id">
<div class="row-head"> <div class="row-head">
<span class="row-title" @click="router.push(`/apply-detail?id=${row.id}`)"> <span class="row-title" @click="openDetail(row.id)">
{{ row.itemTitle }} {{ row.itemTitle }}
</span> </span>
<span :class="STATUS[row.flowStatus]?.cls || 'sh-tag'"> <span :class="STATUS[row.flowStatus]?.cls || 'sh-tag'">
@@ -120,12 +279,14 @@ onMounted(load);
</span> </span>
</div> </div>
<div class="row-meta sh-muted"> <div class="row-meta sh-muted">
<span>办件号 {{ row.id }}</span> <span :title="bizNo(row).startsWith('SVC-') ? '占位编号,正式业务编号随后端 P1-5 下发' : ''">
业务编号 {{ bizNo(row) }}
</span>
<span>提交时间 {{ row.submitTime || "—" }}</span> <span>提交时间 {{ row.submitTime || "—" }}</span>
<span v-if="row.flowInstanceId">流程实例 {{ row.flowInstanceId }}</span> <span v-if="row.flowInstanceId">流程实例 {{ row.flowInstanceId }}</span>
</div> </div>
<div class="row-actions"> <div class="row-actions">
<Button size="small" type="text" @click="router.push(`/apply-detail?id=${row.id}`)">详情</Button> <Button size="small" type="text" @click="openDetail(row.id)">详情</Button>
<Button size="small" type="text" v-if="row.flowStatus === 0" <Button size="small" type="text" v-if="row.flowStatus === 0"
:loading="actingId === row.id" @click="retry(row)">重试提交</Button> :loading="actingId === row.id" @click="retry(row)">重试提交</Button>
<Button size="small" type="text" v-if="row.flowStatus === 10" <Button size="small" type="text" v-if="row.flowStatus === 10"
@@ -140,10 +301,81 @@ onMounted(load);
<Page class="pager" v-if="total > 10" :total="total" :current="page" :page-size="10" show-total <Page class="pager" v-if="total > 10" :total="total" :current="page" :page-size="10" show-total
@on-change="(p: number) => { page = p; load(); }" /> @on-change="(p: number) => { page = p; load(); }" />
<Spin v-if="loading" fix></Spin> <Spin v-if="loading" fix></Spin>
</template>
<!-- 待我审批 / 已办 / 抄送我的:复用审批列表组件(单分区页签,切换即重挂载自取数据) -->
<FlowApprovalList v-else-if="faTabs.length" ref="approvalRef" :key="section" :api="svcFlowApprovalApi"
:tabs="faTabs" @open-record="openRecord" @open-task="openTask" @handled="onHandled"
@open-detail="openDetail" />
<div class="sh-card empty" v-else>
收藏功能本期未接入后端(P1-2),先在事项卡片上查看,稍后可在这里管理
</div>
<FlowRecordDrawer v-model="recordVisible" :api="svcFlowApprovalApi" :biz-id="recordBizId" />
<FlowTaskEmbed v-model="taskVisible" :page-url="taskUrl" :task-id="taskTaskId" :biz-id="taskBizId"
@handled="onHandled" @open-biz="onOpenBiz" @close="onTaskClose" />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.mine-page {
max-width: 1080px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.page-head {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.page-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.page-sub {
font-size: 13px;
color: var(--sh-text-weak);
margin-right: auto;
}
.mine-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.mine-tab {
background: #fff;
border: 1px solid var(--sh-border);
border-radius: var(--sh-radius);
padding: 10px 16px;
min-width: 112px;
text-align: left;
cursor: pointer;
font-family: inherit;
transition: 0.15s;
}
.mine-tab:hover {
border-color: var(--sh-primary);
}
.mine-tab.on {
border-color: var(--sh-primary);
background: color-mix(in srgb, var(--sh-primary) 8%, #fff);
}
.mine-tab .n {
font-size: 19px;
font-weight: 700;
color: var(--sh-primary);
line-height: 1.2;
}
.mine-tab .l {
font-size: 12px;
color: var(--sh-text-weak);
}
.bar { .bar {
display: flex; display: flex;
align-items: center; align-items: center;
+87 -14
View File
@@ -2,9 +2,11 @@
/** /**
* 办事大厅(事项大厅):学生/教师/访客的办理入口。 * 办事大厅(事项大厅):学生/教师/访客的办理入口。
* 数据源 /api/hall/items(后端复用门户 oapi 的已发布事项口径),支持关键词 + 服务对象/分类/部门筛选。 * 数据源 /api/hall/items(后端复用门户 oapi 的已发布事项口径),支持关键词 + 服务对象/分类/部门筛选。
* 卡片只承载"多久办完 / 要不要跑现场 / 能不能在线办"三件事,点击一律先进事项指南(/item),
* 不在大厅直连表单——外链事项例外,其入口在站外,直接新窗口。
* 列表布局由皮肤包决定(宿主只给 .sh-hall-grid 钩子,见 src/themes/**)。 * 列表布局由皮肤包决定(宿主只给 .sh-hall-grid 钩子,见 src/themes/**)。
*/ */
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { hallApi } from "@/api/module/svc"; import { hallApi } from "@/api/module/svc";
import { useSite } from "@/utils/site"; import { useSite } from "@/utils/site";
@@ -87,12 +89,30 @@ function search() {
load(); load();
} }
/** 进入在线办理(携带 siteCode/itemCode,与 CMS 指南页按钮同一形态) */ /** 右上角状态角标(契约 §5 三态口径;「暂未开放」本期不做——列表只返回已发布事项) */
function apply(item: HallItem) { function badgeOf(item: HallItem): { text: string; cls: string } {
router.push({ if (item.applyUrl) return { text: "外链办理", cls: "corner-link" };
path: "/apply", if (item.onlineFlag === 1) return { text: "可在线办理", cls: "corner-ok" };
query: { siteCode: siteCode.value, itemCode: item.itemCode, title: item.title }, return { text: "仅线下办理", cls: "corner-offline" };
}); }
/** 跑现场次数:0=零跑动(契约 §2.1),未配置则不显示 */
function runOf(item: HallItem): string {
if (item.runCount === null || item.runCount === undefined) return "";
return item.runCount === 0 ? "零跑动" : `跑 ${item.runCount} 次`;
}
const cards = computed(() => list.value.map(item => ({ ...item, badge: badgeOf(item) })));
/** 进事项指南(指南页与 /item 路由由并行任务 B 提供;卡片不再直连表单,避免绕过指南) */
function openGuide(item: HallItem) {
router.push({ path: "/item", query: { siteCode: siteCode.value, itemCode: item.itemCode } });
}
/** 外链事项:新窗口打开办理入口(applyUrl 已由后端白名单校验) */
function openApplyUrl(item: HallItem) {
if (!item.applyUrl) return;
window.open(item.applyUrl, "_blank", "noopener");
} }
/** 封面地址不可达时隐藏占位,避免裂图图标 */ /** 封面地址不可达时隐藏占位,避免裂图图标 */
@@ -143,7 +163,8 @@ onMounted(async () => {
<Spin v-if="loading" fix></Spin> <Spin v-if="loading" fix></Spin>
<div class="sh-hall-grid" v-if="list.length"> <div class="sh-hall-grid" v-if="list.length">
<div class="item-card" v-for="item in list" :key="item.id"> <div class="item-card" v-for="item in cards" :key="item.id" @click="openGuide(item)">
<span class="corner" :class="item.badge.cls">{{ item.badge.text }}</span>
<img <img
v-if="item.coverImage" v-if="item.coverImage"
:src="item.coverImage" :src="item.coverImage"
@@ -157,10 +178,11 @@ onMounted(async () => {
<span class="sh-tag" v-if="item.categoryName">{{ item.categoryName }}</span> <span class="sh-tag" v-if="item.categoryName">{{ item.categoryName }}</span>
<span class="sh-tag" v-if="item.deptName">{{ item.deptName }}</span> <span class="sh-tag" v-if="item.deptName">{{ item.deptName }}</span>
</div> </div>
<div class="item-actions"> <div class="item-foot">
<Button size="small" class="sh-btn-primary" type="primary" @click="apply(item)"> <span class="promise" v-if="item.promiseTimeLimit">承诺 {{ item.promiseTimeLimit }}</span>
在线办理 <span class="run" v-if="runOf(item)">· {{ runOf(item) }}</span>
</Button> <span class="go" v-if="item.applyUrl" @click.stop="openApplyUrl(item)">前往办理 ↗</span>
<span class="go" v-else>查看指南 ›</span>
</div> </div>
</div> </div>
</div> </div>
@@ -232,6 +254,8 @@ onMounted(async () => {
} }
} }
.item-card { .item-card {
position: relative;
cursor: pointer;
background: #fff; background: #fff;
border: 1px solid var(--sh-border); border: 1px solid var(--sh-border);
border-radius: var(--sh-radius); border-radius: var(--sh-radius);
@@ -271,8 +295,57 @@ onMounted(async () => {
gap: 6px; gap: 6px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.item-actions { /* 状态角标:三态口径(外链 / 可在线 / 仅线下),配色全部由令牌混合 */
text-align: right; .corner {
position: absolute;
top: 12px;
right: 12px;
z-index: 2;
padding: 2px 8px;
border-radius: 7px;
font-size: 11px;
font-weight: 600;
line-height: 16px;
white-space: nowrap;
}
.corner-ok {
color: var(--sh-success);
background: color-mix(in srgb, var(--sh-success) 12%, #fff);
}
.corner-link {
color: var(--sh-primary);
background: color-mix(in srgb, var(--sh-primary) 12%, #fff);
}
.corner-offline {
color: var(--sh-text-weak);
background: var(--sh-page-bg);
}
/* 卡片底部:承诺时限 + 跑动次数(信息密度),右侧动作文案 */
.item-foot {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed var(--sh-border);
font-size: 13px;
}
.item-foot .promise {
color: var(--sh-primary);
font-weight: 600;
}
.item-foot .run {
color: var(--sh-text-weak);
}
.item-foot .go {
margin-left: auto;
color: var(--sh-primary);
font-weight: 600;
white-space: nowrap;
}
.item-foot .go:hover {
opacity: 0.8;
} }
.empty { .empty {
text-align: center; text-align: center;
+358
View File
@@ -0,0 +1,358 @@
<script setup lang="ts">
/**
* 事项指南页(P0-1「先看指南再办理」):{base}/#/item?siteCode=&itemCode=
* 数据源 GET /api/hall/item(《契约》§3.2,匿名)——游客可读,因此本页不得调用
* /api/apply/**、/api/svc/**(这些是 CAS 保护面,会把游客弹去登录)。
* 结构照 docs/prototype/svc-hall-P0P1-产品原型-v2.html 的 page-guide;
* 审批流程图本期不做(属 flow-center 能力),常见问题/关联事项为占位。
*/
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { hallApi } from "@/api/module/svc";
import { useSite } from "@/utils/site";
import type { ItemGuideVo } from "@/types/svc";
const route = useRoute();
const router = useRouter();
const { resolveSite, rememberItem } = useSite();
const loading = ref(false);
const guide = ref<ItemGuideVo | null>(null);
const error = ref("");
const siteCode = ref("");
const tab = ref("process");
/** 收藏:本期只做本地 UI,不接后端 */
const faved = ref(false);
/** 服务对象中文名:指南接口只回 objectKey,名称取自匿名 /api/hall/filters 字典 */
const objectName = ref("");
const EXPRESS_TEXT: Record<number, string> = { 0: "否", 1: "是(到付)", 2: "是(学校承担)" };
const HANDLE_MODE_TEXT: Record<number, string> = { 1: "线上办理", 2: "线下办理", 3: "线上线下结合办理" };
async function load() {
siteCode.value = resolveSite(route.query.siteCode);
const itemCode = String(route.query.itemCode || "").trim();
if (!siteCode.value) {
error.value = "未配置站点:请从学校门户的办事指南页进入,或联系管理员配置站点标识";
return;
}
if (!itemCode) {
error.value = "缺少事项标识(itemCode):请从办事大厅的事项卡片进入指南页";
return;
}
rememberItem(itemCode);
loading.value = true;
try {
guide.value = (await hallApi.guide(siteCode.value, itemCode).send()) as ItemGuideVo;
error.value = "";
loadObjectName();
} catch (e: any) {
guide.value = null;
error.value = String(e?.message || e || "事项指南加载失败,请稍后重试");
} finally {
loading.value = false;
}
}
/** 字典查询失败不阻塞指南(回落显示原始 key) */
async function loadObjectName() {
const key = guide.value?.objectKey;
if (!key) return;
try {
const res: any = await hallApi.filters(siteCode.value).send();
objectName.value = (res?.objects || []).find((o: any) => o.key === key)?.name || "";
} catch {
// 字典不可用时保持空,由 objectLabel 回落 key
}
}
const objectLabel = computed(() => objectName.value || guide.value?.objectKey || "");
/** 两列键值:空值直接不进数组(渲染出来就没有空行) */
const rows = computed(() => {
const g = guide.value;
if (!g) return [] as { label: string; value: string }[];
const fee = g.feeDesc
? `${g.feeDesc}${g.feeBasis ? `(依据:${g.feeBasis})` : ""}`
: g.feeBasis
? `依据:${g.feeBasis}`
: "";
const runCount = g.runCount === undefined || g.runCount === null ? "" : g.runCount === 0 ? "零跑动" : `${g.runCount} 次`;
return [
{ label: "事项简介", value: g.summary || "" },
{ label: "服务对象", value: objectLabel.value },
{ label: "服务类别", value: g.categoryName || "" },
{ label: "主办部门", value: g.deptName || "" },
{ label: "协办部门", value: g.coopDepts || "" },
{ label: "办理时限", value: g.promiseTimeLimit || g.handleTimeLimit || "" },
{ label: "办理方式", value: HANDLE_MODE_TEXT[Number(g.handleMode)] || "" },
{ label: "经办人员", value: g.handlerName || "" },
{ label: "咨询电话", value: g.contactPhone || "" },
{ label: "办理时间", value: g.handleTimeDesc || "" },
{ label: "办理地点", value: g.handlePlace || "" },
{ label: "跑现场次数", value: runCount },
{ label: "是否快递送达", value: g.expressFlag === undefined || g.expressFlag === null ? "" : EXPRESS_TEXT[g.expressFlag] || "" },
{ label: "收费标准(依据)", value: fee },
{ label: "监督电话", value: g.supervisePhone || "" },
].filter(r => r.value);
});
const headSub = computed(() =>
[
guide.value?.deptName,
guide.value?.categoryName,
objectLabel.value ? `面向${objectLabel.value}` : "",
]
.filter(Boolean)
.join(" · ")
);
/** 三态标签(《契约》§5 同口径:外链 > 在线 > 仅线下) */
const statusTag = computed(() => {
const g = guide.value;
if (!g) return { cls: "", text: "" };
if (g.applyUrl) return { cls: "sh-tag-primary", text: "外链办理" };
if (g.onlineFlag === 1) return { cls: "sh-tag-success", text: "可在线办理" };
return { cls: "", text: "仅线下办理" };
});
/** 我要办理(《契约》§5 三态分流) */
function goApply() {
const g = guide.value;
if (!g) return;
if (g.applyUrl) {
window.open(g.applyUrl, "_blank", "noopener");
return;
}
if (g.onlineFlag === 1) {
router.push({
path: "/apply",
query: { siteCode: siteCode.value, itemCode: g.itemCode, title: g.title },
});
return;
}
Message.warning("该事项仅支持线下办理");
}
function toggleFav() {
faved.value = !faved.value;
if (faved.value) Message.info("登录后可收藏");
}
/** 附件体积(原型口径:214 KB) */
function sizeText(bytes?: number) {
if (!bytes || bytes <= 0) return "";
const mb = bytes / 1048576;
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
onMounted(load);
</script>
<template>
<div class="guide">
<Spin v-if="loading" fix></Spin>
<template v-if="guide">
<div class="crumb">
<router-link :to="{ path: '/hall', query: { siteCode } }">办事大厅</router-link>
<span v-if="guide.categoryName"> / {{ guide.categoryName }}</span>
<span> / {{ guide.title }}</span>
</div>
<div class="sh-card guide-head">
<div class="head-main">
<h1>{{ guide.title }}</h1>
<div class="sub sh-muted" v-if="headSub">{{ headSub }}</div>
</div>
<div class="head-side">
<span class="sh-tag" :class="statusTag.cls">{{ statusTag.text }}</span>
<Button size="small" @click="toggleFav">{{ faved ? "★ 已收藏" : "☆ 收藏" }}</Button>
</div>
</div>
<div class="sh-card" v-if="rows.length">
<div class="section-title">事项信息</div>
<div class="kv">
<div class="kv-row" v-for="r in rows" :key="r.label">
<div class="k">{{ r.label }}</div>
<div class="v">{{ r.value }}</div>
</div>
</div>
</div>
<div class="sh-card">
<Tabs v-model="tab">
<TabPane label="办事流程" name="process" />
<TabPane label="所需材料" name="material" />
<TabPane label="注意事项" name="notice" />
<TabPane label="常见问题" name="faq" />
<TabPane label="关联事项" name="related" />
</Tabs>
<div class="pane" v-if="tab === 'process'">
<div class="rich" v-if="guide.processHtml" v-html="guide.processHtml"></div>
<div class="sh-muted" v-else>暂未提供办事流程说明</div>
</div>
<div class="pane" v-else-if="tab === 'material'">
<div class="rich" v-if="guide.materialHtml" v-html="guide.materialHtml"></div>
<div class="sh-muted" v-else>暂未提供材料清单</div>
</div>
<div class="pane" v-else-if="tab === 'notice'">
<div class="rich" v-if="guide.noticeHtml" v-html="guide.noticeHtml"></div>
<div class="sh-muted" v-else>暂无</div>
<div class="atts" v-if="guide.attachments && guide.attachments.length">
<span class="sh-tag">附件</span>
<a v-for="att in guide.attachments" :key="att.fileUrl" :href="att.fileUrl"
target="_blank" rel="noopener">
{{ att.fileName }}<span class="sh-muted" v-if="sizeText(att.fileSize)">({{ sizeText(att.fileSize) }})</span>
</a>
</div>
</div>
<div class="pane" v-else-if="tab === 'faq'">
<div class="sh-muted">常见问题整理中</div>
</div>
<div class="pane" v-else>
<div class="sh-muted">暂无关联事项</div>
</div>
</div>
<div class="actionbar">
<span class="phone" v-if="guide.contactPhone || guide.handlerName">
<template v-if="guide.contactPhone">咨询电话 {{ guide.contactPhone }}</template>
<template v-if="guide.handlerName">{{ guide.contactPhone ? " · " : "" }}{{ guide.handlerName }}</template>
</span>
<span class="sp"></span>
<Button size="small" @click="toggleFav">{{ faved ? "★ 已收藏" : "☆ 收藏" }}</Button>
<Button class="sh-btn-primary" type="primary" @click="goApply">我要办理</Button>
</div>
</template>
<div class="sh-card state" v-else-if="!loading">
<div class="state-text">{{ error || "事项不存在或未发布" }}</div>
<Button class="sh-btn-primary" type="primary" @click="router.push('/hall')">返回大厅</Button>
</div>
</div>
</template>
<style scoped>
.crumb {
font-size: 13px;
color: var(--sh-text-weak);
margin-bottom: 10px;
}
.crumb a {
color: var(--sh-primary);
text-decoration: none;
}
.guide-head {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.head-main {
flex: 1;
min-width: 260px;
}
.guide-head h1 {
font-size: 22px;
line-height: 1.35;
margin: 0;
font-weight: 600;
}
.guide-head .sub {
margin-top: 4px;
}
.head-side {
display: flex;
align-items: center;
gap: 8px;
}
.section-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
}
.kv {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0 28px;
}
@media (max-width: 820px) {
.kv {
grid-template-columns: 1fr;
}
}
.kv-row {
display: flex;
gap: 12px;
padding: 8px 0;
border-bottom: 1px dashed var(--sh-border);
align-items: flex-start;
}
.k {
color: var(--sh-text-weak);
font-size: 13px;
min-width: 96px;
flex: none;
padding-top: 1px;
}
.v {
font-size: 13.5px;
flex: 1;
}
.rich {
font-size: 13.5px;
line-height: 1.9;
color: var(--sh-text);
}
/* 富文本由后端 jsoup 白名单清洗后原样渲染,这里只做版式兜底 */
.rich :deep(p) {
margin: 0 0 8px;
}
.rich :deep(img) {
max-width: 100%;
}
.atts {
margin-top: 14px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 13px;
}
.atts a {
color: var(--sh-primary);
text-decoration: none;
}
.actionbar {
position: sticky;
bottom: 0;
background: #fff;
border-top: 1px solid var(--sh-border);
padding: 12px 0;
display: flex;
align-items: center;
gap: 12px;
margin-top: 20px;
box-shadow: 0 -4px 12px rgba(16, 24, 40, 0.05);
z-index: 50;
}
.actionbar .phone {
font-size: 12.5px;
color: var(--sh-text-weak);
}
.actionbar .sp {
flex: 1;
}
.state {
text-align: center;
padding: 48px 16px;
}
.state-text {
color: var(--sh-text-weak);
margin-bottom: 16px;
}
</style>
+15 -3
View File
@@ -7,17 +7,29 @@
* - open-task 换址请求带代际号——关闭/换件时废弃在途响应并清 taskUrl/选中 ID; * - open-task 换址请求带代际号——关闭/换件时废弃在途响应并清 taskUrl/选中 ID;
* - open-biz 只用与当前办件上下文一致的 bizId 构造 /apply-detail?id=,不照抄任意 URL。 * - open-biz 只用与当前办件上下文一致的 bizId 构造 /apply-detail?id=,不照抄任意 URL。
*/ */
import { onBeforeUnmount, ref } from "vue"; import { computed, onBeforeUnmount, ref } from "vue";
import { useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import FlowApprovalList from "@/components/flow-approval/FlowApprovalList.vue"; import FlowApprovalList from "@/components/flow-approval/FlowApprovalList.vue";
import FlowRecordDrawer from "@/components/flow-approval/FlowRecordDrawer.vue"; import FlowRecordDrawer from "@/components/flow-approval/FlowRecordDrawer.vue";
import FlowTaskEmbed from "@/components/flow-approval/FlowTaskEmbed.vue"; import FlowTaskEmbed from "@/components/flow-approval/FlowTaskEmbed.vue";
import svcFlowApprovalApi from "@/api/module/approvalAdapter"; import svcFlowApprovalApi from "@/api/module/approvalAdapter";
const route = useRoute();
const router = useRouter(); const router = useRouter();
const listRef = ref<InstanceType<typeof FlowApprovalList> | null>(null); const listRef = ref<InstanceType<typeof FlowApprovalList> | null>(null);
/**
* ApplyMy(我的办事)分区深链:?tab=todo|my|done|cc 决定默认页签。
* FlowApprovalList 的初始页签取 tabs[0],故把请求的页签排到首位即可,不改组件本体。
*/
const TAB_ORDER = ["todo", "my", "done", "cc"] as const;
const tabs = computed(() => {
const want = String(route.query.tab || "");
const head = TAB_ORDER.find(t => t === want) || "todo";
return [head, ...TAB_ORDER.filter(t => t !== head)];
});
// ===== 审批记录抽屉 ===== // ===== 审批记录抽屉 =====
const recordVisible = ref(false); const recordVisible = ref(false);
const recordBizId = ref(""); const recordBizId = ref("");
@@ -102,7 +114,7 @@ onBeforeUnmount(() => {
<h2 class="page-title">我的审批</h2> <h2 class="page-title">我的审批</h2>
<span class="page-sub">「待我审批/我已审批/抄送我的」为流程中心数据(按本应用过滤);「我提交的」为本地办件</span> <span class="page-sub">「待我审批/我已审批/抄送我的」为流程中心数据(按本应用过滤);「我提交的」为本地办件</span>
</div> </div>
<FlowApprovalList ref="listRef" :api="svcFlowApprovalApi" :tabs="['todo', 'my', 'done', 'cc']" <FlowApprovalList ref="listRef" :api="svcFlowApprovalApi" :tabs="tabs"
@open-record="openRecord" @open-task="openTask" @handled="onHandled" @open-record="openRecord" @open-task="openTask" @handled="onHandled"
@open-detail="openDetail" /> @open-detail="openDetail" />