Compare commits
12
Commits
4243a5f0e6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae8c6a911 | ||
|
|
ce876760af | ||
|
|
864038d1b1 | ||
|
|
8ce8082e89 | ||
|
|
dda1a71cf5 | ||
|
|
d7d8789d1a | ||
|
|
351a513547 | ||
|
|
f5b39b96c6 | ||
|
|
f508924ae8 | ||
|
|
a4f954d3b8 | ||
|
|
41b79566c1 | ||
|
|
f636075a5e |
@@ -1,5 +1,5 @@
|
|||||||
# 开发环境 (dev):仅 vite dev --mode dev 加载;build 产物不读任何 .env.*
|
# 开发环境 (dev):仅 vite dev --mode dev 加载;build 产物不读任何 .env.*
|
||||||
# (.env.base/.env.ld/.env.prod 已删,线上由 /api/env-config?target=front 运行时下发)。
|
# (.env.base/.env.ld/.env.prod/.env.mock 已删,线上由 /api/env-config?target=hall 运行时下发)。
|
||||||
# ⚠️ VITE_* 仅本地开发生效。
|
# ⚠️ VITE_* 仅本地开发生效。
|
||||||
# API 走 /api 同源相对路径(vite proxy 转发到本地后端 8086),
|
# API 走 /api 同源相对路径(vite proxy 转发到本地后端 8086),
|
||||||
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境
|
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境
|
||||||
@@ -11,5 +11,6 @@ VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
|||||||
VITE_IS_MOCK=false
|
VITE_IS_MOCK=false
|
||||||
|
|
||||||
# 本地开发:站点标识与皮肤包(线上由 env-config 下发,build 产物不读 .env)
|
# 本地开发:站点标识与皮肤包(线上由 env-config 下发,build 产物不读 .env)
|
||||||
VITE_SITE_CODE=yhms
|
# siteCode=demo:本地 bsch_cms 的演示站点(与 CMS 本地种子数据对应)
|
||||||
|
VITE_SITE_CODE=demo
|
||||||
VITE_THEME=default
|
VITE_THEME=default
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# 本地纯前端调试模式:仅 vite dev --mode mock 加载(build 产物不读任何 .env.*,
|
|
||||||
# 线上由 /api/env-config?target=front 运行时下发)。后端 SSO/base 不可达时启用内置 mock
|
|
||||||
# 启动:npm run dev:mock
|
|
||||||
VITE_BASE_HOST=
|
|
||||||
VITE_SSO_HOST=
|
|
||||||
VITE_SERVICE_HOST=http://localhost:8101
|
|
||||||
VITE_ZIYUANKU_HOST=
|
|
||||||
VITE_ZHUANXIANG_HOST=
|
|
||||||
VITE_IS_MOCK=true
|
|
||||||
@@ -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
|
||||||
'
|
'
|
||||||
|
|||||||
@@ -27,3 +27,6 @@ yarn.lock
|
|||||||
|
|
||||||
# scratch
|
# scratch
|
||||||
.temp
|
.temp
|
||||||
|
|
||||||
|
# Vite 配置热重载产生的临时文件(构建时自动生成,勿入库)
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ push 到 `main` → gitea CI(`.gitea/workflows/build.yml`):`npm run build`
|
|||||||
**首次上线前**需在 projman 应用 `svc-hall` 下新增前端目标 `hall`(路径片段 `hall`),
|
**首次上线前**需在 projman 应用 `svc-hall` 下新增前端目标 `hall`(路径片段 `hall`),
|
||||||
并在配置管理里补 `env-config-hall.json`(`siteCode`、`theme`、`baseHost`、`ssoHost`),
|
并在配置管理里补 `env-config-hall.json`(`siteCode`、`theme`、`baseHost`、`ssoHost`),
|
||||||
再把 CMS 站点的 `svc_hall_base` 指向新前端(`https://<域名>/svc-hall/hall`)。详见
|
再把 CMS 站点的 `svc_hall_base` 指向新前端(`https://<域名>/svc-hall/hall`)。详见
|
||||||
`docs/公共端上线与projman接入说明.md`。
|
`../docs/用户面(hall)上线与projman接入说明.md`。
|
||||||
|
|
||||||
## 契约纪律
|
## 契约纪律
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
//铃铛debug
|
|
||||||
window.conf = {
|
|
||||||
baseHost: "http://basedebug.bestsch.com",
|
|
||||||
ssoHost: "http://ssodebug.bestsch.com",
|
|
||||||
serviceHost: "http://basedebug.bestsch.com/QualityEvaluate",
|
|
||||||
};
|
|
||||||
+4
-4
@@ -8,15 +8,15 @@
|
|||||||
<!-- 运行时配置引导:在任何应用模块执行前同步拉取 /<contextPath>/api/env-config
|
<!-- 运行时配置引导:在任何应用模块执行前同步拉取 /<contextPath>/api/env-config
|
||||||
(后端免登录端点,projman 配置管理在线维护),替代生产构建环境变量。
|
(后端免登录端点,projman 配置管理在线维护),替代生产构建环境变量。
|
||||||
必须同步:host.config.ts 在模块求值期就读 window.__APP_CONFIG__;失败/本地开发
|
必须同步:host.config.ts 在模块求值期就读 window.__APP_CONFIG__;失败/本地开发
|
||||||
(根路径,段数不足 2)则不设置:本地走 .env.dev/.env.mock(VITE_*),线上走同域推导兜底。
|
(根路径,段数不足 2)则不设置:本地走 .env.dev(VITE_*),线上走同域推导兜底。
|
||||||
target = URL 第 2 段目录名:本应用产物部署于 /{contextPath}/public/,故为 public,
|
target = URL 第 2 段目录名:本应用产物部署于 /{contextPath}/hall/,故为 hall,
|
||||||
对应 projman 前端目标名 public(文件 /app/config/env-config-public.json)。
|
对应 projman 前端目标名 hall(文件 /app/config/env-config-hall.json)。
|
||||||
注意:同步 XHR 不支持 timeout 属性(赋值会抛 InvalidAccessError),依赖内网快速失败 -->
|
注意:同步 XHR 不支持 timeout 属性(赋值会抛 InvalidAccessError),依赖内网快速失败 -->
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
var seg = location.pathname.split("/").filter(Boolean);
|
var seg = location.pathname.split("/").filter(Boolean);
|
||||||
if (seg.length < 2) return; // 本地开发(根路径):走 .env.dev / .env.mock
|
if (seg.length < 2) return; // 本地开发(根路径):走 .env.dev
|
||||||
var xhr = new XMLHttpRequest();
|
var xhr = new XMLHttpRequest();
|
||||||
xhr.open("GET", "/" + seg[0] + "/api/env-config?target=" + encodeURIComponent(seg[1]), false);
|
xhr.open("GET", "/" + seg[0] + "/api/env-config?target=" + encodeURIComponent(seg[1]), false);
|
||||||
xhr.send(null);
|
xhr.send(null);
|
||||||
|
|||||||
Generated
+77
-493
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --mode dev",
|
"dev": "vite --mode dev",
|
||||||
"dev:mock": "vite --mode mock",
|
|
||||||
"build": "vite build --mode base",
|
"build": "vite build --mode base",
|
||||||
"build:base": "vite build --mode base",
|
"build:base": "vite build --mode base",
|
||||||
"build:ld": "vite build --mode ld",
|
|
||||||
"build:prod": "vite build --mode prod",
|
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"api:gen": "alova gen -f"
|
"api:gen": "alova gen -f"
|
||||||
},
|
},
|
||||||
@@ -24,12 +21,10 @@
|
|||||||
"axios": "^0.24.0",
|
"axios": "^0.24.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"mockjs": "^1.1.0",
|
|
||||||
"pdfjs-dist": "^4.10.38",
|
"pdfjs-dist": "^4.10.38",
|
||||||
"pinia": "^2.1.4",
|
"pinia": "^2.1.4",
|
||||||
"sass": "^1.64.1",
|
"sass": "^1.64.1",
|
||||||
"view-ui-plus": "^1.3.16",
|
"view-ui-plus": "^1.3.16",
|
||||||
"vite-plugin-mock": "2.9.6",
|
|
||||||
"vue": "^3.4.19",
|
"vue": "^3.4.19",
|
||||||
"vue-router": "4",
|
"vue-router": "4",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
+46
-3
@@ -5,20 +5,31 @@
|
|||||||
* 与后台前端(front/)刻意不同:不渲染平台模块菜单(app_module 是管理端契约),
|
* 与后台前端(front/)刻意不同:不渲染平台模块菜单(app_module 是管理端契约),
|
||||||
* 只做两个入口——办事大厅、我的办件(详情/办理页从列表进入)。
|
* 只做两个入口——办事大厅、我的办件(详情/办理页从列表进入)。
|
||||||
*
|
*
|
||||||
* 皮肤:启动时按运行时配置(env-config 的 theme/themeParams)应用皮肤包;
|
* 皮肤三层优先级(T3 弱联动,2026-10-03):
|
||||||
* 未配置 → default;未注册 → default(整页回退,见 utils/theme.ts)。
|
* CMS 站点推荐(/api/hall/site 的 theme/themeParams,经 utils/cmsTheme 映射;
|
||||||
|
* stale-while-revalidate——localStorage 缓存先画、后台刷新,失败静默)
|
||||||
|
* > env-config 部署级皮肤 > default。
|
||||||
|
* 推荐值、不保证生效:任一层缺失/非法都回落下一层(utils/theme.ts)。
|
||||||
*/
|
*/
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { baseApi } from "@/api/module/base";
|
import { baseApi } from "@/api/module/base";
|
||||||
import config from "@/assets/config/host.config";
|
import config from "@/assets/config/host.config";
|
||||||
import { applyTheme, resolveTheme, type ThemeManifest } from "@/utils/theme";
|
import { applyTheme, resolveTheme, type ThemeManifest } from "@/utils/theme";
|
||||||
|
import { toHallSkin } from "@/utils/cmsTheme";
|
||||||
|
import { useSite } from "@/utils/site";
|
||||||
|
import { hallApi } from "@/api/module/svc";
|
||||||
|
|
||||||
const userName = ref("");
|
const userName = ref("");
|
||||||
const theme = ref<ThemeManifest>(resolveTheme(null));
|
const theme = ref<ThemeManifest>(resolveTheme(null));
|
||||||
|
// CMS 指南页来路(T4 回跳闭环):仅 /#/apply 深链带合法 from 参数时非空
|
||||||
|
const { portalFrom, currentSiteCode } = useSite();
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 皮肤先应用(首屏观感),再拉用户信息——用户信息失败不影响办理页面使用
|
// ① 基线:env-config 部署级皮肤(现状语义不变,先应用保证首屏观感)
|
||||||
theme.value = applyTheme(config.theme, config.themeParams);
|
theme.value = applyTheme(config.theme, config.themeParams);
|
||||||
|
// ② 站点推荐:CMS 站点主题(弱联动)——缓存先画,后台刷新
|
||||||
|
applySiteThemeRecommend();
|
||||||
|
// 用户信息失败不影响办理页面使用
|
||||||
try {
|
try {
|
||||||
const user: any = await baseApi.getUserInfo().send();
|
const user: any = await baseApi.getUserInfo().send();
|
||||||
userName.value = user?.name || "";
|
userName.value = user?.name || "";
|
||||||
@@ -29,6 +40,36 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点主题推荐应用(stale-while-revalidate):
|
||||||
|
* 未登录时该请求会 302 跳 CAS(与 items 同行为,页面本来就要登录);
|
||||||
|
* 任何失败静默保留现皮肤——推荐、不保证生效,绝不阻塞首屏。
|
||||||
|
*/
|
||||||
|
async function applySiteThemeRecommend() {
|
||||||
|
const code = currentSiteCode.value; // useSite() 模块级 ref,当前视图 onMounted 已 resolve
|
||||||
|
if (!code) return;
|
||||||
|
const cacheKey = `svchall.public.siteTheme.${code}`;
|
||||||
|
// ① stale:同步读缓存立即应用(二次访问无闪色)
|
||||||
|
try {
|
||||||
|
const cached = JSON.parse(localStorage.getItem(cacheKey) || "null");
|
||||||
|
const stale = toHallSkin(cached);
|
||||||
|
if (stale) theme.value = applyTheme(stale.name, stale.params);
|
||||||
|
} catch {
|
||||||
|
// 坏缓存当没有
|
||||||
|
}
|
||||||
|
// ② revalidate:后台刷新(失败静默保留现皮肤——推荐、不保证生效)
|
||||||
|
try {
|
||||||
|
const info: any = await hallApi.site(code).send();
|
||||||
|
const pick = toHallSkin({ theme: info?.theme, themeParams: info?.themeParams });
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify({
|
||||||
|
theme: info?.theme || null, themeParams: info?.themeParams || null,
|
||||||
|
}));
|
||||||
|
if (pick) theme.value = applyTheme(pick.name, pick.params);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[public-hall] 站点主题获取失败,沿用当前皮肤", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -41,6 +82,8 @@ onMounted(async () => {
|
|||||||
<nav class="sh-nav">
|
<nav class="sh-nav">
|
||||||
<router-link to="/hall">办事大厅</router-link>
|
<router-link to="/hall">办事大厅</router-link>
|
||||||
<router-link to="/my">我的办件</router-link>
|
<router-link to="/my">我的办件</router-link>
|
||||||
|
<router-link to="/todo">我的审批</router-link>
|
||||||
|
<a v-if="portalFrom" :href="portalFrom" class="sh-back-portal">返回门户</a>
|
||||||
</nav>
|
</nav>
|
||||||
<span class="sh-user" v-if="userName">{{ userName }}</span>
|
<span class="sh-user" v-if="userName">{{ userName }}</span>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { FlowApprovalApi } from "@/components/flow-approval/types";
|
||||||
|
import { approvalApi, applyApi } from "@/api/module/svc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* svc-hall 应用内审批适配器(唯一映射位置;沿 Notice approvalAdapter 形态)。
|
||||||
|
*
|
||||||
|
* 纪律:
|
||||||
|
* - 身份三字段(operatorId/operatorName/schId)不出现——后端从 CAS 会话取;
|
||||||
|
* - 写面 bizId=applyId 是必需上下文(后端写面双闸),组件契约层已强制携带;
|
||||||
|
* - trace 复用既有 /api/apply/{id}/progress(卡片不自取轨迹,宿主转换后经 props 传入);
|
||||||
|
* - alova Method 是 thenable,组件 await 即发。
|
||||||
|
*/
|
||||||
|
export const svcFlowApprovalApi: FlowApprovalApi = {
|
||||||
|
todo: (p) => approvalApi.todo(p?.page ?? 1, p?.size ?? 10).send(),
|
||||||
|
my: (p) =>
|
||||||
|
approvalApi
|
||||||
|
.my(p?.page ?? 1, p?.size ?? 10, p?.flowStatus ?? null)
|
||||||
|
.send(),
|
||||||
|
done: (p) => approvalApi.done(p?.page ?? 1, p?.size ?? 10).send(),
|
||||||
|
cc: (p) => approvalApi.cc(p?.page ?? 1, p?.size ?? 10).send(),
|
||||||
|
byBiz: (bizId) => approvalApi.byBiz(Number(bizId)).send(),
|
||||||
|
trace: (bizId) => applyApi.progress(Number(bizId)).send(),
|
||||||
|
approve: (taskId, opinion, edgeName, bizId) =>
|
||||||
|
approvalApi.approve(taskId, Number(bizId), opinion, edgeName).send(),
|
||||||
|
reject: (taskId, opinion, bizId) => approvalApi.reject(taskId, Number(bizId), opinion).send(),
|
||||||
|
recordUrl: (bizId) => approvalApi.recordUrl(Number(bizId)).send(),
|
||||||
|
taskPageUrl: (bizId, taskId) => approvalApi.taskPageUrl(Number(bizId), taskId).send(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default svcFlowApprovalApi;
|
||||||
@@ -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";
|
||||||
|
|
||||||
@@ -22,6 +26,7 @@ import type {
|
|||||||
const HALL = "/api/hall";
|
const HALL = "/api/hall";
|
||||||
const APPLY = "/api/apply";
|
const APPLY = "/api/apply";
|
||||||
const FILE = "/api/file";
|
const FILE = "/api/file";
|
||||||
|
const APPROVAL = "/api/apply/approval";
|
||||||
|
|
||||||
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
||||||
return new Method<T>("GET", alovaInstance, url, { params });
|
return new Method<T>("GET", alovaInstance, url, { params });
|
||||||
@@ -63,6 +68,14 @@ class HallApi {
|
|||||||
filters(siteCode: string) {
|
filters(siteCode: string) {
|
||||||
return get<HallFilters>(`${HALL}/filters`, { siteCode });
|
return get<HallFilters>(`${HALL}/filters`, { siteCode });
|
||||||
}
|
}
|
||||||
|
/** 站点信息(含 CMS 主题 raw;主题弱联动数据源,失败静默——见 App.vue 应用链,T3) */
|
||||||
|
site(siteCode: string) {
|
||||||
|
return get(`${HALL}/site`, { siteCode });
|
||||||
|
}
|
||||||
|
/** 事项指南(匿名;游客可读) */
|
||||||
|
guide(siteCode: string, itemCode: string) {
|
||||||
|
return get<ItemGuideVo>(`${HALL}/item`, { siteCode, itemCode });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ApplyApi {
|
class ApplyApi {
|
||||||
@@ -99,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}`);
|
||||||
}
|
}
|
||||||
@@ -121,6 +148,49 @@ class FileApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ApprovalApi {
|
||||||
|
/** 应用内审批代理(P3.1,/api/apply/approval/**;身份全部由后端从 CAS 会话取,前端不传) */
|
||||||
|
/** 待我审批(中心 todo;双值双查与合并都在后端) */
|
||||||
|
todo(page?: number, size?: number) {
|
||||||
|
return get(`${APPROVAL}/Todo`, { page, size });
|
||||||
|
}
|
||||||
|
/** 我已审批(P3.5;中心 done,过滤=签名方应用身份在中心侧) */
|
||||||
|
done(page?: number, size?: number) {
|
||||||
|
return get(`${APPROVAL}/Done`, { page, size });
|
||||||
|
}
|
||||||
|
/** 抄送我的(P3.5;中心 cc,同上) */
|
||||||
|
cc(page?: number, size?: number) {
|
||||||
|
return get(`${APPROVAL}/Cc`, { page, size });
|
||||||
|
}
|
||||||
|
/** 我提交的(本地办件;后端做 {total,list}→FlowPage 信封映射;flowStatus 空=全部) */
|
||||||
|
my(page?: number, size?: number, flowStatus?: number | null) {
|
||||||
|
return get(`${APPROVAL}/My`, { page, size, flowStatus: flowStatus ?? undefined });
|
||||||
|
}
|
||||||
|
/** 当前人在该办件上的待办(0/1/N 均 200;卡片/列表复核同源) */
|
||||||
|
byBiz(applyId: number) {
|
||||||
|
return get(`${APPROVAL}/ByBiz`, { applyId });
|
||||||
|
}
|
||||||
|
/** 通过:taskId/applyId 走查询参数(与中心端点同构),opinion/edgeName 走 JSON body */
|
||||||
|
approve(taskId: number, applyId: number, opinion?: string, edgeName?: string) {
|
||||||
|
return postJSON(`${APPROVAL}/Approve?taskId=${encodeURIComponent(taskId)}&applyId=${encodeURIComponent(applyId)}`,
|
||||||
|
{ opinion: opinion || undefined, edgeName: edgeName || undefined });
|
||||||
|
}
|
||||||
|
/** 拒绝:opinion 必填(后端前置校验) */
|
||||||
|
reject(taskId: number, applyId: number, opinion: string) {
|
||||||
|
return postJSON(`${APPROVAL}/Reject?taskId=${encodeURIComponent(taskId)}&applyId=${encodeURIComponent(applyId)}`,
|
||||||
|
{ opinion });
|
||||||
|
}
|
||||||
|
/** 审批记录抽屉地址(token 只读页,instanceId 形态换址) */
|
||||||
|
recordUrl(applyId: number) {
|
||||||
|
return get(`${APPROVAL}/RecordUrl`, { applyId });
|
||||||
|
}
|
||||||
|
/** 完整办理任务页地址(先 byBiz 归属预检再签发,在后端) */
|
||||||
|
taskPageUrl(applyId: number, taskId: number) {
|
||||||
|
return get(`${APPROVAL}/TaskPageUrl`, { applyId, taskId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const hallApi = new HallApi();
|
export const hallApi = new HallApi();
|
||||||
export const applyApi = new ApplyApi();
|
export const applyApi = new ApplyApi();
|
||||||
export const fileApi = new FileApi();
|
export const fileApi = new FileApi();
|
||||||
|
export const approvalApi = new ApprovalApi();
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { BaseConf } from "@/types/Basics";
|
|||||||
/**
|
/**
|
||||||
* 配置优先级(高 → 低):
|
* 配置优先级(高 → 低):
|
||||||
* ① window.__APP_CONFIG__:线上运行时配置,index.html 引导脚本在应用模块加载前同步拉取
|
* ① window.__APP_CONFIG__:线上运行时配置,index.html 引导脚本在应用模块加载前同步拉取
|
||||||
* /<contextPath>/api/env-config?target=<部署目录名>(本应用部署于 /{contextPath}/front/,
|
* /<contextPath>/api/env-config?target=<部署目录名>(本应用部署于 /{contextPath}/hall/,
|
||||||
* 故为 front,对应后端键 yh.env-config.source.front,projman 配置管理维护)写入
|
* 故为 hall,对应 projman 前端目标 hall / 文件 /app/config/env-config-hall.json)写入
|
||||||
* ② import.meta.env.VITE_*:仅本地开发/演示(vite dev --mode dev 读 .env.dev、--mode mock 读 .env.mock);
|
* ② import.meta.env.VITE_*:仅本地开发/演示(vite dev --mode dev 读 .env.dev);
|
||||||
* build 产物不读任何 .env.*(.env.base/.env.ld/.env.prod 已删),VITE_* 恒为 undefined,
|
* build 产物不读任何 .env.*(.env.base/.env.ld/.env.prod/.env.mock 已删),VITE_* 恒为 undefined,
|
||||||
* 本层自动跳过,产物不含任何环境值
|
* 本层自动跳过,产物不含任何环境值
|
||||||
* ③ 同域推导兜底:env-config 未配置/拉取失败时框架至少能渲染并调通同源 API;
|
* ③ 同域推导兜底:env-config 未配置/拉取失败时框架至少能渲染并调通同源 API;
|
||||||
* ssoHost 为空 → 登出跳转不可用——与后端未就绪状态一致,可接受。
|
* ssoHost 为空 → 登出跳转不可用——与后端未就绪状态一致,可接受。
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
@@ -98,18 +98,7 @@ h4.subtitle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-spin-load {
|
|
||||||
width: 60px;
|
|
||||||
height: 90px;
|
|
||||||
padding-top: 60px;
|
|
||||||
background-image: url(../image/loading.gif);
|
|
||||||
background-size: 60px 60px;
|
|
||||||
background-position: top center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
border: none;
|
|
||||||
color: #577afb;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ivu-table-cell-tree {
|
.ivu-table-cell-tree {
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
--sh-text: #24292f;
|
--sh-text: #24292f;
|
||||||
--sh-text-weak: #6b7280;
|
--sh-text-weak: #6b7280;
|
||||||
--sh-border: #e6e9ef;
|
--sh-border: #e6e9ef;
|
||||||
|
/* 语义色令牌(T6 令牌化:此前 ApplyDetail/tag 各写死一份) */
|
||||||
|
--sh-success: #1f9254;
|
||||||
|
--sh-danger: #d03050;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -80,6 +83,12 @@ body {
|
|||||||
background: rgba(255, 255, 255, 0.22);
|
background: rgba(255, 255, 255, 0.22);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
/* 返回门户(T4 回跳闭环):外跳文字链,与 nav 同风格白字,加分隔线区分站内导航。
|
||||||
|
方案原文写 var(--sh-primary),但 nav 位于主色底 header 上会隐形,故取 nav 现有风格。 */
|
||||||
|
.sh-header .sh-nav a.sh-back-portal {
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
.sh-user {
|
.sh-user {
|
||||||
margin-left: 16px;
|
margin-left: 16px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -130,13 +139,13 @@ body {
|
|||||||
background: rgba(26, 92, 176, 0.06);
|
background: rgba(26, 92, 176, 0.06);
|
||||||
}
|
}
|
||||||
.sh-tag-success {
|
.sh-tag-success {
|
||||||
color: #1f9254;
|
color: var(--sh-success);
|
||||||
border-color: #1f9254;
|
border-color: var(--sh-success);
|
||||||
background: rgba(31, 146, 84, 0.07);
|
background: rgba(31, 146, 84, 0.07);
|
||||||
}
|
}
|
||||||
.sh-tag-error {
|
.sh-tag-error {
|
||||||
color: #d03050;
|
color: var(--sh-danger);
|
||||||
border-color: #d03050;
|
border-color: var(--sh-danger);
|
||||||
background: rgba(208, 48, 80, 0.07);
|
background: rgba(208, 48, 80, 0.07);
|
||||||
}
|
}
|
||||||
.sh-tag-warning {
|
.sh-tag-warning {
|
||||||
@@ -154,3 +163,23 @@ body {
|
|||||||
background: var(--sh-primary-dark);
|
background: var(--sh-primary-dark);
|
||||||
border-color: var(--sh-primary-dark);
|
border-color: var(--sh-primary-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* view-ui 主色跟随皮肤(克制补丁:仅 hall 实际出现的交互态,2026-10-03 T3 联动配套;
|
||||||
|
未用到的 Tabs/Switch/ivu-btn-text 不加,不做全组件库 retheme) */
|
||||||
|
.dyn-file-btn:hover .dyn-file-btn-inner {
|
||||||
|
color: var(--sh-primary);
|
||||||
|
}
|
||||||
|
.ivu-checkbox-checked .ivu-checkbox-inner,
|
||||||
|
.ivu-radio-checked .ivu-radio-inner {
|
||||||
|
border-color: var(--sh-primary);
|
||||||
|
}
|
||||||
|
.ivu-checkbox-checked .ivu-checkbox-inner:after,
|
||||||
|
.ivu-radio-checked .ivu-radio-inner:after {
|
||||||
|
background: var(--sh-primary);
|
||||||
|
}
|
||||||
|
.ivu-page-item-active a {
|
||||||
|
color: var(--sh-primary);
|
||||||
|
}
|
||||||
|
.ivu-modal-confirm-head-icon-confirm .ivu-icon-ios-alert {
|
||||||
|
color: var(--sh-accent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
// utils 工具// 颜色
|
// utils 工具// 颜色
|
||||||
|
// (2026-10-03 T6:_varible.scss 已删——本文件仅剩 4 个色值依赖它,就地内联,
|
||||||
|
// 产物 CSS 与删除前逐字节一致;原文件唯一 import 形式为不带下划线的 "./varible")
|
||||||
|
$color-error: #e8684a;
|
||||||
|
$color-success: #45b972;
|
||||||
|
$color-warning: #e9b954;
|
||||||
|
$color-primary: #5979ea;
|
||||||
|
|
||||||
@import "./varible";
|
|
||||||
.bg-error {
|
.bg-error {
|
||||||
background-color: $color-error !important;
|
background-color: $color-error !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
/*scss变量*/
|
|
||||||
|
|
||||||
// 主题色
|
|
||||||
$color-error: #e8684a;
|
|
||||||
$color-success: #45b972;
|
|
||||||
$color-warning: #e9b954;
|
|
||||||
$color-primary: #5979ea;
|
|
||||||
|
|
||||||
// 文字色
|
|
||||||
$color-black: #404042;
|
|
||||||
$color-dark: #333;
|
|
||||||
$color-deep: #555;
|
|
||||||
$color-pl: #999999;
|
|
||||||
$color-weak: #b3b3b3;
|
|
||||||
$color-white: #fff;
|
|
||||||
|
|
||||||
// 背景色
|
|
||||||
$bg-bar: #f9f9f9;
|
|
||||||
$bg-page: #f3f3f3;
|
|
||||||
$bg-page-light: #f9f9f9;
|
|
||||||
|
|
||||||
$border-color: #e9e9e9;
|
|
||||||
@@ -399,8 +399,8 @@ watch(
|
|||||||
transition: color 0.2s, border-color 0.2s;
|
transition: color 0.2s, border-color 0.2s;
|
||||||
}
|
}
|
||||||
.dyn-file-btn:hover .dyn-file-btn-inner {
|
.dyn-file-btn:hover .dyn-file-btn-inner {
|
||||||
color: #2d8cf0;
|
color: var(--sh-primary);
|
||||||
border-color: #2d8cf0;
|
border-color: var(--sh-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fa-card-region">
|
||||||
|
<!-- 不可达:本地状态照常展示,动作明确提示稍后重试(绝不伪装成功,也不静默变只读) -->
|
||||||
|
<Alert v-if="unavailable" type="warning" show-icon class="fa-alert">
|
||||||
|
流程中心暂时不可达,审批动作稍后重试。
|
||||||
|
<a class="fa-link" @click="load">重新加载</a>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<!-- 首次加载:空态与未加载是两回事,误报会误导审批人 -->
|
||||||
|
<div v-else-if="loading" class="fa-muted">审批状态加载中…</div>
|
||||||
|
|
||||||
|
<div v-else class="fa-body">
|
||||||
|
<div class="fa-head">
|
||||||
|
<Tag :color="statusTag">{{ statusText }}</Tag>
|
||||||
|
<span v-if="currentNodeText" class="fa-nodes">当前节点:{{ currentNodeText }}</span>
|
||||||
|
<span v-if="lastTraceText" class="fa-trace">最近轨迹:{{ lastTraceText }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ONE_TASK:当前人可办理(readonly 视角即使有待办也不给动作) -->
|
||||||
|
<div v-if="state === 'ONE_TASK' && !readonly" class="fa-actions">
|
||||||
|
<Button size="small" type="primary" :loading="acting" @click="openApprove(tasks[0])">通过</Button>
|
||||||
|
<Button size="small" :loading="acting" @click="openReject(tasks[0])">驳回</Button>
|
||||||
|
<Button size="small" @click="emitTask(tasks[0])">完整办理</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MULTI_TASK:不默认首条——逐条列出本人的待办,每条自带动作(P1-05 漂移修正) -->
|
||||||
|
<div v-else-if="state === 'MULTI_TASK' && !readonly" class="fa-multi">
|
||||||
|
<p>你在本办件上有 <b>{{ tasks.length }}</b> 个待办(会签/并行分支),请逐条处理:</p>
|
||||||
|
<div class="fa-multi-row" v-for="t in tasks" :key="t.taskId">
|
||||||
|
<span class="fa-multi-node">{{ t.nodeName }}<template v-if="t.arriveTime">({{ t.arriveTime }})</template></span>
|
||||||
|
<Button size="small" type="primary" :loading="acting" @click="openApprove(t)">通过</Button>
|
||||||
|
<Button size="small" :loading="acting" @click="openReject(t)">驳回</Button>
|
||||||
|
<Button size="small" @click="emitTask(t)">完整办理</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="state === 'MULTI_TASK'" class="fa-multi">
|
||||||
|
<span>本办件有多个待办。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NO_TASK / TERMINAL:无动作区,只保留下方的"查看审批记录" -->
|
||||||
|
|
||||||
|
<div class="fa-links">
|
||||||
|
<a class="fa-link" @click="$emit('open-record', bizId)">查看审批记录</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 通过:意见可选;多出口必须先选出口,不选不给提交 -->
|
||||||
|
<Modal v-model="approveVisible" title="通过审核" :loading="acting" @on-ok="doApprove" @on-cancel="reset">
|
||||||
|
<div class="fa-modal-row" v-if="chosenOutlets.length > 1">
|
||||||
|
<span class="fa-modal-label">办理去向:</span>
|
||||||
|
<Select v-model="chosenEdge" size="small" style="width: 260px" placeholder="请选择办理去向">
|
||||||
|
<Option v-for="o in chosenOutlets" :key="o.name" :value="o.name" :label="o.name">
|
||||||
|
{{ o.name }}({{ o.toNodeName }})
|
||||||
|
</Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<p class="fa-modal-tip">
|
||||||
|
通过「<b>{{ title || bizId }}</b>」
|
||||||
|
<template v-if="chosenOutlets.length > 1">,该节点有多个去向,必须选择其一</template>
|
||||||
|
<template v-else>(单去向,无需选择)</template>
|
||||||
|
</p>
|
||||||
|
<Input v-model="opinion" type="textarea" :rows="3" placeholder="办理意见(可选)" />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- 驳回:意见必填 -->
|
||||||
|
<Modal v-model="rejectVisible" title="驳回审核" :loading="acting" @on-ok="doReject" @on-cancel="reset">
|
||||||
|
<p class="fa-modal-tip">驳回「<b>{{ title || bizId }}</b>」,必须填写意见:</p>
|
||||||
|
<Input v-model="opinion" type="textarea" :rows="3" placeholder="驳回意见(必填)" />
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 办件详情审批卡片(hall 本地版,源自 Notice FlowApprovalCard;漂移登记见本目录 README)。
|
||||||
|
* 数据来源只有一个:`api.byBiz(bizId)`——与列表共用同一 taskId 口径。
|
||||||
|
* 轨迹不自取(P1-05):宿主把既有 /api/apply/{id}/progress 转换成 TraceVo 经 props 传入;
|
||||||
|
* 卡片只用它做"最近一步"摘要与节点名兜底。
|
||||||
|
*
|
||||||
|
* 卡片只管**当前人**的动作:
|
||||||
|
* - 非待办人 / readonly / 终态 → 只读状态 + 查看记录;
|
||||||
|
* - tasks>1(会签/并行)→ 逐条列出本人待办、各自带动作,**不默认 tasks[0]**(P1-05);
|
||||||
|
* - outlets>1 → 通过弹窗必须选出口,未选不提交;
|
||||||
|
* - 写调用一律携带 bizId(P0-04:后端写面双闸需要 applyId);
|
||||||
|
* - 动作成功后只刷新自己,本地 flow_status 由回调推进,卡片不本地写状态。
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import type { ByBizResult, ByBizTask, FlowApprovalApi, TraceVo } from "./types";
|
||||||
|
import { INSTANCE_STATUS_TAG, INSTANCE_STATUS_TEXT } from "./types";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
api: FlowApprovalApi;
|
||||||
|
bizType: string;
|
||||||
|
bizId: string;
|
||||||
|
title?: string;
|
||||||
|
/** 外部已有进度时可传入(进度接口转换后的 TraceVo),卡片不重复拉轨迹 */
|
||||||
|
trace?: TraceVo | null;
|
||||||
|
/** 提交人/管理员视角:即便有待办也不给动作 */
|
||||||
|
readonly?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ title: "", trace: null, readonly: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "handled", payload: { taskId: number; action: string }): void;
|
||||||
|
(e: "open-record", bizId: string): void;
|
||||||
|
(e: "open-task", payload: { taskId: number; bizId: string }): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const data = ref<ByBizResult | null>(null);
|
||||||
|
const unavailable = ref(false);
|
||||||
|
const acting = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const approveVisible = ref(false);
|
||||||
|
const rejectVisible = ref(false);
|
||||||
|
const opinion = ref("");
|
||||||
|
const chosenEdge = ref("");
|
||||||
|
const targetTask = ref<ByBizTask | null>(null);
|
||||||
|
|
||||||
|
const tasks = computed<ByBizTask[]>(() => data.value?.tasks || []);
|
||||||
|
|
||||||
|
type CardState = "NO_INSTANCE" | "NO_TASK" | "ONE_TASK" | "MULTI_TASK" | "TERMINAL";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 终态判定优先于"我有待办":办件已完成/拒绝/撤回时即便残留 pending 行也不给动作,
|
||||||
|
* 由 flow-center 复核并拒绝重复办理(前端先少给一个必然失败的按钮)。
|
||||||
|
*/
|
||||||
|
const state = computed<CardState>(() => {
|
||||||
|
const d = data.value;
|
||||||
|
if (!d) return "NO_INSTANCE";
|
||||||
|
if (d.instanceId == null && tasks.value.length === 0) return "NO_INSTANCE";
|
||||||
|
if (d.status != null && d.status !== 10) return "TERMINAL";
|
||||||
|
if (tasks.value.length === 0) return "NO_TASK";
|
||||||
|
if (tasks.value.length > 1) return "MULTI_TASK";
|
||||||
|
return "ONE_TASK";
|
||||||
|
});
|
||||||
|
|
||||||
|
const chosenOutlets = computed(() => targetTask.value?.outlets || []);
|
||||||
|
|
||||||
|
const statusText = computed(() => {
|
||||||
|
const s = data.value?.status;
|
||||||
|
if (s != null && INSTANCE_STATUS_TEXT[s]) return INSTANCE_STATUS_TEXT[s];
|
||||||
|
return props.trace?.auditStatusDesc || "—";
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusTag = computed(() => {
|
||||||
|
const s = data.value?.status;
|
||||||
|
return (s != null && INSTANCE_STATUS_TAG[s]) || "default";
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人读当前节点:本人待办的节点名去重拼接(多待办并行时都显示,不挑首条);
|
||||||
|
* 无待办时退回 trace 的活动节点名摘要——byBiz 的 currentNodes 是节点 id 不得当名称(P1-05)。
|
||||||
|
*/
|
||||||
|
const currentNodeText = computed(() => {
|
||||||
|
if (tasks.value.length) {
|
||||||
|
const names = Array.from(new Set(tasks.value.map((t) => t.nodeName).filter(Boolean)));
|
||||||
|
if (names.length) return names.join("、");
|
||||||
|
}
|
||||||
|
return props.trace?.nodeSummary || props.trace?.currentNodes || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastTraceText = computed(() => {
|
||||||
|
const steps = props.trace?.steps;
|
||||||
|
if (!steps || !steps.length) return "";
|
||||||
|
const last = steps[steps.length - 1];
|
||||||
|
const who = last?.assignees?.map((a) => a?.userName).filter(Boolean).join("、");
|
||||||
|
const label = last?.label || "";
|
||||||
|
if (!last?.nodeName && !label) return "";
|
||||||
|
return [last?.nodeName, who, label].filter(Boolean).join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
unavailable.value = false;
|
||||||
|
try {
|
||||||
|
data.value = (await props.api.byBiz(props.bizId)) as ByBizResult;
|
||||||
|
} catch (e: any) {
|
||||||
|
unavailable.value = true;
|
||||||
|
data.value = null;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openApprove(task: ByBizTask) {
|
||||||
|
targetTask.value = task;
|
||||||
|
opinion.value = "";
|
||||||
|
chosenEdge.value = "";
|
||||||
|
approveVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReject(task: ByBizTask) {
|
||||||
|
targetTask.value = task;
|
||||||
|
opinion.value = "";
|
||||||
|
rejectVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doApprove() {
|
||||||
|
const task = targetTask.value;
|
||||||
|
if (!task) return;
|
||||||
|
// 多出口未选 → 拦下并留在弹窗,不发一个必然被后端拒绝的请求
|
||||||
|
if (task.outlets.length > 1 && !chosenEdge.value) {
|
||||||
|
Message.warning("该节点有多个办理去向,请先选择");
|
||||||
|
acting.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
acting.value = true;
|
||||||
|
try {
|
||||||
|
// bizId 必带(P0-04):svc-hall 写面双闸先校办件可见再校任务归属
|
||||||
|
await props.api.approve(task.taskId, opinion.value.trim() || undefined,
|
||||||
|
task.outlets.length > 1 ? chosenEdge.value : undefined, props.bizId);
|
||||||
|
Message.success("已通过");
|
||||||
|
approveVisible.value = false;
|
||||||
|
emit("handled", { taskId: task.taskId, action: "approve" });
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.msg || e?.message || "操作失败");
|
||||||
|
approveVisible.value = false;
|
||||||
|
} finally {
|
||||||
|
acting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doReject() {
|
||||||
|
const task = targetTask.value;
|
||||||
|
if (!task) return;
|
||||||
|
if (!opinion.value.trim()) {
|
||||||
|
Message.warning("驳回必须填写意见");
|
||||||
|
acting.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
acting.value = true;
|
||||||
|
try {
|
||||||
|
await props.api.reject(task.taskId, opinion.value.trim(), props.bizId);
|
||||||
|
Message.success("已驳回");
|
||||||
|
rejectVisible.value = false;
|
||||||
|
emit("handled", { taskId: task.taskId, action: "reject" });
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.msg || e?.message || "操作失败");
|
||||||
|
rejectVisible.value = false;
|
||||||
|
} finally {
|
||||||
|
acting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitTask(task: ByBizTask) {
|
||||||
|
if (!task) return;
|
||||||
|
emit("open-task", { taskId: task.taskId, bizId: props.bizId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
opinion.value = "";
|
||||||
|
chosenEdge.value = "";
|
||||||
|
targetTask.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.bizId, load);
|
||||||
|
onMounted(load);
|
||||||
|
defineExpose({ load, state });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fa-card-region {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.fa-alert {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.fa-muted {
|
||||||
|
padding: 8px 0;
|
||||||
|
color: var(--sh-text-weak, #6b7280);
|
||||||
|
}
|
||||||
|
.fa-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.fa-nodes,
|
||||||
|
.fa-trace {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--sh-text-weak, #6b7280);
|
||||||
|
}
|
||||||
|
.fa-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.fa-multi p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.fa-multi-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px dashed var(--sh-border, #e6e9ef);
|
||||||
|
}
|
||||||
|
.fa-multi-node {
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
.fa-links {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.fa-link {
|
||||||
|
color: var(--sh-primary, #1a5cb0);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.fa-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.fa-modal-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.fa-modal-label {
|
||||||
|
width: 90px;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.fa-modal-tip {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fa-fill">
|
||||||
|
<div class="fa-card">
|
||||||
|
<Tabs :model-value="innerTab" @on-click="switchTab">
|
||||||
|
<TabPane v-for="t in tabs" :key="t" :label="tabLabel(t)" :name="t" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<!-- 待我审批(中心 todo) -->
|
||||||
|
<div class="fa-tab-body" v-if="innerTab === 'todo'">
|
||||||
|
<div class="fa-table-region">
|
||||||
|
<Table :columns="todoCols" :data="todoRows" :loading="loading" no-data-text="暂无待审批办件">
|
||||||
|
<template #action="{ row }">
|
||||||
|
<a class="fa-link" @click="openRecord(row.bizId)">审批记录</a>
|
||||||
|
<a class="fa-link" @click="openApprove(row)">通过</a>
|
||||||
|
<a class="fa-link fa-danger" @click="openReject(row)">驳回</a>
|
||||||
|
<a class="fa-link" @click="openTask(row)">完整办理</a>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div class="fa-page" v-if="todoPage.total > todoPage.pageSize">
|
||||||
|
<Page :current="todoPage.pageIndex" :page-size="todoPage.pageSize"
|
||||||
|
:total="todoPage.total" show-total @on-change="loadTodo" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 我已审批(P3.5,中心 done) -->
|
||||||
|
<div class="fa-tab-body" v-else-if="innerTab === 'done'">
|
||||||
|
<div class="fa-table-region">
|
||||||
|
<Table :columns="doneCols" :data="doneRows" :loading="loading" no-data-text="暂无已办记录">
|
||||||
|
<template #instStatus="{ row }">
|
||||||
|
<Tag :color="statusTag(row.instanceStatus)">{{ statusText(row.instanceStatus) }}</Tag>
|
||||||
|
</template>
|
||||||
|
<template #doneAction="{ row }">
|
||||||
|
<a class="fa-link" @click="emitOpenDetail(Number(row.bizId))">查看详情</a>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div class="fa-page" v-if="donePage.total > donePage.pageSize">
|
||||||
|
<Page :current="donePage.pageIndex" :page-size="donePage.pageSize"
|
||||||
|
:total="donePage.total" show-total @on-change="loadDone" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 抄送我的(P3.5,中心 cc) -->
|
||||||
|
<div class="fa-tab-body" v-else-if="innerTab === 'cc'">
|
||||||
|
<div class="fa-table-region">
|
||||||
|
<Table :columns="ccCols" :data="ccRows" :loading="loading" no-data-text="暂无抄送记录">
|
||||||
|
<template #ccStatus="{ row }">
|
||||||
|
<Tag :color="statusTag(row.instanceStatus)">{{ statusText(row.instanceStatus) }}</Tag>
|
||||||
|
</template>
|
||||||
|
<template #ccAction="{ row }">
|
||||||
|
<a class="fa-link" @click="emitOpenDetail(Number(row.bizId))">查看详情</a>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div class="fa-page" v-if="ccPage.total > ccPage.pageSize">
|
||||||
|
<Page :current="ccPage.pageIndex" :page-size="ccPage.pageSize"
|
||||||
|
:total="ccPage.total" show-total @on-change="loadCc" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 我提交的(本地办件) -->
|
||||||
|
<div class="fa-tab-body" v-else>
|
||||||
|
<div class="fa-filter">
|
||||||
|
<RadioGroup :model-value="myStatus" type="button" @on-change="onStatusChange">
|
||||||
|
<Radio :label="-1">全部</Radio>
|
||||||
|
<Radio :label="0">待提交</Radio>
|
||||||
|
<Radio :label="10">审批中</Radio>
|
||||||
|
<Radio :label="20">已完成</Radio>
|
||||||
|
<Radio :label="30">已拒绝</Radio>
|
||||||
|
<Radio :label="40">已撤回</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
<div class="fa-table-region">
|
||||||
|
<Table :columns="myCols" :data="myRows" :loading="loading" no-data-text="暂无办件">
|
||||||
|
<template #status="{ row }">
|
||||||
|
<Tag :color="statusTag(row.flowStatus)">{{ statusText(row.flowStatus) }}</Tag>
|
||||||
|
</template>
|
||||||
|
<template #myAction="{ row }">
|
||||||
|
<a class="fa-link" @click="emitOpenDetail(row.id)">查看详情</a>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div class="fa-page" v-if="myPage.total > myPage.pageSize">
|
||||||
|
<Page :current="myPage.pageIndex" :page-size="myPage.pageSize"
|
||||||
|
:total="myPage.total" show-total @on-change="loadMy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 通过:意见可选;多出口必须先选出口 -->
|
||||||
|
<Modal v-model="approveVisible" title="通过审核" :loading="acting" @on-ok="doApprove" @on-cancel="reset">
|
||||||
|
<div class="fa-modal-row" v-if="chosenOutlets.length > 1">
|
||||||
|
<span class="fa-modal-label">办理去向:</span>
|
||||||
|
<Select v-model="chosenEdge" size="small" style="width: 260px" placeholder="请选择办理去向">
|
||||||
|
<Option v-for="o in chosenOutlets" :key="o.name" :value="o.name" :label="o.name">
|
||||||
|
{{ o.name }}({{ o.toNodeName }})
|
||||||
|
</Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<p class="fa-modal-tip">
|
||||||
|
通过「<b>{{ targetTitle }}</b>」
|
||||||
|
<template v-if="chosenOutlets.length > 1">,该节点有多个去向,必须选择其一</template>
|
||||||
|
</p>
|
||||||
|
<Input v-model="opinion" type="textarea" :rows="3" placeholder="办理意见(可选)" />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- 驳回:意见必填 -->
|
||||||
|
<Modal v-model="rejectVisible" title="驳回审核" :loading="acting" @on-ok="doReject" @on-cancel="reset">
|
||||||
|
<p class="fa-modal-tip">驳回「<b>{{ targetTitle }}</b>」,必须填写意见:</p>
|
||||||
|
<Input v-model="opinion" type="textarea" :rows="3" placeholder="驳回意见(必填)" />
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 独立审批列表(hall 本地版,源自 Notice FlowApprovalList;漂移登记见本目录 README)。
|
||||||
|
* 页签:"待我审批"(中心 todo,经 /api/apply/approval/Todo)+ "我提交的"(本地 My)。
|
||||||
|
* hall 无 Notice 的平台布局类(yh-fill/yh-tabs-card/table-region/useElementHeight),
|
||||||
|
* 本文件用自有 fa-* 样式(theme-base.css --sh-* 令牌)与表格自然高度替代。
|
||||||
|
*
|
||||||
|
* 硬纪律(沿 Notice 原版 + P0-04 加严):
|
||||||
|
* 1. 动作只认 `taskId` + `bizId`(行数据里带)——svc-hall 后端写面双闸需要 applyId,
|
||||||
|
* 四个写调用点(通过/驳回×列表卡片)都必须实际携带本行 bizId,不传靠后端拒不算通过;
|
||||||
|
* 2. 列表 todo 出参没有 outlets,点"通过"先现拉 byBiz 复核归属与出口(顺带拦截陈旧行);
|
||||||
|
* 3. 多出口未选不得提交;驳回意见必填;
|
||||||
|
* 4. 选中 bizId/taskId 的生命周期:打开弹窗时锁定,取消/提交成功/失败后清理(P0-04)。
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import type { ByBizTask, CcRow, DoneTask, FlowApprovalApi, FlowPage, MyApplyRow, TodoTask } from "./types";
|
||||||
|
import { INSTANCE_STATUS_TAG, INSTANCE_STATUS_TEXT } from "./types";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
api: FlowApprovalApi;
|
||||||
|
tabs?: Array<"todo" | "my" | "done" | "cc">;
|
||||||
|
}>(),
|
||||||
|
{ tabs: () => ["todo", "my", "done", "cc"] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "handled", payload: { taskId: number; action: string }): void;
|
||||||
|
(e: "open-record", bizId: string): void;
|
||||||
|
(e: "open-task", payload: { bizId: string; taskId: number }): void;
|
||||||
|
/** "我提交的"行查看详情 → 宿主跳 /apply-detail?id=(只用本行 id 构造,不透传任意 URL) */
|
||||||
|
(e: "open-detail", applyId: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const innerTab = ref<"todo" | "my" | "done" | "cc">(props.tabs[0] || "todo");
|
||||||
|
const loading = ref(false);
|
||||||
|
const acting = ref(false);
|
||||||
|
|
||||||
|
const todoRows = ref<Array<TodoTask & { _summary: string }>>([]);
|
||||||
|
const todoPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
|
||||||
|
const myRows = ref<MyApplyRow[]>([]);
|
||||||
|
const myPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
|
||||||
|
const doneRows = ref<DoneTask[]>([]);
|
||||||
|
const donePage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
|
||||||
|
const ccRows = ref<CcRow[]>([]);
|
||||||
|
const ccPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
|
||||||
|
/** -1=全部(本地哨兵;0 在 hall 语义里是"待提交",禁止当"全部"用) */
|
||||||
|
const myStatus = ref(-1);
|
||||||
|
|
||||||
|
const approveVisible = ref(false);
|
||||||
|
const rejectVisible = ref(false);
|
||||||
|
const opinion = ref("");
|
||||||
|
const chosenEdge = ref("");
|
||||||
|
const targetTask = ref<ByBizTask | null>(null);
|
||||||
|
const targetTitle = ref("");
|
||||||
|
/** 当前动作绑定的办件行 ID(P0-04:写调用必须携带,随弹窗生命周期锁定/清理) */
|
||||||
|
const targetBizId = ref("");
|
||||||
|
|
||||||
|
const chosenOutlets = computed(() => targetTask.value?.outlets || []);
|
||||||
|
|
||||||
|
const TAB_LABELS: Record<string, string> = { todo: "待我审批", my: "我提交的", done: "我已审批", cc: "抄送我的" };
|
||||||
|
function tabLabel(t: string) {
|
||||||
|
return TAB_LABELS[t] || t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoCols = [
|
||||||
|
{ title: "办件", key: "bizTitle", minWidth: 220, ellipsis: true, tooltip: true },
|
||||||
|
{ title: "摘要", key: "_summary", minWidth: 160, ellipsis: true, tooltip: true },
|
||||||
|
{ title: "当前节点", key: "nodeName", width: 120 },
|
||||||
|
{ title: "到达时间", key: "arriveTime", width: 170, render: fmtTime },
|
||||||
|
{ title: "操作", slot: "action", width: 240, align: "center" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const doneCols = [
|
||||||
|
{ title: "办件", key: "bizTitle", minWidth: 220, ellipsis: true, tooltip: true },
|
||||||
|
{ title: "办结状态", slot: "instStatus", minWidth: 100 },
|
||||||
|
{ title: "我办理的节点", key: "nodeName", minWidth: 130 },
|
||||||
|
{ title: "办理时间", key: "handleTime", minWidth: 170, render: fmtTime },
|
||||||
|
{ title: "操作", slot: "doneAction", width: 110, align: "center" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ccCols = [
|
||||||
|
{ title: "办件", key: "bizTitle", minWidth: 220, ellipsis: true, tooltip: true },
|
||||||
|
{ title: "办件状态", slot: "ccStatus", minWidth: 100 },
|
||||||
|
{ title: "抄送节点", key: "nodeName", minWidth: 130 },
|
||||||
|
{ title: "抄送时间", key: "sentTime", minWidth: 170, render: fmtTime },
|
||||||
|
{ title: "操作", slot: "ccAction", width: 110, align: "center" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const myCols = [
|
||||||
|
{ title: "事项", key: "itemTitle", minWidth: 220, ellipsis: true, tooltip: true },
|
||||||
|
{ title: "状态", slot: "status", minWidth: 100 },
|
||||||
|
{ title: "提交时间", key: "submitTime", minWidth: 170, render: fmtTime },
|
||||||
|
{ title: "办结时间", key: "finishTime", minWidth: 170, render: fmtTime },
|
||||||
|
{ title: "操作", slot: "myAction", width: 120, align: "center" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtTime(h: any, p: any) {
|
||||||
|
return h("span", {}, String(p.row[p.column.key] || "").replace("T", " ").slice(0, 19) || "—");
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(s: number) {
|
||||||
|
return INSTANCE_STATUS_TEXT[s] || String(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTag(s: number) {
|
||||||
|
return INSTANCE_STATUS_TAG[s] || "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSummary(task: TodoTask): string {
|
||||||
|
if (!task.bizSnapshot) return "—";
|
||||||
|
try {
|
||||||
|
const snap = JSON.parse(task.bizSnapshot);
|
||||||
|
return snap.summary || snap.title || "—";
|
||||||
|
} catch {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(name: any) {
|
||||||
|
innerTab.value = String(name) as "todo" | "my" | "done" | "cc";
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStatusChange(val: any) {
|
||||||
|
myStatus.value = Number(val);
|
||||||
|
loadMy(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTodo(page = 1) {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res: FlowPage<TodoTask> = await props.api.todo({ page, size: todoPage.value.pageSize });
|
||||||
|
todoPage.value = {
|
||||||
|
total: res.total ?? 0,
|
||||||
|
pageIndex: res.pageIndex ?? page,
|
||||||
|
pageSize: res.pageSize ?? todoPage.value.pageSize,
|
||||||
|
};
|
||||||
|
todoRows.value = (res.content || []).map((t) => ({ ...t, _summary: parseSummary(t) }));
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.msg || e?.message || "待审批列表加载失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMy(page = 1) {
|
||||||
|
if (!props.api.my) {
|
||||||
|
myRows.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res: FlowPage<MyApplyRow> = await props.api.my({
|
||||||
|
page,
|
||||||
|
size: myPage.value.pageSize,
|
||||||
|
flowStatus: myStatus.value < 0 ? null : myStatus.value,
|
||||||
|
});
|
||||||
|
myPage.value = {
|
||||||
|
total: res.total ?? 0,
|
||||||
|
pageIndex: res.pageIndex ?? page,
|
||||||
|
pageSize: res.pageSize ?? myPage.value.pageSize,
|
||||||
|
};
|
||||||
|
myRows.value = res.content || [];
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.msg || e?.message || "我的办件加载失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDone(page = 1) {
|
||||||
|
if (!props.api.done) {
|
||||||
|
doneRows.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res: FlowPage<DoneTask> = await props.api.done({ page, size: donePage.value.pageSize });
|
||||||
|
donePage.value = { total: res.total ?? 0, pageIndex: res.pageIndex ?? page, pageSize: res.pageSize ?? donePage.value.pageSize };
|
||||||
|
doneRows.value = res.content || [];
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.msg || e?.message || "已办列表加载失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCc(page = 1) {
|
||||||
|
if (!props.api.cc) {
|
||||||
|
ccRows.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res: FlowPage<CcRow> = await props.api.cc({ page, size: ccPage.value.pageSize });
|
||||||
|
ccPage.value = { total: res.total ?? 0, pageIndex: res.pageIndex ?? page, pageSize: res.pageSize ?? ccPage.value.pageSize };
|
||||||
|
ccRows.value = res.content || [];
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.msg || e?.message || "抄送列表加载失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (innerTab.value === "todo") loadTodo(1);
|
||||||
|
else if (innerTab.value === "done") loadDone(1);
|
||||||
|
else if (innerTab.value === "cc") loadCc(1);
|
||||||
|
else loadMy(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 现取本行的出口与归属:by-biz 只返回"当前人 PENDING",这一步同时复核"这条还是不是我办的"。
|
||||||
|
*/
|
||||||
|
async function resolveTask(row: TodoTask): Promise<ByBizTask | null> {
|
||||||
|
try {
|
||||||
|
const data: any = await props.api.byBiz(row.bizId);
|
||||||
|
const tasks: ByBizTask[] = data?.tasks || [];
|
||||||
|
return tasks.find((t) => Number(t.taskId) === Number(row.taskId)) || null;
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.msg || e?.message || "办理信息获取失败");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openApprove(row: TodoTask) {
|
||||||
|
if (acting.value) return;
|
||||||
|
const task = await resolveTask(row);
|
||||||
|
if (!task) {
|
||||||
|
Message.warning("该任务已不在你的待办中(可能已被办理),列表已刷新");
|
||||||
|
loadTodo(todoPage.value.pageIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targetTask.value = task;
|
||||||
|
targetTitle.value = row.bizTitle;
|
||||||
|
targetBizId.value = row.bizId;
|
||||||
|
opinion.value = "";
|
||||||
|
chosenEdge.value = "";
|
||||||
|
approveVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReject(row: TodoTask) {
|
||||||
|
if (acting.value) return;
|
||||||
|
targetTask.value = { taskId: row.taskId, nodeId: row.nodeId, nodeName: row.nodeName,
|
||||||
|
arriveTime: row.arriveTime, outlets: [] };
|
||||||
|
targetTitle.value = row.bizTitle;
|
||||||
|
targetBizId.value = row.bizId;
|
||||||
|
opinion.value = "";
|
||||||
|
rejectVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTask(row: TodoTask) {
|
||||||
|
emit("open-task", { bizId: row.bizId, taskId: row.taskId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRecord(bizId: string) {
|
||||||
|
emit("open-record", bizId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitOpenDetail(applyId: number) {
|
||||||
|
emit("open-detail", applyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doApprove() {
|
||||||
|
const task = targetTask.value;
|
||||||
|
if (!task || !targetBizId.value) return;
|
||||||
|
if (task.outlets.length > 1 && !chosenEdge.value) {
|
||||||
|
Message.warning("该节点有多个办理去向,请先选择");
|
||||||
|
acting.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
acting.value = true;
|
||||||
|
try {
|
||||||
|
await props.api.approve(task.taskId, opinion.value.trim() || undefined,
|
||||||
|
task.outlets.length > 1 ? chosenEdge.value : undefined, targetBizId.value);
|
||||||
|
Message.success("已通过");
|
||||||
|
approveVisible.value = false;
|
||||||
|
emit("handled", { taskId: task.taskId, action: "approve" });
|
||||||
|
reset();
|
||||||
|
loadTodo(todoPage.value.pageIndex);
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.msg || e?.message || "操作失败");
|
||||||
|
approveVisible.value = false;
|
||||||
|
reset();
|
||||||
|
loadTodo(todoPage.value.pageIndex);
|
||||||
|
} finally {
|
||||||
|
acting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doReject() {
|
||||||
|
const task = targetTask.value;
|
||||||
|
if (!task || !targetBizId.value) return;
|
||||||
|
if (!opinion.value.trim()) {
|
||||||
|
Message.warning("驳回必须填写意见");
|
||||||
|
acting.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
acting.value = true;
|
||||||
|
try {
|
||||||
|
await props.api.reject(task.taskId, opinion.value.trim(), targetBizId.value);
|
||||||
|
Message.success("已驳回");
|
||||||
|
rejectVisible.value = false;
|
||||||
|
emit("handled", { taskId: task.taskId, action: "reject" });
|
||||||
|
reset();
|
||||||
|
loadTodo(todoPage.value.pageIndex);
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.msg || e?.message || "操作失败");
|
||||||
|
rejectVisible.value = false;
|
||||||
|
} finally {
|
||||||
|
acting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 弹窗取消/关闭即清理锁定上下文(P0-04:bizId 不残留到下一次动作) */
|
||||||
|
function reset() {
|
||||||
|
opinion.value = "";
|
||||||
|
chosenEdge.value = "";
|
||||||
|
targetTask.value = null;
|
||||||
|
targetTitle.value = "";
|
||||||
|
targetBizId.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
defineExpose({ load, loadTodo, loadMy, loadDone, loadCc });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fa-fill {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.fa-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--sh-border, #e6e9ef);
|
||||||
|
border-radius: var(--sh-radius, 8px);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.fa-tab-body {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.fa-table-region {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.fa-filter {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.fa-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.fa-link {
|
||||||
|
color: var(--sh-primary, #1a5cb0);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.fa-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.fa-danger {
|
||||||
|
color: var(--sh-danger, #d03050);
|
||||||
|
}
|
||||||
|
.fa-modal-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.fa-modal-label {
|
||||||
|
width: 90px;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.fa-modal-tip {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.fa-card {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.fa-link {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<Drawer :model-value="modelValue" :title="title" :width="drawerWidth" :transfer="false"
|
||||||
|
:styles="{ body: { padding: '0' } }" @update:model-value="close">
|
||||||
|
<div class="fa-embed-region">
|
||||||
|
<div v-if="error" class="fa-embed-error">
|
||||||
|
<Alert type="error" show-icon>{{ error }}</Alert>
|
||||||
|
<Button size="small" @click="load">重试</Button>
|
||||||
|
</div>
|
||||||
|
<!-- iframe 一旦给出就是只读嵌入页:本组件不放任何通过/驳回入口 -->
|
||||||
|
<iframe v-else-if="pageUrl" :src="pageUrl" :title="title" class="fa-embed-frame" />
|
||||||
|
<div v-else class="fa-embed-loading">{{ loading ? "加载中…" : "" }}</div>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 审批记录抽屉(hall 本地版,源自 Notice FlowRecordDrawer;漂移登记见本目录 README)。
|
||||||
|
* iframe 嵌 flow-center 的 **token 只读页**(instanceId 形态换址,后端代理)。
|
||||||
|
*
|
||||||
|
* 三条纪律写死在组件里,不给调用方犯错的机会:
|
||||||
|
* 1. pageUrl **每次打开现取**(token 10 分钟短时效),关闭/换件即清空——不缓存、不进 storage;
|
||||||
|
* 2. 只读:组件内不出现通过/驳回,也不接收这类回调;
|
||||||
|
* 3. 换址失败(未进入流程 / 无权限 / 过期)原文提示 + 重试,不白屏。
|
||||||
|
* P1-09 加严:换办件(bizId 变化)时立即废弃旧地址与在途请求结果——晚到响应只写当前 bizId。
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import type { FlowApprovalApi } from "./types";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
api: FlowApprovalApi;
|
||||||
|
bizId: string;
|
||||||
|
title?: string;
|
||||||
|
width?: number;
|
||||||
|
}>(),
|
||||||
|
{ title: "审批记录", width: 740 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", v: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/** 小屏自适应(基础响应式:1200px 全量适配不在本批) */
|
||||||
|
const drawerWidth = computed(() => (typeof window !== "undefined" && window.innerWidth < 768
|
||||||
|
? Math.min(props.width, window.innerWidth - 24)
|
||||||
|
: props.width));
|
||||||
|
|
||||||
|
const pageUrl = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const bizId = String(props.bizId);
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
pageUrl.value = "";
|
||||||
|
try {
|
||||||
|
const res: any = await props.api.recordUrl(bizId);
|
||||||
|
// 晚到防护:请求期间已换件/已关闭,丢弃本次结果(P1-09)
|
||||||
|
if (bizId !== String(props.bizId) || !props.modelValue) return;
|
||||||
|
const url = res?.pageUrl || "";
|
||||||
|
if (!url) {
|
||||||
|
error.value = "审批记录地址获取失败,请稍后重试";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pageUrl.value = url;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (bizId !== String(props.bizId)) return;
|
||||||
|
// 后端信封 msg 原文透传("办件尚未进入流程,暂无审批记录"/"办件不存在"等)
|
||||||
|
error.value = e?.msg || e?.message || String(e) || "审批记录打开失败";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) load();
|
||||||
|
else {
|
||||||
|
// 关闭即丢地址:token 不进缓存,下次打开重新签发
|
||||||
|
pageUrl.value = "";
|
||||||
|
error.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 换件即清(抽屉开着连续换办件的场景)
|
||||||
|
watch(
|
||||||
|
() => props.bizId,
|
||||||
|
() => {
|
||||||
|
pageUrl.value = "";
|
||||||
|
error.value = "";
|
||||||
|
if (props.modelValue) load();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function close(v: boolean) {
|
||||||
|
emit("update:modelValue", v);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fa-embed-region {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.fa-embed-frame {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.fa-embed-loading {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.fa-embed-error {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<template>
|
||||||
|
<Modal :model-value="modelValue" :title="title" :width="modalWidth" footer-hide transfer
|
||||||
|
@update:model-value="close">
|
||||||
|
<div class="fa-task-wrap">
|
||||||
|
<!-- CAS 会话页被 X-Frame-Options/CSP 挡住时 iframe 只会留白:给出显式逃生口 -->
|
||||||
|
<div class="fa-task-fallback">
|
||||||
|
<span class="fa-task-tip">打不开办理面板?</span>
|
||||||
|
<a class="fa-link" :href="pageUrl" target="_blank" rel="noopener">在新窗口打开任务页</a>
|
||||||
|
</div>
|
||||||
|
<iframe v-if="embedSrc" ref="frameEl" :src="embedSrc" :title="title" class="fa-task-frame"
|
||||||
|
:style="frameStyle" />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 去壳任务办理页容器(hall 本地版,源自 Notice FlowTaskEmbed;漂移登记见本目录 README)。
|
||||||
|
* iframe 嵌 flow-center `/task/{taskId}?embed=1`(CAS 写面,办理权由任务页按 assigneeId 复核)。
|
||||||
|
*
|
||||||
|
* P1-09 加严——iframe 消息四条件全过才处理:
|
||||||
|
* ① 组件处于打开态(modelValue=true);② 期望 origin 算自 pageUrl 且严格相等(非通配);
|
||||||
|
* ③ 消息来源窗口必须是本 iframe(ev.source === contentWindow);④ 消息携带的 taskId(若有)
|
||||||
|
* 与当前任务上下文一致(换任务/换件期间的旧消息作废)。
|
||||||
|
* 关闭/换址时:重置高度、清理上下文——宿主侧同时负责废弃在途换址请求与清空 taskUrl/选中 ID。
|
||||||
|
*/
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
import { isFlowEmbedMessage } from "./types";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
pageUrl: string;
|
||||||
|
taskId?: number | string;
|
||||||
|
/** 当前办件上下文(与消息 bizId 比对,换件期间旧消息作废) */
|
||||||
|
bizId?: string | number;
|
||||||
|
title?: string;
|
||||||
|
width?: number;
|
||||||
|
}>(),
|
||||||
|
{ title: "完整办理", width: 860, taskId: "", bizId: "" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", v: boolean): void;
|
||||||
|
(e: "handled", payload: { taskId: string; action?: string }): void;
|
||||||
|
(e: "open-biz", payload: { bizId?: string | null; bizUrl?: string }): void;
|
||||||
|
(e: "close"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const frameEl = ref<HTMLIFrameElement | null>(null);
|
||||||
|
const frameHeight = ref(0);
|
||||||
|
|
||||||
|
/** 小屏自适应(基础响应式) */
|
||||||
|
const modalWidth = computed(() => (typeof window !== "undefined" && window.innerWidth < 768
|
||||||
|
? Math.min(props.width, window.innerWidth - 24)
|
||||||
|
: props.width));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端 TaskPageUrl 给的是**完整窗口形态**的 `#/task/{id}`("新窗口打开"场景要用它)。
|
||||||
|
* 内嵌时"去壳"是嵌入方的诉求,由本组件补 `embed=1`——hash 路由下 query 必须落在 `#` 之后。
|
||||||
|
*/
|
||||||
|
function asEmbedUrl(raw: string): string {
|
||||||
|
if (!raw) return "";
|
||||||
|
const hashAt = raw.indexOf("#");
|
||||||
|
if (hashAt < 0) return raw;
|
||||||
|
const head = raw.slice(0, hashAt);
|
||||||
|
const hash = raw.slice(hashAt);
|
||||||
|
if (/[?&]embed=1(?:&|$)/.test(hash)) return raw;
|
||||||
|
return head + hash + (hash.includes("?") ? "&" : "?") + "embed=1";
|
||||||
|
}
|
||||||
|
|
||||||
|
const embedSrc = computed(() => asEmbedUrl(props.pageUrl));
|
||||||
|
|
||||||
|
/** iframe 的期望 origin——pageUrl 解析失败即拒绝接收一切消息 */
|
||||||
|
const expectedOrigin = computed(() => {
|
||||||
|
try {
|
||||||
|
const origin = new URL(props.pageUrl).origin;
|
||||||
|
return origin && origin !== "null" ? origin : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const frameStyle = computed(() =>
|
||||||
|
frameHeight.value ? { height: `${Math.min(Math.max(frameHeight.value, 320), 1600)}px` } : {}
|
||||||
|
);
|
||||||
|
|
||||||
|
function onMessage(ev: MessageEvent) {
|
||||||
|
// 条件①:组件打开中才受理(关闭后到达的同源旧消息一律作废)
|
||||||
|
if (!props.modelValue) return;
|
||||||
|
// 条件②:严格 origin
|
||||||
|
if (!expectedOrigin.value || ev.origin !== expectedOrigin.value) return;
|
||||||
|
// 条件③:来源窗口必须是本 iframe
|
||||||
|
if (!frameEl.value || ev.source !== frameEl.value.contentWindow) return;
|
||||||
|
if (!isFlowEmbedMessage(ev.data)) return;
|
||||||
|
|
||||||
|
if (ev.data.type === "handled") {
|
||||||
|
const msgTaskId = String(ev.data.taskId ?? "");
|
||||||
|
// 条件④:任务上下文一致(消息带 taskId 且与当前不同=旧任务迟到消息,作废)
|
||||||
|
if (msgTaskId && props.taskId !== "" && msgTaskId !== String(props.taskId)) return;
|
||||||
|
emit("handled", { taskId: msgTaskId || String(props.taskId), action: ev.data.action });
|
||||||
|
} else if (ev.data.type === "open-biz") {
|
||||||
|
// 条件④(办件维度):消息带 bizId 且与当前办件上下文不同=换件期间旧消息,作废
|
||||||
|
const msgBizId = ev.data.bizId != null ? String(ev.data.bizId) : "";
|
||||||
|
if (msgBizId && props.bizId !== "" && msgBizId !== String(props.bizId)) return;
|
||||||
|
emit("open-biz", { bizId: ev.data.bizId, bizUrl: ev.data.bizUrl });
|
||||||
|
} else if (ev.data.type === "height" && typeof ev.data.height === "number") {
|
||||||
|
frameHeight.value = ev.data.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener("message", onMessage));
|
||||||
|
onBeforeUnmount(() => window.removeEventListener("message", onMessage));
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) return;
|
||||||
|
frameHeight.value = 0;
|
||||||
|
sendClose();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 打开态下换址/换任务:重置自适应高度(iframe 会整体换 src)
|
||||||
|
watch([() => props.pageUrl, () => props.taskId], () => {
|
||||||
|
frameHeight.value = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 父→子可选指令:任务页目前不消费 close 之外的入向消息,保留协议位 */
|
||||||
|
function sendClose() {
|
||||||
|
const win = frameEl.value?.contentWindow;
|
||||||
|
if (!win || !expectedOrigin.value) return;
|
||||||
|
win.postMessage({ proto: "flow", v: 1, type: "close" }, expectedOrigin.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(v: boolean) {
|
||||||
|
emit("update:modelValue", v);
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fa-task-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.fa-task-fallback {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.fa-task-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--sh-text-weak, #6b7280);
|
||||||
|
}
|
||||||
|
.fa-link {
|
||||||
|
color: var(--sh-primary, #1a5cb0);
|
||||||
|
}
|
||||||
|
.fa-task-frame {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 320px;
|
||||||
|
height: 70vh;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# flow-approval 组件(hall 本地版,v2.1 应用内审批)
|
||||||
|
|
||||||
|
源自 `Notice/front/src/components/flow-approval/`(组件版本 v2.1-Notice1,2026-10-19 复制)。
|
||||||
|
组件不直连 flow-center,一律通过注入的 `api`(`FlowApprovalApi`)打本应用后端代理
|
||||||
|
`/api/apply/approval/**`(P3.1,`SvcApprovalController`),由后端持 app 签名调 `/oapi/flow/**`。
|
||||||
|
|
||||||
|
## 与 Notice 原版的漂移登记(本地扩展,Notice 侧不回改)
|
||||||
|
|
||||||
|
| # | 漂移点 | 原因 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `types.ts`:`approve/reject` 增末参 `bizId`(四个写调用点全部实际携带) | P0-04:svc-hall 后端写面双闸(requireVisible + byBiz 归属预检)需要 applyId,bizId 是必需上下文非可选兜底 |
|
||||||
|
| 2 | `types.ts`:删 `instances` 契约、增 `my`(行=`MyApplyRow`,本地 ApplyVo 子集) | P0-06:不代理 /oapi/flow/instances(全校视角);"我提交的"走本地 `/api/apply/approval/My`(信封 FlowPage 映射在后端) |
|
||||||
|
| 3 | `types.ts`:状态 0=**待提交**(Notice 原版把 0 当"全部"——hall 禁用该口径,"全部"用 -1 哨兵) | P0-06:hall 本地 flow_status 0 是真实状态 |
|
||||||
|
| 4 | `FlowApprovalList.vue`:页签 `todo`/`my`;无 `useElementHeight`/`yh-*` 平台布局类,改为本地 `fa-*` 样式(theme-base.css `--sh-*` 令牌)+ 表格自然高度 | P0-05:hall 无平台中央静态通道与该 hook,按 hall 实情落地 |
|
||||||
|
| 5 | `FlowApprovalCard.vue`:MULTI_TASK 逐条列出本人待办、每条自带动作,**不默认 tasks[0]**;`emitTask` 带本卡片 bizId | P1-05:Notice 原版实际挑 tasks[0],与"多待办不猜"文档口径不符,本地修正 |
|
||||||
|
| 6 | `FlowApprovalCard.vue`:轨迹不自取——宿主把 `/api/apply/{id}/progress` 转换成 `TraceVo` 经 props 传入;当前节点名取 tasks[].nodeName 去重(currentNodes 是节点 id 不得当名称) | P1-05 |
|
||||||
|
| 7 | `FlowTaskEmbed.vue`:消息受理四条件(打开中+严格 origin+本 iframe+taskId/bizId 上下文一致);换址/换任务重置高度 | P1-09:关闭/换件期间旧消息与迟到换址响应不得误刷新/误开件 |
|
||||||
|
| 8 | `FlowRecordDrawer.vue`:换 bizId 立即清地址并重取;晚到响应丢弃 | P1-09 同源 |
|
||||||
|
| 9 | 小屏基础自适应(drawer/modal 宽度收窄、列表横向滚动) | 用户裁决:移动端只做新页面基础响应式,1200px 全量适配不在本批 |
|
||||||
|
|
||||||
|
## 必须遵守的契约(沿 Notice 原版)
|
||||||
|
|
||||||
|
1. **身份不从前端来**。`api` 的入参只允许 `bizId / taskId / opinion / edgeName / 分页 / flowStatus`。
|
||||||
|
2. **token 记录页只读**。pageUrl 现取现用、关闭即丢,不缓存不进 storage。
|
||||||
|
3. **多待办不猜**(见漂移 5)。
|
||||||
|
4. **多出口必选**;5. **驳回意见必填、通过意见可选**;6. **列表"通过"前现拉 byBiz 复核**;
|
||||||
|
7. **currentNodes 是节点 id**;8. **错误原文透传**;9. **postMessage 双向校验 origin,禁 `*`**。
|
||||||
|
|
||||||
|
## open-biz 的落地纪律(hall 宿主侧)
|
||||||
|
|
||||||
|
`open-biz` 消息的 `bizUrl` 是 flow-center 记录的业务地址,宿主**只用消息里的 `bizId`
|
||||||
|
(且必须与当前办件上下文一致)构造 `/apply-detail?id=<bizId>`**——不照抄任意 URL 直接路由
|
||||||
|
(P1-09:防伪造跳转;bizUrl 仅作展示/日志参考,不当路由依据)。
|
||||||
|
|
||||||
|
## 同步纪律
|
||||||
|
|
||||||
|
改本目录任一文件必须评估 Notice 原版同步(平台"四端同构"纪律);漂移表更新后两侧 README
|
||||||
|
「组件版本 / 最后同步日期」一起动。Notice 侧地址:`Notice/front/src/components/flow-approval/`。
|
||||||
|
组件版本:**v2.1-hall1** | 最后同步:2026-10-19。
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* v2.1 应用内审批共享组件的契约类型(hall 本地版,源自 Notice/front 同名文件)。
|
||||||
|
*
|
||||||
|
* 本文件刻意**不 import 本应用任何业务类型**:组件靠 `FlowApprovalApi` 注入数据面。
|
||||||
|
* 与 Notice 原版的漂移(hall 本地扩展,登记于本目录 README):
|
||||||
|
* - `approve/reject` 增末参 `bizId`(svc-hall 后端写面要 applyId 做归属预检,必需上下文非可选兜底);
|
||||||
|
* - 增 `my`("我提交的"页签走本地 /api/apply/approval/My,行=ApplyVo 子集,非中心 instances);
|
||||||
|
* - `instances` 契约删除(hall 不代理 /oapi/flow/instances——全校视角不暴露给用户面)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 分页信封(svc-hall 代理层统一映射后的 FlowPage 形态) */
|
||||||
|
export interface FlowPage<T> {
|
||||||
|
total: number;
|
||||||
|
content: T[];
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 待办行("待我审批"数据源,中心 /oapi/flow/tasks/todo 行) */
|
||||||
|
export interface TodoTask {
|
||||||
|
taskId: number;
|
||||||
|
instanceId: number;
|
||||||
|
instanceVersion?: number | null;
|
||||||
|
bizType: string;
|
||||||
|
bizId: string;
|
||||||
|
bizTitle: string;
|
||||||
|
bizSnapshot?: string | null;
|
||||||
|
nodeId: string;
|
||||||
|
nodeName: string;
|
||||||
|
arriveTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "我提交的"行(本地 /api/apply/approval/My;ApplyVo 子集)。
|
||||||
|
* 状态域:0=待提交、10=审批中、20=已完成、30=已拒绝、40=已撤回——
|
||||||
|
* **0 是"待提交"不是"全部"**(Notice 原版 instances 页签把 0 当"全部",本页签禁止复用该口径)。
|
||||||
|
*/
|
||||||
|
export interface MyApplyRow {
|
||||||
|
id: number;
|
||||||
|
itemTitle: string;
|
||||||
|
flowStatus: number;
|
||||||
|
flowInstanceId?: number | null;
|
||||||
|
submitTime?: string | null;
|
||||||
|
finishTime?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 我已办行(P3.5,中心 /oapi/flow/tasks/done;过滤=签名方应用,天然只含本应用办件) */
|
||||||
|
export interface DoneTask {
|
||||||
|
taskId: number;
|
||||||
|
instanceId: number;
|
||||||
|
bizTitle: string;
|
||||||
|
bizType: string;
|
||||||
|
bizId: string;
|
||||||
|
instanceStatus?: number | null;
|
||||||
|
nodeId: string;
|
||||||
|
nodeName: string;
|
||||||
|
handleTime?: string | null;
|
||||||
|
opinion?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 抄送我行(P3.5,中心 /oapi/flow/cc;NOTIFY 落库,bizId 已回填本应用办件 ID) */
|
||||||
|
export interface CcRow {
|
||||||
|
ccId: number;
|
||||||
|
instanceId: number;
|
||||||
|
nodeId?: string | null;
|
||||||
|
nodeName?: string | null;
|
||||||
|
sentTime?: string | null;
|
||||||
|
bizTitle?: string | null;
|
||||||
|
bizId?: string | null;
|
||||||
|
instanceStatus?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 出口:多出口节点办理时须选其一(选中值作为 approve 的 edgeName) */
|
||||||
|
export interface ByBizOutlet {
|
||||||
|
name: string;
|
||||||
|
toNodeId: string;
|
||||||
|
toNodeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前人在该办件上的一条待办 */
|
||||||
|
export interface ByBizTask {
|
||||||
|
taskId: number;
|
||||||
|
nodeId: string;
|
||||||
|
nodeName: string;
|
||||||
|
arriveTime: string;
|
||||||
|
/** 单出口节点为空数组;>1 即"必须先选出口" */
|
||||||
|
outlets: ByBizOutlet[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* by-biz 出参。注意两点契约:
|
||||||
|
* - `tasks` 的 0/1/N 都是成功;N>1 时卡片不得猜选(逐条办理,不默认首条);
|
||||||
|
* - `currentNodes` 是**节点 id** 逗号串(办件原文口径),给人看的节点名用 `tasks[].nodeName`。
|
||||||
|
*/
|
||||||
|
export interface ByBizResult {
|
||||||
|
instanceId: number | null;
|
||||||
|
bizType: string;
|
||||||
|
bizId: string;
|
||||||
|
status: number | null;
|
||||||
|
currentNodes: string | null;
|
||||||
|
tasks: ByBizTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批轨迹(宿主把既有 /api/apply/{id}/progress 转换后经 props 传入;卡片不自取) */
|
||||||
|
export interface TraceStep {
|
||||||
|
nodeId?: string | null;
|
||||||
|
nodeName?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
assignees?: Array<{ userName?: string | null; statusLabel?: string | null; opinion?: string | null }> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraceVo {
|
||||||
|
bound?: boolean;
|
||||||
|
auditStatus?: number | null;
|
||||||
|
auditStatusDesc?: string | null;
|
||||||
|
instanceStatus?: number | null;
|
||||||
|
/** 活动节点名逗号串(由 active steps 的 nodeName 产生——byBiz 的 currentNodes 是节点 ID 不得当名称) */
|
||||||
|
currentNodes?: string | null;
|
||||||
|
nodeSummary?: string | null;
|
||||||
|
steps?: TraceStep[] | null;
|
||||||
|
flowResult?: { result?: string; opinion?: string; rejectNode?: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 换址类返回 */
|
||||||
|
export interface PageUrlResult {
|
||||||
|
pageUrl: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组件与子应用后端之间的唯一契约。返回 alova `Method` 或 Promise 都可——组件只 `await`。
|
||||||
|
* `flowStatus` 筛选一期**不透传**给 My(页签内提供本地状态筛选,走代理端点 query):
|
||||||
|
* 一期实现为前端整页拉取后按状态过滤的本地筛选不引入,直接透传 `flowStatus` query 给后端
|
||||||
|
* (/api/apply/approval/My 支持与否以适配器注释为准——当前透传)。
|
||||||
|
*/
|
||||||
|
export interface FlowApprovalApi {
|
||||||
|
/** 我的待办分页(中心 todo,双值双查在代理后端) */
|
||||||
|
todo(params?: { page?: number; size?: number }): any;
|
||||||
|
/** 我提交的分页(本地办件;flowStatus 可选,null=全部) */
|
||||||
|
my?(params?: { page?: number; size?: number; flowStatus?: number | null }): any;
|
||||||
|
/** 我已办分页(P3.5;中心按签名方应用过滤,前端不传 bizType) */
|
||||||
|
done?(params?: { page?: number; size?: number }): any;
|
||||||
|
/** 抄送我分页(P3.5;同上) */
|
||||||
|
cc?(params?: { page?: number; size?: number }): any;
|
||||||
|
/** 当前人在该办件上的待办 */
|
||||||
|
byBiz(bizId: string): any;
|
||||||
|
/** 审批轨迹(可缺省:传了卡片就不再自取) */
|
||||||
|
trace?(bizId: string): any;
|
||||||
|
/** 通过(bizId=applyId,svc-hall 写面归属预检必需) */
|
||||||
|
approve(taskId: number, opinion?: string, edgeName?: string, bizId?: string | number): any;
|
||||||
|
/** 拒绝(同上) */
|
||||||
|
reject(taskId: number, opinion: string, bizId?: string | number): any;
|
||||||
|
recordUrl(bizId: string): any;
|
||||||
|
taskPageUrl(bizId: string, taskId: number): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 办件状态 → 文案(0=待提交 是 hall 本地扩展;10~40 与中心实例状态同码) */
|
||||||
|
export const INSTANCE_STATUS_TEXT: Record<number, string> = {
|
||||||
|
0: "待提交",
|
||||||
|
10: "审批中",
|
||||||
|
20: "已完成",
|
||||||
|
30: "已拒绝",
|
||||||
|
40: "已撤回",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 办件状态 → view-ui-plus Tag 预设色名(用组件库语义色,不写死色值) */
|
||||||
|
export const INSTANCE_STATUS_TAG: Record<number, string> = {
|
||||||
|
0: "default",
|
||||||
|
10: "blue",
|
||||||
|
20: "green",
|
||||||
|
30: "red",
|
||||||
|
40: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 去壳任务页 postMessage 协议信封(与 flow-center front `utils/taskEmbed.ts` 同字面量) */
|
||||||
|
export const FLOW_EMBED_PROTO = "flow";
|
||||||
|
export const FLOW_EMBED_VERSION = 1;
|
||||||
|
|
||||||
|
export interface FlowEmbedMessage {
|
||||||
|
proto: string;
|
||||||
|
v: number;
|
||||||
|
type: "handled" | "open-biz" | "height";
|
||||||
|
taskId?: string;
|
||||||
|
action?: string;
|
||||||
|
bizId?: string | null;
|
||||||
|
bizUrl?: string;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 父侧只认本协议的消息体;origin 白名单由 FlowTaskEmbed 按 iframe 地址另判 */
|
||||||
|
export function isFlowEmbedMessage(data: unknown): data is FlowEmbedMessage {
|
||||||
|
const d = data as FlowEmbedMessage | null;
|
||||||
|
return !!d && d.proto === FLOW_EMBED_PROTO && d.v === FLOW_EMBED_VERSION && !!d.type;
|
||||||
|
}
|
||||||
+18
-3
@@ -2,10 +2,12 @@ import type { RouteRecordRaw } from "vue-router";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 公共办事大厅路由(用户面;后台管理路由在 svc-hall/front,两仓互不引用)。
|
* 公共办事大厅路由(用户面;后台管理路由在 svc-hall/front,两仓互不引用)。
|
||||||
* 四个页面:事项大厅(入口)/ 在线办理 / 我的办件 / 办件详情。
|
* 六个页面:事项大厅(入口)/ 事项指南 / 在线办理 / 我的办件 / 我的审批 / 办件详情。
|
||||||
* 全部走 hash 路由(部署于 /{contextPath}/public/,hash 免服务端 rewrite)。
|
* 全部走 hash 路由(部署于 /{contextPath}/hall/,hash 免服务端 rewrite)。
|
||||||
* 参数页用 query 传参(与 CMS 指南页"我要办理"外链形态一致:
|
* 参数页用 query 传参(与 CMS 指南页"我要办理"外链形态一致:
|
||||||
* {svc_hall_base}/#/apply?siteCode=&itemCode=)——拼成路径参数会 no-match 空白页。
|
* {svc_hall_base}/#/apply?siteCode=&itemCode=&from=<指南页绝对URL>;
|
||||||
|
* from=T4 回跳闭环参数,hall 侧 resolveFrom 校验可信域后显示"返回门户")
|
||||||
|
* ——拼成路径参数会 no-match 空白页。
|
||||||
*/
|
*/
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@@ -14,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",
|
||||||
@@ -26,6 +35,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/ApplyMy.vue"),
|
component: () => import("@/views/ApplyMy.vue"),
|
||||||
meta: { title: "我的办件" },
|
meta: { title: "我的办件" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/todo",
|
||||||
|
name: "todo",
|
||||||
|
component: () => import("@/views/MyApprovals.vue"),
|
||||||
|
meta: { title: "我的审批" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/apply-detail",
|
path: "/apply-detail",
|
||||||
name: "apply_detail",
|
name: "apply_detail",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/* 校园青绿——第二套皮肤:换令牌 + 换圆角 + 换列表布局(结构变体走 data-sh-* 钩子) */
|
/* 校园青绿——第二套皮肤:换令牌 + 换圆角 + 换列表布局(结构变体走 data-sh-* 钩子)
|
||||||
|
色值与 CMS campus-glow 同源(T3 弱联动对齐,2026-10-03),与 theme.json default 一致 */
|
||||||
:root[data-theme="campus-green"] {
|
:root[data-theme="campus-green"] {
|
||||||
--sh-primary: #0e8a6e;
|
--sh-primary: #0e8a5f;
|
||||||
--sh-primary-dark: #0a6a55;
|
--sh-primary-dark: #0a6244;
|
||||||
--sh-accent: #d97706;
|
--sh-accent: #ff7a1a;
|
||||||
--sh-page-bg: #f2f8f5;
|
--sh-page-bg: #f4f7f5;
|
||||||
--sh-radius: 12px;
|
--sh-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
"specVersion": 1,
|
"specVersion": 1,
|
||||||
"name": "campus-green",
|
"name": "campus-green",
|
||||||
"title": "校园青绿",
|
"title": "校园青绿",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "第二套可切换皮肤:青绿主色 + 更宽松的圆角,用于验证皮肤包机制(新增皮肤=新增目录,宿主零改动)。",
|
"description": "第二套可切换皮肤:青绿主色 + 更宽松的圆角(色值与 CMS campus-glow 同源,T3 弱联动对齐 2026-10-03)。",
|
||||||
"params": [
|
"params": [
|
||||||
{ "key": "primary", "cssVar": "--sh-primary", "label": "主色", "type": "color", "default": "#0e8a6e" },
|
{ "key": "primary", "cssVar": "--sh-primary", "label": "主色", "type": "color", "default": "#0e8a5f" },
|
||||||
{ "key": "primaryDark", "cssVar": "--sh-primary-dark", "label": "主色加深", "type": "color", "default": "#0a6a55" },
|
{ "key": "primaryDark", "cssVar": "--sh-primary-dark", "label": "主色加深", "type": "color", "default": "#0a6244" },
|
||||||
{ "key": "accent", "cssVar": "--sh-accent", "label": "强调色", "type": "color", "default": "#d97706" },
|
{ "key": "accent", "cssVar": "--sh-accent", "label": "强调色", "type": "color", "default": "#ff7a1a" },
|
||||||
{ "key": "pageBg", "cssVar": "--sh-page-bg", "label": "页面底色", "type": "color", "default": "#f2f8f5" },
|
{ "key": "pageBg", "cssVar": "--sh-page-bg", "label": "页面底色", "type": "color", "default": "#f4f7f5" },
|
||||||
{
|
{
|
||||||
"key": "listLayout",
|
"key": "listLayout",
|
||||||
"label": "事项列表布局",
|
"label": "事项列表布局",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* CMS 门户主题 → hall 皮肤的映射适配器(色值级弱联动,2026-10-03 方案 T3)。
|
||||||
|
* 原则:CMS 主题是"推荐值"——未映射/字段缺失一律返回 null,调用方回落 env-config 皮肤;
|
||||||
|
* 联动只对齐品牌色 token,不复刻 CMS 模板结构。
|
||||||
|
* 契约来源:CMS /oapi/site/list raw 透传(SiteOapiController),键:theme、themeParams。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CmsSiteThemeInfo {
|
||||||
|
theme: string | null;
|
||||||
|
themeParams: Record<string, string> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HallSkinChoice {
|
||||||
|
/** hall 皮肤名(一定已注册,未映射返回 null 整体回落) */
|
||||||
|
name: string;
|
||||||
|
/** 覆盖值(键必须命中目标皮肤的 params 声明,applyTheme 会再过滤校验) */
|
||||||
|
params: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CMS 皮肤包名 → hall 皮肤名(新增 CMS 主题时在此登记;不认识=回落) */
|
||||||
|
const SKIN_MAP: Record<string, string> = {
|
||||||
|
"default": "default",
|
||||||
|
"campus-glow": "campus-green",
|
||||||
|
"scholar-blue": "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** CMS default 主题 colorScheme 预设 → hall 参数(值译自 CMS default/theme.json presets 段) */
|
||||||
|
const COLOR_SCHEMES: Record<string, Record<string, string>> = {
|
||||||
|
"blue-white": { primary: "#2f6bd8", primaryDark: "#1d4fae", pageBg: "#f7f9fc" },
|
||||||
|
"classic-red": { primary: "#b01a2e", primaryDark: "#7f1224", pageBg: "#f7f5f2" },
|
||||||
|
"fresh-mint": { primary: "#17a2a2", primaryDark: "#0f7c7c", pageBg: "#f5fbfa" },
|
||||||
|
"warm-kinder": { primary: "#e8722a", primaryDark: "#c25a1a", pageBg: "#fff8f0" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** CMS themeParams 键 → hall 皮肤参数键(同名直传,bg 特译;accent 同名但仅部分 CMS 主题声明) */
|
||||||
|
const PARAM_ALIASE: Record<string, string> = {
|
||||||
|
primary: "primary",
|
||||||
|
primaryDark: "primaryDark",
|
||||||
|
accent: "accent",
|
||||||
|
bg: "pageBg",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toHallSkin(cms: CmsSiteThemeInfo | null | undefined): HallSkinChoice | null {
|
||||||
|
if (!cms || !cms.theme) return null;
|
||||||
|
const skin = SKIN_MAP[cms.theme];
|
||||||
|
if (!skin) return null;
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
// ① colorScheme 预设打底(custom/未配=无)
|
||||||
|
const scheme = cms.themeParams?.colorScheme;
|
||||||
|
if (scheme && COLOR_SCHEMES[scheme]) Object.assign(params, COLOR_SCHEMES[scheme]);
|
||||||
|
// ② 站点显式色值覆盖预设(同键后者赢)
|
||||||
|
const tp = cms.themeParams || {};
|
||||||
|
for (const [cmsKey, hallKey] of Object.entries(PARAM_ALIASE)) {
|
||||||
|
const v = tp[cmsKey];
|
||||||
|
if (typeof v === "string" && /^#[0-9a-fA-F]{3,8}$/.test(v)) params[hallKey] = v;
|
||||||
|
}
|
||||||
|
return { name: skin, params };
|
||||||
|
}
|
||||||
+25
-1
@@ -13,6 +13,8 @@ import config from "@/assets/config/host.config";
|
|||||||
*/
|
*/
|
||||||
const currentSiteCode = ref<string>(localStorage.getItem("svchall.public.site") || "");
|
const currentSiteCode = ref<string>(localStorage.getItem("svchall.public.site") || "");
|
||||||
const lastItemCode = ref<string>(localStorage.getItem("svchall.public.item") || "");
|
const lastItemCode = ref<string>(localStorage.getItem("svchall.public.item") || "");
|
||||||
|
/** CMS 指南页来路(session 级内存,不落 localStorage——回跳是"这一次来的路",不是偏好) */
|
||||||
|
const portalFrom = ref<string>("");
|
||||||
|
|
||||||
export function useSite() {
|
export function useSite() {
|
||||||
/** 从 query / 运行时配置 / 本地记忆依次解析站点,命中即记住 */
|
/** 从 query / 运行时配置 / 本地记忆依次解析站点,命中即记住 */
|
||||||
@@ -29,6 +31,28 @@ export function useSite() {
|
|||||||
return hit;
|
return hit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析并校验 from 参数(CMS 指南页深链带回,T4 回跳闭环):
|
||||||
|
* 仅接受 http/https 且 hostname 属于本部署可信域(当前页域名或 baseHost)的 URL,否则忽略——
|
||||||
|
* 防 javascript: 伪协议与外域注入。
|
||||||
|
*/
|
||||||
|
function resolveFrom(queryFrom?: unknown): string {
|
||||||
|
const raw = typeof queryFrom === "string" ? queryFrom.trim() : "";
|
||||||
|
if (!raw) return "";
|
||||||
|
try {
|
||||||
|
const u = new URL(raw);
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return "";
|
||||||
|
const mine = location.hostname;
|
||||||
|
const base = String(config.baseHost || "");
|
||||||
|
const baseHost = base ? new URL(base).hostname : "";
|
||||||
|
if (u.hostname !== mine && (!baseHost || u.hostname !== baseHost)) return "";
|
||||||
|
portalFrom.value = raw;
|
||||||
|
} catch {
|
||||||
|
// 非法 URL 当没有
|
||||||
|
}
|
||||||
|
return portalFrom.value;
|
||||||
|
}
|
||||||
|
|
||||||
function setSite(siteCode: string) {
|
function setSite(siteCode: string) {
|
||||||
currentSiteCode.value = siteCode;
|
currentSiteCode.value = siteCode;
|
||||||
if (siteCode) {
|
if (siteCode) {
|
||||||
@@ -44,5 +68,5 @@ export function useSite() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { currentSiteCode, lastItemCode, resolveSite, setSite, rememberItem };
|
return { currentSiteCode, lastItemCode, portalFrom, resolveSite, resolveFrom, setSite, rememberItem };
|
||||||
}
|
}
|
||||||
|
|||||||
+402
-16
@@ -8,18 +8,18 @@
|
|||||||
* 于是明明提交成功也弹失败提示。现在按 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();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { resolveSite, rememberItem } = useSite();
|
const { resolveSite, resolveFrom, rememberItem } = useSite();
|
||||||
|
|
||||||
/** 轮询参数:代发起 + 回调一般在 1s 内完成,给到 ~9s 足够,避免用户盯着"待提交"发懵 */
|
/** 轮询参数:代发起 + 回调一般在 1s 内完成,给到 ~9s 足够,避免用户盯着"待提交"发懵 */
|
||||||
const POLL_TIMES = 12;
|
const POLL_TIMES = 12;
|
||||||
@@ -33,10 +33,53 @@ 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 顶栏渲染"返回门户"
|
||||||
|
resolveFrom(route.query.from);
|
||||||
const itemCode = String(route.query.itemCode || "");
|
const itemCode = String(route.query.itemCode || "");
|
||||||
if (!siteCode.value || !itemCode) {
|
if (!siteCode.value || !itemCode) {
|
||||||
loadError.value = "缺少 siteCode/itemCode 参数";
|
loadError.value = "缺少 siteCode/itemCode 参数";
|
||||||
@@ -98,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 校验(字段下方内联红字 + 滚到第一个错误),不再把"必填项未填写"
|
||||||
@@ -114,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>
|
||||||
@@ -149,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>
|
||||||
@@ -156,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">
|
||||||
@@ -176,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;
|
||||||
@@ -198,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>
|
||||||
|
|||||||
+315
-5
@@ -10,6 +10,11 @@ import { Message, Modal } from "view-ui-plus";
|
|||||||
import { applyApi } from "@/api/module/svc";
|
import { applyApi } from "@/api/module/svc";
|
||||||
import type { ApplyVo, FlowProgressVo } from "@/types/svc";
|
import type { ApplyVo, FlowProgressVo } from "@/types/svc";
|
||||||
import { buildFormDataGroups, FormDataGroup } from "@/utils/formDataGroups";
|
import { buildFormDataGroups, FormDataGroup } from "@/utils/formDataGroups";
|
||||||
|
import FlowApprovalCard from "@/components/flow-approval/FlowApprovalCard.vue";
|
||||||
|
import FlowRecordDrawer from "@/components/flow-approval/FlowRecordDrawer.vue";
|
||||||
|
import FlowTaskEmbed from "@/components/flow-approval/FlowTaskEmbed.vue";
|
||||||
|
import svcFlowApprovalApi from "@/api/module/approvalAdapter";
|
||||||
|
import type { TraceVo } from "@/components/flow-approval/types";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -19,6 +24,93 @@ const acting = ref(false);
|
|||||||
const diagramVisible = ref(false);
|
const diagramVisible = ref(false);
|
||||||
const diagramUrl = ref("");
|
const diagramUrl = ref("");
|
||||||
|
|
||||||
|
// ===== 审批卡片(P3.3):轨迹转换 + 记录抽屉 + 完整办理 =====
|
||||||
|
/** /api/apply/{id}/progress → 卡片 TraceVo(P1-05:卡片不自取轨迹,宿主转换后传入) */
|
||||||
|
const cardTrace = computed<TraceVo | null>(() => {
|
||||||
|
const p = progress.value;
|
||||||
|
if (!p) return null;
|
||||||
|
const steps = (p.steps || []).map((st) => ({
|
||||||
|
nodeId: st.nodeId,
|
||||||
|
nodeName: st.nodeName,
|
||||||
|
label: st.label,
|
||||||
|
active: st.active,
|
||||||
|
assignees: (st.assignees || []).map((a) => ({
|
||||||
|
userName: a.userName,
|
||||||
|
statusLabel: a.statusLabel,
|
||||||
|
opinion: a.opinion,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
// 活动节点名摘要:active steps 的节点名去重(byBiz currentNodes 是节点 id,不得当名称)
|
||||||
|
const activeNames = Array.from(
|
||||||
|
new Set((p.steps || []).filter((st) => st.active).map((st) => st.nodeName).filter(Boolean))
|
||||||
|
);
|
||||||
|
const res = parse(apply.value?.flowResultJson);
|
||||||
|
return {
|
||||||
|
instanceStatus: p.status,
|
||||||
|
auditStatus: p.status,
|
||||||
|
nodeSummary: activeNames.join("、") || undefined,
|
||||||
|
currentNodes: activeNames.join("、") || undefined,
|
||||||
|
steps,
|
||||||
|
flowResult: res.result || res.opinion ? { result: res.result, opinion: res.opinion } : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const recordVisible = ref(false);
|
||||||
|
function openRecord() {
|
||||||
|
if (!apply.value?.id) return;
|
||||||
|
recordVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskVisible = ref(false);
|
||||||
|
const taskUrl = ref("");
|
||||||
|
const taskTaskId = ref<number | string>("");
|
||||||
|
/** 当前任务页上下文(P1-09:消息 bizId 与本办件一致才受理 open-biz) */
|
||||||
|
const taskBizId = ref<string | number>("");
|
||||||
|
let taskReqEpoch = 0;
|
||||||
|
|
||||||
|
async function openTask(payload: { taskId: number; bizId: string }) {
|
||||||
|
if (!apply.value?.id) return;
|
||||||
|
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 = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** open-biz 在详情页=回本页(只用一致 bizId;bizUrl 不当路由依据) */
|
||||||
|
function onOpenBiz(payload: { bizId?: string | null }) {
|
||||||
|
const msgBizId = payload.bizId != null ? String(payload.bizId) : "";
|
||||||
|
if (msgBizId && msgBizId !== String(apply.value?.id)) return;
|
||||||
|
taskVisible.value = false;
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片办理完成:详情与进度整体刷新(本地状态实际由回调推进) */
|
||||||
|
function onCardHandled() {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
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" },
|
||||||
10: { text: "审批中", cls: "sh-tag sh-tag-primary" },
|
10: { text: "审批中", cls: "sh-tag sh-tag-primary" },
|
||||||
@@ -32,6 +124,7 @@ const SNAPSHOT_LABEL: Record<string, string> = {
|
|||||||
summary: "事项说明",
|
summary: "事项说明",
|
||||||
deptName: "办理部门",
|
deptName: "办理部门",
|
||||||
handleTimeLimit: "办理时限",
|
handleTimeLimit: "办理时限",
|
||||||
|
promiseTimeLimit: "承诺时限",
|
||||||
contactPhone: "咨询电话",
|
contactPhone: "咨询电话",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,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 [];
|
||||||
@@ -182,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">
|
||||||
@@ -203,6 +387,14 @@ onMounted(load);
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 审批卡片(P3.3):未进流程(flowInstanceId 空)不渲染——该场景走"待提交"主链,无审批面 -->
|
||||||
|
<div class="sh-card" v-if="apply.flowInstanceId">
|
||||||
|
<div class="section-title">审批操作</div>
|
||||||
|
<FlowApprovalCard :api="svcFlowApprovalApi" biz-type="svc-hall" :biz-id="String(apply.id)"
|
||||||
|
:title="snapshot.itemTitle || apply.itemTitle" :trace="cardTrace"
|
||||||
|
@open-record="openRecord" @open-task="openTask" @handled="onCardHandled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sh-card" v-if="result.result || result.opinion">
|
<div class="sh-card" v-if="result.result || result.opinion">
|
||||||
<div class="section-title">审批结果</div>
|
<div class="section-title">审批结果</div>
|
||||||
<p>结果:{{ result.result || "—" }}</p>
|
<p>结果:{{ result.result || "—" }}</p>
|
||||||
@@ -212,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">
|
||||||
@@ -236,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>
|
||||||
@@ -251,6 +452,12 @@ onMounted(load);
|
|||||||
<img :src="diagramUrl" alt="流程图" />
|
<img :src="diagramUrl" alt="流程图" />
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<FlowRecordDrawer v-model="recordVisible" :api="svcFlowApprovalApi"
|
||||||
|
:biz-id="String(apply.id || '')" />
|
||||||
|
<FlowTaskEmbed v-model="taskVisible" :page-url="taskUrl" :task-id="taskTaskId"
|
||||||
|
:biz-id="String(apply.id || '')"
|
||||||
|
@handled="onCardHandled" @open-biz="onOpenBiz" @close="onTaskClose" />
|
||||||
</div>
|
</div>
|
||||||
<div class="sh-card" v-else>加载中…</div>
|
<div class="sh-card" v-else>加载中…</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -272,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;
|
||||||
@@ -325,7 +635,7 @@ onMounted(load);
|
|||||||
}
|
}
|
||||||
.step-dot.active {
|
.step-dot.active {
|
||||||
background: var(--sh-primary);
|
background: var(--sh-primary);
|
||||||
box-shadow: 0 0 0 3px rgba(26, 92, 176, 0.15);
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--sh-primary) 15%, transparent);
|
||||||
}
|
}
|
||||||
.step-body {
|
.step-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -354,10 +664,10 @@ onMounted(load);
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.step-done .assignee-name {
|
.step-done .assignee-name {
|
||||||
color: #1f9254;
|
color: var(--sh-success);
|
||||||
}
|
}
|
||||||
.step-reject .assignee-name {
|
.step-reject .assignee-name {
|
||||||
color: #d03050;
|
color: var(--sh-danger);
|
||||||
}
|
}
|
||||||
.step-invalid {
|
.step-invalid {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
|
|||||||
+249
-17
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 我的审批(/todo,P3.3):待我审批(中心 todo)+ 我提交的(本地办件)两页签。
|
||||||
|
* 完整办理=去壳任务页 iframe(FlowTaskEmbed);审批记录=token 只读抽屉(FlowRecordDrawer)。
|
||||||
|
*
|
||||||
|
* 宿主职责(P1-09):
|
||||||
|
* - open-task 换址请求带代际号——关闭/换件时废弃在途响应并清 taskUrl/选中 ID;
|
||||||
|
* - open-biz 只用与当前办件上下文一致的 bizId 构造 /apply-detail?id=,不照抄任意 URL。
|
||||||
|
*/
|
||||||
|
import { computed, onBeforeUnmount, ref } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
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 svcFlowApprovalApi from "@/api/module/approvalAdapter";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
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 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() {
|
||||||
|
// 办理完成:刷新列表(本地 flow_status 由回调推进,前端只刷数据面)
|
||||||
|
listRef.value?.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* open-biz:只用校验过的本办件 ID 构造 /apply-detail?id=(P1-09)。
|
||||||
|
* 消息 bizId 必须与打开任务页时锁定的办件上下文一致;缺失时用锁定上下文;绝不透传 bizUrl 路由。
|
||||||
|
*/
|
||||||
|
function onOpenBiz(payload: { bizId?: string | null; bizUrl?: string }) {
|
||||||
|
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) {
|
||||||
|
router.push(`/apply-detail?id=${applyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
taskReqEpoch++;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page todo-page">
|
||||||
|
<div class="page-head">
|
||||||
|
<h2 class="page-title">我的审批</h2>
|
||||||
|
<span class="page-sub">「待我审批/我已审批/抄送我的」为流程中心数据(按本应用过滤);「我提交的」为本地办件</span>
|
||||||
|
</div>
|
||||||
|
<FlowApprovalList ref="listRef" :api="svcFlowApprovalApi" :tabs="tabs"
|
||||||
|
@open-record="openRecord" @open-task="openTask" @handled="onHandled"
|
||||||
|
@open-detail="openDetail" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.todo-page {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
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, #6b7280);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+2
-14
@@ -3,7 +3,6 @@ import { createHtmlPlugin } from "vite-plugin-html";
|
|||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import path, { resolve } from "path";
|
import path, { resolve } from "path";
|
||||||
import { createSvgIconsPlugin } from "vite-plugin-svg-icons";
|
import { createSvgIconsPlugin } from "vite-plugin-svg-icons";
|
||||||
import { viteMockServe } from "vite-plugin-mock";
|
|
||||||
import ViteAutoImport from "unplugin-auto-import/vite";
|
import ViteAutoImport from "unplugin-auto-import/vite";
|
||||||
|
|
||||||
import UnoCSS from "@unocss/vite";
|
import UnoCSS from "@unocss/vite";
|
||||||
@@ -27,11 +26,8 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||||||
inject: "body-last", // 插入的位置
|
inject: "body-last", // 插入的位置
|
||||||
customDomId: "__svg__icons__dom__", // svg的id
|
customDomId: "__svg__icons__dom__", // svg的id
|
||||||
}),
|
}),
|
||||||
viteMockServe({
|
// vite-plugin-mock 已裁(2026-10-03 T6):mockPath 指向的 ./src/assets/mock 目录
|
||||||
mockPath: "./src/assets/mock",
|
// 并不存在,dev:mock/.env.mock 一并移除——dev/联调/正式均走真实后端
|
||||||
// 仅 --mode mock 时启用 mock 中间件;dev/联调/正式均走真实后端
|
|
||||||
localEnabled: mode === "mock",
|
|
||||||
}),
|
|
||||||
ViteAutoImport({
|
ViteAutoImport({
|
||||||
imports: [
|
imports: [
|
||||||
"vue",
|
"vue",
|
||||||
@@ -108,14 +104,6 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// css预处理器
|
|
||||||
css: {
|
|
||||||
// preprocessorOptions: {
|
|
||||||
// scss: {
|
|
||||||
// additionalData: '@import "./src/assets/styles/utils/_varible.scss";',
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
},
|
|
||||||
base: "./",
|
base: "./",
|
||||||
build: {
|
build: {
|
||||||
target: "modules", //设置最终构建的浏览器兼容目标 //es2015(编译成es5) | modules
|
target: "modules", //设置最终构建的浏览器兼容目标 //es2015(编译成es5) | modules
|
||||||
|
|||||||
Reference in New Issue
Block a user