Compare commits
53
Commits
17be4e6445
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d5034fbaf | ||
|
|
0b62e7c304 | ||
|
|
7c969955ae | ||
|
|
f085d6d74d | ||
|
|
62d9024f57 | ||
|
|
c068321279 | ||
|
|
310743222c | ||
|
|
46710cd18e | ||
|
|
690d329371 | ||
|
|
6a63a7cb8f | ||
|
|
f90aefc8e3 | ||
|
|
c1fe0783fb | ||
|
|
3de71aa99b | ||
|
|
8fe3e2ef8c | ||
|
|
13afb22060 | ||
|
|
dbbabc56eb | ||
|
|
5e3f151ce7 | ||
|
|
ab647c67ba | ||
|
|
e8ccc7d3ba | ||
|
|
b685ff5dc0 | ||
|
|
9c8b0f900a | ||
|
|
1af6005a55 | ||
|
|
fea12895c1 | ||
|
|
599fed01b5 | ||
|
|
e0d1b2b769 | ||
|
|
71452fcfd2 | ||
|
|
eee4c10155 | ||
|
|
f611c15a6e | ||
|
|
941031e1b4 | ||
|
|
59f8fad4ca | ||
|
|
f4d0c7d97a | ||
|
|
e37ff9f8d1 | ||
|
|
c81f097188 | ||
|
|
05804f41a9 | ||
|
|
29c051350b | ||
|
|
8c2d539f34 | ||
|
|
693aba9968 | ||
|
|
21a0bd36b6 | ||
|
|
796a1b9a7b | ||
|
|
74b9e80b0d | ||
|
|
1516effb69 | ||
|
|
edd91b53b7 | ||
|
|
7c885c0b4b | ||
|
|
31ca694d48 | ||
|
|
f41719f3c4 | ||
|
|
468cb8670f | ||
|
|
830f9088f4 | ||
|
|
c3ff58fd94 | ||
|
|
1b39e0086d | ||
|
|
c50cafe44e | ||
|
|
99e4ea8369 | ||
|
|
2f423c2d33 | ||
|
|
d2d0eb6cae |
@@ -1,7 +0,0 @@
|
|||||||
# 正式环境 - base
|
|
||||||
VITE_BASE_HOST=http://base.yunhaixingtong.com
|
|
||||||
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
|
||||||
VITE_SERVICE_HOST=http://base.yunhaixingtong.com/notice
|
|
||||||
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
|
|
||||||
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
|
||||||
VITE_IS_MOCK=false
|
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
# 开发环境 (dev):API 走 /api 同源相对路径(vite proxy 转发到本地后端 8070),
|
# 开发环境 (dev):仅 vite dev --mode dev 加载;build 产物不读任何 .env.*
|
||||||
|
# (.env.base/.env.ld/.env.prod 已删,线上由 /api/env-config?target=front 运行时下发)。
|
||||||
|
# ⚠️ VITE_* 仅本地开发生效。
|
||||||
|
# API 走 /api 同源相对路径(vite proxy 转发到本地后端 8101),
|
||||||
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境
|
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境
|
||||||
VITE_BASE_HOST=http://base.yunhaixingtong.com
|
VITE_BASE_HOST=http://base.yunhaixingtong.com
|
||||||
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
||||||
VITE_SERVICE_HOST=
|
VITE_SERVICE_HOST=
|
||||||
|
# 流程中心本地直连(详情页撤回/催办直调;生产同主域自动推导 /flow-center)
|
||||||
|
VITE_FLOW_HOST=http://localhost:8085
|
||||||
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
|
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
|
||||||
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
||||||
VITE_IS_MOCK=false
|
VITE_IS_MOCK=false
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# 正式环境 - ld
|
|
||||||
VITE_BASE_HOST=https://ld.bestsch.com
|
|
||||||
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
|
||||||
VITE_SERVICE_HOST=https://ld.yunhaixingtong.com/notice
|
|
||||||
VITE_ZIYUANKU_HOST=https://ld.bestsch.com/BschZyk
|
|
||||||
VITE_ZHUANXIANG_HOST=https://ld.bestsch.com/BschZncpd
|
|
||||||
VITE_IS_MOCK=false
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
# 本地纯前端调试模式:后端 SSO/base 不可达时启用内置 mock
|
# 本地纯前端调试模式:仅 vite dev --mode mock 加载(build 产物不读任何 .env.*,
|
||||||
|
# 线上由 /api/env-config?target=front 运行时下发)。后端 SSO/base 不可达时启用内置 mock
|
||||||
# 启动:npm run dev:mock
|
# 启动:npm run dev:mock
|
||||||
VITE_BASE_HOST=
|
VITE_BASE_HOST=
|
||||||
VITE_SSO_HOST=
|
VITE_SSO_HOST=
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# 生产环境 - prod(vite build --mode prod 加载此文件)
|
|
||||||
VITE_BASE_HOST=http://base.yunhaixingtong.com
|
|
||||||
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
|
||||||
VITE_SERVICE_HOST=http://base.yunhaixingtong.com/notice
|
|
||||||
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
|
|
||||||
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
|
||||||
VITE_IS_MOCK=false
|
|
||||||
@@ -19,7 +19,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 本仓库)
|
||||||
@@ -38,24 +41,34 @@ jobs:
|
|||||||
- name: 构建前端(node 容器)
|
- name: 构建前端(node 容器)
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
docker rm -f frontbuild 2>/dev/null || true
|
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上是共享
|
||||||
docker run -d --name frontbuild -w /build node:20 sleep 3600
|
# 命名空间。旧写法固定名 frontbuild 会在多仓/多条 workflow 并发时互相 docker rm -f 掉
|
||||||
docker cp "${PWD}/." frontbuild:/build/
|
# 对方正在构建的容器,表现为无编译报错的 exit 137(SIGKILL,2026-09-10 三仓同推即撞)。
|
||||||
docker exec frontbuild sh -c '
|
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底(手动重跑/未注入时也不撞名)。
|
||||||
|
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||||
|
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||||
|
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||||
|
docker run -d --name "$BUILD_CONTAINER" -w /build node:20 sleep 3600
|
||||||
|
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||||
|
docker exec "$BUILD_CONTAINER" sh -c '
|
||||||
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
|
||||||
'
|
'
|
||||||
echo "=== dist 产物 ==="
|
echo "=== dist 产物 ==="
|
||||||
docker exec frontbuild sh -c 'ls -la /build/dist/ | head -20'
|
docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
|
||||||
# 从容器 cp dist 到宿主工作目录
|
# 从容器 cp dist 到宿主工作目录
|
||||||
docker cp frontbuild:/build/dist ./dist
|
docker cp "$BUILD_CONTAINER":/build/dist ./dist
|
||||||
docker rm -f frontbuild
|
docker rm -f "$BUILD_CONTAINER"
|
||||||
echo "BUILD CHECK OK"
|
echo "BUILD CHECK OK"
|
||||||
|
|
||||||
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
# 打包 dist → zip(vite outDir 已简化为直出 dist/,无中间层;进入 dist 打包,
|
||||||
|
# zip 根目录直接是 index.html/css/js。此前 outDir=dist/base 时曾误打 dist/ 整层,
|
||||||
|
# zip 根只有 base/ 目录,projman 前端发布校验报「zip 根目录缺少 html 文件」)
|
||||||
- name: 打包产物
|
- name: 打包产物
|
||||||
run: |
|
run: |
|
||||||
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
|
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
|
|||||||
@@ -27,3 +27,6 @@ yarn.lock
|
|||||||
|
|
||||||
# scratch
|
# scratch
|
||||||
.temp
|
.temp
|
||||||
|
|
||||||
|
# vite 配置 bundle 临时产物
|
||||||
|
vite.config.*.timestamp-*
|
||||||
|
|||||||
+46
@@ -5,6 +5,52 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="../../favicon.ico" />
|
<link rel="icon" type="image/svg+xml" href="../../favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- 运行时配置引导:在任何应用模块执行前同步拉取 /<contextPath>/api/env-config
|
||||||
|
(后端免登录端点,projman 配置管理在线维护),替代生产构建环境变量。
|
||||||
|
必须同步:host.config.ts 在模块求值期就读 window.__APP_CONFIG__;失败/本地开发
|
||||||
|
(根路径,段数不足 2)则不设置:本地走 .env.dev/.env.mock(VITE_*),线上走同域推导兜底。
|
||||||
|
target = URL 第 2 段目录名:本应用产物部署于 /{contextPath}/front/,故为 front,
|
||||||
|
对应后端配置键 yh.env-config.source.front(环境变量 YH_ENV_CONFIG_SOURCE_FRONT)。
|
||||||
|
注意:同步 XHR 不支持 timeout 属性(赋值会抛 InvalidAccessError),依赖内网快速失败 -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var seg = location.pathname.split("/").filter(Boolean);
|
||||||
|
if (seg.length < 2) return; // 本地开发(根路径):走 .env.dev / .env.mock
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("GET", "/" + seg[0] + "/api/env-config?target=" + encodeURIComponent(seg[1]), false);
|
||||||
|
xhr.send(null);
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
var conf = JSON.parse(xhr.responseText);
|
||||||
|
if (conf && typeof conf === "object") window.__APP_CONFIG__ = conf;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* 拉取失败保持未设置,走同域推导兜底 */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<!-- 主题通道引导(A 类免插件接入,方案 §11):唯一接入点,不装依赖、不改 vite.config 也能吃主题。
|
||||||
|
origin 零写死,优先级四档:
|
||||||
|
__APP_CONFIG__.staticHost(专用通道覆盖,projman env-config 在线配)
|
||||||
|
> __APP_CONFIG__.baseHost(换域名改配置即生效)
|
||||||
|
> %VITE_THEME_HOST%(仅 dev 注入:主题通道专用域,需通道已开 CORS)
|
||||||
|
> 同源相对 "/static/…"(生产常态:通道与应用同域;本地 dev 由 vite /static 代理兜到通道)
|
||||||
|
为何不直接复用 .env.dev 的 VITE_BASE_HOST:那会让 dev 跨源直连线上域,而通道 Caddy 至今没有
|
||||||
|
Access-Control-Allow-Origin(方案 §10.4 现网无该头)→ manifest 是 fetch 跨源,会被拦成降级。
|
||||||
|
运维加上那行头之后,在 .env.dev 补 VITE_THEME_HOST=http://base.yunhaixingtong.com 即切直连,零改码。
|
||||||
|
document.write 保证 parser-blocking:init 的档位挂载与 FOUC 门先于首帧(同旧插件 initBootstrap 机制,
|
||||||
|
且 vite build 不解析内联脚本里的字符串,规避 "/static/…" 被当本仓资源解析报错;build 产物不读
|
||||||
|
.env.*,占位符原样留字面量 → 判首字符 % 置空,产物零环境值烧入)。
|
||||||
|
挂载/FOUC/CSS 排序全部由通道 init 脚本承担,逻辑升级全走 /static 通道,无需重新构建本应用 -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var c = window.__APP_CONFIG__ || {};
|
||||||
|
var themeHost = "%VITE_THEME_HOST%";
|
||||||
|
if (themeHost.charAt(0) === "%") themeHost = "";
|
||||||
|
var host = String(c.staticHost || c.baseHost || themeHost).replace(/\/+$/, "");
|
||||||
|
document.write('<script src="' + host + '/static/js/yh-theme-init.js"><\/script>');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<title><%- title %></title>
|
<title><%- title %></title>
|
||||||
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
|
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
|
||||||
<script src="./libs/jquery.min.js"></script>
|
<script src="./libs/jquery.min.js"></script>
|
||||||
|
|||||||
Generated
+2587
-2797
File diff suppressed because it is too large
Load Diff
@@ -41,8 +41,6 @@
|
|||||||
"@unocss/preset-uno": "^66.6.0",
|
"@unocss/preset-uno": "^66.6.0",
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
"node-sass": "^6.0.1",
|
|
||||||
"sass-loader": "^10.0.1",
|
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"unocss": "^66.6.0",
|
"unocss": "^66.6.0",
|
||||||
"unplugin-auto-import": "0.18.3",
|
"unplugin-auto-import": "0.18.3",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
+2
-1
@@ -78,7 +78,8 @@ onMounted(async () => {
|
|||||||
<layout v-if="isLogin" :layoutData="layoutData" />
|
<layout v-if="isLogin" :layoutData="layoutData" />
|
||||||
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
||||||
<div class="main-wrapper">
|
<div class="main-wrapper">
|
||||||
<router-view />
|
<!-- key=fullPath:详情页仅 query 变化(id/serid)时强制重渲染,避免显示上一条数据 -->
|
||||||
|
<router-view :key="$route.fullPath" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Method } from "alova";
|
||||||
|
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用内审批接口封装(第三步 T-C4,"我的待审核")。
|
||||||
|
* 走本应用后端代理 /api/notice/approval/**(operator=CAS 登录态,前端不传身份);
|
||||||
|
* 后端经网关调 flow-center,契约=BasePageResult{total,content,...}。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const APPROVAL = "/api/notice/approval";
|
||||||
|
|
||||||
|
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
||||||
|
return new Method<T>("GET", alovaInstance, url, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
function postJSON<T = any>(url: string, data?: Record<string, any>): Method<T> {
|
||||||
|
return new Method<T>(
|
||||||
|
"POST",
|
||||||
|
alovaInstance,
|
||||||
|
url,
|
||||||
|
{ headers: { "Content-Type": "application/json;charset=UTF-8" } },
|
||||||
|
data ? JSON.stringify(data) : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalTodoTask {
|
||||||
|
taskId: number;
|
||||||
|
instanceId: number;
|
||||||
|
instanceVersion: number | null;
|
||||||
|
bizType: string;
|
||||||
|
bizId: string;
|
||||||
|
bizTitle: string;
|
||||||
|
bizSnapshot: string | null;
|
||||||
|
nodeId: string;
|
||||||
|
nodeName: string;
|
||||||
|
assigneeId: number;
|
||||||
|
assigneeName: string;
|
||||||
|
arriveTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalInstance {
|
||||||
|
instanceId: number;
|
||||||
|
status: number;
|
||||||
|
version: number;
|
||||||
|
bizType: string;
|
||||||
|
bizId: string;
|
||||||
|
bizTitle: string;
|
||||||
|
bizUrl: string | null;
|
||||||
|
submitterName: string;
|
||||||
|
submitTime: string;
|
||||||
|
finishTime: string | null;
|
||||||
|
currentNodes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalPage<T> {
|
||||||
|
total: number;
|
||||||
|
content: T[];
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== v2.1 应用内审批:业务详情卡片数据源(/oapi/flow/tasks/by-biz 经本应用后端代理) ===== */
|
||||||
|
|
||||||
|
/** 出口(多出口节点办理时须选其一;单出口节点该数组为空) */
|
||||||
|
export interface ByBizOutlet {
|
||||||
|
name: string;
|
||||||
|
toNodeId: string;
|
||||||
|
toNodeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前人在本通知上的一条待办任务 */
|
||||||
|
export interface ByBizTask {
|
||||||
|
taskId: number;
|
||||||
|
nodeId: string;
|
||||||
|
nodeName: string;
|
||||||
|
arriveTime: string;
|
||||||
|
outlets: ByBizOutlet[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* by-biz 出参。语义要点:
|
||||||
|
* - tasks 长度 0/1/N 都是成功响应,N>1 时卡片不得猜选(引导列表/任务页);
|
||||||
|
* - "通知不存在/不可见/不是我的待办" 三者同形(tasks 空 + instanceId 可能为 null),
|
||||||
|
* 前端不得据此反推是否存在别人的待办;
|
||||||
|
* - currentNodes 是**节点 id** 逗号串(办件原文口径),人读名称用 tasks[].nodeName。
|
||||||
|
*/
|
||||||
|
export interface ByBizResult {
|
||||||
|
instanceId: number | null;
|
||||||
|
bizType: string;
|
||||||
|
bizId: string;
|
||||||
|
status: number | null;
|
||||||
|
currentNodes: string | null;
|
||||||
|
tasks: ByBizTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 换址类端点统一返回 { pageUrl } */
|
||||||
|
export interface PageUrlResult {
|
||||||
|
pageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const approvalApi = {
|
||||||
|
/** 我的待审核(bizType=notice 固定在后端) */
|
||||||
|
todo(page = 1, size = 10): Method<ApprovalPage<ApprovalTodoTask>> {
|
||||||
|
return get(`${APPROVAL}/Todo`, { page, size });
|
||||||
|
},
|
||||||
|
/** 办件视角(status 可空;10 审批中/20 完成/30 拒绝/40 撤回) */
|
||||||
|
instances(status?: number, page = 1, size = 10): Method<ApprovalPage<ApprovalInstance>> {
|
||||||
|
return get(`${APPROVAL}/Instances`, status ? { status, page, size } : { page, size });
|
||||||
|
},
|
||||||
|
/** 当前人在该通知上的待办(卡片可写/只读、多待办与多出口判定的唯一依据) */
|
||||||
|
byBiz(serid: number): Method<ByBizResult> {
|
||||||
|
return get(`${APPROVAL}/ByBiz`, { serid });
|
||||||
|
},
|
||||||
|
/** 审批记录抽屉地址(token 只读页,现取现用) */
|
||||||
|
recordUrl(serid: number): Method<PageUrlResult> {
|
||||||
|
return get(`${APPROVAL}/RecordUrl`, { serid });
|
||||||
|
},
|
||||||
|
/** 完整办理任务页地址(后端已确认 taskId 属于当前人才签发;CAS 页无 token) */
|
||||||
|
taskPageUrl(serid: number, taskId: number): Method<PageUrlResult> {
|
||||||
|
return get(`${APPROVAL}/TaskPageUrl`, { serid, taskId });
|
||||||
|
},
|
||||||
|
/** 通过(opinion 可选;多出口节点必须带 edgeName,否则后端业务错) */
|
||||||
|
approve(taskId: number, opinion?: string, edgeName?: string): Method<void> {
|
||||||
|
const body: Record<string, string> = {};
|
||||||
|
if (opinion) body.opinion = opinion;
|
||||||
|
if (edgeName) body.edgeName = edgeName;
|
||||||
|
return postJSON(`${APPROVAL}/Approve?taskId=${taskId}`, body);
|
||||||
|
},
|
||||||
|
/** 拒绝(opinion 必填) */
|
||||||
|
reject(taskId: number, opinion: string): Method<void> {
|
||||||
|
return postJSON(`${APPROVAL}/Reject?taskId=${taskId}`, { opinion });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default approvalApi;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import approvalApi from "@/api/module/approval";
|
||||||
|
import { noticeApi } from "@/api/module/notice";
|
||||||
|
import type { FlowApprovalApi } from "@/components/flow-approval/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notice ↔ flow-approval 共享组件的适配器(方案 §5.4 的 Notice 侧落点)。
|
||||||
|
*
|
||||||
|
* 组件契约用 `bizId: string`(跨应用通用),Notice 后端用 `serid: number`;
|
||||||
|
* trace 走通知自己的进度接口而不新建 approval 端点,避免同一份轨迹两个来源。
|
||||||
|
*
|
||||||
|
* 这一层是**唯一的**映射位置:列表页与详情页卡片共用,改端点只改这里。
|
||||||
|
* 身份字段(operatorId/schId/name)在此一律不出现——由 NoticeService 从 CAS 会话取。
|
||||||
|
*/
|
||||||
|
export const noticeFlowApprovalApi: FlowApprovalApi = {
|
||||||
|
todo: (p) => approvalApi.todo(p?.page ?? 1, p?.size ?? 10).send(),
|
||||||
|
instances: (p) => approvalApi.instances(p?.status, p?.page ?? 1, p?.size ?? 10).send(),
|
||||||
|
byBiz: (bizId) => approvalApi.byBiz(Number(bizId)).send(),
|
||||||
|
trace: (bizId) => noticeApi.flowTrace(Number(bizId)).send(),
|
||||||
|
approve: (taskId, opinion, edgeName) => approvalApi.approve(taskId, opinion, edgeName).send(),
|
||||||
|
reject: (taskId, opinion) => approvalApi.reject(taskId, opinion).send(),
|
||||||
|
recordUrl: (bizId) => approvalApi.recordUrl(Number(bizId)).send(),
|
||||||
|
taskPageUrl: (bizId, taskId) => approvalApi.taskPageUrl(Number(bizId), taskId).send(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default noticeFlowApprovalApi;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Method } from "alova";
|
||||||
|
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用内「抄送给我的」接口封装(2026-09-24)。
|
||||||
|
* 走本应用后端 /api/notice/cc/My(身份=CAS 登录态,前端不传身份);
|
||||||
|
* 数据源是 flow.cc.sent 回调落的 notice_cc 表,点击行跳通知详情。
|
||||||
|
*/
|
||||||
|
|
||||||
|
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
||||||
|
return new Method<T>("GET", alovaInstance, url, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CcItem {
|
||||||
|
id: number;
|
||||||
|
noticeId: number;
|
||||||
|
flowInstanceId: number | null;
|
||||||
|
nodeId: string | null;
|
||||||
|
nodeName: string | null;
|
||||||
|
sentTime: string;
|
||||||
|
title: string | null;
|
||||||
|
deptName: string | null;
|
||||||
|
publisherName: string | null;
|
||||||
|
noticeStatus: number | null;
|
||||||
|
auditStatus: number | null;
|
||||||
|
auditStatusDesc: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CcPage {
|
||||||
|
total: number;
|
||||||
|
content: CcItem[];
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ccApi = {
|
||||||
|
/** 抄送给我的(分页,sent_time 倒序) */
|
||||||
|
my(page = 1, size = 10): Method<CcPage> {
|
||||||
|
return get(`/api/notice/cc/My`, { page, size });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ccApi;
|
||||||
@@ -15,6 +15,9 @@ import type {
|
|||||||
NoticeRequest,
|
NoticeRequest,
|
||||||
NoticeQuery,
|
NoticeQuery,
|
||||||
DictOption,
|
DictOption,
|
||||||
|
FlowOption,
|
||||||
|
FlowTraceVo,
|
||||||
|
FlowBindingVo,
|
||||||
DeptManageItem,
|
DeptManageItem,
|
||||||
ContactGroupPage,
|
ContactGroupPage,
|
||||||
ContactUserPage,
|
ContactUserPage,
|
||||||
@@ -107,6 +110,36 @@ class BaseApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class NoticeApi {
|
class NoticeApi {
|
||||||
|
// ========== 先审后发(流程中心托管) ==========
|
||||||
|
/** 审核流程下拉选项(流程中心未接入时返回空数组,页面隐藏选择器) */
|
||||||
|
getFlowOptions(schId?: number) {
|
||||||
|
return get<FlowOption[]>(`${NOTICE}/flow/Options`, schId ? { schId } : undefined);
|
||||||
|
}
|
||||||
|
/** 提交/重新提交审核(幂等:bizId=notice.id;审批中会被拒绝) */
|
||||||
|
trySubmitFlow(serid: number) {
|
||||||
|
return postForm<void>(`${NOTICE}/flow/TrySubmit`, { serid });
|
||||||
|
}
|
||||||
|
/** 审批进度(详情页进度卡片;未绑流程返回 bound=false) */
|
||||||
|
flowTrace(serid: number) {
|
||||||
|
return get<FlowTraceVo>(`${NOTICE}/flow/Trace`, { serid });
|
||||||
|
}
|
||||||
|
/** 撤回审核(2026-09-17:后端经 SDK 托管件中转,仅创建人本人+审批中;替代原先前端直调 flow-center) */
|
||||||
|
flowWithdraw(serid: number) {
|
||||||
|
return postForm<void>(`${NOTICE}/flow/Withdraw`, { serid });
|
||||||
|
}
|
||||||
|
/** 办件进度嵌入页地址(Drawer iframe 直嵌;pageUrl 含短时 token 现取现发,勿缓存复用) */
|
||||||
|
flowEmbed(serid: number) {
|
||||||
|
return get<{ pageUrl: string }>(`${NOTICE}/flow/Embed`, { serid });
|
||||||
|
}
|
||||||
|
/** 应用级流程绑定(应用设置 > 流程绑定;发通知页据此判断「提交审核」还是「发送」) */
|
||||||
|
getFlowBinding() {
|
||||||
|
return get<FlowBindingVo>(`${NOTICE}/flow/Binding`);
|
||||||
|
}
|
||||||
|
/** 保存/解除绑定(flowGuid 空=解绑;仅本校管理员) */
|
||||||
|
saveFlowBinding(flowGuid: string) {
|
||||||
|
return postForm<FlowBindingVo>(`${NOTICE}/flow/Binding/Save`, { flowGuid });
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 收件箱 ==========
|
// ========== 收件箱 ==========
|
||||||
/** 已收通知列表 */
|
/** 已收通知列表 */
|
||||||
list(params: NoticeQuery) {
|
list(params: NoticeQuery) {
|
||||||
|
|||||||
@@ -1,22 +1,69 @@
|
|||||||
import { BaseConf } from "@/types/Basics";
|
import { BaseConf } from "@/types/Basics";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过 Vite 环境变量管理配置,不再依赖 window.conf / config.js
|
* 配置优先级(高 → 低):
|
||||||
* 环境变量定义在项目根目录的 .env.dev / .env.base / .env.ld 中
|
* ① window.__APP_CONFIG__:线上运行时配置,index.html 引导脚本在应用模块加载前同步拉取
|
||||||
|
* /<contextPath>/api/env-config?target=<部署目录名>(本应用部署于 /{contextPath}/front/,
|
||||||
|
* 故为 front,对应后端键 yh.env-config.source.front,projman 配置管理维护)写入
|
||||||
|
* ② import.meta.env.VITE_*:仅本地开发/演示(vite dev --mode dev 读 .env.dev、--mode mock 读 .env.mock);
|
||||||
|
* build 产物不读任何 .env.*(.env.base/.env.ld/.env.prod 已删),VITE_* 恒为 undefined,
|
||||||
|
* 本层自动跳过,产物不含任何环境值
|
||||||
|
* ③ 同域推导兜底:env-config 未配置/拉取失败时框架至少能渲染并调通同源 API;
|
||||||
|
* ssoHost 为空 → 登出跳转不可用——与后端未就绪状态一致,可接受。
|
||||||
*/
|
*/
|
||||||
const env = import.meta.env;
|
const env = import.meta.env;
|
||||||
|
|
||||||
|
// ③ 同域推导兜底:context 段取当前路径首段(/{contextPath}/front/ → contextPath,即后端上下文),
|
||||||
|
// 与服务端 context-path 对齐(本地无、base/prod /notice);本地 dev/mock 在根路径运行(无段),
|
||||||
|
// serviceHost 留空走 vite 同源代理(规避 CAS 过滤器不回 CORS 头)
|
||||||
|
const seg = window.location.pathname.split("/").filter(Boolean);
|
||||||
|
const origin = window.location.origin;
|
||||||
|
|
||||||
let conf: BaseConf = {
|
let conf: BaseConf = {
|
||||||
|
baseHost: origin,
|
||||||
|
ssoHost: "",
|
||||||
|
serviceHost: seg.length >= 2 ? `${origin}/${seg[0]}` : "",
|
||||||
|
// 流程中心基址(Notice 详情页撤回/催办直调 /api/flow/*):生产同主域 Caddy 路由 /flow-center/;
|
||||||
|
// 本地 dev 由 VITE_FLOW_HOST 指向 http://localhost:8085(flow-center CORS 已收 8077)
|
||||||
|
flowHost: seg.length >= 2 ? `${origin}/flow-center` : "",
|
||||||
|
relayConf: {
|
||||||
|
ziyuankuHost: "",
|
||||||
|
zhuanxiangHost: "",
|
||||||
|
},
|
||||||
|
isMock: false,
|
||||||
|
isDebug: env.DEV,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 逐键覆盖:只覆盖来源里实际出现且非空的键,未出现的键沿用底层值
|
||||||
|
const applyConf = (target: any, source: any) => {
|
||||||
|
if (!source || typeof source !== "object") return;
|
||||||
|
Object.keys(source).forEach(key => {
|
||||||
|
if (source[key] !== undefined && source[key] !== null && source[key] !== "") {
|
||||||
|
(target as any)[key] = source[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ② 本地开发/演示(build 产物中恒 undefined → 自动跳过)
|
||||||
|
applyConf(conf, {
|
||||||
baseHost: env.VITE_BASE_HOST,
|
baseHost: env.VITE_BASE_HOST,
|
||||||
ssoHost: env.VITE_SSO_HOST,
|
ssoHost: env.VITE_SSO_HOST,
|
||||||
serviceHost: env.VITE_IS_MOCK === "true" ? "" : env.VITE_SERVICE_HOST,
|
serviceHost: env.VITE_IS_MOCK === "true" ? "" : env.VITE_SERVICE_HOST,
|
||||||
|
flowHost: env.VITE_FLOW_HOST,
|
||||||
relayConf: {
|
relayConf: {
|
||||||
ziyuankuHost: env.VITE_ZIYUANKU_HOST,
|
ziyuankuHost: env.VITE_ZIYUANKU_HOST,
|
||||||
zhuanxiangHost: env.VITE_ZHUANXIANG_HOST,
|
zhuanxiangHost: env.VITE_ZHUANXIANG_HOST,
|
||||||
},
|
},
|
||||||
isMock: env.VITE_IS_MOCK === "true",
|
isMock: env.VITE_IS_MOCK === "true",
|
||||||
isDebug: env.DEV,
|
});
|
||||||
};
|
|
||||||
|
// ① 线上运行时配置(最高优先,覆盖前面所有);relayConf 为嵌套对象,按子键合并,
|
||||||
|
// 运行时 JSON 只配部分资源库地址时未配的键沿用兜底值
|
||||||
|
const runtime = (window as any).__APP_CONFIG__;
|
||||||
|
applyConf(conf, runtime);
|
||||||
|
if (runtime && typeof runtime === "object" && runtime.relayConf) {
|
||||||
|
applyConf(conf.relayConf, runtime.relayConf);
|
||||||
|
}
|
||||||
|
|
||||||
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
|
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
|
||||||
var ishttps = "https:" == document.location.protocol ? true : false;
|
var ishttps = "https:" == document.location.protocol ? true : false;
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ export function getMenuList(): Promise<IModule[]> {
|
|||||||
{ id: 1, modId: 1, name: "已收到", page: "/" },
|
{ id: 1, modId: 1, name: "已收到", page: "/" },
|
||||||
{ id: 2, modId: 2, name: "已发送", page: "/list" },
|
{ id: 2, modId: 2, name: "已发送", page: "/list" },
|
||||||
{ id: 3, modId: 3, name: "全部通知", page: "/noticelist" },
|
{ id: 3, modId: 3, name: "全部通知", page: "/noticelist" },
|
||||||
|
{ id: 89, modId: 89, name: "待审核", page: "/approval" },
|
||||||
{ id: 4, modId: 4, name: "通知统计", page: "/stats" },
|
{ id: 4, modId: 4, name: "通知统计", page: "/stats" },
|
||||||
{ id: 21, modId: 21, name: "应用设置", page: "/setting" },
|
{ id: 21, modId: 21, name: "应用设置", page: "/setting" },
|
||||||
|
{ id: 91, modId: 91, name: "抄送给我的", page: "/cc" },
|
||||||
];
|
];
|
||||||
return Promise.resolve(menu);
|
return Promise.resolve(menu);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M859.977 336.128H608.251V171.489c0-41.159-33.318-74.836-74.039-74.836h-44.421c-40.729 0-74.044 33.677-74.044 74.836v164.639H164.023c-16.289 0-29.616 13.473-29.616 29.936v149.669c0 16.469 13.326 29.942 29.616 29.942h14.803v231.241c0 65.859-14.803 120.493-14.803 120.493 0 16.459 13.324 29.938 29.611 29.938h636.729c16.291 0 29.615-13.481 29.615-29.938 0 0-14.81-56.882-14.81-123.486V545.675h14.81c16.287 0 29.616-13.477 29.616-29.938V366.064c-0.001-16.469-13.323-29.936-29.617-29.936zM682.282 897.403V699.089c0-5.989-5.18-11.225-11.103-11.225h-7.407c-5.917 0-11.103 5.236-11.103 11.225v198.314H534.212V699.089c0-5.989-5.181-11.225-11.116-11.225H515.7c-5.918 0-11.106 5.236-11.106 11.225v198.314H378.731V699.089c0-5.989-5.186-11.225-11.111-11.225h-7.399c-5.918 0-11.106 5.236-11.106 11.225v198.314H193.634s14.812-53.884 14.812-120.487V545.675h607.113v231.241c0 66.606 14.804 120.487 14.804 120.487H682.282z m177.695-396.634c0 8.233-6.66 14.968-14.81 14.968H178.826c-8.145 0-14.807-6.735-14.807-14.968V381.028c0-8.235 6.662-14.964 14.807-14.964H445.36V178.971c0-29.184 22.956-52.382 51.835-52.382h29.61c28.876 0 51.828 23.199 51.828 52.382v187.093h266.535c8.15 0 14.81 6.729 14.81 14.964v119.741z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<path fill="#4aaff0" d="M512,585 C415,455 330,398 215,395 C185,395 160,415 160,450 L160,770 C160,815 190,838 240,845 C340,856 430,872 512,888 C594,872 684,856 784,845 C834,838 864,815 864,770 L864,450 C864,415 839,395 809,395 C694,398 609,455 512,585 Z"/>
|
||||||
|
<path fill="#4aaff0" d="M218,312 C335,292 462,352 514,462 C452,404 334,354 218,378 Z"/>
|
||||||
|
<circle fill="#4aaff0" cx="218" cy="345" r="33"/>
|
||||||
|
<path fill="#80d976" d="M508,462 C455,310 525,145 700,105 C758,250 668,425 508,462 Z"/>
|
||||||
|
<path fill="none" stroke="#4aaff0" stroke-width="44" stroke-linecap="round" d="M210,935 Q285.5,881 361,935 T512,935 T663,935 T814,935"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 701 B |
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 通知公告本地 Mock 数据(vite-plugin-mock,dev 下 VITE_IS_MOCK=true 生效)。
|
* 通知公告本地 Mock 数据(vite-plugin-mock,dev 下 VITE_IS_MOCK=true 生效)。
|
||||||
*
|
*
|
||||||
* 背景:本地无法访问 sso.yunhaixingtong.com / base.yunhaixingtong.com,
|
* 背景:本地无法访问真实环境的 SSO / 基础平台域名,
|
||||||
* 后端 CAS 过滤器会拦截 /api/* 请求,导致前端无法联调。
|
* 后端 CAS 过滤器会拦截 /api/* 请求,导致前端无法联调。
|
||||||
* 该文件按后端 NoticeApiController / DictController / ContactsController /
|
* 该文件按后端 NoticeApiController / DictController / ContactsController /
|
||||||
* FileController 的真实响应契约({code, msg, data})提供内存数据,
|
* FileController 的真实响应契约({code, msg, data})提供内存数据,
|
||||||
@@ -252,6 +252,33 @@ function fileUrl(id: number) {
|
|||||||
|
|
||||||
/* ============================ Mock 定义 ============================ */
|
/* ============================ Mock 定义 ============================ */
|
||||||
|
|
||||||
|
/* ---------- 我的待审核(flow-center 代理)内存数据(刷新/改本文件即重置为初始 6 条) ---------- */
|
||||||
|
const approvalTodos = [
|
||||||
|
{ taskId: 5001, instanceId: 8001, instanceVersion: 1, bizType: "notice", bizId: "1", bizTitle: "关于开展2026年秋季学期教学检查的通知", bizSnapshot: JSON.stringify({ summary: "定于下周开展秋季学期教学检查,请各教研组提前准备教案、听课记录。" }), nodeId: "n_dept", nodeName: "部门审核", assigneeId: 186532, assigneeName: "朱皓东", arriveTime: "2026-09-23T09:12:00" },
|
||||||
|
{ taskId: 5002, instanceId: 8002, instanceVersion: 1, bizType: "notice", bizId: "2", bizTitle: "校园网络安全培训通知", bizSnapshot: JSON.stringify({ summary: "本周五下午 15:00 在报告厅开展网络安全培训,请全体教职工参加。" }), nodeId: "n_school", nodeName: "校级审核", assigneeId: 186532, assigneeName: "朱皓东", arriveTime: "2026-09-23T10:40:00" },
|
||||||
|
{ taskId: 5003, instanceId: 8003, instanceVersion: 2, bizType: "notice", bizId: "5", bizTitle: "期中考试考务工作安排", bizSnapshot: JSON.stringify({ summary: "期中考试定于第 10 周,请各年级上报监考安排。" }), nodeId: "n_dept", nodeName: "部门审核", assigneeId: 186532, assigneeName: "朱皓东", arriveTime: "2026-09-22T15:05:00" },
|
||||||
|
{ taskId: 5004, instanceId: 8004, instanceVersion: 1, bizType: "notice", bizId: "7", bizTitle: "教师职称评审材料公示", bizSnapshot: JSON.stringify({ summary: "本年度职称评审材料已汇总,公示期 5 个工作日。" }), nodeId: "n_office", nodeName: "办公室复核", assigneeId: 186532, assigneeName: "朱皓东", arriveTime: "2026-09-22T08:30:00" },
|
||||||
|
{ taskId: 5005, instanceId: 8005, instanceVersion: 1, bizType: "notice", bizId: "9", bizTitle: "2027 年寒假放假通知", bizSnapshot: JSON.stringify({ summary: "寒假自 1 月 20 日起,下学期 2 月 15 日报到。" }), nodeId: "n_dept", nodeName: "部门审核", assigneeId: 186532, assigneeName: "朱皓东", arriveTime: "2026-09-21T16:20:00" },
|
||||||
|
{ taskId: 5006, instanceId: 8006, instanceVersion: 1, bizType: "notice", bizId: "12", bizTitle: "校园运动会报名通知", bizSnapshot: JSON.stringify({ summary: "秋季运动会报名截止本周五,请各班级汇总上报。" }), nodeId: "n_school", nodeName: "校级审核", assigneeId: 186532, assigneeName: "朱皓东", arriveTime: "2026-09-21T11:00:00" },
|
||||||
|
];
|
||||||
|
const approvalInstances = [
|
||||||
|
{ instanceId: 8001, status: 10, version: 1, bizType: "notice", bizId: "1", bizTitle: "关于开展2026年秋季学期教学检查的通知", bizUrl: null, submitterName: "刘文娟", submitTime: "2026-09-20T09:00:00", finishTime: null, currentNodes: "部门审核" },
|
||||||
|
{ instanceId: 8002, status: 20, version: 1, bizType: "notice", bizId: "2", bizTitle: "校园网络安全培训通知", bizUrl: null, submitterName: "黄瑶", submitTime: "2026-09-19T10:00:00", finishTime: "2026-09-21T14:00:00", currentNodes: "" },
|
||||||
|
{ instanceId: 8003, status: 30, version: 2, bizType: "notice", bizId: "5", bizTitle: "期中考试考务工作安排", bizUrl: null, submitterName: "张明", submitTime: "2026-09-18T08:30:00", finishTime: "2026-09-19T09:10:00", currentNodes: "" },
|
||||||
|
{ instanceId: 8004, status: 40, version: 1, bizType: "notice", bizId: "7", bizTitle: "教师职称评审材料公示", bizUrl: null, submitterName: "李强", submitTime: "2026-09-17T16:00:00", finishTime: "2026-09-17T16:30:00", currentNodes: "" },
|
||||||
|
{ instanceId: 8005, status: 10, version: 1, bizType: "notice", bizId: "9", bizTitle: "2027 年寒假放假通知", bizUrl: null, submitterName: "王芳", submitTime: "2026-09-16T09:00:00", finishTime: null, currentNodes: "校级审核" },
|
||||||
|
{ instanceId: 8006, status: 20, version: 1, bizType: "notice", bizId: "12", bizTitle: "校园运动会报名通知", bizUrl: null, submitterName: "陈垠", submitTime: "2026-09-15T10:00:00", finishTime: "2026-09-16T11:00:00", currentNodes: "" },
|
||||||
|
];
|
||||||
|
/** 组装 flow-center BasePageResult{total,content,pageIndex,pageSize,totalPages,hasNext} */
|
||||||
|
function approvalPage<T>(all: T[], query: any) {
|
||||||
|
const pageIndex = Number(query?.page || 1);
|
||||||
|
const pageSize = Number(query?.size || 10);
|
||||||
|
const start = (pageIndex - 1) * pageSize;
|
||||||
|
const total = all.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
return { total, content: all.slice(start, start + pageSize), pageIndex, pageSize, totalPages, hasNext: pageIndex < totalPages };
|
||||||
|
}
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
/* ---------- 布局 / 用户 ---------- */
|
/* ---------- 布局 / 用户 ---------- */
|
||||||
{
|
{
|
||||||
@@ -271,11 +298,15 @@ export default [
|
|||||||
method: "get",
|
method: "get",
|
||||||
response: () =>
|
response: () =>
|
||||||
ok([
|
ok([
|
||||||
{ id: 1, modId: 1, name: "已收到", parentId: 0, page: "/", orderNumber: 1, usable: true, children: [] },
|
// id/modId 对齐平台模块管理的「配置 ID」(对照 /base/GetUserMod 现行数据);
|
||||||
{ id: 2, modId: 2, name: "已发送", parentId: 0, page: "/list", orderNumber: 2, usable: true, children: [] },
|
// icon:YschIcons 图标名,layout 渲染为 iconfont icon-<名称>
|
||||||
{ id: 3, modId: 3, name: "全部通知", parentId: 0, page: "/noticelist", orderNumber: 3, usable: true, children: [] },
|
{ id: 1, modId: 1, name: "已收到", parentId: 0, page: "/", icon: "sjx", orderNumber: 1, usable: true, children: [] },
|
||||||
{ id: 4, modId: 4, name: "通知统计", parentId: 0, page: "/stats", orderNumber: 4, usable: true, children: [] },
|
{ id: 2, modId: 2, name: "已发送", parentId: 0, page: "/list", icon: "fasong", orderNumber: 2, usable: true, children: [] },
|
||||||
{ id: 21, modId: 21, name: "应用设置", parentId: 0, page: "/setting", orderNumber: 5, usable: true, children: [] },
|
{ id: 3, modId: 3, name: "全部通知", parentId: 0, page: "/noticelist", icon: "liebiao", orderNumber: 3, usable: true, children: [] },
|
||||||
|
{ id: 89, modId: 89, name: "待审核", parentId: 0, page: "/approval", icon: "shenpi", orderNumber: 4, usable: true, children: [] },
|
||||||
|
{ id: 4, modId: 4, name: "通知统计", parentId: 0, page: "/stats", icon: "tongji", orderNumber: 5, usable: true, children: [] },
|
||||||
|
{ id: 21, modId: 21, name: "应用设置", parentId: 0, page: "/setting", icon: "shezhi", orderNumber: 6, usable: true, children: [] },
|
||||||
|
{ id: 91, modId: 91, name: "抄送给我的", parentId: 0, page: "/cc", icon: "", orderNumber: 7, usable: true, children: [] },
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -721,4 +752,38 @@ export default [
|
|||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* ---------- 我的待审核(NoticeApproval → /api/notice/approval/**) ---------- */
|
||||||
|
{
|
||||||
|
url: "/api/notice/approval/Todo",
|
||||||
|
method: "get",
|
||||||
|
response: ({ query }: any) => ok(approvalPage(approvalTodos, query)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/notice/approval/Instances",
|
||||||
|
method: "get",
|
||||||
|
response: ({ query }: any) => {
|
||||||
|
const status = Number(query?.status || 0);
|
||||||
|
const list = status ? approvalInstances.filter((i) => i.status === status) : approvalInstances;
|
||||||
|
return ok(approvalPage(list, query));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/notice/approval/Approve",
|
||||||
|
method: "post",
|
||||||
|
response: ({ query }: any) => {
|
||||||
|
const idx = approvalTodos.findIndex((t) => t.taskId === Number(query?.taskId));
|
||||||
|
if (idx >= 0) approvalTodos.splice(idx, 1);
|
||||||
|
return ok(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/notice/approval/Reject",
|
||||||
|
method: "post",
|
||||||
|
response: ({ query }: any) => {
|
||||||
|
const idx = approvalTodos.findIndex((t) => t.taskId === Number(query?.taskId));
|
||||||
|
if (idx >= 0) approvalTodos.splice(idx, 1);
|
||||||
|
return ok(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+28
-10
@@ -27,7 +27,7 @@
|
|||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
width: 3px;
|
width: 3px;
|
||||||
background: #5979ea;
|
background: var(--brand, #577afb);
|
||||||
height: 15px;
|
height: 15px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
h4.second-title {
|
h4.second-title {
|
||||||
@extend .base-subtitle;
|
@extend .base-subtitle;
|
||||||
&::before {
|
&::before {
|
||||||
background: #5979ea;
|
background: var(--brand, #577afb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ h4.subtitle {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
li {
|
li {
|
||||||
height: 42px;
|
height: 42px;
|
||||||
border: 1px solid #ebeef5;
|
border: 1px solid var(--line, #e9edf5);
|
||||||
border-top: none;
|
border-top: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -87,8 +87,8 @@ h4.subtitle {
|
|||||||
}
|
}
|
||||||
li.active {
|
li.active {
|
||||||
// background: #f3f3f3;
|
// background: #f3f3f3;
|
||||||
color: #7892ed;
|
color: var(--brand-2, #7b8ffd);
|
||||||
border-right: 3px solid #5979ea;
|
border-right: 3px solid var(--brand, #577afb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,20 +98,38 @@ h4.subtitle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 表格加载(规范 §4 表格加载行):图形=通道 .ysch-spin(随 --brand),本地零图形资产 */
|
||||||
.table-spin-load {
|
.table-spin-load {
|
||||||
|
position: relative;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 90px;
|
height: 90px;
|
||||||
padding-top: 60px;
|
padding-top: 60px;
|
||||||
background-image: url(../image/loading.gif);
|
|
||||||
background-size: 60px 60px;
|
|
||||||
background-position: top center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
border: none;
|
border: none;
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.table-spin-load .ysch-spin {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
margin-left: -30px;
|
||||||
|
font-size: 60px; /* .ysch-spin 尺寸=1em,60px 图形用字号给 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.ivu-table-cell-tree {
|
.ivu-table-cell-tree {
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 修复表格幻影省略号(2026-10-10):.ivu-table-cell 自带 overflow:hidden + text-overflow:ellipsis,
|
||||||
|
tooltip 列的包装层(.ivu-tooltip 行内块 + width:100%)未溢出也会被画出「...」;
|
||||||
|
恢复组件预期的 flex 层级即可,内层 .ivu-table-cell-tooltip-content 的真省略号与悬浮 tooltip 不受影响。
|
||||||
|
注意:不要再给操作列的裸 .ivu-tooltip 加 display:block——那会以更高特异性顶掉 App.vue 的横排规则,
|
||||||
|
使 operatingHandle 渲染的图标按钮竖排(2026-10-10 踩过,已回退)。 */
|
||||||
|
/* tooltip 列的包装层同时挂 .ivu-tooltip{display:inline-block},靠声明顺序顶掉了组件预期的 flex,这里恢复 */
|
||||||
|
.ivu-table .ivu-table-cell > .ivu-table-cell-tooltip {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
color: $color-warning !important;
|
color: $color-warning !important;
|
||||||
}
|
}
|
||||||
.text-primary {
|
.text-primary {
|
||||||
color: $color-primary !important;
|
color: var(--brand, #577afb) !important;
|
||||||
}
|
}
|
||||||
.text-normal {
|
.text-normal {
|
||||||
color: #404042 !important;
|
color: #404042 !important;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/*scss变量*/
|
/*scss变量*/
|
||||||
|
|
||||||
// 主题色
|
// 主题色(品牌主色不在此定义:由平台主题令牌 var(--brand) 经中央静态通道下发,
|
||||||
|
// 本文件只留业务语义色——成功/警告/错误不随主题档位变化)
|
||||||
$color-error: #e8684a;
|
$color-error: #e8684a;
|
||||||
$color-success: #45b972;
|
$color-success: #45b972;
|
||||||
$color-warning: #e9b954;
|
$color-warning: #e9b954;
|
||||||
$color-primary: #5979ea;
|
|
||||||
|
|
||||||
// 文字色
|
// 文字色
|
||||||
$color-black: #404042;
|
$color-black: #404042;
|
||||||
|
|||||||
@@ -92,16 +92,18 @@ export default {
|
|||||||
display: block;
|
display: block;
|
||||||
.upload-row {
|
.upload-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start; /* 提示语与按钮首行对齐(列表换行后不再垂直居中整块) */
|
||||||
}
|
}
|
||||||
:deep(.ivu-upload) {
|
:deep(.ivu-upload) {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap; /* 允许内部列表换行 */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
/* 文件列表整体另起一行,每项独占一行,不再与按钮挤在一起 */
|
/* 文件列表整体另起一行,每项独占一行,不再与按钮挤在一起 */
|
||||||
:deep(.ivu-upload-list) {
|
:deep(.ivu-upload-list) {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 8px;
|
width: 100%; /* 文件列表整体独占下一行 */
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
:deep(.ivu-upload-list-file) {
|
:deep(.ivu-upload-list-file) {
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
@@ -110,7 +112,7 @@ export default {
|
|||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
color: #f90;
|
color: #f90;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1;
|
line-height: 32px; /* 与默认 Button 等高,紧贴按钮右侧 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ defineExpose({
|
|||||||
no-data-text="暂无数据"
|
no-data-text="暂无数据"
|
||||||
>
|
>
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<div class="table-spin-load">加载中</div>
|
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
<div class="page-panel">
|
<div class="page-panel">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<div class="table-spin-load">加载中</div>
|
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="table-region" ref="regionRef">
|
||||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<div class="table-spin-load">加载中</div>
|
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
||||||
<slot :name="slotName" v-bind="slotProps" />
|
<slot :name="slotName" v-bind="slotProps" />
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="page-panel">
|
<div class="page-panel">
|
||||||
<div class="fl ml-15 align-middle">
|
<div class="fl ml-15 align-middle">
|
||||||
@@ -22,7 +24,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, useSlots } from 'vue'
|
import { computed, ref, useSlots } from 'vue'
|
||||||
import Utils from '@/utils'
|
import { useElementHeight } from '@/utils/hook/useElementHeight'
|
||||||
import { OperatorEnum } from '@/types'
|
import { OperatorEnum } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -44,7 +46,10 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
|
|
||||||
const tableH = ref(props.tableHeight ?? Utils.useTableHeight())
|
const regionRef = ref<HTMLElement | null>(null)
|
||||||
|
const measuredH = useElementHeight(regionRef)
|
||||||
|
// 显式传 tableHeight 则尊重之;否则实测 .table-region(flex:1)高度喂固定表头,首帧未测出前用 undefined(auto)
|
||||||
|
const tableH = computed(() => props.tableHeight ?? (measuredH.value || undefined))
|
||||||
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
|
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
|
||||||
|
|
||||||
const selectList = ref<number[]>([])
|
const selectList = ref<number[]>([])
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/* 管理端配色切换器(yh-theme 定稿 07-seam-pure 顶栏「配色」胶囊,方案 §6.5)。
|
||||||
|
* 状态唯一来源:utils/theme(localStorage 持久化 + <html> 档位类名),与门户皮肤包正交。 */
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
import {
|
||||||
|
loadThemeState,
|
||||||
|
saveThemeState,
|
||||||
|
applyThemeState,
|
||||||
|
type ThemeName,
|
||||||
|
type ThemeState,
|
||||||
|
} from "@/utils/theme";
|
||||||
|
|
||||||
|
const THEME_META: { name: ThemeName; label: string; color: string }[] = [
|
||||||
|
{ name: "blue", label: "原版靛蓝", color: "#577afb" },
|
||||||
|
{ name: "ocean", label: "云海蓝", color: "#2f7ce0" },
|
||||||
|
{ name: "ink", label: "石墨墨", color: "#3d4d63" },
|
||||||
|
{ name: "violet", label: "靛紫", color: "#6d5ae0" },
|
||||||
|
{ name: "teal", label: "松石绿", color: "#0d9488" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const wrapEl = ref<HTMLElement | null>(null);
|
||||||
|
const open = ref(false);
|
||||||
|
const state = ref<ThemeState>(loadThemeState());
|
||||||
|
/* 挂载即把存储态落到 <html>(与 main.ts initTheme 同源幂等;组件晚于引导挂载时也自洽) */
|
||||||
|
applyThemeState(state.value);
|
||||||
|
|
||||||
|
function commit(patch: Partial<ThemeState>) {
|
||||||
|
state.value = { ...state.value, ...patch };
|
||||||
|
applyThemeState(state.value);
|
||||||
|
saveThemeState(state.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(ev: MouseEvent) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
open.value = !open.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换器外任意点击即收起(弹层内部点击由 wrap 判定放过) */
|
||||||
|
function onDocClick(ev: MouseEvent) {
|
||||||
|
if (open.value && wrapEl.value && !wrapEl.value.contains(ev.target as Node)) {
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener("click", onDocClick, true));
|
||||||
|
onBeforeUnmount(() => document.removeEventListener("click", onDocClick, true));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="wrapEl" class="theme-wrap">
|
||||||
|
<button type="button" class="theme-btn" :class="{ on: open }" @click="toggle">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 3a9 9 0 1 0 0 18c1.2 0 1.9-.9 1.9-1.8 0-.6-.3-1-.5-1.4-.2-.4-.4-.8-.4-1.3 0-.9.8-1.7 1.8-1.7H17a4 4 0 0 0 4-4c0-4.3-4-7.8-9-7.8z"/>
|
||||||
|
<circle cx="7.6" cy="11.2" r="1.1"/><circle cx="11" cy="7.6" r="1.1"/><circle cx="15.4" cy="8.6" r="1.1"/>
|
||||||
|
</svg>
|
||||||
|
<span>配色</span>
|
||||||
|
<svg class="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M5.5 9.5l6.5 6.5 6.5-6.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="theme-pop" :class="{ open }">
|
||||||
|
<p class="tp-head">主题色</p>
|
||||||
|
<div class="tp-grid">
|
||||||
|
<button
|
||||||
|
v-for="t in THEME_META"
|
||||||
|
:key="t.name"
|
||||||
|
type="button"
|
||||||
|
class="theme-opt"
|
||||||
|
:class="{ active: state.theme === t.name }"
|
||||||
|
@click="commit({ theme: t.name })"
|
||||||
|
>
|
||||||
|
<i :style="{ background: t.color }"></i>{{ t.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tp-sep"></div>
|
||||||
|
<p class="tp-head">侧栏样式</p>
|
||||||
|
<label class="tp-row">
|
||||||
|
<span>侧栏跟随顶栏色</span>
|
||||||
|
<span class="sw">
|
||||||
|
<input type="checkbox" :checked="state.sideFollow" @change="commit({ sideFollow: ($event.target as HTMLInputElement).checked })" />
|
||||||
|
<i></i>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="tp-row" style="margin-top: 9px">
|
||||||
|
<span>侧栏深色(关=浅色)</span>
|
||||||
|
<span class="sw">
|
||||||
|
<input type="checkbox" :checked="state.sideDeep" @change="commit({ sideDeep: ($event.target as HTMLInputElement).checked })" />
|
||||||
|
<i></i>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="tp-sep"></div>
|
||||||
|
<label class="tp-row">
|
||||||
|
<span>顶部过渡色(渐变)</span>
|
||||||
|
<span class="sw">
|
||||||
|
<input type="checkbox" :checked="state.gradOn" @change="commit({ gradOn: ($event.target as HTMLInputElement).checked })" />
|
||||||
|
<i></i>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 皮肤规格照搬定稿 07-seam-pure.html .theme-wrap/.theme-btn/.theme-pop 段 */
|
||||||
|
.theme-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.theme-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.34);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12.5px;
|
||||||
|
transition: background 0.16s, border-color 0.16s;
|
||||||
|
}
|
||||||
|
.theme-btn:hover,
|
||||||
|
.theme-btn.on {
|
||||||
|
background: rgba(255, 255, 255, 0.26);
|
||||||
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
.theme-btn svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
display: block;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.theme-btn svg circle {
|
||||||
|
fill: currentColor;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
.theme-btn .caret {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
opacity: 0.85;
|
||||||
|
transition: transform 0.18s;
|
||||||
|
}
|
||||||
|
.theme-btn.on .caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.theme-pop {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
width: 224px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text, #1e2635);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--line, #e9edf5);
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: 0 12px 34px -10px rgba(16, 24, 40, 0.28), 0 2px 6px rgba(16, 24, 40, 0.08);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
transition: 0.18s;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
.theme-pop.open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.tp-head {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin: 0 2px 8px;
|
||||||
|
}
|
||||||
|
.tp-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.theme-opt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid var(--line, #e9edf5);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-2, #5a6478);
|
||||||
|
font-size: 12px;
|
||||||
|
transition: 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.theme-opt:hover {
|
||||||
|
border-color: #cfd7e6;
|
||||||
|
background: #fafbfe;
|
||||||
|
}
|
||||||
|
.theme-opt i {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
.theme-opt.active {
|
||||||
|
border-color: var(--brand, #577afb);
|
||||||
|
color: var(--brand, #577afb);
|
||||||
|
background: var(--brand-soft, #eef2ff);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tp-sep {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--line, #e9edf5);
|
||||||
|
margin: 12px 0 10px;
|
||||||
|
}
|
||||||
|
.tp-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-2, #5a6478);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
.sw {
|
||||||
|
position: relative;
|
||||||
|
width: 38px;
|
||||||
|
height: 21px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.sw input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.sw i {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #d7dce6;
|
||||||
|
transition: background 0.2s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.sw i::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2.5px;
|
||||||
|
left: 2.5px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.28);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.sw input:checked + i {
|
||||||
|
background: var(--brand, #577afb);
|
||||||
|
}
|
||||||
|
.sw input:checked + i::after {
|
||||||
|
transform: translateX(17px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/* 默认头像三态组件(YschTheme 公共源,scripts/sync-apps.mjs 下发三端 src/components/UserAvatar.vue):
|
||||||
|
头像 URL 非空 → <img>;否则 style==="text" 且能取到字 → 首字;否则 → 剪影(纯 CSS/SVG,零资源文件)。
|
||||||
|
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
|
||||||
|
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
|
||||||
|
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 头像 URL:非空直接 <img>(三态第一优先) */
|
||||||
|
src?: string | null;
|
||||||
|
/** 取字来源(首字态用):经 pickAvatarText 判定,取不到字回落剪影 */
|
||||||
|
name?: string | null;
|
||||||
|
/** 直径 px;font-size = round(size * 0.44) */
|
||||||
|
size?: number;
|
||||||
|
/** 配色场景:light=页面浅底(品牌实底+白内容);brand=header 品牌色底(剪影 S1 玻璃底 / 首字 S2 白底反色,§10.3 分档) */
|
||||||
|
placement?: "light" | "brand";
|
||||||
|
}>(),
|
||||||
|
{ src: null, name: null, size: 28, placement: "light" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
|
||||||
|
/* 外观偏好异步补齐(yh-theme-init 在挂载后拉服务端真源收敛 localStorage 键)会在
|
||||||
|
document 上派发 yh-appearance-updated;tick 自增让 style 依赖失效重读键,三态即时切换(无需刷新)。 */
|
||||||
|
const appearanceTick = ref(0);
|
||||||
|
const onAppearanceUpdated = () => { appearanceTick.value += 1; };
|
||||||
|
onMounted(() => document.addEventListener("yh-appearance-updated", onAppearanceUpdated));
|
||||||
|
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", onAppearanceUpdated));
|
||||||
|
const style = computed(() => {
|
||||||
|
appearanceTick.value;
|
||||||
|
return resolveAvatarStyle();
|
||||||
|
});
|
||||||
|
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
|
||||||
|
const showText = computed(() => style.value === "text" && !!text.value);
|
||||||
|
|
||||||
|
const rootClass = computed(() => [
|
||||||
|
"yh-avatar",
|
||||||
|
props.placement === "brand" ? "yh-avatar--brand" : "yh-avatar--light",
|
||||||
|
{ "is-text": showText.value },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rootStyle = computed(() => ({
|
||||||
|
width: `${props.size}px`,
|
||||||
|
height: `${props.size}px`,
|
||||||
|
fontSize: `${Math.round(props.size * 0.44)}px`,
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="rootClass" :style="rootStyle">
|
||||||
|
<img v-if="src" class="yh-avatar__img" :src="src" alt="" />
|
||||||
|
<template v-else-if="showText">{{ text }}</template>
|
||||||
|
<svg v-else class="yh-avatar__sil" viewBox="0 0 1024 1024" aria-hidden="true">
|
||||||
|
<circle cx="512" cy="399" r="174" />
|
||||||
|
<ellipse cx="512" cy="946" rx="420" ry="240" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.yh-avatar {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.yh-avatar__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* 剪影 SVG:容器圆 overflow:hidden 裁切肩部溢出(禁用 clipPath);overflow:visible 放行 viewBox 外形状 */
|
||||||
|
.yh-avatar__sil {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
/* 页面内(浅底)——品牌实底 + 白内容 */
|
||||||
|
.yh-avatar--light {
|
||||||
|
background: var(--brand, #577afb);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
/* header(品牌色底)——与页面内反向:剪影档 = S1 半透明白,首字档 = S2 纯白底 + 品牌色字 */
|
||||||
|
.yh-avatar--brand {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.yh-avatar--brand.is-text {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--brand, #577afb);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flow-card-region">
|
||||||
|
<!-- 不可达:本地状态照常展示,动作明确提示稍后重试(绝不伪装成功,也不静默变只读) -->
|
||||||
|
<Alert v-if="unavailable" type="warning" show-icon class="flow-alert">
|
||||||
|
流程中心暂时不可达,审批动作稍后重试。
|
||||||
|
<a class="operating-link" @click="load">重新加载</a>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<!-- 首次加载:data 还没回来时不得渲染成"尚未进入流程"(空态与未加载是两回事,误报会误导审批人) -->
|
||||||
|
<div v-else-if="loading" class="flow-muted">审批状态加载中…</div>
|
||||||
|
|
||||||
|
<!-- NO_INSTANCE:未进入流程 -->
|
||||||
|
<div v-else-if="state === 'NO_INSTANCE'" class="flow-muted">该业务尚未进入审批流程。</div>
|
||||||
|
|
||||||
|
<div v-else class="flow-body">
|
||||||
|
<div class="flow-head">
|
||||||
|
<Tag :color="statusTag">{{ statusText }}</Tag>
|
||||||
|
<span v-if="currentNodeText" class="flow-nodes">当前节点:{{ currentNodeText }}</span>
|
||||||
|
<span v-if="lastTraceText" class="flow-trace">最近轨迹:{{ lastTraceText }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ONE_TASK:当前人可办理(readonly 视角即使有待办也不给动作) -->
|
||||||
|
<div v-if="state === 'ONE_TASK' && !readonly" class="flow-actions">
|
||||||
|
<Button size="small" type="primary" :loading="acting" @click="openApprove">通过</Button>
|
||||||
|
<Button size="small" :loading="acting" @click="openReject">驳回</Button>
|
||||||
|
<Button size="small" @click="emitTask">完整办理</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MULTI_TASK:不猜选,引导逐条办理 -->
|
||||||
|
<div v-else-if="state === 'MULTI_TASK' && !readonly" class="flow-multi">
|
||||||
|
<span>你在本单据上有 <b>{{ tasks.length }}</b> 个待办,请逐条处理。</span>
|
||||||
|
<Button size="small" @click="emitTask">逐条办理</Button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="state === 'MULTI_TASK'" class="flow-multi">
|
||||||
|
<span>本单据有多个待办。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NO_TASK / TERMINAL:无动作区,只保留下方的"查看审批记录" -->
|
||||||
|
|
||||||
|
<div class="flow-links">
|
||||||
|
<a class="operating-link" @click="$emit('open-record', bizId)">查看审批记录</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 通过:意见可选;多出口必须先选出口,不选不给提交 -->
|
||||||
|
<Modal v-model="approveVisible" title="通过审核" :loading="acting" @on-ok="doApprove" @on-cancel="reset">
|
||||||
|
<div class="flow-modal-row" v-if="chosenOutlets.length > 1">
|
||||||
|
<span class="flow-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="flow-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="flow-modal-tip">驳回「<b>{{ title || bizId }}</b>」,必须填写意见:</p>
|
||||||
|
<Input v-model="opinion" type="textarea" :rows="3" placeholder="驳回意见(必填)" />
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 业务详情审批卡片(方案 §4.3)。数据来源只有一个:`api.byBiz(bizId)`——
|
||||||
|
* 与列表共用同一个 taskId 口径,杜绝"卡片和列表算出两个待办"。
|
||||||
|
*
|
||||||
|
* 卡片只管**当前人**的动作:
|
||||||
|
* - 非待办人 / readonly / 终态 → 只读状态 + 查看记录,不出通过/驳回;
|
||||||
|
* - tasks>1(会签/并行分支)→ 不猜选,引导逐条办理;
|
||||||
|
* - outlets>1 → 通过弹窗必须选出口,未选不提交;
|
||||||
|
* - 动作成功后只刷新自己与通知父页,业务状态由 flow-center 回调推进,卡片不本地写状态。
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
/** 外部已有进度时可传入,卡片不重复拉轨迹 */
|
||||||
|
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", taskId: number): 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";
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 人读当前节点:优先本人待办的节点名(by-biz 给的是快照名),退回 trace 摘要 */
|
||||||
|
const currentNodeText = computed(() => tasks.value[0]?.nodeName || props.trace?.nodeSummary || "");
|
||||||
|
|
||||||
|
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() {
|
||||||
|
if (state.value !== "ONE_TASK") return;
|
||||||
|
targetTask.value = tasks.value[0];
|
||||||
|
opinion.value = "";
|
||||||
|
chosenEdge.value = "";
|
||||||
|
approveVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReject() {
|
||||||
|
if (state.value !== "ONE_TASK") return;
|
||||||
|
targetTask.value = tasks.value[0];
|
||||||
|
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 {
|
||||||
|
await props.api.approve(task.taskId, opinion.value.trim() || undefined,
|
||||||
|
task.outlets.length > 1 ? chosenEdge.value : undefined);
|
||||||
|
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());
|
||||||
|
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() {
|
||||||
|
const task = tasks.value[0];
|
||||||
|
if (!task) return;
|
||||||
|
emit("open-task", task.taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
opinion.value = "";
|
||||||
|
chosenEdge.value = "";
|
||||||
|
targetTask.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.bizId, load);
|
||||||
|
onMounted(load);
|
||||||
|
defineExpose({ load, state });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flow-card-region {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.flow-alert {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.flow-muted {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.flow-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.flow-nodes,
|
||||||
|
.flow-trace {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.flow-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.flow-multi {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.flow-links {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.flow-modal-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.flow-modal-label {
|
||||||
|
width: 90px;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.flow-modal-tip {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
<template>
|
||||||
|
<div class="yh-fill">
|
||||||
|
<div class="main-container yh-tabs-card">
|
||||||
|
<Tabs class="tabs-panel" :model-value="innerTab" @on-click="switchTab">
|
||||||
|
<TabPane v-for="t in tabs" :key="t" :label="tabLabel(t)" :name="t" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<!-- 待办 -->
|
||||||
|
<div class="tab-body" v-if="innerTab === 'todo'">
|
||||||
|
<div class="table-region" ref="todoRegionRef">
|
||||||
|
<Table :columns="todoCols" :data="todoRows" :height="todoH || undefined" :loading="loading"
|
||||||
|
no-data-text="暂无待审核通知">
|
||||||
|
<template #action="{ row }">
|
||||||
|
<span class="operating-link" @click="$emit('open-record', row.bizId)">审批记录</span>
|
||||||
|
<span class="operating-link" @click="openApprove(row)">通过</span>
|
||||||
|
<span class="operating-link text-error" @click="openReject(row)">驳回</span>
|
||||||
|
<span class="operating-link" @click="openTask(row)">完整办理</span>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div class="page-panel" v-if="todoPage.total > todoPage.pageSize">
|
||||||
|
<Page class="page-box fr" :current="todoPage.pageIndex" :page-size="todoPage.pageSize"
|
||||||
|
:total="todoPage.total" show-total @on-change="loadTodo" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 办件视角 -->
|
||||||
|
<div class="tab-body" v-else>
|
||||||
|
<div class="filter-bar">
|
||||||
|
<RadioGroup :model-value="instStatus" type="button" @on-change="onStatusChange">
|
||||||
|
<Radio :label="0">全部</Radio>
|
||||||
|
<Radio :label="10">审批中</Radio>
|
||||||
|
<Radio :label="20">已完成</Radio>
|
||||||
|
<Radio :label="30">已拒绝</Radio>
|
||||||
|
<Radio :label="40">已撤回</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
<div class="table-region" ref="instRegionRef">
|
||||||
|
<Table :columns="instCols" :data="instRows" :height="instH || undefined" :loading="loading"
|
||||||
|
no-data-text="暂无办件">
|
||||||
|
<template #status="{ row }">
|
||||||
|
<Tag :color="statusTag(row.status)">{{ statusText(row.status) }}</Tag>
|
||||||
|
</template>
|
||||||
|
<template #instAction="{ row }">
|
||||||
|
<Button size="small" @click="$emit('open-record', row.bizId)">审批记录</Button>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div class="page-panel" v-if="instPage.total > instPage.pageSize">
|
||||||
|
<Page class="page-box fr" :current="instPage.pageIndex" :page-size="instPage.pageSize"
|
||||||
|
:total="instPage.total" show-total @on-change="loadInstances" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 通过:意见可选;多出口必须先选出口 -->
|
||||||
|
<Modal v-model="approveVisible" title="通过审核" :loading="acting" @on-ok="doApprove" @on-cancel="reset">
|
||||||
|
<div class="modal-row" v-if="chosenOutlets.length > 1">
|
||||||
|
<span class="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="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="modal-tip">驳回「<b>{{ targetTitle }}</b>」,必须填写意见:</p>
|
||||||
|
<Input v-model="opinion" type="textarea" :rows="3" placeholder="驳回意见(必填)" />
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 独立审批列表(方案 §4.2)。两个页签共用本组件与同一套动作,数据面全部走注入的 `api`,
|
||||||
|
* 组件不认得 Notice 也不认得 flow-center。
|
||||||
|
*
|
||||||
|
* 三条硬纪律:
|
||||||
|
* 1. 动作只认 `taskId`(行数据里带),不按行号/标题猜;
|
||||||
|
* 2. 列表的 todo 出参**没有 outlets**(那是 by-biz 才给的),所以点"通过"时现拉一次
|
||||||
|
* `byBiz(bizId)` 定位本行 taskId:既拿到出口清单,也顺带确认"这条仍然是我的待办"——
|
||||||
|
* 已被他人办理/已失效的行会在这里被拦下并刷新列表,而不是发一个必然失败的请求;
|
||||||
|
* 3. 多出口未选不得提交;驳回意见必填。
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||||
|
import type { ByBizTask, FlowApprovalApi, FlowPage, InstanceItem, TodoTask } from "./types";
|
||||||
|
import { INSTANCE_STATUS_TAG, INSTANCE_STATUS_TEXT } from "./types";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
api: FlowApprovalApi;
|
||||||
|
bizType: string;
|
||||||
|
tabs?: Array<"todo" | "instances">;
|
||||||
|
/** 保留位:多业务共页时由调用方渲染筛选(本期 Notice 单一 bizType,不用) */
|
||||||
|
showAppFilter?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ tabs: () => ["todo", "instances"], showAppFilter: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const innerTab = ref<"todo" | "instances">(props.tabs[0] || "todo");
|
||||||
|
const loading = ref(false);
|
||||||
|
const acting = ref(false);
|
||||||
|
|
||||||
|
const todoRegionRef = ref<HTMLElement | null>(null);
|
||||||
|
const instRegionRef = ref<HTMLElement | null>(null);
|
||||||
|
const todoH = useElementHeight(todoRegionRef);
|
||||||
|
const instH = useElementHeight(instRegionRef);
|
||||||
|
|
||||||
|
const todoRows = ref<Array<TodoTask & { _summary: string }>>([]);
|
||||||
|
const todoPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
|
||||||
|
const instRows = ref<InstanceItem[]>([]);
|
||||||
|
const instPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
|
||||||
|
const instStatus = ref(0);
|
||||||
|
|
||||||
|
const approveVisible = ref(false);
|
||||||
|
const rejectVisible = ref(false);
|
||||||
|
const opinion = ref("");
|
||||||
|
const chosenEdge = ref("");
|
||||||
|
const targetTask = ref<ByBizTask | null>(null);
|
||||||
|
const targetTitle = ref("");
|
||||||
|
|
||||||
|
const chosenOutlets = computed(() => targetTask.value?.outlets || []);
|
||||||
|
|
||||||
|
const TAB_LABELS: Record<string, string> = { todo: "我的待审核", instances: "办件视角" };
|
||||||
|
function tabLabel(t: string) {
|
||||||
|
return TAB_LABELS[t] || t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoCols = [
|
||||||
|
{ title: "通知", key: "bizTitle", minWidth: 220, ellipsis: true, tooltip: true },
|
||||||
|
{ title: "摘要", key: "_summary", minWidth: 180, ellipsis: true, tooltip: true },
|
||||||
|
{ title: "当前节点", key: "nodeName", width: 120 },
|
||||||
|
{ title: "到达时间", key: "arriveTime", width: 170, render: fmtTime },
|
||||||
|
{ title: "操作", slot: "action", width: 250, align: "center" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const instCols = [
|
||||||
|
{ title: "通知", key: "bizTitle", minWidth: 240, ellipsis: true, tooltip: true },
|
||||||
|
{ title: "状态", slot: "status", minWidth: 100 },
|
||||||
|
{ title: "提交人", key: "submitterName", minWidth: 100 },
|
||||||
|
{ title: "提交时间", key: "submitTime", minWidth: 170, render: fmtTime },
|
||||||
|
{ title: "办结时间", key: "finishTime", minWidth: 170, render: fmtTime },
|
||||||
|
{ title: "操作", slot: "instAction", width: 150, 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" | "instances";
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStatusChange(val: any) {
|
||||||
|
instStatus.value = Number(val);
|
||||||
|
loadInstances(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, pageIndex: res.pageIndex, pageSize: res.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 loadInstances(page = 1) {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res: FlowPage<InstanceItem> = await props.api.instances({
|
||||||
|
status: instStatus.value || undefined,
|
||||||
|
page,
|
||||||
|
size: instPage.value.pageSize,
|
||||||
|
});
|
||||||
|
instPage.value = { total: res.total, pageIndex: res.pageIndex, pageSize: res.pageSize };
|
||||||
|
instRows.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 loadInstances(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;
|
||||||
|
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;
|
||||||
|
opinion.value = "";
|
||||||
|
rejectVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTask(row: TodoTask) {
|
||||||
|
emit("open-task", { bizId: row.bizId, taskId: row.taskId });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
await props.api.approve(task.taskId, opinion.value.trim() || undefined,
|
||||||
|
task.outlets.length > 1 ? chosenEdge.value : undefined);
|
||||||
|
Message.success("已通过");
|
||||||
|
approveVisible.value = false;
|
||||||
|
emit("handled", { taskId: task.taskId, action: "approve" });
|
||||||
|
loadTodo(todoPage.value.pageIndex);
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.msg || e?.message || "操作失败");
|
||||||
|
approveVisible.value = false;
|
||||||
|
loadTodo(todoPage.value.pageIndex);
|
||||||
|
} 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());
|
||||||
|
Message.success("已驳回");
|
||||||
|
rejectVisible.value = false;
|
||||||
|
emit("handled", { taskId: task.taskId, action: "reject" });
|
||||||
|
loadTodo(todoPage.value.pageIndex);
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.msg || e?.message || "操作失败");
|
||||||
|
rejectVisible.value = false;
|
||||||
|
} finally {
|
||||||
|
acting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
opinion.value = "";
|
||||||
|
chosenEdge.value = "";
|
||||||
|
targetTask.value = null;
|
||||||
|
targetTitle.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
defineExpose({ load, loadTodo, loadInstances });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-bar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.modal-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.modal-label {
|
||||||
|
width: 90px;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.modal-tip {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<Drawer :model-value="modelValue" :title="title" :width="width" :transfer="false"
|
||||||
|
:styles="{ body: { padding: '0' } }" @update:model-value="close">
|
||||||
|
<div class="flow-embed-region">
|
||||||
|
<div v-if="error" class="flow-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="flow-embed-frame" />
|
||||||
|
<div v-else class="flow-embed-loading">{{ loading ? "加载中…" : "" }}</div>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 审批记录抽屉(方案 §4.4):iframe 嵌 flow-center 的 **token 只读页**。
|
||||||
|
*
|
||||||
|
* 三条纪律写死在组件里,不给调用方犯错的机会:
|
||||||
|
* 1. pageUrl **每次打开现取**(token 10 分钟短时效),关闭即清空——不缓存、不进 storage;
|
||||||
|
* 2. 只读:组件内不出现通过/驳回,也不接收这类回调;
|
||||||
|
* 3. 换址失败(未进入流程 / 无权限 / 过期)原文提示 + 重试,不白屏。
|
||||||
|
*/
|
||||||
|
import { 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;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const pageUrl = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
pageUrl.value = "";
|
||||||
|
try {
|
||||||
|
const res: any = await props.api.recordUrl(props.bizId);
|
||||||
|
const url = res?.pageUrl || "";
|
||||||
|
if (!url) {
|
||||||
|
error.value = "审批记录地址获取失败,请稍后重试";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pageUrl.value = url;
|
||||||
|
} catch (e: any) {
|
||||||
|
// 后端信封 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 = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function close(v: boolean) {
|
||||||
|
emit("update:modelValue", v);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flow-embed-region {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.flow-embed-frame {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.flow-embed-loading {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.flow-embed-error {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<Modal :model-value="modelValue" :title="title" :width="width" footer-hide transfer
|
||||||
|
@update:model-value="close">
|
||||||
|
<div class="flow-task-wrap">
|
||||||
|
<!-- CAS 会话页被 X-Frame-Options/CSP 挡住时 iframe 只会留白:给出显式逃生口 -->
|
||||||
|
<!-- 新窗口用的是后端原样地址(完整窗口形态,不该带 embed 壳标记) -->
|
||||||
|
<div class="flow-task-fallback">
|
||||||
|
<span class="flow-task-tip">打不开办理面板?</span>
|
||||||
|
<a class="operating-link" :href="pageUrl" target="_blank" rel="noopener">在新窗口打开任务页</a>
|
||||||
|
</div>
|
||||||
|
<iframe v-if="embedSrc" ref="frameEl" :src="embedSrc" :title="title" class="flow-task-frame"
|
||||||
|
:style="frameStyle" />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 去壳任务办理页容器(方案 §4.5):iframe 嵌 flow-center `/task/{taskId}?embed=1`。
|
||||||
|
*
|
||||||
|
* 该页是 **CAS 写面**(不是 token 页),办理权由任务页自己的用户面接口按 assigneeId 复核;
|
||||||
|
* 本组件只负责三件事——收 `handled` 通知父页刷新、转 `open-biz` 让父页开业务详情、按 `height` 自适应。
|
||||||
|
*
|
||||||
|
* origin 双向校验:子页 targetOrigin 取 document.referrer 的 origin;父页这里把**期望 origin
|
||||||
|
* 直接算自 pageUrl**(不是通配),并且再核对消息确实来自本 iframe。跨端口 dev(8077↔8088)
|
||||||
|
* 靠的是两端 origin 都在 pageUrl 里显式出现,而不是放宽成 `*`。
|
||||||
|
*/
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
import { isFlowEmbedMessage } from "./types";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
pageUrl: string;
|
||||||
|
taskId?: number | string;
|
||||||
|
title?: string;
|
||||||
|
width?: number;
|
||||||
|
}>(),
|
||||||
|
{ title: "完整办理", width: 860, taskId: "" }
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端 TaskPageUrl 给的是**完整窗口形态**的 `#/task/{id}`(svc-hall 等"新窗口打开"场景要用它)。
|
||||||
|
* 内嵌时"去壳"是嵌入方的诉求,所以由本组件补 `embed=1`——hash 路由下 query 必须落在 `#` 之后,
|
||||||
|
* 写成 `?embed=1#/task/1` 是进不去的(vue-router 读的是 hash 段)。
|
||||||
|
*/
|
||||||
|
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 (!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") {
|
||||||
|
emit("handled", { taskId: String(ev.data.taskId ?? props.taskId), action: ev.data.action });
|
||||||
|
} else if (ev.data.type === "open-biz") {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 父→子可选指令:任务页目前不消费 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>
|
||||||
|
.flow-task-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.flow-task-fallback {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.flow-task-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.flow-task-frame {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 320px;
|
||||||
|
height: 70vh;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# flow-approval 共享组件(v2.1 应用内审批)
|
||||||
|
|
||||||
|
子应用侧的审批 UI 组件族。**组件不直连 flow-center**,一律通过注入的 `api`(`FlowApprovalApi`)
|
||||||
|
打本应用自己的后端代理,由本应用后端持 app 签名调 `/oapi/flow/**`。
|
||||||
|
|
||||||
|
对应方案:`flow-center/docs/ysch-flow应用内审批-列表与卡片-实施方案v2.1-20261019.md` §4/§5.4/§7。
|
||||||
|
组件版本:**v2.1-Notice1** | 最后同步:2026-10-19。
|
||||||
|
|
||||||
|
## 文件
|
||||||
|
|
||||||
|
| 文件 | 职责 | 可写 |
|
||||||
|
|---|---|---|
|
||||||
|
| `types.ts` | `FlowApprovalApi` 契约 + `TodoTask/InstanceItem/ByBizResult/TraceVo` + postMessage 信封 | - |
|
||||||
|
| `FlowApprovalList.vue` | 独立待办/办件列表(页签、分页、行内动作、出口选择) | 是 |
|
||||||
|
| `FlowApprovalCard.vue` | 业务详情卡片(0/1/N 待办、终态、只读) | 是(当前人待办时) |
|
||||||
|
| `FlowRecordDrawer.vue` | 审批记录抽屉(token 只读页 iframe) | 否 |
|
||||||
|
| `FlowTaskEmbed.vue` | 完整办理任务页 iframe/modal(CAS 去壳页) | 是 |
|
||||||
|
|
||||||
|
## 用法
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import FlowApprovalList from "@/components/flow-approval/FlowApprovalList.vue";
|
||||||
|
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 myFlowApi from "@/api/module/approvalAdapter"; // 你的 FlowApprovalApi 实现
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FlowApprovalList :api="myFlowApi" biz-type="notice" :tabs="['todo', 'instances']"
|
||||||
|
@open-record="openRecord" @open-task="openTask" @handled="reload" />
|
||||||
|
<FlowRecordDrawer v-model="recordVisible" :api="myFlowApi" :biz-id="recordBizId" />
|
||||||
|
<FlowTaskEmbed v-model="taskVisible" :page-url="taskUrl" @handled="reload" @open-biz="goBiz" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
`FlowApprovalApi` 的返回值可以是 alova `Method`,也可以是 Promise——组件只 `await`。
|
||||||
|
|
||||||
|
## 必须遵守的契约(改代码前先读)
|
||||||
|
|
||||||
|
1. **身份不从前端来**。`api` 的入参只允许 `bizId / taskId / opinion / edgeName / 分页`。
|
||||||
|
`operatorId / schId / name` 一律由子应用后端从 CAS 会话取;组件与适配器都不带这些字段。
|
||||||
|
2. **token 记录页只读**。`FlowRecordDrawer` 内不出现通过/驳回,也不接收动作回调;
|
||||||
|
`pageUrl` 每次打开现取、关闭即丢,不缓存、不进 storage。
|
||||||
|
3. **多待办不猜**。`byBiz().tasks.length > 1` → 卡片只显示"你有 N 个待办"+引导,不代替用户挑一条办理。
|
||||||
|
4. **多出口必选**。`outlets.length > 1` 时通过弹窗必须选到 `edgeName` 才允许提交;未选不发请求。
|
||||||
|
5. **驳回意见必填,通过意见可选**。
|
||||||
|
6. **列表行"通过"前现拉一次 `byBiz`**。`tasks/todo` 出参没有 `outlets`,且行数据可能是陈旧快照;
|
||||||
|
点通过时以 `byBiz(bizId)` 里 `taskId` 命中为准——命中不到就是"已不属于你的待办",刷新列表。
|
||||||
|
7. **`currentNodes` 是节点 id 逗号串**(办件原文口径,与 `/oapi/flow/instances`、`progress` 一致)。
|
||||||
|
要给人看节点名,用 `tasks[].nodeName`。
|
||||||
|
8. **错误原文透传**。flow-center 业务码拒绝的 `msg` 直接展示;网络故障提示稍后重试,不伪装成功。
|
||||||
|
9. **postMessage 双向校验 origin**。父侧期望 origin 算自 `pageUrl`,并核对 `event.source` 是本 iframe;
|
||||||
|
子侧 targetOrigin 取 `document.referrer` 的 origin。任一侧都不许用 `*`。
|
||||||
|
|
||||||
|
## 去壳任务页协议(子页 → 父页)
|
||||||
|
|
||||||
|
flow-center 前端 `/task/{taskId}?embed=1` 发出,字面量与
|
||||||
|
`flow-center/front/src/utils/taskEmbed.ts` 一致:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"proto":"flow","v":1,"type":"handled","taskId":"442","action":"approve"}
|
||||||
|
{"proto":"flow","v":1,"type":"open-biz","bizId":"71","bizUrl":"/list/detail?serid=71"}
|
||||||
|
{"proto":"flow","v":1,"type":"height","height":1200}
|
||||||
|
```
|
||||||
|
|
||||||
|
父 → 子:`{"proto":"flow","v":1,"type":"close"}`(任务页当前不消费,协议占位)。
|
||||||
|
|
||||||
|
## 复制到 svc-hall 的同步清单
|
||||||
|
|
||||||
|
平台现行约定不抽 npm 包(《云海子应用前端接入规范》),因此**改本目录任一文件必须双端同步**。
|
||||||
|
|
||||||
|
1. 整目录复制到 `svc-hall/hall/src/components/flow-approval/`;
|
||||||
|
2. 只改两处:`api` 适配器(`/api/apply/approval/**`)与 `bizType`(决策见方案 §8.5,二选一不许混用);
|
||||||
|
3. 两侧 README 头部「组件版本 / 最后同步日期」一起更新;
|
||||||
|
4. 依赖前提:宿主应用需有平台中央静态通道下发的布局类
|
||||||
|
(`page-wrapper` / `yh-fill` / `yh-tabs-card` / `tab-body` / `table-region` / `page-panel` /
|
||||||
|
`operating-link` / `text-error`)与 `@/utils/hook/useElementHeight`;
|
||||||
|
5. UI 库沿用 `view-ui-plus`,不新增 UI/图标依赖,不写死品牌色。
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* v2.1 应用内审批共享组件的契约类型(方案 §4)。
|
||||||
|
*
|
||||||
|
* 本文件刻意**不 import 本应用任何业务类型**:组件靠 `FlowApprovalApi` 注入数据面,
|
||||||
|
* 迁移到下一个子应用(svc-hall)时只换适配器,类型与组件本体一字不改。
|
||||||
|
* 改这里必须双端同步(与平台"四端同构文件"纪律一致)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 分页信封(flow-center BasePageResult 经子应用后端原样透出) */
|
||||||
|
export interface FlowPage<T> {
|
||||||
|
total: number;
|
||||||
|
content: T[];
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 待办行(列表"我的待办"数据源) */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 办件行(列表"办件视角"数据源) */
|
||||||
|
export interface InstanceItem {
|
||||||
|
instanceId: number;
|
||||||
|
status: number;
|
||||||
|
bizType: string;
|
||||||
|
bizId: string;
|
||||||
|
bizTitle: string;
|
||||||
|
bizUrl?: string | null;
|
||||||
|
submitterName?: string | null;
|
||||||
|
submitTime?: string | null;
|
||||||
|
finishTime?: string | null;
|
||||||
|
currentNodes?: string | 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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批轨迹(各子应用自有进度接口形态;组件只用其中"最近一步"做摘要,缺位则不展示) */
|
||||||
|
export interface TraceStep {
|
||||||
|
/** 节点 id(与 currentNodes 同一口径,用于把 id 串翻成节点名) */
|
||||||
|
nodeId?: string | null;
|
||||||
|
nodeName?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
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;
|
||||||
|
currentNodes?: string | null;
|
||||||
|
nodeSummary?: string | null;
|
||||||
|
steps?: TraceStep[] | null;
|
||||||
|
flowResult?: { result?: string; opinion?: string; rejectNode?: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 换址类返回 */
|
||||||
|
export interface PageUrlResult {
|
||||||
|
pageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组件与子应用后端之间的唯一契约。方法返回 alova `Method` 或 Promise 都可——组件只 `await`,
|
||||||
|
* 两种都成立(alova 的 Method 实现了 thenable)。
|
||||||
|
*/
|
||||||
|
export interface FlowApprovalApi {
|
||||||
|
/** 我的待办分页 */
|
||||||
|
todo(params?: { page?: number; size?: number }): any;
|
||||||
|
/** 办件视角分页 */
|
||||||
|
instances(params?: { status?: number; page?: number; size?: number }): any;
|
||||||
|
/** 当前人在该单据上的待办 */
|
||||||
|
byBiz(bizId: string): any;
|
||||||
|
/** 审批轨迹(可缺省:传了就不再由卡片自取) */
|
||||||
|
trace?(bizId: string): any;
|
||||||
|
approve(taskId: number, opinion?: string, edgeName?: string): any;
|
||||||
|
reject(taskId: number, opinion: string): any;
|
||||||
|
recordUrl(bizId: string): any;
|
||||||
|
taskPageUrl(bizId: string, taskId: number): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 办件状态 → 文案(flow-center 实例状态;组件不依赖子应用的枚举字典) */
|
||||||
|
export const INSTANCE_STATUS_TEXT: Record<number, string> = {
|
||||||
|
10: "审批中",
|
||||||
|
20: "已完成",
|
||||||
|
30: "已拒绝",
|
||||||
|
40: "已撤回",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 办件状态 → view-ui-plus Tag 预设色名(用组件库语义色,不写死色值) */
|
||||||
|
export const INSTANCE_STATUS_TAG: Record<number, string> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
+76
-41
@@ -1,8 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onBeforeMount, watch, computed } from "vue";
|
import { ref, onBeforeMount, onBeforeUnmount, onMounted, watch, computed } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message } from "view-ui-plus";
|
||||||
import { resolveMenuPage } from "@/utils/menuRoute";
|
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||||
|
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
|
||||||
|
import UserAvatar from "@/components/UserAvatar.vue";
|
||||||
|
import defaultLogo from "@/assets/image/yunhai-logo.svg";
|
||||||
|
|
||||||
const props = defineProps<{ layoutData: Record<string, any> }>();
|
const props = defineProps<{ layoutData: Record<string, any> }>();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -22,6 +25,27 @@ const modList = computed(() => props.layoutData?.modList || []);
|
|||||||
|
|
||||||
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
|
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
|
||||||
|
|
||||||
|
// 顶栏 Logo:localStorage["yh-logo-url"](平台键,yh-theme-init 异步补齐段写入)优先,本仓资产兜底——绝无空 src/裂图。
|
||||||
|
// 刷新:mounted 读一次 + 监听 yh-appearance-updated(服务端偏好到达后即时切换,无需刷新页面)。
|
||||||
|
const logoUrl = ref<string>(defaultLogo);
|
||||||
|
const readLogoUrl = () => {
|
||||||
|
let v: string | null = null;
|
||||||
|
try {
|
||||||
|
v = localStorage.getItem("yh-logo-url");
|
||||||
|
} catch {
|
||||||
|
v = null;
|
||||||
|
}
|
||||||
|
logoUrl.value = v || defaultLogo;
|
||||||
|
};
|
||||||
|
const onLogoError = () => {
|
||||||
|
if (logoUrl.value !== defaultLogo) logoUrl.value = defaultLogo;
|
||||||
|
};
|
||||||
|
onMounted(() => {
|
||||||
|
readLogoUrl();
|
||||||
|
document.addEventListener("yh-appearance-updated", readLogoUrl);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readLogoUrl));
|
||||||
|
|
||||||
onBeforeMount(() => {
|
onBeforeMount(() => {
|
||||||
routeChange(route);
|
routeChange(route);
|
||||||
});
|
});
|
||||||
@@ -94,15 +118,21 @@ const routeChange = (to: any) => {
|
|||||||
navPath.value = [];
|
navPath.value = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 统一解析菜单项目标:容忍平台配置的尾空格/旧 /notice 前缀/外链,返回内部路由 path 或外链 URL。
|
||||||
|
* 模板高亮与点击都走这里,避免「raw page(带尾空格)vs 解析后 path」不一致导致点不动/不高亮。 */
|
||||||
|
const resolveItemPage = (item: any) =>
|
||||||
|
resolveMenuPage(item?.page, item?.modId ?? item?.id, item?.name);
|
||||||
|
const pageOf = (item: any): string => resolveItemPage(item).path;
|
||||||
|
|
||||||
const linkMenu = (item: any) => {
|
const linkMenu = (item: any) => {
|
||||||
const page: string = item?.page || "";
|
const { path: page, external } = resolveItemPage(item);
|
||||||
if (!page) {
|
if (!page) {
|
||||||
// 解析不出有效页面:显式提示,而不是静默不动或被重定向吞到默认页
|
// 解析不出有效页面:显式提示,而不是静默不动或被重定向吞到默认页
|
||||||
Message.warning(`菜单「${item?.name || "未命名"}」未配置有效页面路径,请检查基础平台模块设置`);
|
Message.warning(`菜单「${item?.name || "未命名"}」未配置有效页面路径,请检查基础平台模块设置`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 平台配置的外部链接模块:整页跳转
|
// 平台配置的外部链接模块:整页跳转
|
||||||
if (item?.external) {
|
if (external) {
|
||||||
window.location.href = page;
|
window.location.href = page;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -114,11 +144,12 @@ const linkMenu = (item: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const linkUserInfo = () => {
|
const linkUserInfo = () => {
|
||||||
window.location.href = baseHost.value + "/v/userinfo";
|
// 头像/姓名点击统一跳 UserHome 设置页「个人信息」pane(默认头像轮 2026-09-30,原 /v/userinfo 已下线语义)
|
||||||
|
window.location.href = baseHost.value + "/userhome/front/index.html#/settings?pane=profile";
|
||||||
};
|
};
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
let url = window.location.href;
|
let url = window.location.href;
|
||||||
url = "/cas/logout?service=" + url;
|
url = "/logout?service=" + url;
|
||||||
window.location.href = ssoHost.value + url;
|
window.location.href = ssoHost.value + url;
|
||||||
};
|
};
|
||||||
const childChange = (index: number) => {
|
const childChange = (index: number) => {
|
||||||
@@ -128,31 +159,16 @@ const childChange = (index: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 根据菜单项映射图标:优先按解析后的 page 判断(平台模块 id 不可依赖),
|
/** 菜单图标:读取用户中心模块管理配置的 YschIcons 图标名(GetUserMod 的 icon 字段,如 list → icon-list)。
|
||||||
* 兜底保留旧版 modId 对照。图标为本地 bsch-icons(iconfont 掩码类,见 styles/icons.css) */
|
* icons.css(ysch-icons 掩码版,基类 iconfont,类名 icon-<名称>)现由中央静态通道下发;
|
||||||
const getMenuIcon = (item?: any): string => {
|
* 平台未配置图标时统一兜底 icon-list(2026-10-09 定稿:菜单项恒有图标,保整列缩进/行高一致) */
|
||||||
const page: string = item?.page || "";
|
const getMenuIcon = (item?: any): string =>
|
||||||
if (page === "/list/new") return "icon-xiugaijl";
|
`icon-${String(item?.icon || "").trim() || "list"}`;
|
||||||
if (page === "/list") return "icon-fasong";
|
|
||||||
if (page === "/" || page === "/accept") return "icon-sjx";
|
|
||||||
if (page === "/noticelist") return "icon-liebiao";
|
|
||||||
if (page === "/stats") return "icon-tongji";
|
|
||||||
if (page === "/setting") return "icon-shezhi";
|
|
||||||
switch (item?.modId ?? item?.id) {
|
|
||||||
case 1: return "icon-xiugaijl";
|
|
||||||
case 2: return "icon-fasong";
|
|
||||||
case 3: return "icon-sjx";
|
|
||||||
case 4: return "icon-liebiao";
|
|
||||||
case 5: return "icon-wenzhang";
|
|
||||||
case 21: return "icon-shezhi";
|
|
||||||
case 22: return "icon-xiugaijl";
|
|
||||||
default: return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function linkHomePage() {
|
function linkHomePage() {
|
||||||
if (baseHost.value) {
|
if (baseHost.value) {
|
||||||
window.location.href = baseHost.value + "/BschHome/front/index.html";
|
// 旧 BschHome 已下线(线上 404),首页统一指向用户中心 userhome
|
||||||
|
window.location.href = baseHost.value + "/userhome/front/index.html";
|
||||||
} else {
|
} else {
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
@@ -162,16 +178,13 @@ function linkHomePage() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="layout-wrap">
|
<div class="layout-wrap">
|
||||||
<header class="header-container fix">
|
<header class="header-container fix">
|
||||||
<div class="fl align-middle icon-box">
|
<!-- 云海 logo 徽标(结构与 Cms SchoolLayout 对齐,皮肤见通道 base.layout.css .logo-badge) -->
|
||||||
<div title="常用应用" :class="['icon-btn', isPanel ? 'active' : '']" @click="isPanel = !isPanel">
|
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
|
||||||
<i class="iconfont icon-apps"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="delimiter fl align-middle"></div>
|
<div class="delimiter fl align-middle"></div>
|
||||||
<h4 class="fl align-middle">{{ appName }}</h4>
|
<h4 class="fl align-middle">{{ appName }}</h4>
|
||||||
<div class="school-bg"></div>
|
<div class="school-bg"></div>
|
||||||
<div class="header-aside fr">
|
<div class="header-aside fr">
|
||||||
<img :src="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
|
<UserAvatar class="fl align-middle" :src="userInfo.avatar" :name="userInfo.name" :size="28" placement="brand" @click="linkUserInfo" />
|
||||||
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
|
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
|
||||||
{{ userInfo.name }}
|
{{ userInfo.name }}
|
||||||
</a>
|
</a>
|
||||||
@@ -180,6 +193,8 @@ function linkHomePage() {
|
|||||||
<i class="iconfont icon-logout"></i>
|
<i class="iconfont icon-logout"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 配色切换器已移除(oapi 代理轮 2026-10-08 拍板):设置入口收敛在 UserHome 设置中心,
|
||||||
|
本地切换会被服务端生效值收敛打回,不留半残入口 -->
|
||||||
</header>
|
</header>
|
||||||
<div class="left-menu" v-if="!isAlone">
|
<div class="left-menu" v-if="!isAlone">
|
||||||
<div class="menu-top-box"></div>
|
<div class="menu-top-box"></div>
|
||||||
@@ -189,14 +204,14 @@ function linkHomePage() {
|
|||||||
<li>
|
<li>
|
||||||
<a href="javascript:;" @click="childChange(index)">
|
<a href="javascript:;" @click="childChange(index)">
|
||||||
<i :class="item.childState ? 'arrow-down' : 'arrow-up'"></i>
|
<i :class="item.childState ? 'arrow-down' : 'arrow-up'"></i>
|
||||||
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
|
<i :class="['iconfont', getMenuIcon(item)]"></i>
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<transition name="fade_child_ul">
|
<transition name="fade_child_ul">
|
||||||
<div class="child-item-wrap" v-show="item.childState">
|
<div class="child-item-wrap" v-show="item.childState">
|
||||||
<li
|
<li
|
||||||
:class="citem.page == currPath ? 'active child-item' : 'child-item'"
|
:class="pageOf(citem) == currPath ? 'active child-item' : 'child-item'"
|
||||||
v-for="citem in item.children"
|
v-for="citem in item.children"
|
||||||
:key="citem.id"
|
:key="citem.id"
|
||||||
>
|
>
|
||||||
@@ -207,9 +222,9 @@ function linkHomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
<li v-else :class="item.page == currPath ? 'active' : ''">
|
<li v-else :class="pageOf(item) == currPath ? 'active' : ''">
|
||||||
<a href="javascript:;" @click="linkMenu(item)">
|
<a href="javascript:;" @click="linkMenu(item)">
|
||||||
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
|
<i :class="['iconfont', getMenuIcon(item)]"></i>
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -222,8 +237,10 @@ function linkHomePage() {
|
|||||||
<Breadcrumb separator=">">
|
<Breadcrumb separator=">">
|
||||||
<a href="javascript:;" @click="linkHomePage">
|
<a href="javascript:;" @click="linkHomePage">
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
<i class="iconfont icon-category"></i>
|
<span class="crumb-home">
|
||||||
|
<i class="iconfont icon-shouye"></i>
|
||||||
首页
|
首页
|
||||||
|
</span>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</a>
|
</a>
|
||||||
<BreadcrumbItem v-if="!isAlone" to="/">{{ appName }}</BreadcrumbItem>
|
<BreadcrumbItem v-if="!isAlone" to="/">{{ appName }}</BreadcrumbItem>
|
||||||
@@ -250,7 +267,7 @@ function linkHomePage() {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="link-panel">
|
<div class="link-panel">
|
||||||
<a :href="baseHost + '/BschHome/front/index.html#/apps'" target="_blank" rel="noopener noreferrer">
|
<a :href="baseHost + '/userhome/front/index.html#/apps'" target="_blank" rel="noopener noreferrer">
|
||||||
全部应用
|
全部应用
|
||||||
<i>→</i>
|
<i>→</i>
|
||||||
</a>
|
</a>
|
||||||
@@ -261,9 +278,27 @@ function linkHomePage() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 仅覆盖/补充必要的动态样式,其余样式由全局 layout.css 提供 */
|
/* 仅覆盖/补充必要的动态样式,其余样式由中央静态通道的 base.layout.css(骨架 + 皮肤)提供 */
|
||||||
/* 菜单图标(本地 bsch-icons 掩码类)与文字间距 */
|
/* 菜单图标(通道下发的 ysch-icons 掩码类)与文字间距 */
|
||||||
.left-menu a .iconfont {
|
.left-menu a .iconfont {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
/* 顶栏已由通道皮肤改为 flex 垂直居中(skin.src.css .header-container),
|
||||||
|
这里不再用 float 时代的 13px 顶偏移补偿,仅留右侧间距;徽标大小/圆底/阴影属皮肤 */
|
||||||
|
.header-container .logo-badge {
|
||||||
|
margin: 0 10px 0 0;
|
||||||
|
}
|
||||||
|
/* 面包屑「首页」垂直居中:骨架遗留的 .path-nav .icon-category{position:relative;top:1px}
|
||||||
|
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
|
||||||
|
这里用 inline-flex 真居中并关掉该补偿(不动通道产物,覆盖收在本组件内) */
|
||||||
|
/* 面包屑「首页」与后续级目的文字对齐(2026-10-09 定稿):此前 .crumb-home 用 inline-flex,
|
||||||
|
行内级盒的基线取首项——16px 无字图标(其基线=方块底缘),整盒被顶高约 4px,首页文字
|
||||||
|
比相邻级目高。改回普通行内流:图标(.iconfont 基类 inline-block,基线=底缘)以 -0.125em
|
||||||
|
基线偏移做光学居中,文字与相邻级目共用同一行盒基线,天然对齐 */
|
||||||
|
.crumb-home .iconfont {
|
||||||
|
position: static;
|
||||||
|
top: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
vertical-align: -0.125em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
+2
-4
@@ -13,10 +13,8 @@ import mittBus from "@/utils/mitt";
|
|||||||
import "view-ui-plus/dist/styles/viewuiplus.css";
|
import "view-ui-plus/dist/styles/viewuiplus.css";
|
||||||
|
|
||||||
import "@/assets/styles/main.scss";
|
import "@/assets/styles/main.scss";
|
||||||
// 本地化的布局/主题样式(原远程 base.layout.beta / _iview_override / default-theme 合并)
|
// 布局/主题/图标(base.layout.css + iview.override.css + icons.css)由中央静态通道下发:
|
||||||
import "@/assets/styles/layout.css";
|
// index.html 的 yh-theme-init.js 在 DOMContentLoaded 后按 manifest 版本追加 <link>,天然排在本文件组件库样式之后
|
||||||
// 规范 iconfont 图标本地实现(icon-browse / icon-edit / icon-ashbin 等,平台主题不含字体)
|
|
||||||
import "@/assets/styles/icons.css";
|
|
||||||
//导入自定义指令
|
//导入自定义指令
|
||||||
import directive from "@/utils/directive/index";
|
import directive from "@/utils/directive/index";
|
||||||
|
|
||||||
|
|||||||
+21
-4
@@ -9,10 +9,12 @@ import type { RouteRecordRaw } from "vue-router";
|
|||||||
* - /list/detail?serid=&type=、/noticelist/detail?serid=&type=
|
* - /list/detail?serid=&type=、/noticelist/detail?serid=&type=
|
||||||
* type: 0=编辑视角(不标读) 1=普通查看(标读)
|
* type: 0=编辑视角(不标读) 1=普通查看(标读)
|
||||||
*
|
*
|
||||||
* modId 是菜单 ↔ 路由的稳定契约(对照后端 GetUserMod 现行数据):
|
* modId 是菜单 ↔ 路由的稳定契约(严格对照后端 /base/GetUserMod 现行返回的 modId):
|
||||||
* 1=已收到 2=已发送 3=全部通知 4=通知统计 21=应用设置
|
* 1=已收到 2=已发送 3=全部通知 4=通知统计 21=应用设置 89=待审核 91=抄送给我的
|
||||||
* 发通知为「已发送」的深层子页(modId 22 不在菜单树,meta.parentId=2 关联高亮),
|
* (后两条平台已挂一级菜单,page 分别为 /approval、/cc)。
|
||||||
* 与 UserBase「应用管理>应用类型管理」范式一致。
|
* 深层子页不在菜单树,靠 meta.parentId 关联高亮父菜单:/list/new=22(父2)、
|
||||||
|
* /list/detail=23(父2)、/noticelist/detail=24(父3)。深层页 modId 不得与任何
|
||||||
|
* 平台一级模块 ID(含 89/91)重复。
|
||||||
*/
|
*/
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
// 已收通知(收件箱):后端菜单 page 配置为 "/",即应用入口页
|
// 已收通知(收件箱):后端菜单 page 配置为 "/",即应用入口页
|
||||||
@@ -72,6 +74,21 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/notice/NoticeSetting.vue"),
|
component: () => import("@/views/notice/NoticeSetting.vue"),
|
||||||
meta: { title: "应用设置", modId: 21 },
|
meta: { title: "应用设置", modId: 21 },
|
||||||
},
|
},
|
||||||
|
// 待审核(应用内审批;平台模块管理已挂一级菜单,配置 ID = 89,path = /approval)
|
||||||
|
{
|
||||||
|
path: "/approval",
|
||||||
|
name: "notice_approval",
|
||||||
|
component: () => import("@/views/notice/NoticeApproval.vue"),
|
||||||
|
meta: { title: "我的待审核", modId: 89 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// 抄送给我的(流程 NOTIFY 知会;平台模块管理已挂一级菜单,配置 ID = 91,path = /cc)
|
||||||
|
{
|
||||||
|
path: "/cc",
|
||||||
|
name: "notice_cc",
|
||||||
|
component: () => import("@/views/notice/NoticeCc.vue"),
|
||||||
|
meta: { title: "抄送给我的", modId: 91 },
|
||||||
|
},
|
||||||
|
|
||||||
/* ---- 旧版路径兼容重定向(书签、残留的平台模块配置) ---- */
|
/* ---- 旧版路径兼容重定向(书签、残留的平台模块配置) ---- */
|
||||||
{ path: "/accept", redirect: "/" },
|
{ path: "/accept", redirect: "/" },
|
||||||
|
|||||||
@@ -64,10 +64,81 @@ export interface NoticeVo {
|
|||||||
smsFlag: number;
|
smsFlag: number;
|
||||||
/** 接收人列表(JSON) */
|
/** 接收人列表(JSON) */
|
||||||
receiveList: string | null;
|
receiveList: string | null;
|
||||||
|
/** 附件ID串("1,2,3";后端 Lombok 序列化名为 upFile,对齐旧 .NET UpFile 语义) */
|
||||||
|
upFile?: string | null;
|
||||||
|
/** @deprecated 旧 .NET 接口的 PascalCase 附件字段,仅作读取兼容 */
|
||||||
|
UpFile?: string | null;
|
||||||
createBy: string | null;
|
createBy: string | null;
|
||||||
createTime: string | null;
|
createTime: string | null;
|
||||||
updateBy: string | null;
|
updateBy: string | null;
|
||||||
updateTime: string | null;
|
updateTime: string | null;
|
||||||
|
/** 绑定的审核流程 GUID(null=直发未接流程中心) */
|
||||||
|
flowGuid?: string | null;
|
||||||
|
/** 审核状态:0 待提交 / 10 审批中 / 20 已通过 / 30 已驳回 / 40 已撤回(null=未绑流程) */
|
||||||
|
auditStatus?: number | null;
|
||||||
|
/** 审核状态文案 */
|
||||||
|
auditStatusDesc?: string | null;
|
||||||
|
/** 流程中心办件 id */
|
||||||
|
flowInstanceId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ 先审后发(流程中心) ============================
|
||||||
|
|
||||||
|
/** 审核流程下拉选项(对齐 FlowModels.FlowSummaryVo) */
|
||||||
|
export interface FlowOption {
|
||||||
|
flowGuid: string;
|
||||||
|
name: string;
|
||||||
|
version: number;
|
||||||
|
nodeSummary: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用级流程绑定视图(GET /api/notice/flow/Binding;应用设置 > 流程绑定 + 发通知页按钮显隐) */
|
||||||
|
export interface FlowBindingVo {
|
||||||
|
/** 本校是否已绑定审核流程(false=全部直发,现有功能不变) */
|
||||||
|
bound: boolean;
|
||||||
|
flowGuid: string | null;
|
||||||
|
flowName: string | null;
|
||||||
|
/** 流程中心是否接入(false 时设置页隐藏绑定入口) */
|
||||||
|
flowEnabled: boolean;
|
||||||
|
/** 当前用户是否可维护绑定(本校管理员;false 时设置页只读展示) */
|
||||||
|
canEdit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批进度(GET /api/notice/flow/Trace) */
|
||||||
|
export interface FlowTraceVo {
|
||||||
|
bound: boolean;
|
||||||
|
flowGuid: string | null;
|
||||||
|
flowInstanceId: number | null;
|
||||||
|
auditStatus: number | null;
|
||||||
|
auditStatusDesc: string | null;
|
||||||
|
status: number | null;
|
||||||
|
flowResult?: { result?: string; opinion?: string; rejectNode?: string } | null;
|
||||||
|
instanceStatus?: number | null;
|
||||||
|
version?: number | null;
|
||||||
|
nodeSummary?: string | null;
|
||||||
|
currentNodes?: string | null;
|
||||||
|
assigneeIds?: number[] | null;
|
||||||
|
submitTime?: string | null;
|
||||||
|
finishTime?: string | null;
|
||||||
|
/** 办理轨迹(traceJson 解析后的数组) */
|
||||||
|
trace?: any[] | null;
|
||||||
|
/** 结构化步骤(2026-09-17 接入:steps[0] 固定"发起申请",同节点多任务每人一行) */
|
||||||
|
steps?: FlowProgressStepVo[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流程步骤(SDK FlowProgressStep 同构:后端 /flow/Trace 的 steps 元素) */
|
||||||
|
export interface FlowProgressStepVo {
|
||||||
|
nodeId: string;
|
||||||
|
nodeName: string | null;
|
||||||
|
label: string | null;
|
||||||
|
active: boolean;
|
||||||
|
assignees?: {
|
||||||
|
userName?: string | null;
|
||||||
|
status?: number | null;
|
||||||
|
statusLabel?: string | null;
|
||||||
|
opinion?: string | null;
|
||||||
|
handleTime?: string | null;
|
||||||
|
}[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================ 通知详情 ============================
|
// ============================ 通知详情 ============================
|
||||||
@@ -76,6 +147,11 @@ export interface NoticeVo {
|
|||||||
export interface NoticeDetailVo {
|
export interface NoticeDetailVo {
|
||||||
serid: number;
|
serid: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
flowGuid?: string | null;
|
||||||
|
auditStatus?: number | null;
|
||||||
|
auditStatusDesc?: string | null;
|
||||||
|
flowInstanceId?: number | null;
|
||||||
|
flowResult?: { result?: string; opinion?: string; rejectNode?: string } | null;
|
||||||
/** 通知内容(旧字段名 Contents) */
|
/** 通知内容(旧字段名 Contents) */
|
||||||
Contents: string;
|
Contents: string;
|
||||||
/** 发布部门ID(旧字段名 DeptID) */
|
/** 发布部门ID(旧字段名 DeptID) */
|
||||||
@@ -194,6 +270,7 @@ export interface NoticeRequest {
|
|||||||
smsFlag?: number;
|
smsFlag?: number;
|
||||||
/** 转发源通知ID */
|
/** 转发源通知ID */
|
||||||
sourceId?: number;
|
sourceId?: number;
|
||||||
|
// 审核流程不由前端指定:绑定在「应用设置 > 流程绑定」,后端按「已绑定 + 通知对象=全部用户」判定
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 通知列表查询参数(对齐 NoticeQuery.java + BasePageQuery.java) */
|
/** 通知列表查询参数(对齐 NoticeQuery.java + BasePageQuery.java) */
|
||||||
@@ -202,6 +279,11 @@ export interface NoticeQuery {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
|
/**
|
||||||
|
* 审核状态筛选(列表「状态」下拉):0 待提交 / 10 审批中 / 20 已通过 / 30 已驳回 / 40 已撤回;
|
||||||
|
* -1 = 无需审核(直发通知);不传不过滤
|
||||||
|
*/
|
||||||
|
auditStatus?: number;
|
||||||
type?: number;
|
type?: number;
|
||||||
/** 列表范围(管理端):"user" 只看自己发布的,不传=全部 */
|
/** 列表范围(管理端):"user" 只看自己发布的,不传=全部 */
|
||||||
scope?: string;
|
scope?: string;
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* 默认头像样式(首字 / 剪影)公共源(YschTheme,scripts/sync-apps.mjs 下发三端 src/utils/avatar.ts)。
|
||||||
|
* 取值链与主题同构(theme-core.js resolveEffectiveThemeState 的合成链写法):
|
||||||
|
* URL ?yh-avatar=text|silhouette(仅预览/排障覆盖:只影响读、绝不写回)
|
||||||
|
* > localStorage["yh-avatar"](用户自选,纯字符串)
|
||||||
|
* > localStorage["yh-avatar-sch-default"](学校默认,UserHome 登录后写入)
|
||||||
|
* > "text"(系统默认)
|
||||||
|
* 值域恒为 "text" | "silhouette",键缺失/值非法均静默回落下一级,不报错不阻塞渲染。
|
||||||
|
* 渲染三态与取字规则的唯一契约见 UserHome《设置页修复与默认头像落地方案》§10.1/§10.2。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AvatarStyle = "text" | "silhouette";
|
||||||
|
|
||||||
|
interface StorageLike {
|
||||||
|
getItem(k: string): string | null;
|
||||||
|
setItem(k: string, v: string): void;
|
||||||
|
removeItem(k: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AVATAR_STORAGE_KEY = "yh-avatar";
|
||||||
|
|
||||||
|
/** 学校默认头像存储键(UserHome 登录时写入;值格式与用户键相同,仅无"用户自选"语义) */
|
||||||
|
export const SCHOOL_DEFAULT_AVATAR_STORAGE_KEY = "yh-avatar-sch-default";
|
||||||
|
|
||||||
|
export const AVATAR_STYLES: readonly AvatarStyle[] = ["text", "silhouette"];
|
||||||
|
|
||||||
|
export const DEFAULT_AVATAR_STYLE: AvatarStyle = "text";
|
||||||
|
|
||||||
|
/** 值合法才采用(用户键/学校键同一判据;脏数据不生效) */
|
||||||
|
function readAvatarStyleOrNull(raw: string | null): AvatarStyle | null {
|
||||||
|
return raw === "text" || raw === "silhouette" ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL 覆盖参数(?yh-avatar=text|silhouette):非法值忽略,且只影响"读哪一档"、绝不写回 localStorage */
|
||||||
|
function readAvatarOverride(): AvatarStyle | null {
|
||||||
|
try {
|
||||||
|
const search = (globalThis.location && globalThis.location.search) || "";
|
||||||
|
const m = new RegExp("[?&]yh-avatar=([^&]*)").exec(search);
|
||||||
|
return m ? readAvatarStyleOrNull(decodeURIComponent(m[1])) : null;
|
||||||
|
} catch {
|
||||||
|
/* 无 location 环境(测试/SSR):无覆盖 */
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生效样式合成(与 resolveEffectiveThemeState 同构):
|
||||||
|
* URL 覆盖 > localStorage["yh-avatar"](用户自选)> localStorage["yh-avatar-sch-default"](学校默认)
|
||||||
|
* > DEFAULT_AVATAR_STYLE。
|
||||||
|
*/
|
||||||
|
export function resolveAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): AvatarStyle {
|
||||||
|
let style: AvatarStyle | null = null;
|
||||||
|
if (storage) {
|
||||||
|
style = readAvatarStyleOrNull(storage.getItem(AVATAR_STORAGE_KEY));
|
||||||
|
if (!style) style = readAvatarStyleOrNull(storage.getItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY));
|
||||||
|
}
|
||||||
|
if (!style) style = DEFAULT_AVATAR_STYLE;
|
||||||
|
return readAvatarOverride() || style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存用户自选(直写 localStorage,无响应式;调用方操作后自行刷新 UI) */
|
||||||
|
export function saveAvatarStyle(style: AvatarStyle, storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||||
|
try {
|
||||||
|
storage?.setItem(AVATAR_STORAGE_KEY, style);
|
||||||
|
} catch {
|
||||||
|
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认) */
|
||||||
|
export function clearAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||||
|
try {
|
||||||
|
storage?.removeItem(AVATAR_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
/* 隐私模式静默 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取字(顺序判定,先命中先返回):含 "@" 先截 "@" 前段(邮箱取首字母);
|
||||||
|
* 首字符必须用展开 [...s][0](禁用 charAt(0),避免代理对截半出乱码)。
|
||||||
|
* ASCII 字母转大写;纯数字回落 ""(剪影);符号/emoji/空白回落 ""(剪影);其余(CJK 等)原样。
|
||||||
|
*/
|
||||||
|
export function pickAvatarText(name: string | null | undefined): string {
|
||||||
|
let s = String(name ?? "");
|
||||||
|
const at = s.indexOf("@");
|
||||||
|
if (at > -1) s = s.slice(0, at);
|
||||||
|
const ch = [...s.trim()][0];
|
||||||
|
if (ch === undefined) return "";
|
||||||
|
if (/^[A-Za-z]$/.test(ch)) return ch.toUpperCase();
|
||||||
|
if (/^[0-9]$/.test(ch)) return "";
|
||||||
|
if (/^[\p{P}\p{S}\s]$/u.test(ch)) return "";
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* 流程示意图 PNG 显示口径(2026-10-10 用户反馈:嵌入页图过大,同 flow-center/front 同名工具):
|
||||||
|
* 服务端按 SCALE=2 出图(视网膜清晰度),按自然尺寸渲染等于整体放大一倍。
|
||||||
|
* 须按逻辑尺寸(自然宽一半)显示;超宽长图收缩到容器宽。
|
||||||
|
* 用法:<img ref="diagramEl" :src="..." @load="onDiagramLoad">(图均为打开时挂载,@load 必触发)。
|
||||||
|
*/
|
||||||
|
export function fitDiagram(el: HTMLImageElement | null | undefined): void {
|
||||||
|
if (!el || !el.naturalWidth) return;
|
||||||
|
const want = el.naturalWidth / 2;
|
||||||
|
const cap = (el.parentElement?.clientWidth || 0) - 8;
|
||||||
|
el.style.width = Math.round(Math.min(want, cap > 120 ? cap : want)) + "px";
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from "vue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实测某容器元素的可用高度(px),随尺寸变化实时更新。
|
||||||
|
*
|
||||||
|
* 用途:表格页把 <Table> 放进 flex:1 的 .table-region 里,用本组合式量出该区域
|
||||||
|
* 的真实高度喂给 view-ui 的 `:height`(固定表头需要一个数值高度),从而彻底替代
|
||||||
|
* 旧的 useTableHeight()「视口高 - 一堆写死常量」的算法——加/删工具条、改顶栏高
|
||||||
|
* 都不再需要同步改任何魔法数字。
|
||||||
|
*
|
||||||
|
* @param el 目标容器 ref(模板里 ref 绑定的那个元素)
|
||||||
|
* @returns 响应式高度(px);挂载测量前为 0,调用方可 `:height="h || undefined"` 兜底
|
||||||
|
*/
|
||||||
|
export function useElementHeight(el: Ref<HTMLElement | null>): Ref<number> {
|
||||||
|
const height = ref(0);
|
||||||
|
let ro: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
if (el.value) height.value = Math.floor(el.value.clientHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const observe = () => {
|
||||||
|
ro?.disconnect();
|
||||||
|
if (!el.value || typeof ResizeObserver === "undefined") return;
|
||||||
|
measure();
|
||||||
|
ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(observe);
|
||||||
|
// v-if / v-show 切换导致容器重建时,重新挂观察
|
||||||
|
watch(el, observe);
|
||||||
|
onBeforeUnmount(() => ro?.disconnect());
|
||||||
|
|
||||||
|
return height;
|
||||||
|
}
|
||||||
+1
-1
@@ -100,7 +100,7 @@ export default class Utils {
|
|||||||
"div",
|
"div",
|
||||||
{
|
{
|
||||||
class: `operating-btn iconfont ${iconClass}`,
|
class: `operating-btn iconfont ${iconClass}`,
|
||||||
onClick: callback,
|
onClick: disabled ? null : callback,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261014-1811-d7e6a3c),勿手改。
|
||||||
|
* 分发方式:YschTheme `npm run sync` 拷进各端 src/utils/theme.ts(与 ThemeSwitcher.vue 配套,MD5 对齐报告)。
|
||||||
|
* 运行时档位类名由 /static/js/yh-theme-init.js 在 <head> 内先行挂好,本模块只负责读写与切换。
|
||||||
|
*/
|
||||||
|
export type ThemeName = "blue" | "ocean" | "ink" | "violet" | "teal";
|
||||||
|
|
||||||
|
/** 定稿默认档:blue + side-light + grad-on + 侧栏跟随(2026-10-11 翻转:什么都不配=浅色,深色需主动开启) */
|
||||||
|
export interface ThemeState {
|
||||||
|
theme: ThemeName;
|
||||||
|
/** 侧栏深色(false=浅色档) */
|
||||||
|
sideDeep: boolean;
|
||||||
|
/** 顶栏渐变(联动侧栏竖向渐变) */
|
||||||
|
gradOn: boolean;
|
||||||
|
/** 侧栏颜色跟随顶栏品牌色(false=中性灰 side-plain) */
|
||||||
|
sideFollow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StorageLike {
|
||||||
|
getItem(k: string): string | null;
|
||||||
|
setItem(k: string, v: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THEME_NAMES: readonly ThemeName[] = ["blue", "ocean", "ink", "violet", "teal"];
|
||||||
|
|
||||||
|
export const THEME_STORAGE_KEY = "yh-theme";
|
||||||
|
|
||||||
|
/* 学校默认档存储键(UserHome 登录时写入;值格式与 yh-theme 完全相同,仅无"用户自选"语义) */
|
||||||
|
export const SCHOOL_DEFAULT_STORAGE_KEY = "yh-theme-sch-default";
|
||||||
|
|
||||||
|
export const DEFAULT_THEME_STATE: ThemeState = {
|
||||||
|
theme: "blue",
|
||||||
|
sideDeep: false,
|
||||||
|
gradOn: true,
|
||||||
|
sideFollow: true
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 宽松解析:任一字段非法/缺失即回退默认档(脏数据不阻塞启动) */
|
||||||
|
export function parseThemeState(raw: string | null): ThemeState {
|
||||||
|
if (!raw) return { ...DEFAULT_THEME_STATE };
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(raw);
|
||||||
|
if (!o || typeof o !== "object") return { ...DEFAULT_THEME_STATE };
|
||||||
|
return {
|
||||||
|
theme: THEME_NAMES.includes(o.theme) ? o.theme : DEFAULT_THEME_STATE.theme,
|
||||||
|
sideDeep: typeof o.sideDeep === "boolean" ? o.sideDeep : DEFAULT_THEME_STATE.sideDeep,
|
||||||
|
gradOn: typeof o.gradOn === "boolean" ? o.gradOn : DEFAULT_THEME_STATE.gradOn,
|
||||||
|
sideFollow: typeof o.sideFollow === "boolean" ? o.sideFollow : DEFAULT_THEME_STATE.sideFollow
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_THEME_STATE };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 严格读取:四字段全部合法才返回状态,否则 null(合成链"值非法→回落下一级"用;用户键仍走宽松解析保现状) */
|
||||||
|
function readThemeStateOrNull(raw: string | null): ThemeState | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(raw);
|
||||||
|
if (!o || typeof o !== "object") return null;
|
||||||
|
if (!THEME_NAMES.includes(o.theme)) return null;
|
||||||
|
if (typeof o.sideDeep !== "boolean" || typeof o.gradOn !== "boolean" || typeof o.sideFollow !== "boolean") return null;
|
||||||
|
return { theme: o.theme, sideDeep: o.sideDeep, gradOn: o.gradOn, sideFollow: o.sideFollow };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL 覆盖参数(?yh-theme= / ?yh-side=deep|light / ?yh-grad=on|off):仅预览/排障用,
|
||||||
|
非法值忽略(对应字段不覆盖),且只影响"读哪一档"、绝不写回 localStorage。 */
|
||||||
|
function readThemeOverrides(): { theme?: ThemeName; sideDeep?: boolean; gradOn?: boolean } | null {
|
||||||
|
let out = null;
|
||||||
|
try {
|
||||||
|
const search = (globalThis.location && globalThis.location.search) || "";
|
||||||
|
const get = (name) => {
|
||||||
|
const m = new RegExp("[?&]" + name + "=([^&]*)").exec(search);
|
||||||
|
return m ? decodeURIComponent(m[1]) : null;
|
||||||
|
};
|
||||||
|
const t = get("yh-theme");
|
||||||
|
if (t && THEME_NAMES.includes(t)) {
|
||||||
|
out = out || {};
|
||||||
|
out.theme = t;
|
||||||
|
}
|
||||||
|
const s = get("yh-side");
|
||||||
|
if (s === "deep" || s === "light") {
|
||||||
|
out = out || {};
|
||||||
|
out.sideDeep = s === "deep";
|
||||||
|
}
|
||||||
|
const g = get("yh-grad");
|
||||||
|
if (g === "on" || g === "off") {
|
||||||
|
out = out || {};
|
||||||
|
out.gradOn = g === "on";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 无 location 环境(测试/SSR):无覆盖 */
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生效档合成(唯一实现,init 启动段与 UserHome theme.ts 共用):
|
||||||
|
* URL 覆盖 > localStorage["yh-theme"](用户自选,存在即用——宽松修补,与历史行为一致)
|
||||||
|
* > localStorage["yh-theme-sch-default"](学校默认,整体合法才生效)
|
||||||
|
* > DEFAULT_THEME_STATE。
|
||||||
|
* 键不存在/解析失败/值非法均静默回落,不报错不阻塞首帧。
|
||||||
|
*/
|
||||||
|
export function resolveEffectiveThemeState(storage: StorageLike | undefined = globalThis.localStorage): ThemeState {
|
||||||
|
let state = null;
|
||||||
|
if (storage) {
|
||||||
|
const userRaw = storage.getItem(THEME_STORAGE_KEY);
|
||||||
|
if (userRaw) state = parseThemeState(userRaw);
|
||||||
|
if (!state) state = readThemeStateOrNull(storage.getItem(SCHOOL_DEFAULT_STORAGE_KEY));
|
||||||
|
}
|
||||||
|
if (!state) state = { ...DEFAULT_THEME_STATE };
|
||||||
|
const ov = readThemeOverrides();
|
||||||
|
if (ov) {
|
||||||
|
if (ov.theme) state = { ...state, theme: ov.theme };
|
||||||
|
if (ov.sideDeep != null) state = { ...state, sideDeep: ov.sideDeep };
|
||||||
|
if (ov.gradOn != null) state = { ...state, gradOn: ov.gradOn };
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadThemeState(storage: StorageLike | undefined = globalThis.localStorage): ThemeState {
|
||||||
|
if (!storage) return { ...DEFAULT_THEME_STATE };
|
||||||
|
return parseThemeState(storage.getItem(THEME_STORAGE_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveThemeState(state: ThemeState, storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||||
|
try {
|
||||||
|
storage?.setItem(THEME_STORAGE_KEY, JSON.stringify(state));
|
||||||
|
} catch {
|
||||||
|
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把状态落到根元素档位类名(幂等:先清后挂),返回传入的 state 便于链式 */
|
||||||
|
export function applyThemeState(state: ThemeState, root: HTMLElement = document.documentElement): ThemeState {
|
||||||
|
root.dataset.theme = state.theme;
|
||||||
|
root.classList.toggle("side-deep", state.sideDeep);
|
||||||
|
root.classList.toggle("side-light", !state.sideDeep);
|
||||||
|
root.classList.toggle("grad-on", state.gradOn);
|
||||||
|
root.classList.toggle("grad-off", !state.gradOn);
|
||||||
|
root.classList.toggle("side-plain", !state.sideFollow);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动引导:各端 main.ts(或本仓 init 脚本的启动段)于挂载前调用,消除首帧闪色 */
|
||||||
|
export function initTheme(): ThemeState {
|
||||||
|
return applyThemeState(loadThemeState());
|
||||||
|
}
|
||||||
+158
-11
@@ -2,14 +2,15 @@
|
|||||||
import { ref, reactive, shallowRef, computed, onMounted, onBeforeUnmount } from "vue";
|
import { ref, reactive, shallowRef, computed, onMounted, onBeforeUnmount } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useRequest } from "alova/client";
|
import { useRequest } from "alova/client";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message, Modal } from "view-ui-plus";
|
||||||
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
||||||
import "@wangeditor/editor/dist/css/style.css";
|
import "@wangeditor/editor/dist/css/style.css";
|
||||||
import { noticeApi } from "@/api/module/notice";
|
import { noticeApi } from "@/api/module/notice";
|
||||||
import type { NoticeDetailVo, NoticeRequest, UpFileVo } from "@/types/notice";
|
import type { NoticeDetailVo, NoticeRequest, UpFileVo, FlowBindingVo } from "@/types/notice";
|
||||||
import UserCheckInput from "@/components/UserCheckInput.vue";
|
import UserCheckInput from "@/components/UserCheckInput.vue";
|
||||||
import NoticeUpload from "@/components/NoticeUpload.vue";
|
import NoticeUpload from "@/components/NoticeUpload.vue";
|
||||||
import config from "@/assets/config/host.config";
|
import config from "@/assets/config/host.config";
|
||||||
|
import { fitDiagram } from "@/utils/diagramImg";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -26,6 +27,29 @@ const form = reactive({
|
|||||||
content: "",
|
content: "",
|
||||||
isShortMsg: false,
|
isShortMsg: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ============ 先审后发(应用设置 > 流程绑定,按学校生效) ============
|
||||||
|
规则(后端为单一事实源):已绑定 + 通知对象=全部用户 → 必须走审核,只显示「提交审核」,
|
||||||
|
隐藏「发送」与「定时发送」;指定人员与未绑定 → 原直发链路不变。 */
|
||||||
|
const flowBinding = ref<FlowBindingVo>({ bound: false, flowGuid: null, flowName: null, flowEnabled: true });
|
||||||
|
const noticeFlowGuid = ref(""); // 编辑回显:该通知自身的流程快照(决定沿用哪个流程重审)
|
||||||
|
const auditStatus = ref<number | null>(null); // 编辑回显的审核态
|
||||||
|
const auditResult = ref<{ result?: string; opinion?: string; rejectNode?: string } | null>(null);
|
||||||
|
|
||||||
|
/** 通知对象是否「全部用户」(UserCheckInput 全部态 v-model 值为 "all") */
|
||||||
|
const isAllTarget = computed(() => {
|
||||||
|
const v = (form.rdoname || "").trim().toLowerCase();
|
||||||
|
return v === "" || v === "all";
|
||||||
|
});
|
||||||
|
/** 是否走审核(与后端 publish/edit 判定同式:已绑定 && 全部用户) */
|
||||||
|
const needAudit = computed(() => flowBinding.value.bound && isAllTarget.value);
|
||||||
|
/** 提示条展示的流程:优先该通知自身快照,其次应用绑定 */
|
||||||
|
const auditFlowGuid = computed(() => noticeFlowGuid.value || flowBinding.value.flowGuid || "");
|
||||||
|
const auditFlowName = computed(() => {
|
||||||
|
if (!noticeFlowGuid.value) return flowBinding.value.flowName || "";
|
||||||
|
return noticeFlowGuid.value === flowBinding.value.flowGuid ? flowBinding.value.flowName || "" : "";
|
||||||
|
});
|
||||||
|
|
||||||
const fileids = ref<number[]>([]);
|
const fileids = ref<number[]>([]);
|
||||||
const editFileList = ref<{ name: string; fileid: number }[]>([]);
|
const editFileList = ref<{ name: string; fileid: number }[]>([]);
|
||||||
const isSms = ref(false);
|
const isSms = ref(false);
|
||||||
@@ -52,6 +76,33 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* 流程绑定(决定按钮显隐;流程中心未接入时 bound 恒为 false → 原直发界面) */
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const view = await noticeApi.getFlowBinding().send();
|
||||||
|
if (view) flowBinding.value = view;
|
||||||
|
} catch {
|
||||||
|
flowBinding.value = { bound: false, flowGuid: null, flowName: null, flowEnabled: false };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 流程图 Drawer(后端 DiagramDef 中转 PNG:同源免 token;与设置页同款,修复旧深链 404) */
|
||||||
|
const flowDiagramVisible = ref(false);
|
||||||
|
const flowDiagramEl = ref<HTMLImageElement | null>(null);
|
||||||
|
const flowDiagramSrc = computed(() =>
|
||||||
|
flowDiagramVisible.value && auditFlowGuid.value
|
||||||
|
? `${config.serviceHost}/api/notice/flow/DiagramDef?flowGuid=${encodeURIComponent(auditFlowGuid.value)}`
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
function openFlowDiagram() {
|
||||||
|
if (!auditFlowGuid.value) return;
|
||||||
|
flowDiagramVisible.value = true;
|
||||||
|
}
|
||||||
|
function onFlowDiagramError() {
|
||||||
|
flowDiagramVisible.value = false;
|
||||||
|
Message.error("流程图加载失败(流程可能未发布或绑定已解除)");
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ 富文本编辑器(wangeditor v5 for vue3) ============ */
|
/* ============ 富文本编辑器(wangeditor v5 for vue3) ============ */
|
||||||
const editorRef = shallowRef();
|
const editorRef = shallowRef();
|
||||||
const editorLoading = ref(true);
|
const editorLoading = ref(true);
|
||||||
@@ -134,6 +185,10 @@ onMounted(async () => {
|
|||||||
form.dept = detail.DeptID || "";
|
form.dept = detail.DeptID || "";
|
||||||
form.rdoname = detail.tlist === "all" || !Array.isArray(detail.tlist) ? "all" : detail.tlist.join(",");
|
form.rdoname = detail.tlist === "all" || !Array.isArray(detail.tlist) ? "all" : detail.tlist.join(",");
|
||||||
if (detail.DsfsTime) timeForm.date1 = detail.DsfsTime;
|
if (detail.DsfsTime) timeForm.date1 = detail.DsfsTime;
|
||||||
|
// 审核绑定回显(驳回/撤回态显示意见与重提按钮)
|
||||||
|
noticeFlowGuid.value = detail.flowGuid || "";
|
||||||
|
auditStatus.value = detail.auditStatus ?? null;
|
||||||
|
auditResult.value = detail.flowResult || null;
|
||||||
} else {
|
} else {
|
||||||
form.title = detail.title || "";
|
form.title = detail.title || "";
|
||||||
}
|
}
|
||||||
@@ -177,6 +232,7 @@ async function doAdd(_type?: string) {
|
|||||||
deptId: form.dept,
|
deptId: form.dept,
|
||||||
fileIds: fileids.value.join(","), //附件
|
fileIds: fileids.value.join(","), //附件
|
||||||
smsFlag: form.isShortMsg ? 1 : 0, //是否发送短信
|
smsFlag: form.isShortMsg ? 1 : 0, //是否发送短信
|
||||||
|
// 审核流程不再由前端指定:后端按「应用设置绑定 + 通知对象=全部用户」判定
|
||||||
};
|
};
|
||||||
if (isTime) {
|
if (isTime) {
|
||||||
//定时发送
|
//定时发送
|
||||||
@@ -211,14 +267,34 @@ async function doAdd(_type?: string) {
|
|||||||
try {
|
try {
|
||||||
if (!isEdit.value) {
|
if (!isEdit.value) {
|
||||||
await sendCreate(data);
|
await sendCreate(data);
|
||||||
Message.success("发送成功");
|
Message.success(needAudit.value ? "已提交审核,审核通过后自动发布" : "发送成功");
|
||||||
router.push("/list");
|
router.push("/list");
|
||||||
} else {
|
} else {
|
||||||
data.sourceId = serid.value;
|
data.sourceId = serid.value;
|
||||||
if (data.title.indexOf("[修订稿]") == -1) data.title = "[修订稿]" + data.title;
|
if (data.title.indexOf("[修订稿]") == -1) data.title = "[修订稿]" + data.title;
|
||||||
await sendEdit(data);
|
// §3.6:审核轨道内已发布(audit=20)编辑告知——修改后重新走审批,期间对用户不可见
|
||||||
Message.success("保存成功");
|
if (needAudit.value && auditStatus.value === 20) {
|
||||||
router.push("/list");
|
Modal.confirm({
|
||||||
|
title: "重新送审确认",
|
||||||
|
content: "该通知已发布,修改后将重新走审批流程,期间对用户不可见,确认继续?",
|
||||||
|
onOk: async () => {
|
||||||
|
await saveEditAndLeave(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 绑定生效后把存量通知(此前直发、无审核轨道)改为全部用户:保存即将转入待审核
|
||||||
|
if (needAudit.value && !noticeFlowGuid.value) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: "转为待审核确认",
|
||||||
|
content: "本应用已绑定审核流程,发送全部用户需审核通过后发布。保存后该通知将进入待审核状态,审核期间对用户不可见,确认继续?",
|
||||||
|
onOk: async () => {
|
||||||
|
await saveEditAndLeave(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveEditAndLeave(data);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Message.error(e.message || "保存失败");
|
Message.error(e.message || "保存失败");
|
||||||
@@ -226,6 +302,27 @@ async function doAdd(_type?: string) {
|
|||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 编辑保存 + 离开(§3.6 抽出:确认分支复用) */
|
||||||
|
async function saveEditAndLeave(data: NoticeRequest) {
|
||||||
|
await sendEdit(data);
|
||||||
|
// 编辑驳回/撤回态:后端 edit 内已自动重提(复用同办件 R4);待提交态由重提按钮处理
|
||||||
|
Message.success(needAudit.value ? "已保存并提交审核" : "保存成功");
|
||||||
|
router.push("/list");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单独重提审核(待提交/驳回/撤回态;审批中按钮隐藏) */async function doResubmit() {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
await noticeApi.trySubmitFlow(serid.value).send();
|
||||||
|
Message.success("已提交审核");
|
||||||
|
router.push("/list");
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e.message || "提交审核失败");
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -249,6 +346,20 @@ async function doAdd(_type?: string) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
|
<!-- 绑定了审核流程且通知对象=全部用户:告知需要审核(流程在「应用设置 > 流程绑定」维护) -->
|
||||||
|
<Alert v-if="needAudit" type="info" show-icon class="audit-alert">
|
||||||
|
本应用已绑定审核流程{{ auditFlowName ? `《${auditFlowName}》` : "" }}:发送全部用户需审核通过后发布
|
||||||
|
<template v-if="auditFlowGuid"> <Button type="text" size="small" @click="openFlowDiagram">查看流程图</Button></template>
|
||||||
|
<template v-if="isAllTarget"> 如需直接发送,可将通知对象改为「指定用户」</template>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<!-- 驳回/撤回意见提示(编辑驳回/撤回态) -->
|
||||||
|
<Alert v-if="isEdit && (auditStatus === 30 || auditStatus === 40)" type="warning" show-icon class="audit-alert">
|
||||||
|
审核{{ auditStatus === 30 ? "已驳回" : "已撤回" }}
|
||||||
|
<template v-if="auditResult?.opinion"> 办理意见:{{ auditResult.opinion }}<template v-if="auditResult?.rejectNode">({{ auditResult.rejectNode }})</template></template>
|
||||||
|
修改保存后将自动重新提交审核
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<!-- 富文本编辑器 -->
|
<!-- 富文本编辑器 -->
|
||||||
<div class="editor-wrap">
|
<div class="editor-wrap">
|
||||||
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
|
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
|
||||||
@@ -262,8 +373,19 @@ async function doAdd(_type?: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bottom-btn">
|
<div class="bottom-btn">
|
||||||
<Button type="primary" :loading="isLoading" @click="doAdd()">{{ isEdit ? "保存" : "发送" }}</Button>
|
<Button type="primary" :loading="isLoading" @click="doAdd()">
|
||||||
<Button v-if="!isEdit" style="margin-left: 10px" @click="isModal.timiSend = true">定时发送</Button>
|
{{ isEdit ? (needAudit ? "保存并重新提交审核" : "保存") : needAudit ? "提交审核" : "发送" }}
|
||||||
|
</Button>
|
||||||
|
<!-- 待提交态单独重提(提交流程中心失败后的幂等重推入口) -->
|
||||||
|
<Button
|
||||||
|
v-if="isEdit && noticeFlowGuid && auditStatus === 0"
|
||||||
|
style="margin-left: 10px"
|
||||||
|
:loading="isLoading"
|
||||||
|
@click="doResubmit"
|
||||||
|
>重新提交审核</Button
|
||||||
|
>
|
||||||
|
<!-- 定时发送与审核互斥:走审核时隐藏(后端同步清空定时时间兜底) -->
|
||||||
|
<Button v-if="!isEdit && !needAudit" style="margin-left: 10px" @click="isModal.timiSend = true">定时发送</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 定时发送 -->
|
<!-- 定时发送 -->
|
||||||
@@ -297,11 +419,22 @@ async function doAdd(_type?: string) {
|
|||||||
<Button @click="isModal.timiSend = false" style="margin-left: 8px">取消</Button>
|
<Button @click="isModal.timiSend = false" style="margin-left: 8px">取消</Button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- 流程图 Drawer(后端 DiagramDef 同源 PNG;竖版长图容器内纵向滚动) -->
|
||||||
|
<Drawer v-model="flowDiagramVisible" title="流程示意图" :width="740" :transfer="false"
|
||||||
|
:styles="{ body: { padding: '0' } }">
|
||||||
|
<div class="flow-diagram-frame">
|
||||||
|
<img ref="flowDiagramEl" v-if="flowDiagramSrc" :src="flowDiagramSrc" alt="流程示意图" @load="fitDiagram(flowDiagramEl)" @error="onFlowDiagramError" />
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.audit-alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
/* 页面超高时的滚动由 App.vue 的 .main-wrapper 统一承接 */
|
/* 页面超高时的滚动由 App.vue 的 .main-wrapper 统一承接 */
|
||||||
.myform {
|
.myform {
|
||||||
:deep(.ivu-form-item) {
|
:deep(.ivu-form-item) {
|
||||||
@@ -309,11 +442,11 @@ async function doAdd(_type?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.editor-wrap {
|
.editor-wrap {
|
||||||
border: 1px solid #dcdee2;
|
border: 1px solid var(--line);
|
||||||
border-radius: 4px;
|
border-radius: var(--r-s);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
.editor-toolbar {
|
.editor-toolbar {
|
||||||
border-bottom: 1px solid #e8e8e8;
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.editor-content {
|
.editor-content {
|
||||||
:deep(.w-e-text-container) {
|
:deep(.w-e-text-container) {
|
||||||
@@ -325,4 +458,18 @@ async function doAdd(_type?: string) {
|
|||||||
.bottom-btn {
|
.bottom-btn {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
/* 流程图 Drawer:浅底色 + 纵向滚动(竖版长图),图片撑满可读宽度 */
|
||||||
|
.flow-diagram-frame {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.flow-diagram-frame img {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+249
-114
@@ -5,43 +5,79 @@ import { Message, Tooltip } from "view-ui-plus";
|
|||||||
import { noticeApi } from "@/api/module/notice";
|
import { noticeApi } from "@/api/module/notice";
|
||||||
import type { NoticeDetailVo, NoticeQuery, NoticeVo, NoticePageResult } from "@/types/notice";
|
import type { NoticeDetailVo, NoticeQuery, NoticeVo, NoticePageResult } from "@/types/notice";
|
||||||
import FilePreviewModal from "@/components/FilePreviewModal.vue";
|
import FilePreviewModal from "@/components/FilePreviewModal.vue";
|
||||||
|
import SvgIcon from "@/components/SvgIcon.vue";
|
||||||
import Utils from "@/utils";
|
import Utils from "@/utils";
|
||||||
|
|
||||||
/* ============ 左侧列表 ============ */
|
/* ============ 左侧列表(每页 50,滚动到底部加载下一页并追加) ============ */
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
const pageNum = ref(1);
|
const PAGE_SIZE = 50;
|
||||||
const pageSize = ref(50);
|
const pageIndex = ref(1);
|
||||||
|
const list = ref<NoticeVo[]>([]);
|
||||||
|
const unreadCount = ref(0);
|
||||||
|
const hasNext = ref(false);
|
||||||
|
const loadingMore = ref(false);
|
||||||
const selected = ref(0);
|
const selected = ref(0);
|
||||||
|
|
||||||
const { data: page, loading, send: reload } = useRequest(
|
const { send: reload } = useRequest((params: NoticeQuery) => noticeApi.list(params), { immediate: false });
|
||||||
(params: NoticeQuery) => noticeApi.list(params),
|
|
||||||
{
|
|
||||||
immediate: false,
|
|
||||||
initialData: { content: [], total: 0, pageIndex: 1, pageSize: 50, totalPages: 0, hasNext: false, unreadCount: 0 },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
async function run() {
|
async function fetchList(append: boolean) {
|
||||||
const data: NoticePageResult<NoticeVo> = await reload({
|
const data: NoticePageResult<NoticeVo> = await reload({
|
||||||
keyword: keyword.value || "",
|
keyword: keyword.value || "",
|
||||||
pageIndex: pageNum.value,
|
pageIndex: append ? pageIndex.value + 1 : 1,
|
||||||
pageSize: pageSize.value,
|
pageSize: PAGE_SIZE,
|
||||||
orderByTime: "1",
|
orderByTime: "1",
|
||||||
});
|
});
|
||||||
|
const content = data?.content || [];
|
||||||
|
if (append) {
|
||||||
|
list.value = list.value.concat(content);
|
||||||
|
pageIndex.value += 1;
|
||||||
|
} else {
|
||||||
|
list.value = content;
|
||||||
|
pageIndex.value = 1;
|
||||||
|
}
|
||||||
|
unreadCount.value = data?.unreadCount || 0;
|
||||||
|
hasNext.value = !!data?.hasNext;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
await fetchList(false);
|
||||||
// 默认选中第一条(同旧版进入页面即展示最新通知)
|
// 默认选中第一条(同旧版进入页面即展示最新通知)
|
||||||
const list = data?.content || [];
|
const first = list.value[0];
|
||||||
if (!detail.value && list.length) {
|
if (!detail.value && first) {
|
||||||
selected.value = 0;
|
selected.value = 0;
|
||||||
await loadDetail(list[0].id, true);
|
await loadDetail(first.id, true);
|
||||||
|
markRead(first);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onSearch() {
|
|
||||||
pageNum.value = 1;
|
/* 触底预取:距底部 60px 即发下一页请求,避免滑到尽头才出现内容 */
|
||||||
run();
|
function onListScroll(e: Event) {
|
||||||
|
const el = e.target as HTMLElement;
|
||||||
|
if (!hasNext.value || loadingMore.value) return;
|
||||||
|
if (el.scrollTop + el.clientHeight < el.scrollHeight - 60) return;
|
||||||
|
loadMore();
|
||||||
}
|
}
|
||||||
|
async function loadMore() {
|
||||||
|
loadingMore.value = true;
|
||||||
|
try {
|
||||||
|
await fetchList(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.message || "加载更多失败");
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已读状态本地置位:标读接口已改库,无需重拉列表(重拉会丢掉已追加的分页与滚动位置) */
|
||||||
|
function markRead(item: NoticeVo) {
|
||||||
|
if (item.read) return;
|
||||||
|
item.read = true;
|
||||||
|
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||||
|
}
|
||||||
|
|
||||||
function onClear() {
|
function onClear() {
|
||||||
keyword.value = "";
|
keyword.value = "";
|
||||||
onSearch();
|
run();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 一键已读(旧版 icon-saoba 清除按钮) */
|
/* 一键已读(旧版 icon-saoba 清除按钮) */
|
||||||
@@ -49,7 +85,8 @@ async function allRead() {
|
|||||||
try {
|
try {
|
||||||
await noticeApi.initializeRed().send();
|
await noticeApi.initializeRed().send();
|
||||||
Message.success("操作成功!");
|
Message.success("操作成功!");
|
||||||
run();
|
list.value.forEach(markRead);
|
||||||
|
unreadCount.value = 0;
|
||||||
if (detail.value) loadDetail(detail.value.serid, false);
|
if (detail.value) loadDetail(detail.value.serid, false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Message.error(e.message || "操作失败");
|
Message.error(e.message || "操作失败");
|
||||||
@@ -67,16 +104,13 @@ onMounted(async () => {
|
|||||||
async function checkNotice(item: NoticeVo, index: number) {
|
async function checkNotice(item: NoticeVo, index: number) {
|
||||||
selected.value = index;
|
selected.value = index;
|
||||||
await loadDetail(item.id, true);
|
await loadDetail(item.id, true);
|
||||||
|
markRead(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDetail(serid: number, markRead: boolean) {
|
async function loadDetail(serid: number, markAsRead: boolean) {
|
||||||
detailLoading.value = true;
|
detailLoading.value = true;
|
||||||
try {
|
try {
|
||||||
detail.value = await noticeApi.modify(serid, markRead ? 1 : 0).send();
|
detail.value = await noticeApi.modify(serid, markAsRead ? 1 : 0).send();
|
||||||
if (markRead) {
|
|
||||||
// 标读后刷新左侧未读状态
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Message.error(e.message || "加载通知失败");
|
Message.error(e.message || "加载通知失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -111,7 +145,7 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi) */
|
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian/yasuo/weizhi) */
|
||||||
function getFileIcon(name: string) {
|
function getFileIcon(name: string) {
|
||||||
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
|
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
|
||||||
if (["doc", "docx", "wps"].includes(suffix)) return "icon-word";
|
if (["doc", "docx", "wps"].includes(suffix)) return "icon-word";
|
||||||
@@ -119,7 +153,8 @@ function getFileIcon(name: string) {
|
|||||||
if (["ppt", "pptx"].includes(suffix)) return "icon-ppt";
|
if (["ppt", "pptx"].includes(suffix)) return "icon-ppt";
|
||||||
if (["pdf"].includes(suffix)) return "icon-pdf1";
|
if (["pdf"].includes(suffix)) return "icon-pdf1";
|
||||||
if (["txt"].includes(suffix)) return "icon-txt";
|
if (["txt"].includes(suffix)) return "icon-txt";
|
||||||
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(suffix)) return "icon-tupian1";
|
// 平台 icons.css 的 icon-tupian 是线性「图片」图标;icon-tupian1 是旧彩色图转单色,看不出是图片
|
||||||
|
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(suffix)) return "icon-tupian";
|
||||||
if (["zip", "rar"].includes(suffix)) return "icon-yasuo";
|
if (["zip", "rar"].includes(suffix)) return "icon-yasuo";
|
||||||
return "icon-weizhi";
|
return "icon-weizhi";
|
||||||
}
|
}
|
||||||
@@ -163,15 +198,15 @@ function shortDate(date: string) {
|
|||||||
/* 时间显示(列表 MM/dd HH:mm,同旧版) */</script>
|
/* 时间显示(列表 MM/dd HH:mm,同旧版) */</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="accept-wrap">
|
<div class="accept-wrap">
|
||||||
<!-- 左侧通知列表 -->
|
<!-- 左侧通知列表 -->
|
||||||
<div class="title-list">
|
<div class="title-list">
|
||||||
<div class="total">
|
<div class="total">
|
||||||
<span>未读<span class="num">{{ page.unreadCount || 0 }}</span>条</span>
|
<span>未读<span class="num">{{ unreadCount }}</span>条</span>
|
||||||
<Tooltip content="一键已读" placement="top" transfer>
|
<Tooltip content="一键已读" placement="top" transfer>
|
||||||
<i class="iconfont icon-saoba clear-icon" @click="allRead"></i>
|
<SvgIcon class="clear-icon" name="read-all" :size="18" color="var(--text-3)" hover-color="var(--brand)" @click="allRead" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
@@ -180,13 +215,13 @@ function shortDate(date: string) {
|
|||||||
clearable
|
clearable
|
||||||
search
|
search
|
||||||
placeholder="搜索发布人,标题,部门......"
|
placeholder="搜索发布人,标题,部门......"
|
||||||
@on-search="onSearch"
|
@on-search="run"
|
||||||
@on-clear="onClear"
|
@on-clear="onClear"
|
||||||
></Input>
|
></Input>
|
||||||
</div>
|
</div>
|
||||||
<div class="notice-items">
|
<div class="notice-items" @scroll="onListScroll">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in page.content"
|
v-for="(item, index) in list"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:class="{ list_item: true, readed: item.read, active: index == selected }"
|
:class="{ list_item: true, readed: item.read, active: index == selected }"
|
||||||
@click="checkNotice(item, index)"
|
@click="checkNotice(item, index)"
|
||||||
@@ -197,7 +232,9 @@ function shortDate(date: string) {
|
|||||||
<span>{{ shortDate(item.createTime) }}</span>
|
<span>{{ shortDate(item.createTime) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!page.content.length" class="empty">暂无通知</div>
|
<div v-if="loadingMore" class="load-more">加载中...</div>
|
||||||
|
<div v-else-if="list.length && !hasNext" class="load-more">没有更多了</div>
|
||||||
|
<div v-if="!list.length" class="empty">暂无通知</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,11 +248,11 @@ function shortDate(date: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="title-content">
|
<div class="title-content">
|
||||||
<p>来源:{{ detail.DeptName }}</p>
|
<p><span class="meta-label">来源</span>:{{ detail.DeptName }}</p>
|
||||||
<p>发布人:{{ detail.TeaName }}</p>
|
<p><span class="meta-label">发布人</span>:{{ detail.TeaName }}</p>
|
||||||
<p>发布时间:{{ detail.EditDate }}</p>
|
<p><span class="meta-label">发布时间</span>:{{ detail.EditDate }}</p>
|
||||||
<p>
|
<p>
|
||||||
<span>附件:{{ (detail.ChaoSend || []).length }}个</span>
|
<span class="meta-label">附件</span>:{{ (detail.ChaoSend || []).length }}个
|
||||||
<span class="to-file" v-if="detail.ChaoSend && detail.ChaoSend.length > 0" @click="downAll">全部下载</span>
|
<span class="to-file" v-if="detail.ChaoSend && detail.ChaoSend.length > 0" @click="downAll">全部下载</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,188 +299,286 @@ function shortDate(date: string) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* 根容器 page-wrapper > main-container:内容区可视高度 = 100vh - 112(头部+面包屑) - 24(容器底padding) */
|
/* 已收到页 · 对齐原型 07-seam-pure.html 的 .nt-split(灰底上并排两张白卡)。
|
||||||
.accept-wrap {
|
通道把 .main-container 画成单张白卡,这里压平它、让两张卡直接坐灰底;
|
||||||
display: flex;
|
外层 .page-wrapper.yh-fill 由通道转 flex 纵向铺满,卡片 flex:1 撑满、四周留白 14/18/24 全站一致。
|
||||||
gap: 15px;
|
所有颜色/圆角/阴影一律走主题令牌,禁止硬编码。 */
|
||||||
height: calc(100vh - 136px);
|
.page-wrapper .main-container {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
.accept-wrap {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 296px minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 左:列表卡(原型 .nt-list) ---------- */
|
||||||
.title-list {
|
.title-list {
|
||||||
width: 360px;
|
|
||||||
flex: none;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #ebeef4;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 14px 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
.total {
|
.total {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 15px 10px;
|
padding: 13px 16px 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
.num {
|
.num {
|
||||||
padding: 0 5px;
|
padding: 0 2px;
|
||||||
color: #5c9edc;
|
color: var(--brand);
|
||||||
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.clear-icon {
|
.clear-icon {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 18px;
|
|
||||||
color: #999;
|
|
||||||
&:hover {
|
|
||||||
color: #2d8cf0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.search-box {
|
.search-box {
|
||||||
padding: 0 15px;
|
padding: 0 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
/* 让 iview 搜索框吃原型灰底胶囊观感(浅改,不动结构) */
|
||||||
|
:deep(.ivu-input-wrapper) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
:deep(.ivu-input) {
|
||||||
|
background: #f7f9fc;
|
||||||
|
border-color: transparent;
|
||||||
|
border-radius: var(--r-s);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
:deep(.ivu-input:hover) {
|
||||||
|
background: #f1f4f9;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
:deep(.ivu-input-wrapper-focused .ivu-input) {
|
||||||
|
background: #fff;
|
||||||
|
border-color: var(--brand);
|
||||||
|
box-shadow: 0 0 0 3px var(--brand-a);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.notice-items {
|
.notice-items {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin-top: 12px;
|
padding: 0 8px 12px;
|
||||||
.list_item {
|
.list_item {
|
||||||
padding: 10px 15px;
|
padding: 10px 12px;
|
||||||
cursor: pointer;
|
margin-bottom: 2px;
|
||||||
|
border-radius: var(--r-s);
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
.tt {
|
.tt {
|
||||||
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #272829;
|
color: var(--text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.other {
|
.other {
|
||||||
margin-top: 6px;
|
margin-top: 5px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 12px;
|
font-size: 11.5px;
|
||||||
color: #999;
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f6f9ff;
|
background: #f7f9fc;
|
||||||
}
|
}
|
||||||
&.active {
|
&.active {
|
||||||
background: #ecf3ff;
|
background: var(--brand-soft);
|
||||||
border-left-color: #2d8cf0;
|
border-left-color: var(--brand);
|
||||||
|
.tt {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/* 已读弱化 */
|
/* 已读弱化 */
|
||||||
&.readed .tt {
|
&.readed .tt {
|
||||||
color: #8a94a6;
|
color: var(--text-2);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.empty {
|
.empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #999;
|
color: var(--text-3);
|
||||||
padding: 40px 0;
|
padding: 40px 0;
|
||||||
}
|
}
|
||||||
|
.load-more {
|
||||||
|
padding: 10px 0 4px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 右:详情卡(原型 .nt-detail) ---------- */
|
||||||
.detail-content {
|
.detail-content {
|
||||||
flex: 1;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #ebeef4;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
padding: 18px 22px 24px;
|
||||||
&.empty-detail {
|
&.empty-detail {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #999;
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.title-header {
|
.title-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
.tt {
|
.tt {
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #272829;
|
letter-spacing: 0.2px;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.ops {
|
.ops {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
.op {
|
.op {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #515a6e;
|
color: var(--text-2);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 5px;
|
||||||
font-size: 13px;
|
font-size: 12.5px;
|
||||||
|
transition: color 0.15s;
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #2d8cf0;
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.title-content {
|
.title-content {
|
||||||
margin-top: 10px;
|
margin-top: 14px;
|
||||||
padding: 8px 0;
|
padding-bottom: 16px;
|
||||||
border-bottom: 1px dashed #e8e8e8;
|
border-bottom: 1px dashed var(--line);
|
||||||
font-size: 13px;
|
display: grid;
|
||||||
color: #808695;
|
gap: 9px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-2);
|
||||||
p {
|
p {
|
||||||
margin: 4px 0;
|
margin: 0;
|
||||||
|
}
|
||||||
|
/* 标签两端撑开:冒号纵向对齐、值从同一列起(宽度取「发布时间」4 字) */
|
||||||
|
.meta-label {
|
||||||
|
display: inline-block;
|
||||||
|
width: 4em;
|
||||||
|
text-align: justify;
|
||||||
|
text-align-last: justify;
|
||||||
}
|
}
|
||||||
.to-file {
|
.to-file {
|
||||||
margin-left: 10px;
|
margin-left: 8px;
|
||||||
color: #2d8cf0;
|
color: var(--brand);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.notice-content {
|
.notice-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-top: 10px;
|
padding-top: 14px;
|
||||||
|
.ql-editor {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
.file {
|
.file {
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r);
|
||||||
|
overflow: hidden;
|
||||||
.file-title {
|
.file-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
background: #f7f7f7;
|
align-items: center;
|
||||||
padding: 8px 12px;
|
padding: 9px 14px;
|
||||||
font-size: 13px;
|
background: #f7f9fc;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-2);
|
||||||
.all-down {
|
.all-down {
|
||||||
color: #2d8cf0;
|
color: var(--brand);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.file-list {
|
.file-list {
|
||||||
padding: 10px 12px;
|
padding: 2px 0;
|
||||||
.file-item {
|
.file-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 6px;
|
padding: 11px 14px;
|
||||||
border-bottom: 1px dashed #f0f0f0;
|
transition: background 0.15s;
|
||||||
|
&:hover {
|
||||||
|
background: #fafbfe;
|
||||||
|
}
|
||||||
|
.left {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
.file-type-icon {
|
.file-type-icon {
|
||||||
font-size: 26px;
|
width: 30px;
|
||||||
color: #515a6e;
|
height: 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--brand-soft);
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
.center {
|
.center {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
min-width: 0;
|
||||||
.name {
|
.name {
|
||||||
color: #272829;
|
font-size: 12.5px;
|
||||||
font-size: 13px;
|
color: var(--text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.size {
|
.size {
|
||||||
font-size: 12px;
|
font-size: 11.5px;
|
||||||
color: #999;
|
color: var(--text-3);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
a {
|
||||||
|
color: var(--brand);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12.5px;
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.right a {
|
|
||||||
margin-right: 8px;
|
|
||||||
color: #2d8cf0;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrapper yh-fill">
|
||||||
|
<FlowApprovalList ref="listRef" :api="approvalApiAdapter" biz-type="notice"
|
||||||
|
:tabs="['todo', 'instances']"
|
||||||
|
@open-record="openRecord" @open-task="openTask" @handled="onHandled" />
|
||||||
|
|
||||||
|
<!-- 审批记录:token 只读页,地址由抽屉自己现取(不缓存、无审批按钮) -->
|
||||||
|
<FlowRecordDrawer v-model="recordVisible" :api="approvalApiAdapter" :biz-id="recordBizId" />
|
||||||
|
|
||||||
|
<!-- 完整办理:CAS 去壳任务页,地址由后端确认"taskId 属于当前人"后才签发 -->
|
||||||
|
<FlowTaskEmbed v-model="taskVisible" :page-url="taskPageUrl" :task-id="taskTaskId"
|
||||||
|
@handled="onTaskHandled" @open-biz="onOpenBiz" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 我的待审核(v2.1 重构):列表/动作/记录抽屉/任务页容器全部下沉到 flow-approval 共享组件,
|
||||||
|
* 本页只剩两件事——注入 Notice 自己的 API 适配器,以及决定"记录/任务页"这两个浮层放在哪。
|
||||||
|
*
|
||||||
|
* 页签沿用「我的待审核 + 办件视角」(方案 D5:前置阶段不扩四页签,避免行为变化)。
|
||||||
|
* 身份仍然一个都不从前端传:适配器只送 serid/taskId/opinion/edgeName,
|
||||||
|
* operatorId/schId/name 由 NoticeService 从 CAS 会话取。
|
||||||
|
*/
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { 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 noticeFlowApprovalApi from "@/api/module/approvalAdapter";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const listRef = ref<any>(null);
|
||||||
|
|
||||||
|
const approvalApiAdapter = noticeFlowApprovalApi;
|
||||||
|
|
||||||
|
/* ===== 审批记录抽屉 ===== */
|
||||||
|
const recordVisible = ref(false);
|
||||||
|
const recordBizId = ref("");
|
||||||
|
|
||||||
|
function openRecord(bizId: string) {
|
||||||
|
recordBizId.value = String(bizId);
|
||||||
|
recordVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 完整办理(去壳任务页) ===== */
|
||||||
|
const taskVisible = ref(false);
|
||||||
|
const taskPageUrl = ref("");
|
||||||
|
const taskTaskId = ref<number | string>("");
|
||||||
|
|
||||||
|
async function openTask(payload: { bizId: string; taskId: number }) {
|
||||||
|
taskPageUrl.value = "";
|
||||||
|
try {
|
||||||
|
const res = await approvalApiAdapter.taskPageUrl(String(payload.bizId), payload.taskId);
|
||||||
|
const url = (res as any)?.pageUrl || "";
|
||||||
|
if (!url) {
|
||||||
|
Message.error((res as any)?.msg || "办理页地址获取失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
taskTaskId.value = payload.taskId;
|
||||||
|
taskPageUrl.value = url;
|
||||||
|
taskVisible.value = true;
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.msg || e?.message || "办理页打开失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务页里办完了:关弹窗 + 刷新列表(不靠轮询) */
|
||||||
|
function onTaskHandled() {
|
||||||
|
taskVisible.value = false;
|
||||||
|
listRef.value?.load?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务页"查看业务详情"交回本页:列表页里即跳通知详情(同应用内路由,不用 window.open 白跳中心站) */
|
||||||
|
function onOpenBiz(payload: { bizId?: string | null; bizUrl?: string }) {
|
||||||
|
taskVisible.value = false;
|
||||||
|
const serid = payload?.bizId;
|
||||||
|
if (serid) {
|
||||||
|
router.push(`/list/detail?serid=${serid}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload?.bizUrl) window.open(payload.bizUrl, "_blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHandled() {
|
||||||
|
listRef.value?.load?.();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrapper yh-fill">
|
||||||
|
<div class="main-container">
|
||||||
|
<TablePageAlovaNew ref="listRef" :data-api="fetchPage" :columns="cols">
|
||||||
|
<template #title="{ row }">
|
||||||
|
<a v-if="row.title" class="notice-link" @click="openDetail(row)">{{ row.title }}</a>
|
||||||
|
<span v-else class="notice-gone">通知已删除</span>
|
||||||
|
</template>
|
||||||
|
<template #status="{ row }">
|
||||||
|
<Tag v-if="row.auditStatusDesc && row.auditStatusDesc !== '通知已删除'"
|
||||||
|
:color="statusColor(row.auditStatus)">{{ row.auditStatusDesc }}</Tag>
|
||||||
|
<Tag v-else-if="row.noticeStatus === 1" color="green">已发布</Tag>
|
||||||
|
<Tag v-else>—</Tag>
|
||||||
|
</template>
|
||||||
|
</TablePageAlovaNew>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
|
||||||
|
import ccApi, { type CcItem } from "@/api/module/cc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抄送给我的(2026-09-24):流程 NOTIFY 节点激活即抄送(审批过程中就出现在列表里,
|
||||||
|
* 此时通知未发布——被抄送人可见由 NoticeFlowGuard 第 4 条放行)。点击标题跳通知详情;
|
||||||
|
* 编辑视角(type=0)不标读:未发布通知的阅读统计无意义。
|
||||||
|
*/
|
||||||
|
const router = useRouter();
|
||||||
|
const listRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
|
// usePagination 的 handler 必须返回 Method(组件内部统一 send 并吃全局响应拦截),返回 Promise 会卡在 loading
|
||||||
|
const fetchPage = (p: { pageIndex: number; pageSize: number }) => ccApi.my(p.pageIndex, p.pageSize);
|
||||||
|
|
||||||
|
const cols = [
|
||||||
|
{ title: "通知标题", slot: "title", minWidth: 260, ellipsis: true, tooltip: true },
|
||||||
|
{ title: "来源部门", key: "deptName", width: 140 },
|
||||||
|
{ title: "抄送环节", key: "nodeName", width: 140 },
|
||||||
|
{ title: "抄送时间", key: "sentTime", width: 170, render: fmtTime },
|
||||||
|
{ title: "状态", slot: "status", width: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtTime(h: any, p: any) {
|
||||||
|
return h(
|
||||||
|
"span",
|
||||||
|
{},
|
||||||
|
String(p.row[p.column.key] || "").replace("T", " ").slice(0, 19) || "—"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(auditStatus: number | null) {
|
||||||
|
return ({ 0: "default", 10: "blue", 20: "green", 30: "red", 40: "orange" } as Record<number, string>)[
|
||||||
|
auditStatus ?? -1
|
||||||
|
] || "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(row: CcItem) {
|
||||||
|
router.push({ path: "/list/detail", query: { serid: row.noticeId, type: 0 } });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notice-link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-gone {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,9 +3,14 @@ import { ref, computed, onMounted } from "vue";
|
|||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message } from "view-ui-plus";
|
||||||
import { noticeApi } from "@/api/module/notice";
|
import { noticeApi } from "@/api/module/notice";
|
||||||
import type { NoticeDetailVo, NextPrevVo, ReadGroup } from "@/types/notice";
|
import type { NoticeDetailVo, NextPrevVo, ReadGroup, FlowTraceVo } from "@/types/notice";
|
||||||
import FilePreviewModal from "@/components/FilePreviewModal.vue";
|
import FilePreviewModal from "@/components/FilePreviewModal.vue";
|
||||||
|
import FlowApprovalCard from "@/components/flow-approval/FlowApprovalCard.vue";
|
||||||
|
import FlowTaskEmbed from "@/components/flow-approval/FlowTaskEmbed.vue";
|
||||||
|
import noticeFlowApprovalApi from "@/api/module/approvalAdapter";
|
||||||
import Utils from "@/utils";
|
import Utils from "@/utils";
|
||||||
|
import { fitDiagram } from "@/utils/diagramImg";
|
||||||
|
import config from "@/assets/config/host.config";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -21,6 +26,160 @@ const navType = computed(() => (viewType.value === 0 ? "0" : "1"));
|
|||||||
|
|
||||||
const detail = ref<NoticeDetailVo | null>(null);
|
const detail = ref<NoticeDetailVo | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
/* ============ 先审后发:审批进度卡片 + 撤回/催办 ============ */
|
||||||
|
const flowTrace = ref<FlowTraceVo | null>(null);
|
||||||
|
const flowActing = ref(false);
|
||||||
|
|
||||||
|
async function loadTrace() {
|
||||||
|
if (!detail.value?.flowGuid) return;
|
||||||
|
try {
|
||||||
|
flowTrace.value = await noticeApi.flowTrace(serid.value).send();
|
||||||
|
} catch {
|
||||||
|
flowTrace.value = null; // 进度不可用时只展示本地镜像
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前节点:flow-center 的 currentNodes 是**节点 id 逗号串**(办件原文口径),
|
||||||
|
* 用人看的名字得拿 steps 的 nodeId→nodeName 翻;翻不到(老实例/步骤缺失)才回落原 id。
|
||||||
|
*/
|
||||||
|
const currentNodeText = computed(() => {
|
||||||
|
const ids = String(flowTrace.value?.currentNodes || "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!ids.length) return "";
|
||||||
|
const names = new Map<string, string>();
|
||||||
|
for (const s of flowTrace.value?.steps || []) {
|
||||||
|
if (s.nodeId && s.nodeName) names.set(String(s.nodeId), s.nodeName);
|
||||||
|
}
|
||||||
|
return ids.map((id) => names.get(id) || id).join("、");
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤回:走本应用后端(2026-09-17 接入:后端经 SDK 托管件中转并复核创建人,
|
||||||
|
* 不再前端直调 flow-center——跨应用 CAS 票据 + CORS 双重外溢且"谁能撤"落在前端)。
|
||||||
|
* 催办:仍是"人"的动作,走 flow-center 用户面(同主域 CAS 免二次登录)。
|
||||||
|
*/
|
||||||
|
async function flowAction(action: "withdraw" | "urge") {
|
||||||
|
const instanceId = detail.value?.flowInstanceId;
|
||||||
|
if (!instanceId) return;
|
||||||
|
flowActing.value = true;
|
||||||
|
try {
|
||||||
|
if (action === "withdraw") {
|
||||||
|
await noticeApi.flowWithdraw(serid.value).send();
|
||||||
|
Message.success("已撤回审核,可编辑后重新提交");
|
||||||
|
await load();
|
||||||
|
await loadTrace();
|
||||||
|
} else {
|
||||||
|
if (!config.flowHost) return;
|
||||||
|
const resp = await fetch(`${config.flowHost}/api/flow/instance/${instanceId}/urge`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const res = await resp.json().catch(() => null);
|
||||||
|
if (resp.ok && res && res.code === 0) {
|
||||||
|
Message.success("已发送催办提醒");
|
||||||
|
} else {
|
||||||
|
Message.error(res?.msg || "催办失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(
|
||||||
|
action === "withdraw"
|
||||||
|
? "撤回失败(已有人办理时不可撤回):" + (e?.msg || e?.message || e)
|
||||||
|
: "流程中心不可达:" + (e?.message || e),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
flowActing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 流程图弹窗(2026-09-17:后端同源出图 /flow/Diagram,img 免 token 免跨域,不再深链 flow-center) */
|
||||||
|
const diagramVisible = ref(false);
|
||||||
|
const diagramEl = ref<HTMLImageElement | null>(null);
|
||||||
|
function openFlowDiagram() {
|
||||||
|
if (!detail.value?.flowGuid || !detail.value?.flowInstanceId) return;
|
||||||
|
diagramVisible.value = true;
|
||||||
|
}
|
||||||
|
const diagramSrc = computed(() =>
|
||||||
|
diagramVisible.value && detail.value?.flowInstanceId
|
||||||
|
? `${config.serviceHost}/api/notice/flow/Diagram?serid=${serid.value}`
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
function onDiagramError() {
|
||||||
|
diagramVisible.value = false;
|
||||||
|
Message.error("流程图加载失败(可能已过期,请刷新页面重试)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 办件进度嵌入页(iframe 免签嵌入 flow-center 统一进度页):每次打开现取 pageUrl——
|
||||||
|
* token 10 分钟短时效,不缓存、不落 localStorage 复用;嵌入页内部自己滚动 */
|
||||||
|
const embedVisible = ref(false);
|
||||||
|
const embedPageUrl = ref("");
|
||||||
|
async function openFlowEmbed() {
|
||||||
|
if (!detail.value?.flowInstanceId) return;
|
||||||
|
embedVisible.value = true;
|
||||||
|
embedPageUrl.value = "";
|
||||||
|
try {
|
||||||
|
const res = await noticeApi.flowEmbed(serid.value).send();
|
||||||
|
embedPageUrl.value = res?.pageUrl || "";
|
||||||
|
} catch (e: any) {
|
||||||
|
embedVisible.value = false;
|
||||||
|
Message.error(e?.msg || e?.message || "嵌入页地址获取失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ v2.1 审批动作:卡片办理后刷新;完整办理走 CAS 去壳任务页 ============ */
|
||||||
|
|
||||||
|
/** 卡片里通过/驳回成功:刷新进度与详情(业务状态由 flow-center 回调推进,前端不本地写) */
|
||||||
|
async function onApprovalHandled() {
|
||||||
|
await Promise.all([load(), loadTrace()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskVisible = ref(false);
|
||||||
|
const taskPageUrl = ref("");
|
||||||
|
const taskCurrentId = ref<number | string>("");
|
||||||
|
|
||||||
|
/** 任务页地址由后端签发(它会先确认 taskId 属于当前人待办),前端不拼 flow-center 基址 */
|
||||||
|
async function openTaskFromCard(taskId: number) {
|
||||||
|
taskPageUrl.value = "";
|
||||||
|
try {
|
||||||
|
const res: any = await noticeFlowApprovalApi.taskPageUrl(String(serid.value), taskId);
|
||||||
|
if (!res?.pageUrl) {
|
||||||
|
Message.error(res?.msg || "办理页地址获取失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
taskCurrentId.value = taskId;
|
||||||
|
taskPageUrl.value = res.pageUrl;
|
||||||
|
taskVisible.value = true;
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.msg || e?.message || "办理页打开失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务页里办完:关弹窗并刷新进度/详情,不等轮询 */
|
||||||
|
async function onTaskHandled() {
|
||||||
|
taskVisible.value = false;
|
||||||
|
await Promise.all([load(), loadTrace()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务页"查看业务详情":bizId 就是 notice.id。本页通常就是该单据详情 → 关弹窗刷新即可;
|
||||||
|
* 指向别的单据才跳转。bizUrl 是裸相对路径(不含 ctx/#),由本应用路由接管,不能 window.open
|
||||||
|
* 原串(会白跳一个 404)。
|
||||||
|
*/
|
||||||
|
function onTaskOpenBiz(payload: { bizId?: string | null; bizUrl?: string }) {
|
||||||
|
taskVisible.value = false;
|
||||||
|
const target = String(payload?.bizId ?? "");
|
||||||
|
if (target && target !== String(serid.value)) {
|
||||||
|
router.push(`/list/detail?serid=${target}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
loadTrace();
|
||||||
|
}
|
||||||
|
|
||||||
const readGroups = ref<ReadGroup[]>([]);
|
const readGroups = ref<ReadGroup[]>([]);
|
||||||
const showRead = ref(false);
|
const showRead = ref(false);
|
||||||
const prevId = ref(0);
|
const prevId = ref(0);
|
||||||
@@ -28,6 +187,7 @@ const nextId = ref(0);
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await load();
|
await load();
|
||||||
|
loadTrace(); // 详情加载后并行取审批进度(未绑流程接口自身返回 bound=false)
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -38,6 +198,7 @@ async function load() {
|
|||||||
const nav: NextPrevVo = await noticeApi.getNextPrevNotice(serid.value, navType.value).send();
|
const nav: NextPrevVo = await noticeApi.getNextPrevNotice(serid.value, navType.value).send();
|
||||||
prevId.value = nav.prevId || 0;
|
prevId.value = nav.prevId || 0;
|
||||||
nextId.value = nav.nextId || 0;
|
nextId.value = nav.nextId || 0;
|
||||||
|
loadTrace();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Message.error(e.message || "加载通知失败");
|
Message.error(e.message || "加载通知失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -97,6 +258,15 @@ function onPrint() {
|
|||||||
win.print();
|
win.print();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 审核状态 Tag 颜色 */
|
||||||
|
function auditColor(auditStatus?: number | null) {
|
||||||
|
if (auditStatus === 20) return "green";
|
||||||
|
if (auditStatus === 30) return "red";
|
||||||
|
if (auditStatus === 40) return "orange";
|
||||||
|
if (auditStatus === 0) return "default";
|
||||||
|
return "blue"; // 审批中
|
||||||
|
}
|
||||||
|
|
||||||
function downFile(id: number) {
|
function downFile(id: number) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
window.location.href = noticeApi.fileDownUrl(id);
|
window.location.href = noticeApi.fileDownUrl(id);
|
||||||
@@ -109,7 +279,7 @@ function downAll() {
|
|||||||
function displayName(item: { name?: string; filename?: string }) {
|
function displayName(item: { name?: string; filename?: string }) {
|
||||||
return item.name || item.filename || "";
|
return item.name || item.filename || "";
|
||||||
}
|
}
|
||||||
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi) */
|
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian/yasuo/weizhi) */
|
||||||
function getFileIcon(name: string) {
|
function getFileIcon(name: string) {
|
||||||
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
|
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
|
||||||
if (["doc", "docx", "wps"].includes(suffix)) return "icon-word";
|
if (["doc", "docx", "wps"].includes(suffix)) return "icon-word";
|
||||||
@@ -117,7 +287,8 @@ function getFileIcon(name: string) {
|
|||||||
if (["ppt", "pptx"].includes(suffix)) return "icon-ppt";
|
if (["ppt", "pptx"].includes(suffix)) return "icon-ppt";
|
||||||
if (["pdf"].includes(suffix)) return "icon-pdf1";
|
if (["pdf"].includes(suffix)) return "icon-pdf1";
|
||||||
if (["txt"].includes(suffix)) return "icon-txt";
|
if (["txt"].includes(suffix)) return "icon-txt";
|
||||||
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(suffix)) return "icon-tupian1";
|
// 平台 icons.css 的 icon-tupian 是线性「图片」图标;icon-tupian1 是旧彩色图转单色,看不出是图片
|
||||||
|
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(suffix)) return "icon-tupian";
|
||||||
if (["zip", "rar"].includes(suffix)) return "icon-yasuo";
|
if (["zip", "rar"].includes(suffix)) return "icon-yasuo";
|
||||||
return "icon-weizhi";
|
return "icon-weizhi";
|
||||||
}
|
}
|
||||||
@@ -176,6 +347,76 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
|||||||
<!-- 查看视角:普通标题 -->
|
<!-- 查看视角:普通标题 -->
|
||||||
<h2 v-else class="notice-title2">{{ detail.title }}</h2>
|
<h2 v-else class="notice-title2">{{ detail.title }}</h2>
|
||||||
|
|
||||||
|
<!-- 审批进度卡片(绑流程通知;提交人/管理员/办理人可见)
|
||||||
|
只回答"到哪一步了":状态 / 流程摘要 / 当前节点 / 办理意见 + 入口;
|
||||||
|
完整流水(按节点/时间线/列表三视图)在「全部审批记录」抽屉里,同一份 trace 不两处铺开。 -->
|
||||||
|
<div v-if="flowTrace && flowTrace.bound" class="flow-card">
|
||||||
|
<div class="flow-card-head">
|
||||||
|
<span class="flow-title">审批进度</span>
|
||||||
|
<Tag :color="auditColor(flowTrace.auditStatus)">{{ flowTrace.auditStatusDesc || "—" }}</Tag>
|
||||||
|
<span v-if="flowTrace.nodeSummary" class="flow-nodes">流程:{{ flowTrace.nodeSummary }}</span>
|
||||||
|
<span v-if="currentNodeText" class="flow-nodes">当前节点:{{ currentNodeText }}</span>
|
||||||
|
<div class="flow-ops">
|
||||||
|
<Button v-if="detail.flowGuid" size="small" @click="openFlowDiagram">查看流程图</Button>
|
||||||
|
<Button
|
||||||
|
v-if="detail.flowInstanceId"
|
||||||
|
size="small"
|
||||||
|
style="margin-left: 8px"
|
||||||
|
@click="openFlowEmbed"
|
||||||
|
>全部审批记录</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-if="detail.flowInstanceId && flowTrace.auditStatus === 10"
|
||||||
|
size="small"
|
||||||
|
:loading="flowActing"
|
||||||
|
style="margin-left: 8px"
|
||||||
|
@click="flowAction('withdraw')"
|
||||||
|
>撤回审核</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-if="detail.flowInstanceId && flowTrace.auditStatus === 10"
|
||||||
|
size="small"
|
||||||
|
:loading="flowActing"
|
||||||
|
style="margin-left: 8px"
|
||||||
|
@click="flowAction('urge')"
|
||||||
|
>催办</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 驳回/撤回意见 -->
|
||||||
|
<div v-if="flowTrace.flowResult && flowTrace.flowResult.opinion" class="flow-opinion">
|
||||||
|
办理意见:{{ flowTrace.flowResult.opinion }}
|
||||||
|
<template v-if="flowTrace.flowResult.rejectNode">({{ flowTrace.flowResult.rejectNode }})</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- v2.1 审批动作卡片:当前人有待办→通过/驳回/完整办理;非待办人与终态→只读 + 查看记录。
|
||||||
|
与上方"审批进度"合用一份 flowTrace,动作区归卡片,状态/时间线仍归原卡片(方案 §7.4) -->
|
||||||
|
<FlowApprovalCard v-if="flowTrace && flowTrace.bound" :api="noticeFlowApprovalApi"
|
||||||
|
biz-type="notice" :biz-id="String(serid)" :title="detail.title"
|
||||||
|
:trace="flowTrace"
|
||||||
|
@handled="onApprovalHandled" @open-record="openFlowEmbed"
|
||||||
|
@open-task="openTaskFromCard" />
|
||||||
|
|
||||||
|
<!-- 流程图弹窗(后端同源出图,免 token 免跨域) -->
|
||||||
|
<Modal v-model="diagramVisible" title="流程示意图" :width="740" footer-hide>
|
||||||
|
<div class="diagram-wrap">
|
||||||
|
<img v-if="diagramSrc" ref="diagramEl" :src="diagramSrc" alt="流程示意图" @load="fitDiagram(diagramEl)" @error="onDiagramError" />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- 办件进度嵌入页(Drawer 内 iframe 撑满,嵌入页内部滚动;token 每次打开现取) -->
|
||||||
|
<!-- 全部审批记录:flow-center 只读嵌入页(按节点/时间线/列表三视图 + 流程图 + 业务信息) -->
|
||||||
|
<Drawer v-model="embedVisible" title="全部审批记录" :width="740" :transfer="false" :styles="{ body: { padding: '0' } }">
|
||||||
|
<div class="embed-frame-wrap">
|
||||||
|
<iframe v-if="embedPageUrl" :src="embedPageUrl" title="办件进度"></iframe>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<!-- 完整办理:flow-center 去壳任务页(CAS 写面;退回/移交/多出口在那一侧) -->
|
||||||
|
<FlowTaskEmbed v-model="taskVisible" :page-url="taskPageUrl" :task-id="taskCurrentId"
|
||||||
|
@handled="onTaskHandled" @open-biz="onTaskOpenBiz" />
|
||||||
|
|
||||||
<div v-if="viewType !== 0" class="meta">
|
<div v-if="viewType !== 0" class="meta">
|
||||||
<span>发文单位:{{ detail.DeptName || "—" }}</span>
|
<span>发文单位:{{ detail.DeptName || "—" }}</span>
|
||||||
<span>发布人:{{ detail.TeaName || "—" }}</span>
|
<span>发布人:{{ detail.TeaName || "—" }}</span>
|
||||||
@@ -196,9 +437,12 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 管理视角底部操作(同旧版:编辑 / 打印) -->
|
<!-- 管理视角底部操作(同旧版:编辑 / 打印;审批中隐藏编辑——后端 403 死按钮,BP-R5-1) -->
|
||||||
<div v-if="viewType === 0" class="bottom-btns">
|
<div v-if="viewType === 0" class="bottom-btns">
|
||||||
<Button @click="onEdit">编辑</Button>
|
<Button v-if="detail.auditStatus !== 10" @click="onEdit">编辑</Button>
|
||||||
|
<Tooltip v-else content="审批中,请先撤回再编辑" placement="top" transfer>
|
||||||
|
<Button disabled>编辑</Button>
|
||||||
|
</Tooltip>
|
||||||
<Button style="margin-left: 10px" @click="onPrint">打印</Button>
|
<Button style="margin-left: 10px" @click="onPrint">打印</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -233,6 +477,19 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 高度模型(2026-10-10 定稿):卡片撑满视口(与全站其他页一致),但必须能"随长文生长"。
|
||||||
|
不能直接用通道 .yh-fill:其卡片是 flex:1(basis:0)+ min-height:420px,卡片被钉在容器高,
|
||||||
|
而 .main-container 带 overflow:hidden(base.layout.css:222)→ 长文被裁掉且滚不到(最小复现已验证)。
|
||||||
|
故此处 page-wrapper 转 flex 纵向(全局已给 min-height:100%),卡片 flex:1 0 auto(basis:auto)
|
||||||
|
→ 短文撑满、长文随内容长高、滚动留在 main-wrapper 整页滚。 */
|
||||||
|
.page-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.page-wrapper > .main-container {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
.ops-top {
|
.ops-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -244,16 +501,68 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
|||||||
}
|
}
|
||||||
.notice-title2 {
|
.notice-title2 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
}
|
}
|
||||||
|
.flow-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-s);
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
.flow-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.flow-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.flow-nodes {
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.flow-ops {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.flow-opinion {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: var(--r-s);
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.diagram-wrap {
|
||||||
|
text-align: center;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
.diagram-wrap img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
/* 嵌入页 iframe 撑满 Drawer body(body padding 已由 :styles 置 0),嵌入页内部滚动 */
|
||||||
|
.embed-frame-wrap {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.embed-frame-wrap iframe {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
.meta {
|
.meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
border-bottom: 1px dashed #e8e8e8;
|
border-bottom: 1px dashed var(--line);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #808695;
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
/* 管理视角统计条 */
|
/* 管理视角统计条 */
|
||||||
.stat-bar {
|
.stat-bar {
|
||||||
@@ -262,7 +571,7 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #17233d;
|
color: var(--text);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.progress-box {
|
.progress-box {
|
||||||
@@ -272,7 +581,7 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
|||||||
width: 300px;
|
width: 300px;
|
||||||
}
|
}
|
||||||
.percent-label {
|
.percent-label {
|
||||||
color: #f60;
|
color: var(--brand);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -284,14 +593,14 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.stat-nums .item {
|
.stat-nums .item {
|
||||||
color: #8a94a6;
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.stat-nums b {
|
.stat-nums b {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
}
|
}
|
||||||
.stat-nums .blue {
|
.stat-nums .blue {
|
||||||
color: #5c9edc;
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
.stat-nums .orange {
|
.stat-nums .orange {
|
||||||
color: #f60;
|
color: #f60;
|
||||||
@@ -302,38 +611,42 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.divider {
|
.divider {
|
||||||
border-bottom: 1px dashed #e8e8e8;
|
border-bottom: 1px dashed var(--line);
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
|
/* 正文区最小高度:短通知别让附件/按钮紧贴一行字(旧版用 clientHeight-356 铺满视口,
|
||||||
|
会把附件顶出首屏;这里只留"呼吸区",剩余空白落在按钮下方的卡片底部) */
|
||||||
|
min-height: 200px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.files {
|
.files {
|
||||||
border-top: 1px dashed #e8e8e8;
|
border-top: 1px dashed var(--line);
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
.files-title {
|
.files-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #808695;
|
color: var(--text-3);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.down-all {
|
.down-all {
|
||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
color: #2d8cf0;
|
color: var(--brand);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.file-item {
|
.file-item {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: #515a6e;
|
color: var(--text-2);
|
||||||
margin: 0 16px 8px 0;
|
margin: 0 16px 8px 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
.file-type-icon {
|
.file-type-icon {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #515a6e;
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
.file-name {
|
.file-name {
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
@@ -342,26 +655,26 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.file-size {
|
.file-size {
|
||||||
color: #999;
|
color: var(--text-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.file-op {
|
.file-op {
|
||||||
color: #2d8cf0;
|
color: var(--brand);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.bottom-btns {
|
.bottom-btns {
|
||||||
border-top: 1px dashed #e8e8e8;
|
border-top: 1px dashed var(--line);
|
||||||
padding-top: 14px;
|
padding-top: 14px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
/* 阅读情况弹窗 */
|
/* 阅读情况弹窗 */
|
||||||
.read-tip {
|
.read-tip {
|
||||||
background: #e8f4ff;
|
background: var(--brand-soft);
|
||||||
border: 1px solid #d3e9ff;
|
border: 1px solid var(--line);
|
||||||
color: #515a6e;
|
color: var(--text-2);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 4px;
|
border-radius: var(--r-s);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
@@ -378,7 +691,7 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
|||||||
flex: none;
|
flex: none;
|
||||||
width: 130px;
|
width: 130px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
color: #515a6e;
|
color: var(--text-2);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
}
|
}
|
||||||
@@ -394,13 +707,13 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.green {
|
.green {
|
||||||
color: #19be6b;
|
color: var(--green);
|
||||||
}
|
}
|
||||||
.red {
|
.red {
|
||||||
color: #ed4014;
|
color: var(--red);
|
||||||
}
|
}
|
||||||
.empty {
|
.empty {
|
||||||
color: #999;
|
color: var(--text-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch, h } from "vue";
|
import { ref, computed, onMounted, watch, h, resolveComponent } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useRequest } from "alova/client";
|
import { useRequest } from "alova/client";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message } from "view-ui-plus";
|
||||||
@@ -7,6 +7,7 @@ import { noticeApi } from "@/api/module/notice";
|
|||||||
import type { NoticeVo, NoticeQuery } from "@/types/notice";
|
import type { NoticeVo, NoticeQuery } from "@/types/notice";
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
import Utils from "@/utils";
|
import Utils from "@/utils";
|
||||||
|
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -17,13 +18,25 @@ const isManage = computed(() => route.path === "/noticelist");
|
|||||||
/* ============ 搜索条件(规范 §1:日期 fl + 下拉 fl + 关键字 fr 宽200 即搜) ============ */
|
/* ============ 搜索条件(规范 §1:日期 fl + 下拉 fl + 关键字 fr 宽200 即搜) ============ */
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
const deptType = ref("");
|
const deptType = ref("");
|
||||||
|
const auditType = ref<number | "">(""); // 状态筛选(audit_status;-1=无需审核)
|
||||||
const startDt = ref("");
|
const startDt = ref("");
|
||||||
const endDt = ref("");
|
const endDt = ref("");
|
||||||
const { data: deptOptions } = useRequest(noticeApi.getSysDic());
|
const { data: deptOptions } = useRequest(noticeApi.getSysDic());
|
||||||
|
|
||||||
function fmtDate(d: Date) {
|
/** 状态筛选选项(与列表「审核状态」列取值一一对应;-1 = 未走审核的直发通知) */
|
||||||
|
const auditStatusOptions = [
|
||||||
|
{ value: 0, label: "待提交" },
|
||||||
|
{ value: 10, label: "审批中" },
|
||||||
|
{ value: 20, label: "已通过" },
|
||||||
|
{ value: 30, label: "已驳回" },
|
||||||
|
{ value: 40, label: "已撤回" },
|
||||||
|
{ value: -1, label: "无需审核" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtDate(d: Date | string) {
|
||||||
|
const dt = new Date(d);
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============ 分页(规范 §3:customPageCount 页长记忆) ============ */
|
/* ============ 分页(规范 §3:customPageCount 页长记忆) ============ */
|
||||||
@@ -43,8 +56,10 @@ async function run() {
|
|||||||
pageSize: pageSize.value,
|
pageSize: pageSize.value,
|
||||||
orderByTime: "1",
|
orderByTime: "1",
|
||||||
};
|
};
|
||||||
if (startDt.value) params.startTime = startDt.value;
|
// DatePicker 选过日期后 v-model 值是 Date 对象,后端 LocalDate 只认 yyyy-MM-dd → 统一格式化
|
||||||
if (endDt.value) params.endTime = endDt.value;
|
if (startDt.value) params.startTime = fmtDate(new Date(startDt.value));
|
||||||
|
if (endDt.value) params.endTime = fmtDate(new Date(endDt.value));
|
||||||
|
if (auditType.value !== "" && auditType.value !== null) params.auditStatus = Number(auditType.value);
|
||||||
if (!isManage.value) {
|
if (!isManage.value) {
|
||||||
params.scope = "user";
|
params.scope = "user";
|
||||||
}
|
}
|
||||||
@@ -90,6 +105,7 @@ watch(
|
|||||||
pageNum.value = 1;
|
pageNum.value = 1;
|
||||||
keyword.value = "";
|
keyword.value = "";
|
||||||
deptType.value = "";
|
deptType.value = "";
|
||||||
|
auditType.value = "";
|
||||||
if (!isManage.value) {
|
if (!isManage.value) {
|
||||||
const start = new Date();
|
const start = new Date();
|
||||||
start.setMonth(start.getMonth() - 1);
|
start.setMonth(start.getMonth() - 1);
|
||||||
@@ -130,9 +146,10 @@ function del(row: NoticeVo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 附件下载:逐个触发(间隔 500ms,同旧版) */
|
/* 附件下载:逐个触发(间隔 500ms,同旧版) */
|
||||||
function downloadFiles(row: NoticeVo & { UpFile?: string }) {
|
function downloadFiles(row: NoticeVo) {
|
||||||
if (!row.UpFile) return;
|
const upFile = row.upFile ?? row.UpFile;
|
||||||
row.UpFile.split(",").forEach((id: string, i: number) => {
|
if (!upFile) return;
|
||||||
|
upFile.split(",").forEach((id: string, i: number) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = noticeApi.fileDownUrl(Number(id));
|
window.location.href = noticeApi.fileDownUrl(Number(id));
|
||||||
}, i * 500);
|
}, i * 500);
|
||||||
@@ -151,6 +168,18 @@ const columns = [
|
|||||||
align: "left",
|
align: "left",
|
||||||
render: (h: any, p: any) => h("span", {}, String(p.row.createTime || "").slice(0, 16)),
|
render: (h: any, p: any) => h("span", {}, String(p.row.createTime || "").slice(0, 16)),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "审核状态",
|
||||||
|
width: 100,
|
||||||
|
align: "center",
|
||||||
|
render: (h: any, p: any) => {
|
||||||
|
const st = p.row.auditStatus;
|
||||||
|
if (st === null || st === undefined) return h("span", { style: "color:var(--text-3);" }, "—");
|
||||||
|
// 色系对齐流程侧:待提交灰 / 审批中橙 / 已通过绿 / 已驳回红 / 已撤回灰
|
||||||
|
const colors: Record<number, string> = { 0: "default", 10: "warning", 20: "success", 30: "error", 40: "default" };
|
||||||
|
return h(resolveComponent("Tag"), { color: colors[st] || "default" }, () => p.row.auditStatusDesc || "—");
|
||||||
|
},
|
||||||
|
},
|
||||||
{ title: "已读人数", key: "readCount", width: 110, align: "left" },
|
{ title: "已读人数", key: "readCount", width: 110, align: "left" },
|
||||||
{
|
{
|
||||||
title: "未读人数",
|
title: "未读人数",
|
||||||
@@ -160,30 +189,38 @@ const columns = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "附件",
|
title: "附件",
|
||||||
width: 70,
|
/* 图标外包 Tooltip 后占 32+2*2=36px,cell 左右内边距各 18px:70px 列只剩 34px 内容宽,
|
||||||
|
溢出 2px 会让 cell 的 text-overflow:ellipsis 在图标后画出「...」,故留到 80 */
|
||||||
|
width: 80,
|
||||||
align: "center",
|
align: "center",
|
||||||
render: (h: any, p: any) =>
|
render: (h: any, p: any) =>
|
||||||
p.row.UpFile
|
(p.row.upFile ?? p.row.UpFile)
|
||||||
? Utils.operatingHandle(h, "附件下载", "icon-attachment", () => downloadFiles(p.row))
|
? Utils.operatingHandle(h, "附件下载", "icon-attachment", () => downloadFiles(p.row))
|
||||||
: h("span", { style: "color:#999;" }, "无"),
|
: h("span", { style: "color:var(--text-3);" }, "无"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "操作",
|
title: "操作",
|
||||||
width: 180,
|
width: 180,
|
||||||
align: "center",
|
align: "center",
|
||||||
fixed: "right",
|
fixed: "right",
|
||||||
render: (h: any, p: any) => [
|
render: (h: any, p: any) => {
|
||||||
|
// BP-R5-1:审批中(audit=10)禁止删除(后端 403)——渲染禁用态而非隐藏,保持操作列对齐
|
||||||
|
const delDisabled = p.row.auditStatus === 10;
|
||||||
|
return [
|
||||||
Utils.operatingHandle(h, "查看通知", "icon-browse", () => openDetail(p.row)),
|
Utils.operatingHandle(h, "查看通知", "icon-browse", () => openDetail(p.row)),
|
||||||
Utils.operatingHandle(h, "删除通知", "icon-ashbin", () => del(p.row)),
|
Utils.operatingHandle(h, "删除通知", "icon-ashbin", () => del(p.row), delDisabled),
|
||||||
],
|
];
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true }));
|
/* 表格高度:不再用 useTableHeight 猜常量,改为实测 .table-region(flex:1)真实高度喂给固定表头 */
|
||||||
|
const tableRegionRef = ref<HTMLElement | null>(null);
|
||||||
|
const tableH = useElementHeight(tableRegionRef);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<!-- 1. 操作按钮区 -->
|
<!-- 1. 操作按钮区 -->
|
||||||
<div class="fix mb-12">
|
<div class="fix mb-12">
|
||||||
@@ -200,6 +237,15 @@ const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOper
|
|||||||
<Select v-model="deptType" class="search-wrap_sel ml-12" clearable placeholder="来源部门" @on-change="doSearch">
|
<Select v-model="deptType" class="search-wrap_sel ml-12" clearable placeholder="来源部门" @on-change="doSearch">
|
||||||
<Option v-for="d in deptOptions" :key="d.k" :value="d.k">{{ d.v }}</Option>
|
<Option v-for="d in deptOptions" :key="d.k" :value="d.k">{{ d.v }}</Option>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Select
|
||||||
|
v-model="auditType"
|
||||||
|
class="search-wrap_sel ml-12"
|
||||||
|
clearable
|
||||||
|
placeholder="状态"
|
||||||
|
@on-change="doSearch"
|
||||||
|
>
|
||||||
|
<Option v-for="s in auditStatusOptions" :key="s.value" :value="s.value">{{ s.label }}</Option>
|
||||||
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
class="fr"
|
class="fr"
|
||||||
style="width: 200px"
|
style="width: 200px"
|
||||||
@@ -213,14 +259,16 @@ const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOper
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 3. 表格 + 分页 -->
|
<!-- 3. 表格 + 分页 -->
|
||||||
|
<div class="table-region" ref="tableRegionRef">
|
||||||
<Table
|
<Table
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="page.content"
|
:data="page.content"
|
||||||
class="link-table table-bottom-box"
|
class="link-table table-bottom-box"
|
||||||
:height="tableHeight"
|
:height="tableH || undefined"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
no-data-text="暂无数据"
|
no-data-text="暂无数据"
|
||||||
></Table>
|
></Table>
|
||||||
|
</div>
|
||||||
<div class="page-panel">
|
<div class="page-panel">
|
||||||
<div class="fl ml-15 align-middle"></div>
|
<div class="fl ml-15 align-middle"></div>
|
||||||
<Page
|
<Page
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import draggable from "vuedraggable";
|
|||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
import UserCheckModal from "@/components/UserCheckModal.vue";
|
import UserCheckModal from "@/components/UserCheckModal.vue";
|
||||||
import TypeSetBox from "@/components/TypeSetBox.vue";
|
import TypeSetBox from "@/components/TypeSetBox.vue";
|
||||||
import { settingApi, baseApi } from "@/api/module/notice";
|
import { settingApi, baseApi, noticeApi } from "@/api/module/notice";
|
||||||
import type { DeptManageItem, ContactGroupPage, ContactUserPage, SchoolTreeNode } from "@/types/notice";
|
import type { DeptManageItem, ContactGroupPage, ContactUserPage, SchoolTreeNode, FlowOption } from "@/types/notice";
|
||||||
|
import config from "@/assets/config/host.config";
|
||||||
import Utils from "@/utils";
|
import Utils from "@/utils";
|
||||||
|
import { fitDiagram } from "@/utils/diagramImg";
|
||||||
|
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||||
|
|
||||||
/* ============ Tab 切换(规范 §1:tabs-wrapper + Tabs type=card,切换只 v-show 内容区) ============ */
|
/* ============ Tab 切换(单卡片 yh-tabs-card 内含线性 Tabs,切换只 v-show 对应 tab-body) ============ */
|
||||||
const currentType = ref(1);
|
const currentType = ref(1);
|
||||||
|
|
||||||
function tabChange(name: string) {
|
function tabChange(name: string) {
|
||||||
@@ -18,9 +21,98 @@ function tabChange(name: string) {
|
|||||||
getDeptList();
|
getDeptList();
|
||||||
} else if (currentType.value === 3) {
|
} else if (currentType.value === 3) {
|
||||||
getGroupList();
|
getGroupList();
|
||||||
|
} else if (currentType.value === 2) {
|
||||||
|
loadFlowBinding();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ 流程绑定(应用设置 > 流程绑定) ============ */
|
||||||
|
|
||||||
|
const flowOptions = ref<FlowOption[]>([]);
|
||||||
|
const flowEnabled = ref(true);
|
||||||
|
const flowCanEdit = ref(false); // 仅本校管理员可维护绑定
|
||||||
|
const boundFlowGuid = ref(""); // 已保存的绑定(提交前的编辑值另用 selectedGuid)
|
||||||
|
const boundFlowName = ref("");
|
||||||
|
const selectedGuid = ref("");
|
||||||
|
const bindingSaving = ref(false);
|
||||||
|
|
||||||
|
async function loadFlowBinding() {
|
||||||
|
try {
|
||||||
|
const view = await noticeApi.getFlowBinding().send();
|
||||||
|
flowEnabled.value = view?.flowEnabled !== false;
|
||||||
|
flowCanEdit.value = view?.canEdit === true;
|
||||||
|
boundFlowGuid.value = view?.flowGuid || "";
|
||||||
|
boundFlowName.value = view?.flowName || "";
|
||||||
|
selectedGuid.value = boundFlowGuid.value;
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e.message || "获取流程绑定失败");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flowOptions.value = (await noticeApi.getFlowOptions().send()) || [];
|
||||||
|
} catch {
|
||||||
|
flowOptions.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFlowBinding() {
|
||||||
|
bindingSaving.value = true;
|
||||||
|
try {
|
||||||
|
const view = await noticeApi.saveFlowBinding(selectedGuid.value).send();
|
||||||
|
boundFlowGuid.value = view?.flowGuid || "";
|
||||||
|
boundFlowName.value = view?.flowName || "";
|
||||||
|
selectedGuid.value = boundFlowGuid.value;
|
||||||
|
Message.success(boundFlowGuid.value ? "绑定成功" : "已解除绑定");
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e.message || "保存失败");
|
||||||
|
} finally {
|
||||||
|
bindingSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbindFlow() {
|
||||||
|
MyConfirm({
|
||||||
|
title: "解除绑定",
|
||||||
|
msg: "确定解除流程绑定吗?",
|
||||||
|
info: "解除后发送全部用户将不再需要审核,直接发布",
|
||||||
|
async onOk() {
|
||||||
|
selectedGuid.value = "";
|
||||||
|
await saveFlowBinding();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流程图 Drawer(后端 DiagramDef 中转 PNG:同源免 token 免跨域。修复旧深链 404——
|
||||||
|
* 生产前端静态挂 /flow-center/front*,Caddy 无 /flow-center/ 根路由,window.open 深链必 404) */
|
||||||
|
const flowDiagramVisible = ref(false);
|
||||||
|
const flowDiagramEl = ref<HTMLImageElement | null>(null);
|
||||||
|
const flowDiagramGuid = ref(""); // 打开抽屉时定格的流程 GUID(绑定态=boundFlowGuid,预览态=selectedGuid)
|
||||||
|
const flowDiagramSrc = computed(() =>
|
||||||
|
flowDiagramVisible.value && flowDiagramGuid.value
|
||||||
|
? `${config.serviceHost}/api/notice/flow/DiagramDef?flowGuid=${encodeURIComponent(flowDiagramGuid.value)}`
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
function openFlowDiagram() {
|
||||||
|
if (!boundFlowGuid.value) return;
|
||||||
|
flowDiagramGuid.value = boundFlowGuid.value;
|
||||||
|
flowDiagramVisible.value = true;
|
||||||
|
}
|
||||||
|
/** 预览下拉当前选中(未必已保存)的流程 */
|
||||||
|
function previewSelectedFlow() {
|
||||||
|
if (!selectedGuid.value) return;
|
||||||
|
flowDiagramGuid.value = selectedGuid.value;
|
||||||
|
flowDiagramVisible.value = true;
|
||||||
|
}
|
||||||
|
function onFlowDiagramError() {
|
||||||
|
flowDiagramVisible.value = false;
|
||||||
|
Message.error("流程图加载失败(流程可能未发布或绑定已解除)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前绑定流程的节点摘要(下拉里回显) */
|
||||||
|
const boundFlowSummary = computed(() => {
|
||||||
|
const hit = flowOptions.value.find((f) => f.flowGuid === boundFlowGuid.value);
|
||||||
|
return hit?.nodeSummary || "—";
|
||||||
|
});
|
||||||
|
|
||||||
/* ============ 发文部门(直接复用 UserBase 同源 TypeSetBox 卡片组件) ============ */
|
/* ============ 发文部门(直接复用 UserBase 同源 TypeSetBox 卡片组件) ============ */
|
||||||
interface DeptItem {
|
interface DeptItem {
|
||||||
SerID: number;
|
SerID: number;
|
||||||
@@ -328,18 +420,22 @@ const groupColumns = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isSearch: true, isPage: true }));
|
/* 自定义组表格高度:实测 .table-region(flex:1),替代 useTableHeight 常量减法 */
|
||||||
|
const groupRegionRef = ref<HTMLElement | null>(null);
|
||||||
|
const groupH = useElementHeight(groupRegionRef);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tabs-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<Tabs class="tabs-panel" type="card" :value="String(currentType)" @on-click="tabChange">
|
<div class="main-container yh-tabs-card">
|
||||||
|
<Tabs class="tabs-panel" :value="String(currentType)" @on-click="tabChange">
|
||||||
<TabPane label="发文部门" name="1"></TabPane>
|
<TabPane label="发文部门" name="1"></TabPane>
|
||||||
<TabPane label="自定义组" name="3"></TabPane>
|
<TabPane label="自定义组" name="3"></TabPane>
|
||||||
|
<TabPane label="流程绑定" name="2"></TabPane>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<!-- 发文部门 -->
|
<!-- 发文部门 -->
|
||||||
<div class="main-container" v-show="currentType === 1">
|
<div class="tab-body" v-show="currentType === 1">
|
||||||
<Alert show-icon>设置说明:拖动卡片调整部门排序;停用的部门不出现在「通知发送」的部门下拉中</Alert>
|
<Alert show-icon>设置说明:拖动卡片调整部门排序;停用的部门不出现在「通知发送」的部门下拉中</Alert>
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<Button class="fl" type="primary" @click="addDeptShow">新增部门</Button>
|
<Button class="fl" type="primary" @click="addDeptShow">新增部门</Button>
|
||||||
@@ -367,17 +463,19 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 自定义组 -->
|
<!-- 自定义组 -->
|
||||||
<div class="main-container" v-show="currentType === 3">
|
<div class="tab-body" v-show="currentType === 3">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<Button class="fl" type="primary" @click="addGroupShow">新建联系组</Button>
|
<Button class="fl" type="primary" @click="addGroupShow">新建联系组</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="table-region" ref="groupRegionRef">
|
||||||
<Table
|
<Table
|
||||||
:columns="groupColumns"
|
:columns="groupColumns"
|
||||||
:data="groupData"
|
:data="groupData"
|
||||||
class="link-table table-bottom-box"
|
class="link-table table-bottom-box"
|
||||||
:height="groupTableHeight"
|
:height="groupH || undefined"
|
||||||
no-data-text="暂无数据"
|
no-data-text="暂无数据"
|
||||||
></Table>
|
></Table>
|
||||||
|
</div>
|
||||||
<div class="page-panel">
|
<div class="page-panel">
|
||||||
<div class="fl ml-15 align-middle"></div>
|
<div class="fl ml-15 align-middle"></div>
|
||||||
<Page
|
<Page
|
||||||
@@ -407,6 +505,60 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 流程绑定(应用设置 > 流程绑定) -->
|
||||||
|
<div class="tab-body" v-show="currentType === 2">
|
||||||
|
<Alert show-icon type="warning">
|
||||||
|
绑定说明:绑定后「发送全部用户」的通知必须审核通过才发布(发通知页只显示「提交审核」按钮,
|
||||||
|
隐藏发送与定时发送);「发送指定人员」不受影响,仍直接发布。未绑定则保持现有发送方式。
|
||||||
|
</Alert>
|
||||||
|
<div v-if="!flowEnabled" class="flow-binding-empty">流程中心未接入,暂不支持流程绑定。</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flow-binding-form">
|
||||||
|
<span class="label">审核流程:</span>
|
||||||
|
<Select
|
||||||
|
v-model="selectedGuid"
|
||||||
|
clearable
|
||||||
|
:disabled="!flowCanEdit"
|
||||||
|
placeholder="不绑定,全部直发"
|
||||||
|
style="width: 360px"
|
||||||
|
>
|
||||||
|
<Option value="">不绑定,全部直发</Option>
|
||||||
|
<Option v-for="f in flowOptions" :key="f.flowGuid" :value="f.flowGuid">
|
||||||
|
{{ f.name }}({{ f.nodeSummary || "—" }})
|
||||||
|
</Option>
|
||||||
|
</Select>
|
||||||
|
<template v-if="flowCanEdit">
|
||||||
|
<Button type="primary" :loading="bindingSaving" @click="saveFlowBinding">保存绑定</Button>
|
||||||
|
<Button v-if="boundFlowGuid" @click="unbindFlow">解除绑定</Button>
|
||||||
|
<Button v-if="selectedGuid" @click="previewSelectedFlow">查看流程图</Button>
|
||||||
|
</template>
|
||||||
|
<span v-else class="readonly-hint">仅本校管理员可修改流程绑定</span>
|
||||||
|
</div>
|
||||||
|
<div class="flow-binding-state">
|
||||||
|
当前状态:
|
||||||
|
<template v-if="boundFlowGuid">
|
||||||
|
已绑定
|
||||||
|
<Tag color="blue">{{ boundFlowName || boundFlowGuid }}</Tag>
|
||||||
|
<span class="node-summary">{{ boundFlowSummary }}</span>
|
||||||
|
<Button type="text" size="small" @click="openFlowDiagram">查看流程图</Button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Tag>未绑定</Tag>
|
||||||
|
<span class="node-summary">发送全部用户与指定人员均直接发布</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 流程图 Drawer(后端 DiagramDef 同源 PNG;竖版长图容器内纵向滚动) -->
|
||||||
|
<Drawer v-model="flowDiagramVisible" title="流程示意图" :width="740" :transfer="false"
|
||||||
|
:styles="{ body: { padding: '0' } }">
|
||||||
|
<div class="flow-diagram-frame">
|
||||||
|
<img ref="flowDiagramEl" v-if="flowDiagramSrc" :src="flowDiagramSrc" alt="流程示意图" @load="fitDiagram(flowDiagramEl)" @error="onFlowDiagramError" />
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
<!-- 新增发文部门 Modal(编辑走 TypeSetBox 内联编辑) -->
|
<!-- 新增发文部门 Modal(编辑走 TypeSetBox 内联编辑) -->
|
||||||
<Modal v-model="deptModal" :mask-closable="false" width="450px" title="新增部门">
|
<Modal v-model="deptModal" :mask-closable="false" width="450px" title="新增部门">
|
||||||
<Form ref="deptFormRef" :model="deptForm" :rules="deptRule" :label-width="110">
|
<Form ref="deptFormRef" :model="deptForm" :rules="deptRule" :label-width="110">
|
||||||
@@ -479,4 +631,45 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
/* 流程绑定面板 */
|
||||||
|
.flow-binding-form {
|
||||||
|
margin: 16px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.flow-binding-form .label {
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.flow-binding-state {
|
||||||
|
color: var(--text-2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.flow-binding-state .node-summary {
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.readonly-hint {
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.flow-binding-empty {
|
||||||
|
margin-top: 16px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
/* 流程图 Drawer:浅底色 + 纵向滚动(竖版长图),图片撑满可读宽度 */
|
||||||
|
.flow-diagram-frame {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.flow-diagram-frame img {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { Icon } from "view-ui-plus";
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container stats-dev">
|
<div class="main-container stats-dev">
|
||||||
<Icon type="ios-stats" size="56" color="#c5c8ce" />
|
<Icon type="ios-stats" size="56" color="var(--text-3)" />
|
||||||
<p class="tip">通知统计功能开发中,敬请期待……</p>
|
<p class="tip">通知统计功能开发中,敬请期待……</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,8 +21,7 @@ import { Icon } from "view-ui-plus";
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: calc(100vh - 136px);
|
color: var(--text-3);
|
||||||
color: #999;
|
|
||||||
}
|
}
|
||||||
.tip {
|
.tip {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
|
|||||||
+18
-5
@@ -12,9 +12,11 @@ import presetUno from "@unocss/preset-uno";
|
|||||||
import presetRemToPx from "@unocss/preset-rem-to-px";
|
import presetRemToPx from "@unocss/preset-rem-to-px";
|
||||||
|
|
||||||
export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
||||||
// 构建输出目录:base -> dist/base, ld -> dist/ld, 默认 dist/base
|
// 构建产物直出 dist/(env-config 模式,与 Cms/UserBase 同风格):CI zip 打包取 dist 根,
|
||||||
const subDir = mode === "ld" ? "ld" : "base";
|
// 不再有 dist/base 中间层(此前 outDir=dist/base 时 zip 打 dist 整层,zip 根只有 base/
|
||||||
const outDir = command === "build" ? `dist/${subDir}` : "dist";
|
// 目录,projman 前端发布校验报「zip 根目录缺少 html 文件」);dev(serve) 不写产物,
|
||||||
|
// 两者共用该值无冲突
|
||||||
|
const outDir = "dist";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -71,7 +73,6 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||||||
"flex-center": "flex items-center justify-center",
|
"flex-center": "flex items-center justify-center",
|
||||||
// 常用组件
|
// 常用组件
|
||||||
btn: "px-16px py-8px rounded-4px cursor-pointer transition-all duration-200",
|
btn: "px-16px py-8px rounded-4px cursor-pointer transition-all duration-200",
|
||||||
"btn-primary": "bg-blue-600 text-white hover:bg-blue-700",
|
|
||||||
card: "bg-white rounded-8px shadow-md p-16px",
|
card: "bg-white rounded-8px shadow-md p-16px",
|
||||||
// 文本截断
|
// 文本截断
|
||||||
"text-ellipsis": "truncate",
|
"text-ellipsis": "truncate",
|
||||||
@@ -88,6 +89,9 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
// 主题接入已免插件化(A 类,YschTheme《管理端主题远程化与公共静态通道方案》§11):
|
||||||
|
// 唯一接入点 = index.html 一段同步 loader,挂载/FOUC/CSS 排序全由通道 init 承担;
|
||||||
|
// 下面的 /static 代理只是 dev 联调便利(prod 同源用不到),删除不影响线上主题
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -104,6 +108,15 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||||||
target: "http://localhost:8101",
|
target: "http://localhost:8101",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
// 主题通道本地联调:dev 下 loader 走同源相对(见 index.html 注释的优先级四档),
|
||||||
|
// manifest/CSS 经此代理到中央通道;changeOrigin 会把 Host 改写成目标域,Caddy 按 Host 匹配站点,
|
||||||
|
// 无需再手工补 Host 头(这是旧 vite 插件里那段 headers.Host 的等价替代)。
|
||||||
|
// 联调本地通道源:YschTheme 侧 npm run serve:static(默认 9527),再用
|
||||||
|
// YH_THEME_PROXY_TARGET=http://localhost:9527 覆盖本 target
|
||||||
|
"/static": {
|
||||||
|
target: process.env.YH_THEME_PROXY_TARGET || "http://base.yunhaixingtong.com",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// css预处理器
|
// css预处理器
|
||||||
@@ -117,7 +130,7 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||||||
base: "./",
|
base: "./",
|
||||||
build: {
|
build: {
|
||||||
target: "modules", //设置最终构建的浏览器兼容目标 //es2015(编译成es5) | modules
|
target: "modules", //设置最终构建的浏览器兼容目标 //es2015(编译成es5) | modules
|
||||||
outDir, // 构建输出目录,根据环境变量区分 base/ld
|
outDir, // 构建输出目录(恒 dist 根,见文件头说明)
|
||||||
assetsDir: "assets", // 静态资源得存放路径文件名 assets
|
assetsDir: "assets", // 静态资源得存放路径文件名 assets
|
||||||
sourcemap: false, //构建后是否生成 source map 文件
|
sourcemap: false, //构建后是否生成 source map 文件
|
||||||
minify: "esbuild", // 项目压缩 :boolean | 'terser' | 'esbuild'
|
minify: "esbuild", // 项目压缩 :boolean | 'terser' | 'esbuild'
|
||||||
|
|||||||
Reference in New Issue
Block a user