Compare commits
44
Commits
f41719f3c4
...
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 |
@@ -19,7 +19,10 @@ jobs:
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- 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 本仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
@@ -51,6 +54,8 @@ jobs:
|
||||
set -e
|
||||
cd /build
|
||||
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 run build
|
||||
'
|
||||
|
||||
@@ -27,3 +27,6 @@ yarn.lock
|
||||
|
||||
# scratch
|
||||
.temp
|
||||
|
||||
# vite 配置 bundle 临时产物
|
||||
vite.config.*.timestamp-*
|
||||
|
||||
+22
@@ -29,6 +29,28 @@
|
||||
}
|
||||
})();
|
||||
</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>
|
||||
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
|
||||
<script src="./libs/jquery.min.js"></script>
|
||||
|
||||
Generated
+777
@@ -75,6 +75,431 @@
|
||||
"alova": "dist/bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alova/wormhole/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
@@ -10766,6 +11191,358 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
||||
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
||||
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
||||
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
||||
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
@@ -61,6 +61,45 @@ export interface ApprovalPage<T> {
|
||||
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>> {
|
||||
@@ -70,9 +109,24 @@ export const approvalApi = {
|
||||
instances(status?: number, page = 1, size = 10): Method<ApprovalPage<ApprovalInstance>> {
|
||||
return get(`${APPROVAL}/Instances`, status ? { status, page, size } : { page, size });
|
||||
},
|
||||
/** 通过(opinion 可选) */
|
||||
approve(taskId: number, opinion?: string): Method<void> {
|
||||
return postJSON(`${APPROVAL}/Approve?taskId=${taskId}`, opinion ? { opinion } : {});
|
||||
/** 当前人在该通知上的待办(卡片可写/只读、多待办与多出口判定的唯一依据) */
|
||||
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> {
|
||||
|
||||
@@ -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;
|
||||
@@ -127,6 +127,10 @@ class NoticeApi {
|
||||
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`);
|
||||
|
||||
@@ -9,8 +9,10 @@ export function getMenuList(): Promise<IModule[]> {
|
||||
{ id: 1, modId: 1, name: "已收到", page: "/" },
|
||||
{ id: 2, modId: 2, name: "已发送", page: "/list" },
|
||||
{ id: 3, modId: 3, name: "全部通知", page: "/noticelist" },
|
||||
{ id: 89, modId: 89, name: "待审核", page: "/approval" },
|
||||
{ id: 4, modId: 4, name: "通知统计", page: "/stats" },
|
||||
{ id: 21, modId: 21, name: "应用设置", page: "/setting" },
|
||||
{ id: 91, modId: 91, name: "抄送给我的", page: "/cc" },
|
||||
];
|
||||
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 |
@@ -252,6 +252,33 @@ function fileUrl(id: number) {
|
||||
|
||||
/* ============================ 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 [
|
||||
/* ---------- 布局 / 用户 ---------- */
|
||||
{
|
||||
@@ -271,12 +298,15 @@ export default [
|
||||
method: "get",
|
||||
response: () =>
|
||||
ok([
|
||||
// icon:用户中心模块管理配置的 YschIcons 图标名,layout 渲染为 iconfont icon-<名称>
|
||||
// id/modId 对齐平台模块管理的「配置 ID」(对照 /base/GetUserMod 现行数据);
|
||||
// icon:YschIcons 图标名,layout 渲染为 iconfont icon-<名称>
|
||||
{ id: 1, modId: 1, name: "已收到", parentId: 0, page: "/", icon: "sjx", orderNumber: 1, usable: true, children: [] },
|
||||
{ id: 2, modId: 2, name: "已发送", parentId: 0, page: "/list", icon: "fasong", orderNumber: 2, usable: true, children: [] },
|
||||
{ id: 3, modId: 3, name: "全部通知", parentId: 0, page: "/noticelist", icon: "liebiao", orderNumber: 3, usable: true, children: [] },
|
||||
{ id: 4, modId: 4, name: "通知统计", parentId: 0, page: "/stats", icon: "tongji", orderNumber: 4, usable: true, children: [] },
|
||||
{ id: 21, modId: 21, name: "应用设置", parentId: 0, page: "/setting", icon: "shezhi", orderNumber: 5, 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: [] },
|
||||
]),
|
||||
},
|
||||
{
|
||||
@@ -722,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
File diff suppressed because one or more lines are too long
+28
-10
@@ -27,7 +27,7 @@
|
||||
content: "";
|
||||
display: block;
|
||||
width: 3px;
|
||||
background: #5979ea;
|
||||
background: var(--brand, #577afb);
|
||||
height: 15px;
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
@@ -39,7 +39,7 @@
|
||||
h4.second-title {
|
||||
@extend .base-subtitle;
|
||||
&::before {
|
||||
background: #5979ea;
|
||||
background: var(--brand, #577afb);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ h4.subtitle {
|
||||
overflow-y: auto;
|
||||
li {
|
||||
height: 42px;
|
||||
border: 1px solid #ebeef5;
|
||||
border: 1px solid var(--line, #e9edf5);
|
||||
border-top: none;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@@ -87,8 +87,8 @@ h4.subtitle {
|
||||
}
|
||||
li.active {
|
||||
// background: #f3f3f3;
|
||||
color: #7892ed;
|
||||
border-right: 3px solid #5979ea;
|
||||
color: var(--brand-2, #7b8ffd);
|
||||
border-right: 3px solid var(--brand, #577afb);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,20 +98,38 @@ h4.subtitle {
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格加载(规范 §4 表格加载行):图形=通道 .ysch-spin(随 --brand),本地零图形资产 */
|
||||
.table-spin-load {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 90px;
|
||||
padding-top: 60px;
|
||||
background-image: url(../image/loading.gif);
|
||||
background-size: 60px 60px;
|
||||
background-position: top center;
|
||||
background-repeat: no-repeat;
|
||||
border: none;
|
||||
color: #577afb;
|
||||
color: var(--brand);
|
||||
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 {
|
||||
margin-right: 4px;
|
||||
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;
|
||||
}
|
||||
.text-primary {
|
||||
color: $color-primary !important;
|
||||
color: var(--brand, #577afb) !important;
|
||||
}
|
||||
.text-normal {
|
||||
color: #404042 !important;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/*scss变量*/
|
||||
|
||||
// 主题色
|
||||
// 主题色(品牌主色不在此定义:由平台主题令牌 var(--brand) 经中央静态通道下发,
|
||||
// 本文件只留业务语义色——成功/警告/错误不随主题档位变化)
|
||||
$color-error: #e8684a;
|
||||
$color-success: #45b972;
|
||||
$color-warning: #e9b954;
|
||||
$color-primary: #5979ea;
|
||||
|
||||
// 文字色
|
||||
$color-black: #404042;
|
||||
|
||||
@@ -92,16 +92,18 @@ export default {
|
||||
display: block;
|
||||
.upload-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start; /* 提示语与按钮首行对齐(列表换行后不再垂直居中整块) */
|
||||
}
|
||||
:deep(.ivu-upload) {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap; /* 允许内部列表换行 */
|
||||
align-items: center;
|
||||
}
|
||||
/* 文件列表整体另起一行,每项独占一行,不再与按钮挤在一起 */
|
||||
:deep(.ivu-upload-list) {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
width: 100%; /* 文件列表整体独占下一行 */
|
||||
margin-top: 4px;
|
||||
}
|
||||
:deep(.ivu-upload-list-file) {
|
||||
padding: 4px 0;
|
||||
@@ -110,7 +112,7 @@ export default {
|
||||
margin-left: 10px;
|
||||
color: #f90;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
line-height: 32px; /* 与默认 Button 等高,紧贴按钮右侧 */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -116,7 +116,7 @@ defineExpose({
|
||||
no-data-text="暂无数据"
|
||||
>
|
||||
<template #loading>
|
||||
<div class="table-spin-load">加载中</div>
|
||||
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||
</template>
|
||||
</Table>
|
||||
<div class="page-panel">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||
<template #loading>
|
||||
<div class="table-spin-load">加载中</div>
|
||||
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<template>
|
||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||
<template #loading>
|
||||
<div class="table-spin-load">加载中</div>
|
||||
</template>
|
||||
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
||||
<slot :name="slotName" v-bind="slotProps" />
|
||||
</template>
|
||||
</Table>
|
||||
<div class="table-region" ref="regionRef">
|
||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||
<template #loading>
|
||||
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||
</template>
|
||||
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
||||
<slot :name="slotName" v-bind="slotProps" />
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle">
|
||||
@@ -22,7 +24,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useSlots } from 'vue'
|
||||
import Utils from '@/utils'
|
||||
import { useElementHeight } from '@/utils/hook/useElementHeight'
|
||||
import { OperatorEnum } from '@/types'
|
||||
|
||||
interface Props {
|
||||
@@ -44,7 +46,10 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
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 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;
|
||||
}
|
||||
+64
-30
@@ -1,8 +1,11 @@
|
||||
<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 { Message } from "view-ui-plus";
|
||||
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 route = useRoute();
|
||||
@@ -22,6 +25,27 @@ const modList = computed(() => props.layoutData?.modList || []);
|
||||
|
||||
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(() => {
|
||||
routeChange(route);
|
||||
});
|
||||
@@ -94,15 +118,21 @@ const routeChange = (to: any) => {
|
||||
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 page: string = item?.page || "";
|
||||
const { path: page, external } = resolveItemPage(item);
|
||||
if (!page) {
|
||||
// 解析不出有效页面:显式提示,而不是静默不动或被重定向吞到默认页
|
||||
Message.warning(`菜单「${item?.name || "未命名"}」未配置有效页面路径,请检查基础平台模块设置`);
|
||||
return;
|
||||
}
|
||||
// 平台配置的外部链接模块:整页跳转
|
||||
if (item?.external) {
|
||||
if (external) {
|
||||
window.location.href = page;
|
||||
return;
|
||||
}
|
||||
@@ -114,7 +144,8 @@ const linkMenu = (item: any) => {
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
let url = window.location.href;
|
||||
@@ -129,12 +160,10 @@ const childChange = (index: number) => {
|
||||
};
|
||||
|
||||
/** 菜单图标:读取用户中心模块管理配置的 YschIcons 图标名(GetUserMod 的 icon 字段,如 list → icon-list)。
|
||||
* 本地 icons.css 即 ysch-icons 掩码版(基类 iconfont,类名 icon-<名称>);
|
||||
* 平台约定留空不下发图标,此时返回空串由模板 v-if 不渲染 */
|
||||
const getMenuIcon = (item?: any): string => {
|
||||
const icon = String(item?.icon || "").trim();
|
||||
return icon ? `icon-${icon}` : "";
|
||||
};
|
||||
* icons.css(ysch-icons 掩码版,基类 iconfont,类名 icon-<名称>)现由中央静态通道下发;
|
||||
* 平台未配置图标时统一兜底 icon-list(2026-10-09 定稿:菜单项恒有图标,保整列缩进/行高一致) */
|
||||
const getMenuIcon = (item?: any): string =>
|
||||
`icon-${String(item?.icon || "").trim() || "list"}`;
|
||||
|
||||
function linkHomePage() {
|
||||
if (baseHost.value) {
|
||||
@@ -149,16 +178,13 @@ function linkHomePage() {
|
||||
<template>
|
||||
<div class="layout-wrap">
|
||||
<header class="header-container fix">
|
||||
<div class="fl align-middle icon-box">
|
||||
<div title="首页" class="icon-btn" @click="linkHomePage">
|
||||
<i class="iconfont icon-shouye"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 云海 logo 徽标(结构与 Cms SchoolLayout 对齐,皮肤见通道 base.layout.css .logo-badge) -->
|
||||
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<h4 class="fl align-middle">{{ appName }}</h4>
|
||||
<div class="school-bg"></div>
|
||||
<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">
|
||||
{{ userInfo.name }}
|
||||
</a>
|
||||
@@ -167,6 +193,8 @@ function linkHomePage() {
|
||||
<i class="iconfont icon-logout"></i>
|
||||
</a>
|
||||
</div>
|
||||
<!-- 配色切换器已移除(oapi 代理轮 2026-10-08 拍板):设置入口收敛在 UserHome 设置中心,
|
||||
本地切换会被服务端生效值收敛打回,不留半残入口 -->
|
||||
</header>
|
||||
<div class="left-menu" v-if="!isAlone">
|
||||
<div class="menu-top-box"></div>
|
||||
@@ -176,14 +204,14 @@ function linkHomePage() {
|
||||
<li>
|
||||
<a href="javascript:;" @click="childChange(index)">
|
||||
<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 }}
|
||||
</a>
|
||||
</li>
|
||||
<transition name="fade_child_ul">
|
||||
<div class="child-item-wrap" v-show="item.childState">
|
||||
<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"
|
||||
:key="citem.id"
|
||||
>
|
||||
@@ -194,9 +222,9 @@ function linkHomePage() {
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
<li v-else :class="item.page == currPath ? 'active' : ''">
|
||||
<li v-else :class="pageOf(item) == currPath ? 'active' : ''">
|
||||
<a href="javascript:;" @click="linkMenu(item)">
|
||||
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
|
||||
<i :class="['iconfont', getMenuIcon(item)]"></i>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</li>
|
||||
@@ -210,7 +238,7 @@ function linkHomePage() {
|
||||
<a href="javascript:;" @click="linkHomePage">
|
||||
<BreadcrumbItem>
|
||||
<span class="crumb-home">
|
||||
<i class="iconfont icon-category"></i>
|
||||
<i class="iconfont icon-shouye"></i>
|
||||
首页
|
||||
</span>
|
||||
</BreadcrumbItem>
|
||||
@@ -250,21 +278,27 @@ function linkHomePage() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 仅覆盖/补充必要的动态样式,其余样式由全局 layout.css 提供 */
|
||||
/* 菜单图标(本地 bsch-icons 掩码类)与文字间距 */
|
||||
/* 仅覆盖/补充必要的动态样式,其余样式由中央静态通道的 base.layout.css(骨架 + 皮肤)提供 */
|
||||
/* 菜单图标(通道下发的 ysch-icons 掩码类)与文字间距 */
|
||||
.left-menu a .iconfont {
|
||||
margin-right: 8px;
|
||||
}
|
||||
/* 面包屑「首页」垂直居中:layout.css 遗留的 .path-nav .icon-category{position:relative;top:1px}
|
||||
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
|
||||
这里用 inline-flex 真居中并关掉该补偿(不动压缩产物 layout.css,覆盖收在本组件内) */
|
||||
.crumb-home {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
/* 顶栏已由通道皮肤改为 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>
|
||||
+2
-4
@@ -13,10 +13,8 @@ import mittBus from "@/utils/mitt";
|
||||
import "view-ui-plus/dist/styles/viewuiplus.css";
|
||||
|
||||
import "@/assets/styles/main.scss";
|
||||
// 本地化的布局/主题样式(原远程 base.layout.beta / _iview_override / default-theme 合并)
|
||||
import "@/assets/styles/layout.css";
|
||||
// 规范 iconfont 图标本地实现(icon-browse / icon-edit / icon-ashbin 等,平台主题不含字体)
|
||||
import "@/assets/styles/icons.css";
|
||||
// 布局/主题/图标(base.layout.css + iview.override.css + icons.css)由中央静态通道下发:
|
||||
// index.html 的 yh-theme-init.js 在 DOMContentLoaded 后按 manifest 版本追加 <link>,天然排在本文件组件库样式之后
|
||||
//导入自定义指令
|
||||
import directive from "@/utils/directive/index";
|
||||
|
||||
|
||||
+16
-6
@@ -9,10 +9,12 @@ import type { RouteRecordRaw } from "vue-router";
|
||||
* - /list/detail?serid=&type=、/noticelist/detail?serid=&type=
|
||||
* type: 0=编辑视角(不标读) 1=普通查看(标读)
|
||||
*
|
||||
* modId 是菜单 ↔ 路由的稳定契约(对照后端 GetUserMod 现行数据):
|
||||
* 1=已收到 2=已发送 3=全部通知 4=通知统计 21=应用设置
|
||||
* 发通知为「已发送」的深层子页(modId 22 不在菜单树,meta.parentId=2 关联高亮),
|
||||
* 与 UserBase「应用管理>应用类型管理」范式一致。
|
||||
* modId 是菜单 ↔ 路由的稳定契约(严格对照后端 /base/GetUserMod 现行返回的 modId):
|
||||
* 1=已收到 2=已发送 3=全部通知 4=通知统计 21=应用设置 89=待审核 91=抄送给我的
|
||||
* (后两条平台已挂一级菜单,page 分别为 /approval、/cc)。
|
||||
* 深层子页不在菜单树,靠 meta.parentId 关联高亮父菜单:/list/new=22(父2)、
|
||||
* /list/detail=23(父2)、/noticelist/detail=24(父3)。深层页 modId 不得与任何
|
||||
* 平台一级模块 ID(含 89/91)重复。
|
||||
*/
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// 已收通知(收件箱):后端菜单 page 配置为 "/",即应用入口页
|
||||
@@ -72,12 +74,20 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/notice/NoticeSetting.vue"),
|
||||
meta: { title: "应用设置", modId: 21 },
|
||||
},
|
||||
// 我的待审核(应用内审批;modId 23 深层页不在菜单树,菜单挂载待后端 GetUserMod 配置)
|
||||
// 待审核(应用内审批;平台模块管理已挂一级菜单,配置 ID = 89,path = /approval)
|
||||
{
|
||||
path: "/approval",
|
||||
name: "notice_approval",
|
||||
component: () => import("@/views/notice/NoticeApproval.vue"),
|
||||
meta: { title: "我的待审核", modId: 23, parentId: 2 },
|
||||
meta: { title: "我的待审核", modId: 89 },
|
||||
},
|
||||
|
||||
// 抄送给我的(流程 NOTIFY 知会;平台模块管理已挂一级菜单,配置 ID = 91,path = /cc)
|
||||
{
|
||||
path: "/cc",
|
||||
name: "notice_cc",
|
||||
component: () => import("@/views/notice/NoticeCc.vue"),
|
||||
meta: { title: "抄送给我的", modId: 91 },
|
||||
},
|
||||
|
||||
/* ---- 旧版路径兼容重定向(书签、残留的平台模块配置) ---- */
|
||||
|
||||
@@ -64,6 +64,10 @@ export interface NoticeVo {
|
||||
smsFlag: number;
|
||||
/** 接收人列表(JSON) */
|
||||
receiveList: string | null;
|
||||
/** 附件ID串("1,2,3";后端 Lombok 序列化名为 upFile,对齐旧 .NET UpFile 语义) */
|
||||
upFile?: string | null;
|
||||
/** @deprecated 旧 .NET 接口的 PascalCase 附件字段,仅作读取兼容 */
|
||||
UpFile?: string | null;
|
||||
createBy: string | null;
|
||||
createTime: string | null;
|
||||
updateBy: string | null;
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
class: `operating-btn iconfont ${iconClass}`,
|
||||
onClick: callback,
|
||||
onClick: disabled ? null : callback,
|
||||
},
|
||||
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());
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { NoticeDetailVo, NoticeRequest, UpFileVo, FlowBindingVo } from "@/t
|
||||
import UserCheckInput from "@/components/UserCheckInput.vue";
|
||||
import NoticeUpload from "@/components/NoticeUpload.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { fitDiagram } from "@/utils/diagramImg";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -85,11 +86,21 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
/** 流程图:flow-center 示意图深链(Notice 指定到人场景用人员视图) */
|
||||
/** 流程图 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) {
|
||||
window.open(`${config.flowHost}/#/diagram?flowGuid=${auditFlowGuid.value}&mode=user`, "_blank");
|
||||
}
|
||||
if (!auditFlowGuid.value) return;
|
||||
flowDiagramVisible.value = true;
|
||||
}
|
||||
function onFlowDiagramError() {
|
||||
flowDiagramVisible.value = false;
|
||||
Message.error("流程图加载失败(流程可能未发布或绑定已解除)");
|
||||
}
|
||||
|
||||
/* ============ 富文本编辑器(wangeditor v5 for vue3) ============ */
|
||||
@@ -408,6 +419,14 @@ async function saveEditAndLeave(data: NoticeRequest) {
|
||||
<Button @click="isModal.timiSend = false" style="margin-left: 8px">取消</Button>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
@@ -423,11 +442,11 @@ async function saveEditAndLeave(data: NoticeRequest) {
|
||||
}
|
||||
}
|
||||
.editor-wrap {
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-s);
|
||||
z-index: 100;
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.editor-content {
|
||||
:deep(.w-e-text-container) {
|
||||
@@ -439,4 +458,18 @@ async function saveEditAndLeave(data: NoticeRequest) {
|
||||
.bottom-btn {
|
||||
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>
|
||||
|
||||
+249
-114
@@ -5,43 +5,79 @@ import { Message, Tooltip } from "view-ui-plus";
|
||||
import { noticeApi } from "@/api/module/notice";
|
||||
import type { NoticeDetailVo, NoticeQuery, NoticeVo, NoticePageResult } from "@/types/notice";
|
||||
import FilePreviewModal from "@/components/FilePreviewModal.vue";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import Utils from "@/utils";
|
||||
|
||||
/* ============ 左侧列表 ============ */
|
||||
/* ============ 左侧列表(每页 50,滚动到底部加载下一页并追加) ============ */
|
||||
const keyword = ref("");
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(50);
|
||||
const PAGE_SIZE = 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 { data: page, loading, send: reload } = useRequest(
|
||||
(params: NoticeQuery) => noticeApi.list(params),
|
||||
{
|
||||
immediate: false,
|
||||
initialData: { content: [], total: 0, pageIndex: 1, pageSize: 50, totalPages: 0, hasNext: false, unreadCount: 0 },
|
||||
}
|
||||
);
|
||||
const { send: reload } = useRequest((params: NoticeQuery) => noticeApi.list(params), { immediate: false });
|
||||
|
||||
async function run() {
|
||||
async function fetchList(append: boolean) {
|
||||
const data: NoticePageResult<NoticeVo> = await reload({
|
||||
keyword: keyword.value || "",
|
||||
pageIndex: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
pageIndex: append ? pageIndex.value + 1 : 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
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 || [];
|
||||
if (!detail.value && list.length) {
|
||||
const first = list.value[0];
|
||||
if (!detail.value && first) {
|
||||
selected.value = 0;
|
||||
await loadDetail(list[0].id, true);
|
||||
await loadDetail(first.id, true);
|
||||
markRead(first);
|
||||
}
|
||||
}
|
||||
function onSearch() {
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
|
||||
/* 触底预取:距底部 60px 即发下一页请求,避免滑到尽头才出现内容 */
|
||||
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() {
|
||||
keyword.value = "";
|
||||
onSearch();
|
||||
run();
|
||||
}
|
||||
|
||||
/* 一键已读(旧版 icon-saoba 清除按钮) */
|
||||
@@ -49,7 +85,8 @@ async function allRead() {
|
||||
try {
|
||||
await noticeApi.initializeRed().send();
|
||||
Message.success("操作成功!");
|
||||
run();
|
||||
list.value.forEach(markRead);
|
||||
unreadCount.value = 0;
|
||||
if (detail.value) loadDetail(detail.value.serid, false);
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "操作失败");
|
||||
@@ -67,16 +104,13 @@ onMounted(async () => {
|
||||
async function checkNotice(item: NoticeVo, index: number) {
|
||||
selected.value = index;
|
||||
await loadDetail(item.id, true);
|
||||
markRead(item);
|
||||
}
|
||||
|
||||
async function loadDetail(serid: number, markRead: boolean) {
|
||||
async function loadDetail(serid: number, markAsRead: boolean) {
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
detail.value = await noticeApi.modify(serid, markRead ? 1 : 0).send();
|
||||
if (markRead) {
|
||||
// 标读后刷新左侧未读状态
|
||||
run();
|
||||
}
|
||||
detail.value = await noticeApi.modify(serid, markAsRead ? 1 : 0).send();
|
||||
} catch (e: any) {
|
||||
Message.error(e.message || "加载通知失败");
|
||||
} 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) {
|
||||
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
|
||||
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 (["pdf"].includes(suffix)) return "icon-pdf1";
|
||||
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";
|
||||
return "icon-weizhi";
|
||||
}
|
||||
@@ -163,15 +198,15 @@ function shortDate(date: string) {
|
||||
/* 时间显示(列表 MM/dd HH:mm,同旧版) */</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<div class="accept-wrap">
|
||||
<!-- 左侧通知列表 -->
|
||||
<div class="title-list">
|
||||
<div class="total">
|
||||
<span>未读<span class="num">{{ page.unreadCount || 0 }}</span>条</span>
|
||||
<span>未读<span class="num">{{ unreadCount }}</span>条</span>
|
||||
<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>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
@@ -180,13 +215,13 @@ function shortDate(date: string) {
|
||||
clearable
|
||||
search
|
||||
placeholder="搜索发布人,标题,部门......"
|
||||
@on-search="onSearch"
|
||||
@on-search="run"
|
||||
@on-clear="onClear"
|
||||
></Input>
|
||||
</div>
|
||||
<div class="notice-items">
|
||||
<div class="notice-items" @scroll="onListScroll">
|
||||
<div
|
||||
v-for="(item, index) in page.content"
|
||||
v-for="(item, index) in list"
|
||||
:key="item.id"
|
||||
:class="{ list_item: true, readed: item.read, active: index == selected }"
|
||||
@click="checkNotice(item, index)"
|
||||
@@ -197,7 +232,9 @@ function shortDate(date: string) {
|
||||
<span>{{ shortDate(item.createTime) }}</span>
|
||||
</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>
|
||||
|
||||
@@ -211,11 +248,11 @@ function shortDate(date: string) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="title-content">
|
||||
<p>来源:{{ detail.DeptName }}</p>
|
||||
<p>发布人:{{ detail.TeaName }}</p>
|
||||
<p>发布时间:{{ detail.EditDate }}</p>
|
||||
<p><span class="meta-label">来源</span>:{{ detail.DeptName }}</p>
|
||||
<p><span class="meta-label">发布人</span>:{{ detail.TeaName }}</p>
|
||||
<p><span class="meta-label">发布时间</span>:{{ detail.EditDate }}</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>
|
||||
</p>
|
||||
</div>
|
||||
@@ -262,188 +299,286 @@ function shortDate(date: string) {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 根容器 page-wrapper > main-container:内容区可视高度 = 100vh - 112(头部+面包屑) - 24(容器底padding) */
|
||||
.accept-wrap {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
height: calc(100vh - 136px);
|
||||
/* 已收到页 · 对齐原型 07-seam-pure.html 的 .nt-split(灰底上并排两张白卡)。
|
||||
通道把 .main-container 画成单张白卡,这里压平它、让两张卡直接坐灰底;
|
||||
外层 .page-wrapper.yh-fill 由通道转 flex 纵向铺满,卡片 flex:1 撑满、四周留白 14/18/24 全站一致。
|
||||
所有颜色/圆角/阴影一律走主题令牌,禁止硬编码。 */
|
||||
.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 {
|
||||
width: 360px;
|
||||
flex: none;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef4;
|
||||
border-radius: 3px;
|
||||
padding: 14px 0;
|
||||
display: flex;
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 15px 10px;
|
||||
padding: 13px 16px 9px;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
.num {
|
||||
padding: 0 5px;
|
||||
color: #5c9edc;
|
||||
padding: 0 2px;
|
||||
color: var(--brand);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.clear-icon {
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
&:hover {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin-top: 12px;
|
||||
padding: 0 8px 12px;
|
||||
.list_item {
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--r-s);
|
||||
border-left: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
.tt {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #272829;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.other {
|
||||
margin-top: 6px;
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
&:hover {
|
||||
background: #f6f9ff;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
&.active {
|
||||
background: #ecf3ff;
|
||||
border-left-color: #2d8cf0;
|
||||
background: var(--brand-soft);
|
||||
border-left-color: var(--brand);
|
||||
.tt {
|
||||
color: var(--brand);
|
||||
}
|
||||
}
|
||||
/* 已读弱化 */
|
||||
&.readed .tt {
|
||||
color: #8a94a6;
|
||||
color: var(--text-2);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
color: var(--text-3);
|
||||
padding: 40px 0;
|
||||
}
|
||||
.load-more {
|
||||
padding: 10px 0 4px;
|
||||
text-align: center;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 右:详情卡(原型 .nt-detail) ---------- */
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef4;
|
||||
border-radius: 3px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.title-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
.tt {
|
||||
font-size: 18px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #272829;
|
||||
letter-spacing: 0.2px;
|
||||
color: var(--text);
|
||||
}
|
||||
.ops {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
.op {
|
||||
cursor: pointer;
|
||||
color: #515a6e;
|
||||
color: var(--text-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
gap: 5px;
|
||||
font-size: 12.5px;
|
||||
transition: color 0.15s;
|
||||
&:hover {
|
||||
color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.title-content {
|
||||
margin-top: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed #e8e8e8;
|
||||
font-size: 13px;
|
||||
color: #808695;
|
||||
margin-top: 14px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2);
|
||||
p {
|
||||
margin: 4px 0;
|
||||
margin: 0;
|
||||
}
|
||||
/* 标签两端撑开:冒号纵向对齐、值从同一列起(宽度取「发布时间」4 字) */
|
||||
.meta-label {
|
||||
display: inline-block;
|
||||
width: 4em;
|
||||
text-align: justify;
|
||||
text-align-last: justify;
|
||||
}
|
||||
.to-file {
|
||||
margin-left: 10px;
|
||||
color: #2d8cf0;
|
||||
margin-left: 8px;
|
||||
color: var(--brand);
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
.notice-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-top: 10px;
|
||||
padding-top: 14px;
|
||||
.ql-editor {
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
.file {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
overflow: hidden;
|
||||
.file-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: #f7f7f7;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
align-items: center;
|
||||
padding: 9px 14px;
|
||||
background: #f7f9fc;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2);
|
||||
.all-down {
|
||||
color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
.file-list {
|
||||
padding: 10px 12px;
|
||||
padding: 2px 0;
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 6px;
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
padding: 11px 14px;
|
||||
transition: background 0.15s;
|
||||
&:hover {
|
||||
background: #fafbfe;
|
||||
}
|
||||
.left {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.file-type-icon {
|
||||
font-size: 26px;
|
||||
color: #515a6e;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
font-size: 16px;
|
||||
}
|
||||
.center {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
.name {
|
||||
color: #272829;
|
||||
font-size: 13px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.size {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
.right a {
|
||||
margin-right: 8px;
|
||||
color: #2d8cf0;
|
||||
font-size: 13px;
|
||||
.right {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex: 0 0 auto;
|
||||
a {
|
||||
color: var(--brand);
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,232 +1,89 @@
|
||||
<template>
|
||||
<div class="approval-page">
|
||||
<Tabs v-model="tab" @on-click="load">
|
||||
<TabPane label="我的待审核" name="todo" />
|
||||
<TabPane label="办件视角" name="instances" />
|
||||
</Tabs>
|
||||
<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" />
|
||||
|
||||
<!-- 待审核列表 -->
|
||||
<div v-if="tab === 'todo'">
|
||||
<Table :columns="todoCols" :data="todoRows" :loading="loading" no-data-text="暂无待审核通知">
|
||||
<template #opinion="{ row }">
|
||||
<span class="opinion">{{ row._opinion }}</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<Button size="small" type="primary" :loading="acting" @click="approve(row)">通过</Button>
|
||||
<Button size="small" type="error" style="margin-left: 6px" @click="openReject(row)"
|
||||
>驳回</Button
|
||||
>
|
||||
</template>
|
||||
</Table>
|
||||
<Page
|
||||
v-if="todoPage.total > todoPage.pageSize"
|
||||
:current="todoPage.pageIndex"
|
||||
:page-size="todoPage.pageSize"
|
||||
:total="todoPage.total"
|
||||
show-total
|
||||
style="margin-top: 14px; text-align: right"
|
||||
@on-change="loadTodo"
|
||||
/>
|
||||
</div>
|
||||
<!-- 审批记录:token 只读页,地址由抽屉自己现取(不缓存、无审批按钮) -->
|
||||
<FlowRecordDrawer v-model="recordVisible" :api="approvalApiAdapter" :biz-id="recordBizId" />
|
||||
|
||||
<!-- 办件视角 -->
|
||||
<div v-else>
|
||||
<div class="filter-bar">
|
||||
<RadioGroup v-model="instStatus" type="button" @on-change="loadInstances(1)">
|
||||
<Radio :label="0">全部</Radio>
|
||||
<Radio :label="10">审批中</Radio>
|
||||
<Radio :label="20">已完成</Radio>
|
||||
<Radio :label="30">已拒绝</Radio>
|
||||
<Radio :label="40">已撤回</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<Table :columns="instCols" :data="instRows" :loading="loading" no-data-text="暂无办件">
|
||||
<template #status="{ row }">
|
||||
<Tag :color="instStatusColor(row.status)">{{ instStatusText(row.status) }}</Tag>
|
||||
</template>
|
||||
</Table>
|
||||
<Page
|
||||
v-if="instPage.total > instPage.pageSize"
|
||||
:current="instPage.pageIndex"
|
||||
:page-size="instPage.pageSize"
|
||||
:total="instPage.total"
|
||||
show-total
|
||||
style="margin-top: 14px; text-align: right"
|
||||
@on-change="loadInstances"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 驳回弹窗:意见必填 -->
|
||||
<Modal
|
||||
v-model="rejectVisible"
|
||||
title="驳回审核"
|
||||
:loading="acting"
|
||||
@on-ok="doReject"
|
||||
@on-cancel="rejectTaskId = null"
|
||||
>
|
||||
<p style="margin-bottom: 8px">
|
||||
驳回「<b>{{ rejectTitle }}</b
|
||||
>」,必须填写意见:
|
||||
</p>
|
||||
<Input v-model="rejectOpinion" type="textarea" :rows="3" placeholder="驳回意见(必填)" />
|
||||
</Modal>
|
||||
<!-- 完整办理:CAS 去壳任务页,地址由后端确认"taskId 属于当前人"后才签发 -->
|
||||
<FlowTaskEmbed v-model="taskVisible" :page-url="taskPageUrl" :task-id="taskTaskId"
|
||||
@handled="onTaskHandled" @open-biz="onOpenBiz" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import approvalApi, {
|
||||
type ApprovalInstance,
|
||||
type ApprovalTodoTask,
|
||||
} from "@/api/module/approval";
|
||||
|
||||
/**
|
||||
* 我的待审核(应用内审批,第三步):待办列表 + 办件视角,通过/驳回走本应用后端代理
|
||||
* (operator=登录态,对端按任务办理人复核;非办理人会收到业务错提示)。
|
||||
* bizSnapshot 是发起时透传的 JSON 摘要,解析失败只展示标题。
|
||||
* 我的待审核(v2.1 重构):列表/动作/记录抽屉/任务页容器全部下沉到 flow-approval 共享组件,
|
||||
* 本页只剩两件事——注入 Notice 自己的 API 适配器,以及决定"记录/任务页"这两个浮层放在哪。
|
||||
*
|
||||
* 页签沿用「我的待审核 + 办件视角」(方案 D5:前置阶段不扩四页签,避免行为变化)。
|
||||
* 身份仍然一个都不从前端传:适配器只送 serid/taskId/opinion/edgeName,
|
||||
* operatorId/schId/name 由 NoticeService 从 CAS 会话取。
|
||||
*/
|
||||
const tab = ref<"todo" | "instances">("todo");
|
||||
const loading = ref(false);
|
||||
const acting = ref(false);
|
||||
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 todoRows = ref<(ApprovalTodoTask & { _summary: string })[]>([]);
|
||||
const todoPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
|
||||
const router = useRouter();
|
||||
const listRef = ref<any>(null);
|
||||
|
||||
const instRows = ref<ApprovalInstance[]>([]);
|
||||
const instPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
|
||||
const instStatus = ref(0);
|
||||
const approvalApiAdapter = noticeFlowApprovalApi;
|
||||
|
||||
const rejectVisible = ref(false);
|
||||
const rejectTaskId = ref<number | null>(null);
|
||||
const rejectTitle = ref("");
|
||||
const rejectOpinion = ref("");
|
||||
/* ===== 审批记录抽屉 ===== */
|
||||
const recordVisible = ref(false);
|
||||
const recordBizId = ref("");
|
||||
|
||||
const todoCols = [
|
||||
{ title: "通知", key: "bizTitle", minWidth: 220, ellipsis: true, tooltip: true },
|
||||
{ title: "摘要", key: "_summary", minWidth: 200, ellipsis: true, tooltip: true },
|
||||
{ title: "当前节点", key: "nodeName", width: 110 },
|
||||
{ title: "到达时间", key: "arriveTime", width: 170, render: fmtTime },
|
||||
{ title: "操作", slot: "action", width: 150 },
|
||||
];
|
||||
|
||||
const instCols = [
|
||||
{ title: "通知", key: "bizTitle", minWidth: 240, ellipsis: true, tooltip: true },
|
||||
{ title: "状态", slot: "status", width: 100 },
|
||||
{ title: "提交人", key: "submitterName", width: 100 },
|
||||
{ title: "提交时间", key: "submitTime", width: 170, render: fmtTime },
|
||||
{ title: "办结时间", key: "finishTime", width: 170, render: fmtTime },
|
||||
];
|
||||
|
||||
function fmtTime(h: any, p: any) {
|
||||
return h(
|
||||
"span",
|
||||
{},
|
||||
String(p.row[p.column.key] || "").replace("T", " ").slice(0, 19) || "—"
|
||||
);
|
||||
function openRecord(bizId: string) {
|
||||
recordBizId.value = String(bizId);
|
||||
recordVisible.value = true;
|
||||
}
|
||||
|
||||
function parseSummary(task: ApprovalTodoTask): string {
|
||||
if (!task.bizSnapshot) return "—";
|
||||
try {
|
||||
const snap = JSON.parse(task.bizSnapshot);
|
||||
return snap.summary || snap.title || "—";
|
||||
} catch {
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
/* ===== 完整办理(去壳任务页) ===== */
|
||||
const taskVisible = ref(false);
|
||||
const taskPageUrl = ref("");
|
||||
const taskTaskId = ref<number | string>("");
|
||||
|
||||
async function loadTodo(page = 1) {
|
||||
loading.value = true;
|
||||
async function openTask(payload: { bizId: string; taskId: number }) {
|
||||
taskPageUrl.value = "";
|
||||
try {
|
||||
const res = await approvalApi.todo(page, todoPage.value.pageSize).send();
|
||||
todoPage.value = res;
|
||||
todoRows.value = (res.content || []).map((t) => ({ ...t, _summary: parseSummary(t) }));
|
||||
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?.message || "待审核列表加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
Message.error(e?.msg || e?.message || "办理页打开失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInstances(page = 1) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await approvalApi
|
||||
.instances(instStatus.value || undefined, page, instPage.value.pageSize)
|
||||
.send();
|
||||
instPage.value = res;
|
||||
instRows.value = res.content || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "办件列表加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
/** 任务页里办完了:关弹窗 + 刷新列表(不靠轮询) */
|
||||
function onTaskHandled() {
|
||||
taskVisible.value = false;
|
||||
listRef.value?.load?.();
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (tab.value === "todo") {
|
||||
loadTodo(1);
|
||||
} else {
|
||||
loadInstances(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function approve(row: ApprovalTodoTask) {
|
||||
acting.value = true;
|
||||
try {
|
||||
await approvalApi.approve(row.taskId).send();
|
||||
Message.success("已通过");
|
||||
loadTodo(todoPage.value.pageIndex);
|
||||
} catch (e: any) {
|
||||
// 非办理人/已被办理等业务错,后端信封 msg 直接展示
|
||||
Message.error(e?.message || "操作失败");
|
||||
} finally {
|
||||
acting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openReject(row: ApprovalTodoTask) {
|
||||
rejectTaskId.value = row.taskId;
|
||||
rejectTitle.value = row.bizTitle;
|
||||
rejectOpinion.value = "";
|
||||
rejectVisible.value = true;
|
||||
}
|
||||
|
||||
async function doReject() {
|
||||
if (!rejectOpinion.value.trim()) {
|
||||
Message.warning("驳回必须填写意见");
|
||||
/** 任务页"查看业务详情"交回本页:列表页里即跳通知详情(同应用内路由,不用 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;
|
||||
}
|
||||
acting.value = true;
|
||||
try {
|
||||
await approvalApi.reject(rejectTaskId.value!, rejectOpinion.value.trim()).send();
|
||||
Message.success("已驳回");
|
||||
rejectVisible.value = false;
|
||||
loadTodo(todoPage.value.pageIndex);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "操作失败");
|
||||
} finally {
|
||||
acting.value = false;
|
||||
}
|
||||
if (payload?.bizUrl) window.open(payload.bizUrl, "_blank");
|
||||
}
|
||||
|
||||
function instStatusText(s: number) {
|
||||
return ({ 10: "审批中", 20: "已完成", 30: "已拒绝", 40: "已撤回" } as Record<number, string>)[s] || String(s);
|
||||
function onHandled() {
|
||||
listRef.value?.load?.();
|
||||
}
|
||||
|
||||
function instStatusColor(s: number) {
|
||||
return ({ 10: "blue", 20: "green", 30: "red", 40: "default" } as Record<number, string>)[s] || "default";
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.approval-page {
|
||||
padding: 12px 0;
|
||||
}
|
||||
.filter-bar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
+190
-109
@@ -5,7 +5,11 @@ import { Message } from "view-ui-plus";
|
||||
import { noticeApi } from "@/api/module/notice";
|
||||
import type { NoticeDetailVo, NextPrevVo, ReadGroup, FlowTraceVo } from "@/types/notice";
|
||||
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 { fitDiagram } from "@/utils/diagramImg";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -36,6 +40,23 @@ async function loadTrace() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前节点: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 双重外溢且"谁能撤"落在前端)。
|
||||
@@ -77,6 +98,7 @@ async function flowAction(action: "withdraw" | "urge") {
|
||||
|
||||
/* 流程图弹窗(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;
|
||||
@@ -90,6 +112,74 @@ 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 showRead = ref(false);
|
||||
const prevId = ref(0);
|
||||
@@ -189,7 +279,7 @@ function downAll() {
|
||||
function displayName(item: { name?: string; filename?: string }) {
|
||||
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) {
|
||||
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
|
||||
if (["doc", "docx", "wps"].includes(suffix)) return "icon-word";
|
||||
@@ -197,7 +287,8 @@ function getFileIcon(name: string) {
|
||||
if (["ppt", "pptx"].includes(suffix)) return "icon-ppt";
|
||||
if (["pdf"].includes(suffix)) return "icon-pdf1";
|
||||
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";
|
||||
return "icon-weizhi";
|
||||
}
|
||||
@@ -256,15 +347,24 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
<!-- 查看视角:普通标题 -->
|
||||
<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="flowTrace.currentNodes" class="flow-nodes">当前节点:{{ flowTrace.currentNodes }}</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"
|
||||
@@ -288,42 +388,35 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
办理意见:{{ flowTrace.flowResult.opinion }}
|
||||
<template v-if="flowTrace.flowResult.rejectNode">({{ flowTrace.flowResult.rejectNode }})</template>
|
||||
</div>
|
||||
<!-- 结构化步骤时间线(2026-09-17 接入 SDK progress;steps[0] 固定"发起申请",同节点多任务每人一行) -->
|
||||
<div v-if="flowTrace.steps && flowTrace.steps.length" class="flow-timeline">
|
||||
<div
|
||||
v-for="(s, i) in flowTrace.steps"
|
||||
:key="'fs' + i"
|
||||
class="flow-step"
|
||||
:class="{ 'flow-step-active': s.active }"
|
||||
>
|
||||
<span class="flow-step-node">{{ s.label || s.nodeName || `节点${i + 1}` }}</span>
|
||||
<template v-if="s.assignees && s.assignees.length">
|
||||
<span v-for="(a, j) in s.assignees" :key="j" class="flow-step-user">
|
||||
{{ a.userName || "" }}
|
||||
<em v-if="a.statusLabel" class="flow-step-status">{{ a.statusLabel }}</em>
|
||||
<em v-if="a.opinion" class="flow-step-op">({{ a.opinion }})</em>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 节点时间线(traceJson 解析,steps 不可用时的兜底) -->
|
||||
<div v-else-if="flowTrace.trace && flowTrace.trace.length" class="flow-timeline">
|
||||
<div v-for="(t, i) in flowTrace.trace" :key="i" class="flow-step">
|
||||
<span class="flow-step-node">{{ t.nodeName || t.node || t.taskName || `节点${i + 1}` }}</span>
|
||||
<span class="flow-step-user">{{ t.userName || t.assigneeName || t.handlerName || "" }}</span>
|
||||
<span class="flow-step-time">{{ t.handleTime || t.time || t.createTime || "" }}</span>
|
||||
<span class="flow-step-op" v-if="t.opinion">{{ t.opinion }}</span>
|
||||
</div>
|
||||
</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="860" footer-hide>
|
||||
<Modal v-model="diagramVisible" title="流程示意图" :width="740" footer-hide>
|
||||
<div class="diagram-wrap">
|
||||
<img v-if="diagramSrc" :src="diagramSrc" alt="流程示意图" @error="onDiagramError" />
|
||||
<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">
|
||||
<span>发文单位:{{ detail.DeptName || "—" }}</span>
|
||||
<span>发布人:{{ detail.TeaName || "—" }}</span>
|
||||
@@ -384,6 +477,19 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -395,14 +501,16 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
}
|
||||
.notice-title2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.flow-card {
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-s);
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #f8f8f9;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
.flow-card-head {
|
||||
display: flex;
|
||||
@@ -413,10 +521,10 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
}
|
||||
.flow-title {
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
color: var(--text);
|
||||
}
|
||||
.flow-nodes {
|
||||
color: #808695;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.flow-ops {
|
||||
margin-left: auto;
|
||||
@@ -424,68 +532,37 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
.flow-opinion {
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
color: #ed4014;
|
||||
background: var(--panel);
|
||||
border-radius: var(--r-s);
|
||||
color: var(--red);
|
||||
font-size: 13px;
|
||||
}
|
||||
.flow-timeline {
|
||||
margin-top: 8px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.flow-step {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
}
|
||||
.flow-step:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.flow-step-node {
|
||||
min-width: 120px;
|
||||
}
|
||||
.flow-step-user {
|
||||
min-width: 90px;
|
||||
color: #2d8cf0;
|
||||
}
|
||||
.flow-step-time {
|
||||
color: #999;
|
||||
}
|
||||
.flow-step-op {
|
||||
color: #ed4014;
|
||||
}
|
||||
.flow-step-active .flow-step-node {
|
||||
color: #2d8cf0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.flow-step-status {
|
||||
font-style: normal;
|
||||
color: #808695;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.flow-step .flow-step-op {
|
||||
font-style: normal;
|
||||
}
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed #e8e8e8;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
font-size: 13px;
|
||||
color: #808695;
|
||||
color: var(--text-3);
|
||||
}
|
||||
/* 管理视角统计条 */
|
||||
.stat-bar {
|
||||
@@ -494,7 +571,7 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
gap: 24px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
color: #17233d;
|
||||
color: var(--text);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.progress-box {
|
||||
@@ -504,7 +581,7 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
width: 300px;
|
||||
}
|
||||
.percent-label {
|
||||
color: #f60;
|
||||
color: var(--brand);
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -516,14 +593,14 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stat-nums .item {
|
||||
color: #8a94a6;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.stat-nums b {
|
||||
font-size: 20px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
.stat-nums .blue {
|
||||
color: #5c9edc;
|
||||
color: var(--brand);
|
||||
}
|
||||
.stat-nums .orange {
|
||||
color: #f60;
|
||||
@@ -534,66 +611,70 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.divider {
|
||||
border-bottom: 1px dashed #e8e8e8;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
margin: 8px 0;
|
||||
}
|
||||
.content {
|
||||
padding: 16px 0;
|
||||
/* 正文区最小高度:短通知别让附件/按钮紧贴一行字(旧版用 clientHeight-356 铺满视口,
|
||||
会把附件顶出首屏;这里只留"呼吸区",剩余空白落在按钮下方的卡片底部) */
|
||||
min-height: 200px;
|
||||
line-height: 1.8;
|
||||
word-break: break-all;
|
||||
color: var(--text);
|
||||
}
|
||||
.files {
|
||||
border-top: 1px dashed #e8e8e8;
|
||||
border-top: 1px dashed var(--line);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.files-title {
|
||||
font-size: 13px;
|
||||
color: #808695;
|
||||
color: var(--text-3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.down-all {
|
||||
margin-left: 12px;
|
||||
color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #515a6e;
|
||||
color: var(--text-2);
|
||||
margin: 0 16px 8px 0;
|
||||
font-size: 13px;
|
||||
.file-type-icon {
|
||||
font-size: 16px;
|
||||
color: #515a6e;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.file-name {
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-size {
|
||||
color: #999;
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.file-op {
|
||||
color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.bottom-btns {
|
||||
border-top: 1px dashed #e8e8e8;
|
||||
border-top: 1px dashed var(--line);
|
||||
padding-top: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* 阅读情况弹窗 */
|
||||
.read-tip {
|
||||
background: #e8f4ff;
|
||||
border: 1px solid #d3e9ff;
|
||||
color: #515a6e;
|
||||
background: var(--brand-soft);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text-2);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--r-s);
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -610,7 +691,7 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
flex: none;
|
||||
width: 130px;
|
||||
text-align: right;
|
||||
color: #515a6e;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
@@ -626,13 +707,13 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
cursor: default;
|
||||
}
|
||||
.green {
|
||||
color: #19be6b;
|
||||
color: var(--green);
|
||||
}
|
||||
.red {
|
||||
color: #ed4014;
|
||||
color: var(--red);
|
||||
}
|
||||
.empty {
|
||||
color: #999;
|
||||
color: var(--text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { useRequest } from "alova/client";
|
||||
import { Message } from "view-ui-plus";
|
||||
@@ -7,6 +7,7 @@ import { noticeApi } from "@/api/module/notice";
|
||||
import type { NoticeVo, NoticeQuery } from "@/types/notice";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import Utils from "@/utils";
|
||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -32,9 +33,10 @@ const auditStatusOptions = [
|
||||
{ value: -1, label: "无需审核" },
|
||||
];
|
||||
|
||||
function fmtDate(d: Date) {
|
||||
function fmtDate(d: Date | string) {
|
||||
const dt = new Date(d);
|
||||
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 页长记忆) ============ */
|
||||
@@ -54,8 +56,9 @@ async function run() {
|
||||
pageSize: pageSize.value,
|
||||
orderByTime: "1",
|
||||
};
|
||||
if (startDt.value) params.startTime = startDt.value;
|
||||
if (endDt.value) params.endTime = endDt.value;
|
||||
// DatePicker 选过日期后 v-model 值是 Date 对象,后端 LocalDate 只认 yyyy-MM-dd → 统一格式化
|
||||
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) {
|
||||
params.scope = "user";
|
||||
@@ -143,9 +146,10 @@ function del(row: NoticeVo) {
|
||||
}
|
||||
|
||||
/* 附件下载:逐个触发(间隔 500ms,同旧版) */
|
||||
function downloadFiles(row: NoticeVo & { UpFile?: string }) {
|
||||
if (!row.UpFile) return;
|
||||
row.UpFile.split(",").forEach((id: string, i: number) => {
|
||||
function downloadFiles(row: NoticeVo) {
|
||||
const upFile = row.upFile ?? row.UpFile;
|
||||
if (!upFile) return;
|
||||
upFile.split(",").forEach((id: string, i: number) => {
|
||||
setTimeout(() => {
|
||||
window.location.href = noticeApi.fileDownUrl(Number(id));
|
||||
}, i * 500);
|
||||
@@ -170,9 +174,10 @@ const columns = [
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const st = p.row.auditStatus;
|
||||
if (st === null || st === undefined) return h("span", { style: "color:#999;" }, "—");
|
||||
const colors: Record<number, string> = { 0: "default", 10: "blue", 20: "green", 30: "red", 40: "orange" };
|
||||
return h("Tag", { color: colors[st] || "default" }, () => p.row.auditStatusDesc || "—");
|
||||
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" },
|
||||
@@ -184,12 +189,14 @@ const columns = [
|
||||
},
|
||||
{
|
||||
title: "附件",
|
||||
width: 70,
|
||||
/* 图标外包 Tooltip 后占 32+2*2=36px,cell 左右内边距各 18px:70px 列只剩 34px 内容宽,
|
||||
溢出 2px 会让 cell 的 text-overflow:ellipsis 在图标后画出「...」,故留到 80 */
|
||||
width: 80,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
p.row.UpFile
|
||||
(p.row.upFile ?? p.row.UpFile)
|
||||
? Utils.operatingHandle(h, "附件下载", "icon-attachment", () => downloadFiles(p.row))
|
||||
: h("span", { style: "color:#999;" }, "无"),
|
||||
: h("span", { style: "color:var(--text-3);" }, "无"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
@@ -197,21 +204,23 @@ const columns = [
|
||||
align: "center",
|
||||
fixed: "right",
|
||||
render: (h: any, p: any) => {
|
||||
// BP-R5-1:审批中(audit=10)禁止删除(后端 403)——不再渲染死按钮,审批通过/驳回/撤回后恢复
|
||||
const ops = [Utils.operatingHandle(h, "查看通知", "icon-browse", () => openDetail(p.row))];
|
||||
if (p.row.auditStatus !== 10) {
|
||||
ops.push(Utils.operatingHandle(h, "删除通知", "icon-ashbin", () => del(p.row)));
|
||||
}
|
||||
return ops;
|
||||
// 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-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>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<!-- 1. 操作按钮区 -->
|
||||
<div class="fix mb-12">
|
||||
@@ -250,14 +259,16 @@ const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOper
|
||||
</div>
|
||||
|
||||
<!-- 3. 表格 + 分页 -->
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="page.content"
|
||||
class="link-table table-bottom-box"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无数据"
|
||||
></Table>
|
||||
<div class="table-region" ref="tableRegionRef">
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="page.content"
|
||||
class="link-table table-bottom-box"
|
||||
:height="tableH || undefined"
|
||||
:loading="loading"
|
||||
no-data-text="暂无数据"
|
||||
></Table>
|
||||
</div>
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle"></div>
|
||||
<Page
|
||||
|
||||
@@ -9,8 +9,10 @@ import { settingApi, baseApi, noticeApi } from "@/api/module/notice";
|
||||
import type { DeptManageItem, ContactGroupPage, ContactUserPage, SchoolTreeNode, FlowOption } from "@/types/notice";
|
||||
import config from "@/assets/config/host.config";
|
||||
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);
|
||||
|
||||
function tabChange(name: string) {
|
||||
@@ -79,9 +81,30 @@ function unbindFlow() {
|
||||
});
|
||||
}
|
||||
|
||||
/** 流程图(flow-center 示意图深链,与发通知页同一入口) */
|
||||
function openFlowDiagram(guid: string) {
|
||||
if (guid) window.open(`${config.flowHost}/#/diagram?flowGuid=${guid}`, "_blank");
|
||||
/** 流程图 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("流程图加载失败(流程可能未发布或绑定已解除)");
|
||||
}
|
||||
|
||||
/** 当前绑定流程的节点摘要(下拉里回显) */
|
||||
@@ -397,19 +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>
|
||||
|
||||
<template>
|
||||
<div class="tabs-wrapper">
|
||||
<Tabs class="tabs-panel" type="card" :value="String(currentType)" @on-click="tabChange">
|
||||
<div class="page-wrapper yh-fill">
|
||||
<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="3"></TabPane>
|
||||
<TabPane label="流程绑定" name="2"></TabPane>
|
||||
</Tabs>
|
||||
|
||||
<!-- 发文部门 -->
|
||||
<div class="main-container" v-show="currentType === 1">
|
||||
<div class="tab-body" v-show="currentType === 1">
|
||||
<Alert show-icon>设置说明:拖动卡片调整部门排序;停用的部门不出现在「通知发送」的部门下拉中</Alert>
|
||||
<div class="search-wrap fix">
|
||||
<Button class="fl" type="primary" @click="addDeptShow">新增部门</Button>
|
||||
@@ -437,17 +463,19 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
|
||||
</div>
|
||||
|
||||
<!-- 自定义组 -->
|
||||
<div class="main-container" v-show="currentType === 3">
|
||||
<div class="tab-body" v-show="currentType === 3">
|
||||
<div class="search-wrap fix">
|
||||
<Button class="fl" type="primary" @click="addGroupShow">新建联系组</Button>
|
||||
</div>
|
||||
<Table
|
||||
:columns="groupColumns"
|
||||
:data="groupData"
|
||||
class="link-table table-bottom-box"
|
||||
:height="groupTableHeight"
|
||||
no-data-text="暂无数据"
|
||||
></Table>
|
||||
<div class="table-region" ref="groupRegionRef">
|
||||
<Table
|
||||
:columns="groupColumns"
|
||||
:data="groupData"
|
||||
class="link-table table-bottom-box"
|
||||
:height="groupH || undefined"
|
||||
no-data-text="暂无数据"
|
||||
></Table>
|
||||
</div>
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle"></div>
|
||||
<Page
|
||||
@@ -478,7 +506,7 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
|
||||
</div>
|
||||
|
||||
<!-- 流程绑定(应用设置 > 流程绑定) -->
|
||||
<div class="main-container" v-show="currentType === 2">
|
||||
<div class="tab-body" v-show="currentType === 2">
|
||||
<Alert show-icon type="warning">
|
||||
绑定说明:绑定后「发送全部用户」的通知必须审核通过才发布(发通知页只显示「提交审核」按钮,
|
||||
隐藏发送与定时发送);「发送指定人员」不受影响,仍直接发布。未绑定则保持现有发送方式。
|
||||
@@ -502,6 +530,7 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
|
||||
<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>
|
||||
@@ -511,7 +540,7 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
|
||||
已绑定
|
||||
<Tag color="blue">{{ boundFlowName || boundFlowGuid }}</Tag>
|
||||
<span class="node-summary">{{ boundFlowSummary }}</span>
|
||||
<Button type="text" size="small" @click="openFlowDiagram(boundFlowGuid)">查看流程图</Button>
|
||||
<Button type="text" size="small" @click="openFlowDiagram">查看流程图</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Tag>未绑定</Tag>
|
||||
@@ -520,6 +549,15 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
|
||||
</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 v-model="deptModal" :mask-closable="false" width="450px" title="新增部门">
|
||||
@@ -601,23 +639,37 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
|
||||
gap: 10px;
|
||||
}
|
||||
.flow-binding-form .label {
|
||||
color: #515a6e;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.flow-binding-state {
|
||||
color: #515a6e;
|
||||
color: var(--text-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.flow-binding-state .node-summary {
|
||||
color: #808695;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.readonly-hint {
|
||||
color: #808695;
|
||||
color: var(--text-3);
|
||||
margin-left: 8px;
|
||||
}
|
||||
.flow-binding-empty {
|
||||
margin-top: 16px;
|
||||
color: #808695;
|
||||
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>
|
||||
|
||||
@@ -7,9 +7,9 @@ import { Icon } from "view-ui-plus";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="page-wrapper yh-fill">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -21,8 +21,7 @@ import { Icon } from "view-ui-plus";
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 136px);
|
||||
color: #999;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.tip {
|
||||
margin-top: 16px;
|
||||
|
||||
+12
-1
@@ -73,7 +73,6 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
||||
"flex-center": "flex items-center justify-center",
|
||||
// 常用组件
|
||||
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",
|
||||
// 文本截断
|
||||
"text-ellipsis": "truncate",
|
||||
@@ -90,6 +89,9 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
// 主题接入已免插件化(A 类,YschTheme《管理端主题远程化与公共静态通道方案》§11):
|
||||
// 唯一接入点 = index.html 一段同步 loader,挂载/FOUC/CSS 排序全由通道 init 承担;
|
||||
// 下面的 /static 代理只是 dev 联调便利(prod 同源用不到),删除不影响线上主题
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -106,6 +108,15 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
||||
target: "http://localhost:8101",
|
||||
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预处理器
|
||||
|
||||
Reference in New Issue
Block a user