Compare commits

...
52 Commits
Author SHA1 Message Date
fuenmao f183bfe508 查询方式优化 2026-11-09 17:08:34 +08:00
fuenmao b79034311b 新增公司盖章与骑缝章配置 2026-10-15 17:42:13 +08:00
fuenmao a2a163425c 取消电话必填限制 2026-10-14 16:26:37 +08:00
fuenmao 54ac8369b5 调整默认登录信息 2026-09-20 14:12:49 +08:00
fuenmao 30ac8b51b5 优化审核提示 2026-09-18 21:14:21 +08:00
fuenmao 6be0d4f54a 优化操作 2026-09-18 16:19:29 +08:00
fuenmao ea2c823b75 相关bug调整 2026-09-18 16:08:27 +08:00
fuenmao c812071d8a 修改相关问题 2026-09-18 10:53:56 +08:00
fuenmao 2f0d04b982 修复相关bug 2026-09-17 16:03:40 +08:00
fuenmao 7cffe57e4d 新增必填项验证 2026-09-17 14:33:55 +08:00
fuenmao 04c621961c 修改相关bug 2026-09-17 11:37:32 +08:00
fuenmao a0a181af57 入职样式优化 2026-09-15 15:38:19 +08:00
fuenmao d407f1c80b Merge branch 'master' of https://gitea.cnzhyx.cn/yuanlin/OnboardXFront 2026-09-15 13:43:41 +08:00
fuenmao 70bf1907b5 前端双部署 2026-09-15 13:43:22 +08:00
tiananlin 56e6d5a4d7 Merge pull request 'Tiananlin patch 1' (#1) from tiananlin-patch-1 into master
Reviewed-on: #1
2026-09-15 11:43:12 +08:00
tiananlin cbedb66bff 更新 Dockerfiles/Dockerfile-public/default.conf 2026-09-15 11:31:44 +08:00
tiananlin d6d4fb2885 删除 Dockerfiles/Dockerfile 2026-09-15 11:20:34 +08:00
tiananlin 763c27d5c6 删除 Dockerfiles/default.conf 2026-09-15 11:20:28 +08:00
tiananlin 52c8ddc09e 更新 Dockerfiles/Dockerfile-public/Dockerfile 2026-09-15 11:19:54 +08:00
tiananlin fd5b85e20f 添加 Dockerfiles/Dockerfile-admin/Dockerfile 2026-09-15 11:19:23 +08:00
tiananlin 8d4bee03e8 添加 Dockerfiles/Dockerfile-admin/default.conf 2026-09-15 11:18:30 +08:00
tiananlin c4daaae117 添加 Dockerfiles/Dockerfile-public/Dockerfile 2026-09-15 11:18:04 +08:00
tiananlin aa4961cd6e 添加 Dockerfiles/Dockerfile-public/default.conf 2026-09-15 11:15:44 +08:00
tiananlin 0e8fe2cbfe 更新 Dockerfiles/Dockerfile 2026-09-15 11:10:19 +08:00
fuenmao 7de33e5f13 优化文档下载 2026-09-14 17:55:19 +08:00
fuenmao 40010b5668 员工入职填写优化 2026-09-14 16:45:50 +08:00
fuenmao 0624710136 修复相关bug 2026-09-14 15:59:18 +08:00
fuenmao 5d1ec4b137 员工入职页面配色调整与title调整 2026-09-11 14:35:24 +08:00
fuenmao cdf2b0776a 新增员工确认信息实名认证相关信息板块 2026-09-11 11:31:16 +08:00
fuenmao 446e83eed1 新增员工统计板块 2026-09-11 10:39:17 +08:00
fuenmao 4371eca6c4 权限相关调整 2026-09-10 16:43:23 +08:00
fuenmao 0db1848b10 Merge branch 'master' of https://gitea.cnzhyx.cn/yuanlin/OnboardXFront 2026-09-09 16:36:41 +08:00
fuenmao fadc3328e7 11 2026-09-09 16:36:23 +08:00
fuenmao 68704a091b 111 2026-09-09 14:57:46 +08:00
fuenmao 709e02a04f 完善第三方签署 2026-09-08 17:48:42 +08:00
fuenmao 802d9cde89 电子签基础功能 2026-09-07 16:18:01 +08:00
fuenmao 7e74fb3b4b 提示优化 2026-09-02 15:55:12 +08:00
fuenmao 058727982b 入职任务预览,入职任务cope,验证填入的信息 2026-08-29 17:26:38 +08:00
tiananlin 43fc6873b3 添加 Dockerfiles/default.conf 2026-08-28 18:11:48 +08:00
tiananlin 19dfca3ed0 添加 Dockerfiles/Dockerfile 2026-08-28 18:11:24 +08:00
fuenmao e2697b4bdb 1111 2026-08-28 17:32:47 +08:00
fuenmao 14515ccdd6 1111 2026-08-28 11:09:20 +08:00
fuenmao d0d7ca8e69 3333 2026-08-27 16:27:17 +08:00
fuenmao 6d4ad7236f 代码优化 2026-08-27 11:09:33 +08:00
fuenmao c091fc92b6 11 2026-08-26 19:21:35 +08:00
fuenmao ec7e889edc 11 2026-08-26 18:17:36 +08:00
fuenmao ba023f2af4 111 2026-08-26 16:11:30 +08:00
fuenmao dd7c9e2bb6 11 2026-08-26 15:30:02 +08:00
fuenmao f1a910c25a 11 2026-08-26 11:00:10 +08:00
fuenmao eab359d9fa 11 2026-08-25 20:37:24 +08:00
fuenmao aec6c1f36c 调整 2026-08-25 13:54:25 +08:00
fuenmao 49a1a3ee6a 逻辑调整 2026-08-25 10:20:59 +08:00
64 changed files with 14668 additions and 2884 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
.gitignore
.idea
.vscode
node_modules
dist
dist-*
coverage
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
stats.html
+8 -1
View File
@@ -1,5 +1,12 @@
# title # title
VITE_GLOB_APP_TITLE = 未病局管理系统 VITE_GLOB_APP_TITLE = 员工管理系统
# 前端构建类型与入口。默认保持完整后台版本。
VITE_APP_VARIANT = admin
VITE_APP_ENTRY = /src/main.ts
VITE_APP_FAVICON = /api/sys/ico
# 员工公开入职前端地址;各模式可在对应 .env 文件中覆盖。
VITE_PUBLIC_ONBOARDING_URL = http://localhost:8849
# 本地运行端口号 # 本地运行端口号
VITE_PORT = 8848 VITE_PORT = 8848
+3
View File
@@ -1,6 +1,9 @@
# 本地环境 # 本地环境
VITE_USER_NODE_ENV = development VITE_USER_NODE_ENV = development
# 本地后台生成的员工链接固定指向独立公开前端开发端口
VITE_PUBLIC_ONBOARDING_URL = http://localhost:8849
# 公共基础路径 # 公共基础路径
VITE_PUBLIC_PATH = / VITE_PUBLIC_PATH = /
+5 -2
View File
@@ -1,6 +1,9 @@
# 线上环境 # 线上环境
VITE_USER_NODE_ENV = production VITE_USER_NODE_ENV = production
# 后台生成员工入职链接时使用的固定公开域名
VITE_PUBLIC_ONBOARDING_URL = https://onboardx.cnzhyx.cn
# 公共基础路径 # 公共基础路径
VITE_PUBLIC_PATH = / VITE_PUBLIC_PATH = /
@@ -25,10 +28,10 @@ VITE_PWA = true
VITE_HTTP_PROXY = true VITE_HTTP_PROXY = true
# 线上环境接口地址 # 线上环境接口地址
VITE_API_URL = http://medical.23544.com:7106/ VITE_API_URL = https://onboardx-api.cnzhyx.cn/
# 开发环境跨域代理,支持配置多个 # 开发环境跨域代理,支持配置多个
VITE_PROXY = [["/api","http://medical.23544.com:7106/"]] VITE_PROXY = [["/api","https://onboardx-api.cnzhyx.cn/"]]
#是否启用mqtt #是否启用mqtt
VITE_MQTT = false VITE_MQTT = false
+28
View File
@@ -0,0 +1,28 @@
# 员工公开入职前端
VITE_USER_NODE_ENV = production
VITE_APP_VARIANT = public
VITE_APP_ENTRY = /src/public-main.ts
VITE_APP_FAVICON = /logo.png
VITE_GLOB_APP_TITLE = 入职服务
VITE_PORT = 8849
VITE_OPEN = true
VITE_DEVTOOLS = false
VITE_REPORT = false
VITE_CODEINSPECTOR = false
VITE_PUBLIC_PATH = /
VITE_ROUTER_MODE = hash
VITE_BUILD_COMPRESS = none
VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
VITE_DROP_CONSOLE = true
# 生成自销毁 Service Worker,用来清除该域名历史遗留的后台 PWA 缓存。
VITE_PWA = true
VITE_HTTP_PROXY = true
VITE_API_URL = http://127.0.0.1:5566
VITE_PROXY = [["/api","http://127.0.0.1:5566"]]
VITE_MQTT = false
VITE_PUBLIC_ONBOARDING_URL = http://localhost:8849
+1
View File
@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-*
dist-ssr dist-ssr
stats.html stats.html
*.local *.local
+44
View File
@@ -0,0 +1,44 @@
# 构建阶段
FROM registry-vpc.cn-chengdu.aliyuncs.com/zhixueli-prod/node:18.20.3 AS builder
# 设置工作目录
WORKDIR /app
# 设置 npm 镜像源
RUN npm config set registry https://registry.npmmirror.com/
RUN npm config set fetch-retries 3
RUN npm config set fetch-retry-mintimeout 5000
RUN npm config set fetch-retry-maxtimeout 60000
# 安装pnpm
RUN npm install -g pnpm@10.16.1
# 复制 package.json
COPY package.json pnpm-lock.yaml ./
# 设置 pnpm 下载源
RUN pnpm config set registry https://registry.npmmirror.com/
# 安装依赖
RUN pnpm install --frozen-lockfile --fetch-timeout 300000
# 复制源代码
COPY . .
# 构建项目
RUN npm run build:admin
# 部署阶段
FROM registry-vpc.cn-chengdu.aliyuncs.com/zhixueli-prod/nginx:alpine
# 复制构建产物到 Nginx 目录
COPY --from=builder --chown=nginx:nginx /app/dist-admin /usr/share/nginx/html
# 复制 Nginx 配置
COPY Dockerfiles/Dockerfile-admin/default.conf /etc/nginx/conf.d/default.conf
# 暴露端口
EXPOSE 80
# 启动 Nginx
CMD ["nginx", "-g", "daemon off;"]
+27
View File
@@ -0,0 +1,27 @@
server {
listen 80;
server_name localhost;
client_max_body_size 100m;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://onboardx-api-svc:5566/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
+44
View File
@@ -0,0 +1,44 @@
# 构建阶段
FROM registry-vpc.cn-chengdu.aliyuncs.com/zhixueli-prod/node:18.20.3 AS builder
# 设置工作目录
WORKDIR /app
# 设置 npm 镜像源
RUN npm config set registry https://registry.npmmirror.com/
RUN npm config set fetch-retries 3
RUN npm config set fetch-retry-mintimeout 5000
RUN npm config set fetch-retry-maxtimeout 60000
# 安装pnpm
RUN npm install -g pnpm@10.16.1
# 复制 package.json
COPY package.json pnpm-lock.yaml ./
# 设置 pnpm 下载源
RUN pnpm config set registry https://registry.npmmirror.com/
# 安装依赖
RUN pnpm install --frozen-lockfile --fetch-timeout 300000
# 复制源代码
COPY . .
# 构建项目
RUN npm run build:public
# 部署阶段
FROM registry-vpc.cn-chengdu.aliyuncs.com/zhixueli-prod/nginx:alpine
# 复制构建产物到 Nginx 目录
COPY --from=builder --chown=nginx:nginx /app/dist-public /usr/share/nginx/html
# 复制 Nginx 配置
COPY Dockerfiles/Dockerfile-public/default.conf /etc/nginx/conf.d/default.conf
# 暴露端口
EXPOSE 80
# 启动 Nginx
CMD ["nginx", "-g", "daemon off;"]
@@ -0,0 +1,45 @@
server {
listen 80;
server_name _;
client_max_body_size 100m;
root /usr/share/nginx/html;
index index.html;
add_header X-Robots-Tag "noindex, nofollow" always;
# 公开构建中的 sw.js 只负责注销该域名历史遗留的后台 PWA。
location = /sw.js {
add_header Cache-Control "no-store, no-cache, must-revalidate";
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
try_files $uri =404;
}
# 员工域名只允许入职公开接口。内部后端路由不包含外层 /api。
location ^~ /api/open/onboarding/ {
proxy_pass http://onboardx-api-svc:5566/open/onboarding/;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# 公开域名拒绝系统、后台业务及其他 API。
location ^~ /api/ {
return 404;
}
# Hash 路由的有效链接在服务器看来也是根路径,必须返回公开版 index.html。
location / {
try_files $uri $uri/ /index.html;
}
}
+23
View File
@@ -0,0 +1,23 @@
# 完整后台前端
FROM registry-vpc.cn-chengdu.aliyuncs.com/zhixueli-prod/node:18.20.3 AS builder
WORKDIR /app
RUN npm config set registry https://registry.npmmirror.com/
RUN npm config set fetch-retries 3
RUN npm config set fetch-retry-mintimeout 5000
RUN npm config set fetch-retry-maxtimeout 60000
RUN npm install -g pnpm@10.16.1
COPY package.json pnpm-lock.yaml ./
RUN pnpm config set registry https://registry.npmmirror.com/
RUN pnpm install --frozen-lockfile --fetch-timeout 300000
COPY . .
RUN npm run build:admin
FROM registry-vpc.cn-chengdu.aliyuncs.com/zhixueli-prod/nginx:alpine
COPY --from=builder --chown=nginx:nginx /app/dist-admin /usr/share/nginx/html
COPY Dockerfiles/admin.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+23
View File
@@ -0,0 +1,23 @@
# 纯员工入职前端
FROM registry-vpc.cn-chengdu.aliyuncs.com/zhixueli-prod/node:18.20.3 AS builder
WORKDIR /app
RUN npm config set registry https://registry.npmmirror.com/
RUN npm config set fetch-retries 3
RUN npm config set fetch-retry-mintimeout 5000
RUN npm config set fetch-retry-maxtimeout 60000
RUN npm install -g pnpm@10.16.1
COPY package.json pnpm-lock.yaml ./
RUN pnpm config set registry https://registry.npmmirror.com/
RUN pnpm install --frozen-lockfile --fetch-timeout 300000
COPY . .
RUN npm run build:public
FROM registry-vpc.cn-chengdu.aliyuncs.com/zhixueli-prod/nginx:alpine
COPY --from=builder --chown=nginx:nginx /app/dist-public /usr/share/nginx/html
COPY Dockerfiles/public.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+34
View File
@@ -0,0 +1,34 @@
server {
listen 80;
server_name _;
client_max_body_size 100m;
root /usr/share/nginx/html;
index index.html;
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
try_files $uri =404;
}
location = /sw.js {
add_header Cache-Control "no-store, no-cache, must-revalidate";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://onboardx-api-svc:5566/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
@@ -0,0 +1,22 @@
# onboardx-api.cnzhyx.cn 的外层 Nginx 示例。
# 只暴露契约锁回调;后台和员工接口分别通过两个前端域名反向代理。
server {
listen 80;
server_name onboardx-api.cnzhyx.cn;
location = /api/open/onboarding/qiyuesuo/callback {
proxy_pass http://onboardx-api-svc:5566/open/onboarding/qiyuesuo/callback;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location / {
return 404;
}
}
+82
View File
@@ -0,0 +1,82 @@
# 服务器最外层 Nginx 示例。
# 使用前请确认三个本机端口及证书路径与实际环境一致。
upstream onboardx_admin_web {
server 127.0.0.1:18081;
keepalive 16;
}
upstream onboardx_public_web {
server 127.0.0.1:18082;
keepalive 16;
}
upstream onboardx_backend {
server 127.0.0.1:15566;
keepalive 16;
}
server {
listen 80;
server_name onboardx.cnzhyx.cn onboardx-admin.cnzhyx.cn onboardx-api.cnzhyx.cn;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name onboardx-admin.cnzhyx.cn;
ssl_certificate /etc/nginx/certs/cnzhyx.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/cnzhyx.cn/privkey.pem;
location / {
proxy_pass http://onboardx_admin_web;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl http2;
server_name onboardx.cnzhyx.cn;
ssl_certificate /etc/nginx/certs/cnzhyx.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/cnzhyx.cn/privkey.pem;
location / {
proxy_pass http://onboardx_public_web;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl http2;
server_name onboardx-api.cnzhyx.cn;
ssl_certificate /etc/nginx/certs/cnzhyx.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/cnzhyx.cn/privkey.pem;
client_max_body_size 10m;
location = /api/open/onboarding/qiyuesuo/callback {
proxy_pass http://onboardx_backend/open/onboarding/qiyuesuo/callback;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location / {
return 404;
}
}
+45
View File
@@ -0,0 +1,45 @@
server {
listen 80;
server_name _;
client_max_body_size 100m;
root /usr/share/nginx/html;
index index.html;
add_header X-Robots-Tag "noindex, nofollow" always;
# 公开构建中的 sw.js 只负责注销该域名历史遗留的后台 PWA。
location = /sw.js {
add_header Cache-Control "no-store, no-cache, must-revalidate";
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
try_files $uri =404;
}
# 员工域名只允许入职公开接口。内部后端路由不包含外层 /api。
location ^~ /api/open/onboarding/ {
proxy_pass http://onboardx-api-svc:5566/open/onboarding/;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# 公开域名拒绝系统、后台业务及其他 API。
location ^~ /api/ {
return 404;
}
# Hash 路由的有效链接在服务器看来也是根路径,必须返回公开版 index.html。
location / {
try_files $uri $uri/ /index.html;
}
}
+117
View File
@@ -0,0 +1,117 @@
# OnboardX 双前端域名部署说明
## 1. 最终访问结构
- 员工入职:https://onboardx.cnzhyx.cn
- 后台管理:https://onboardx-admin.cnzhyx.cn
- 契约锁回调:https://onboardx-api.cnzhyx.cn/api/open/onboarding/qiyuesuo/callback
- 后端仍然只有一套,不需要复制数据库或部署第二套 API。
两个前端是同一份源码的不同构建结果:
- build:admin 生成完整后台,输出到 dist-admin。
- build:public 只生成员工填报、链接失效和 404 页面,输出到 dist-public。
员工域名的容器只转发 /api/open/onboarding/。任何 /api/sys/、/api/biz/ 或其他 API 都会直接返回 404。
## 2. 本次涉及的配置文件
- Dockerfile.admin:构建、运行完整后台前端。
- Dockerfile.public:构建、运行纯员工入职前端。
- admin.conf:后台前端容器内部 Nginx。
- public.conf:员工前端容器内部 Nginx,并限制公开 API。
- edge-domains.conf.example:服务器最外层三个 HTTPS 域名的参考配置。
- api-public-callback.conf.example:只单独配置 API 回调域名时的精简参考。
## 3. 上线前准备
### 3.1 DNS
在域名服务商处确保以下记录都指向同一台服务器公网 IP:
onboardx.cnzhyx.cn
onboardx-admin.cnzhyx.cn
onboardx-api.cnzhyx.cn
### 3.2 HTTPS 证书
证书必须覆盖新域名 onboardx-admin.cnzhyx.cn。如果已有 *.cnzhyx.cn 通配符证书,可以继续使用。
### 3.3 后端容器服务名
两个前端容器内部配置默认访问:
http://onboardx-api-svc:5566
因此两个前端容器必须与后端处于同一个 Docker 网络,并且后端在该网络中的名称或别名为 onboardx-api-svc。如果线上服务名不同,只修改 admin.conf 和 public.conf 中的 upstream 地址,不要改公开接口范围。
## 4. 构建两个前端镜像
在前端项目根目录执行:
docker build -f Dockerfiles/Dockerfile.admin -t onboardx-admin-web:20260915 .
docker build -f Dockerfiles/Dockerfile.public -t onboardx-public-web:20260915 .
VITE_PUBLIC_ONBOARDING_URL 是构建期配置,生产环境已经设置为:
https://onboardx.cnzhyx.cn
以后如果更换员工域名,修改 .env.production 后必须重新构建后台镜像,不能只改 Nginx。
## 5. 启动两个前端容器
下面仅作为普通 Docker 示例;如果使用宝塔、Portainer、Kubernetes 或云平台,就在对应界面填写相同的镜像、端口和网络关系。
docker run -d --name onboardx-admin-web --restart always --network onboardx-net -p 127.0.0.1:18081:80 onboardx-admin-web:20260915
docker run -d --name onboardx-public-web --restart always --network onboardx-net -p 127.0.0.1:18082:80 onboardx-public-web:20260915
如果现有后端不在 onboardx-net,应让三个容器加入同一个已有网络。不要为了省事把后端所有端口直接暴露到公网。
## 6. 配置服务器最外层 Nginx
1. 复制 edge-domains.conf.example 到服务器 Nginx 的站点配置目录。
2. 把证书路径改为服务器上的真实路径。
3. 确认端口:
- 18081 是后台前端。
- 18082 是员工前端。
- 15566 是后端映射到服务器本机的端口;如果后端没有映射该端口,应改为实际地址。
4. 执行 nginx -t 检查配置。
5. 检查通过后执行 nginx -s reload 平滑重载。
服务器最外层 Nginx 只负责域名、HTTPS 和转发。真正的公开 API 白名单在员工前端容器的 public.conf 内再次限制,形成两层保护。
## 7. 推荐发布顺序
1. 先完成 onboardx-admin.cnzhyx.cn 的 DNS 和证书。
2. 启动后台前端容器,确认新后台域名登录及业务功能正常。
3. 启动员工前端容器,但先不要发送新链接。
4. 将原来的 onboardx.cnzhyx.cn 切换到员工前端容器。
5. 打开一次员工域名,确认显示“入职链接无效”。
6. 从后台复制一条有效链接,用电脑和安卓手机各走一遍。
7. 最后确认契约锁回调仍然成功。
员工域名以前部署过后台 PWA。本次公开构建会发布一个自销毁 Service Worker,自动注销旧 PWA 并清理旧缓存。老手机第一次打开时可能自动刷新一次;若仍看到旧页面,关闭该页面后重新打开即可。
## 8. 安卓下载处理
- 普通桌面和 iOS 浏览器通过不携带后台凭证的文件流下载。
- 安卓及安卓 WebView 直接访问同域公开文件接口,避免 blob: 下载被系统 WebView 拦截。
- 安卓微信如果不支持文件接管,页面会提示用户点击右上角“在浏览器打开”后重新下载。
- 后台 PWA 的 Service Worker 不再拦截 /api/,所以文件接口不会错误回退成 index.html。
## 9. 上线验收清单
- 访问 https://onboardx.cnzhyx.cn/,只显示“入职链接无效”。
- 访问 /#/onboarding/fill 但不带 Token,自动进入链接无效页。
- 修改 Token 任意一个字符,提示链接不存在或无效。
- 公开域名访问 /api/sys/ 返回 404。
- 公开域名访问 /api/biz/ 返回 404。
- 公开域名不能出现后台登录页、菜单、员工档案或系统管理页面。
- 后台生成的员工链接始终以 https://onboardx.cnzhyx.cn 开头。
- onboardx-admin.cnzhyx.cn 的登录、上传、下载和业务操作正常。
- 安卓 Chrome、安卓微信、桌面浏览器均测试合同下载。
- 契约锁回调地址保持不变且能正常更新签署状态。
## 10. 回滚原则
后台前端和员工前端可以分别回滚到各自上一个镜像版本。不要把员工域名重新指向完整后台作为临时回滚方案,否则会重新暴露后台入口。若员工端出现故障,应只回滚 onboardx-public-web 镜像。
+15 -2
View File
@@ -129,12 +129,25 @@ const createCompression = (viteEnv: ViteEnv): PluginOption | PluginOption[] => {
* @param viteEnv * @param viteEnv
*/ */
const createVitePwa = (viteEnv: ViteEnv): PluginOption | PluginOption[] => { const createVitePwa = (viteEnv: ViteEnv): PluginOption | PluginOption[] => {
const { VITE_GLOB_APP_TITLE } = viteEnv; const { VITE_GLOB_APP_TITLE, VITE_APP_VARIANT } = viteEnv;
// 员工公开域名曾部署过完整后台 PWA。发布自销毁 SW 可主动注销旧注册并清理旧缓存,
// 后续公开页面不再由 Service Worker 接管。
if (VITE_APP_VARIANT === "public") {
return VitePWA({
registerType: "autoUpdate",
manifest: false,
selfDestroying: true
});
}
return VitePWA({ return VitePWA({
registerType: "autoUpdate", registerType: "autoUpdate",
workbox: { workbox: {
// 添加此项配置,增加需要缓存的最大文件大小 // 添加此项配置,增加需要缓存的最大文件大小
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024 maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
// API、下载和回调均不能回退到 SPA 的 index.html。
navigateFallbackDenylist: [/^\/api\//]
}, },
manifest: { manifest: {
name: VITE_GLOB_APP_TITLE, name: VITE_GLOB_APP_TITLE,
+2
View File
@@ -29,6 +29,8 @@ declare module 'vue' {
ElCascader: typeof import('element-plus/es')['ElCascader'] ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElContainer: typeof import('element-plus/es')['ElContainer'] ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+3 -2
View File
@@ -2,7 +2,8 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/api/sys/ico" /> <link rel="icon" href="%VITE_APP_FAVICON%" />
<meta name="referrer" content="no-referrer" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%- title %></title> <title><%- title %></title>
</head> </head>
@@ -100,6 +101,6 @@
if (globalState.isDark) html.style.background = "#141414"; if (globalState.isDark) html.style.background = "#141414";
} }
</script> </script>
<script type="module" src="/src/main.ts"></script> <script type="module" src="%VITE_APP_ENTRY%"></script>
</body> </body>
</html> </html>
+3
View File
@@ -20,10 +20,13 @@
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:public": "vite --mode public",
"serve": "vite", "serve": "vite",
"build:dev": "vue-tsc && vite build --mode development", "build:dev": "vue-tsc && vite build --mode development",
"build:test": "vue-tsc && vite build --mode test", "build:test": "vue-tsc && vite build --mode test",
"build:pro": "vite build --mode production", "build:pro": "vite build --mode production",
"build:admin": "vite build --mode production --outDir dist-admin",
"build:public": "vite build --mode public --outDir dist-public",
"type:check": "vue-tsc --noEmit --skipLibCheck", "type:check": "vue-tsc --noEmit --skipLibCheck",
"preview": "pnpm run build:dev && vite preview", "preview": "pnpm run build:dev && vite preview",
"lint:eslint": "eslint --fix --ext .js,.ts,.vue ./src", "lint:eslint": "eslint --fix --ext .js,.ts,.vue ./src",
+17
View File
@@ -0,0 +1,17 @@
<template>
<el-config-provider :locale="zhCn" :button="buttonConfig">
<router-view />
</el-config-provider>
</template>
<script setup lang="ts">
import { reactive, onMounted } from "vue";
import { ElConfigProvider } from "element-plus";
import zhCn from "element-plus/es/locale/lang/zh-cn";
const buttonConfig = reactive({ autoInsertSpace: false });
onMounted(() => {
document.documentElement.classList.add("is-public-app");
});
</script>
-34
View File
@@ -1,34 +0,0 @@
import { moduleRequest } from "@/api/request";
const http = moduleRequest("/open/onboarding/");
export const onboardingPublicApi = {
detail(token: string) {
return http.post("detail", { token });
},
saveEmployeeInfo(params: Record<string, unknown>) {
return http.post("save-employee-info", params);
},
saveDynamicValues(params: {
token: string;
values: Array<{
fieldId: number;
fieldValue: string;
}>;
}) {
return http.post("save-dynamic-values", params);
},
confirmContract(params: {
token: string;
taskContractId: number;
}) {
return http.post("confirm-contract", params);
},
submit(token: string) {
return http.post("submit", { token });
}
};
+549 -94
View File
@@ -3,16 +3,171 @@ import type { ReqId, ResPage } from "@/api/interface";
const taskHttp = moduleRequest("/biz/onboarding/task/"); const taskHttp = moduleRequest("/biz/onboarding/task/");
const auditHttp = moduleRequest("/biz/onboarding/audit/"); const auditHttp = moduleRequest("/biz/onboarding/audit/");
const contractDocumentHttp = moduleRequest("/biz/onboarding/contract-document/");
const electronicSignHttp = moduleRequest("/biz/onboarding/electronic-sign/");
const fileHttp = moduleRequest("/sys/dev/file/");
/**
* 下载创建任务前的合同预览,并将统一响应中的 JSON 错误转换成可读异常。
*/
async function requestContractPreview(
action: string,
params: Record<string, unknown>,
fallbackMessage: string
) {
let response: any;
try {
response = await contractDocumentHttp.post<any>(
action,
params,
{ responseType: "blob", loading: false, cancel: false }
);
} catch (error: any) {
const errorBlob = error?.response?.data;
if (errorBlob instanceof Blob) {
try {
const result = JSON.parse(await errorBlob.text());
throw new Error(result?.msg || fallbackMessage);
} catch (parseError) {
if (!(parseError instanceof SyntaxError)) throw parseError;
}
}
throw error;
}
const blob = response?.data;
const contentType = String(
response?.headers?.["content-type"] || blob?.type || ""
);
if (
blob instanceof Blob &&
contentType.toLowerCase().includes("application/json")
) {
try {
const result = JSON.parse(await blob.text());
throw new Error(result?.msg || fallbackMessage);
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(fallbackMessage);
}
throw error;
}
}
return response;
}
export interface OnboardingTask { export interface OnboardingTask {
id: number; id: number;
createUserId?: number | string;
taskNo: string; taskNo: string;
companyId: number; companyId: number;
companyName?: string;
departmentId?: number;
departmentName?: string;
positionId: number; positionId: number;
positionName?: string;
employmentType?: "full_time" | "part_time";
onboardingTemplateId?: number;
onboardingTemplateName?: string;
taskStatus: string; taskStatus: string;
rejectReason?: string;
linkExpireAt?: string; linkExpireAt?: string;
submitCount: number; submitCount: number;
createTime: string; createTime: string;
remark?: string;
}
/** 当前查询条件下、不受状态筛选影响的任务统计。 */
export interface OnboardingTaskStatistics {
pendingFill: number;
reviewing: number;
rejected: number;
approved: number;
}
/** 带状态统计的入职任务分页结果。 */
export interface OnboardingTaskPage extends ResPage<OnboardingTask> {
statistics: OnboardingTaskStatistics;
}
/** 批量复制任务返回的单条新任务链接。 */
export interface OnboardingTaskCreatedResult {
taskId: number;
taskNo: string;
linkToken: string;
expiresAt: string;
}
/** 批量复制任务结果。 */
export interface OnboardingTaskCopyResult {
tasks: OnboardingTaskCreatedResult[];
}
/** 一次电子签流程中的单份合同文件。 */
export interface OnboardingElectronicSignDocument {
id: number;
taskContractId: number;
providerDocumentId?: string;
documentName: string;
documentSort: number;
originalDocxFileId?: number;
signedPdfFileId?: number;
employeeSignedPreviewed: boolean;
companySealEnabled: boolean;
acrossPageSealEnabled: boolean;
}
/** 员工已签、企业未盖章文件的契约锁只读预览。 */
export interface OnboardingElectronicSignPreview {
signDocumentId: number;
previewUrl: string;
}
/** 管理端查看的入职电子签流程详情。 */
export interface OnboardingElectronicSignDetail {
taskId: number;
taskNo: string;
canInitiate: boolean;
cannotInitiateReason?: string;
canReinitiate: boolean;
cannotReinitiateReason?: string;
hasProcess: boolean;
processId?: number;
providerContractId?: string;
bizId?: string;
subject?: string;
signStatus?: string;
providerStatus?: string;
employeeName?: string;
employeeMobile?: string;
signUrl?: string;
signUrlExpireAt?: string;
signUrlValid: boolean;
startedAt?: string;
sentAt?: string;
completedAt?: string;
archivedAt?: string;
archiveFileId?: number;
lastError?: string;
documents: OnboardingElectronicSignDocument[];
historyProcesses: OnboardingElectronicSignHistory[];
}
/** 已被新流程替代的电子签历史记录。 */
export interface OnboardingElectronicSignHistory {
processId: number;
bizId: string;
providerContractId?: string;
signStatus?: string;
providerStatus?: string;
employeeName?: string;
employeeMobile?: string;
startedAt?: string;
reissuedAt?: string;
reissueReason?: string;
lastError?: string;
} }
export interface OnboardingCompanyOption { export interface OnboardingCompanyOption {
@@ -20,24 +175,47 @@ export interface OnboardingCompanyOption {
companyName: string; companyName: string;
} }
export interface OnboardingDepartmentOption {
id: number;
departmentName: string;
}
export interface OnboardingPositionOption { export interface OnboardingPositionOption {
id: number; id: number;
positionName: string; positionName: string;
} }
export interface OnboardingCompany extends OnboardingCompanyOption { export interface OnboardingCompany extends OnboardingCompanyOption {
createUserId?: number | string;
companyCode: string; companyCode: string;
legalName: string; legalName: string;
unifiedSocialCreditCode?: string; unifiedSocialCreditCode?: string;
legalRepresentative?: string; legalRepresentative?: string;
registeredAddress?: string; registeredAddress?: string;
contactPhone?: string; contactPhone?: string;
qiyuesuoCategoryId?: string;
qiyuesuoSealId?: string;
remark?: string; remark?: string;
status: string; status: string;
} }
export interface OnboardingPosition extends OnboardingPositionOption { export interface OnboardingDepartment extends OnboardingDepartmentOption {
createUserId?: number | string;
companyId: number; companyId: number;
companyName?: string;
departmentCode: string;
sortCode: number;
remark?: string;
status: string;
createTime?: string;
}
export interface OnboardingPosition extends OnboardingPositionOption {
createUserId?: number | string;
companyId: number;
companyName?: string;
departmentId?: number;
departmentName?: string;
positionCode: string; positionCode: string;
defaultContractYears?: number; defaultContractYears?: number;
defaultProbationMonths?: number; defaultProbationMonths?: number;
@@ -47,6 +225,13 @@ export interface OnboardingPosition extends OnboardingPositionOption {
export interface OnboardingContractTemplate { export interface OnboardingContractTemplate {
id: number; id: number;
createUserId?: number | string;
companyId?: number;
companyName?: string;
departmentId?: number;
departmentName?: string;
positionIds: number[];
positionNames?: string[];
templateCode: string; templateCode: string;
templateName: string; templateName: string;
contractType: string; contractType: string;
@@ -62,6 +247,8 @@ export interface OnboardingContractVersion {
effectiveDate?: string; effectiveDate?: string;
expiryDate?: string; expiryDate?: string;
versionStatus: string; versionStatus: string;
companySealEnabled?: boolean | null;
acrossPageSealEnabled?: boolean | null;
remark?: string; remark?: string;
} }
@@ -80,23 +267,14 @@ export interface OnboardingTemplateVariable {
remark?: string; remark?: string;
} }
export interface OnboardingContractScope {
id: number;
companyId: number;
positionId?: number;
templateId: number;
templateVersionId: number;
required: boolean;
sortCode?: number;
}
export interface OnboardingTaskContractVariableItem { export interface OnboardingTaskContractVariableItem {
id: number; id: number;
variableCode: string; variableCode: string;
variableName: string; variableName: string;
variableType: string; variableType: string;
dataType: string; dataType: string;
variableValue?: string; sortCode: number;
variableValue?: string | number;
required: boolean; required: boolean;
hrEditable: boolean; hrEditable: boolean;
editable: boolean; editable: boolean;
@@ -111,18 +289,45 @@ export interface OnboardingTaskContractVariableGroup {
export interface OnboardingEmployee { export interface OnboardingEmployee {
id: number; id: number;
createUserId?: number | string;
employeeNo?: string; employeeNo?: string;
employeeName: string; employeeName: string;
companyId: number; companyId: number;
companyName?: string;
departmentId?: number;
departmentName?: string;
positionId: number; positionId: number;
positionName?: string;
employmentType?: "full_time" | "part_time";
regularizationStatus?: "probation" | "regular" | "not_applicable";
socialInsuranceStatus?: "unknown" | "not_purchased" | "purchased" | "stopped" | "not_applicable";
regularizationDate?: string;
socialInsuranceCompany?: string;
contractNature?: string;
phone: string; phone: string;
idCardNumber: string; idCardNumber: string;
entryDate?: string; entryDate?: string;
createTime: string; createTime?: string;
}
/** 不受快捷维度筛选影响的正式员工统计。 */
export interface OnboardingEmployeeStatistics {
total: number;
fullTime: number;
partTime: number;
probation: number;
socialPurchased: number;
expiringContract: number;
}
/** 附带统计的正式员工档案分页结果。 */
export interface OnboardingEmployeePage extends ResPage<OnboardingEmployee> {
statistics: OnboardingEmployeeStatistics;
} }
export interface OnboardingFormField { export interface OnboardingFormField {
id: number; id: number;
createUserId?: number | string;
fieldCode: string; fieldCode: string;
fieldName: string; fieldName: string;
dataType: string; dataType: string;
@@ -134,6 +339,7 @@ export interface OnboardingFormField {
export interface OnboardingMaterialType { export interface OnboardingMaterialType {
id: number; id: number;
createUserId?: number | string;
materialCode: string; materialCode: string;
materialName: string; materialName: string;
allowedExtensions?: string; allowedExtensions?: string;
@@ -142,37 +348,114 @@ export interface OnboardingMaterialType {
status: string; status: string;
} }
export interface OnboardingFormRequirement { export interface OnboardingTemplateContractConfig {
id: number; contractTemplateId: number;
companyId: number; contractTemplateVersionId: number;
positionId?: number; required: boolean;
printCopies: number;
sortCode?: number;
}
export interface OnboardingTemplateFieldConfig {
fieldId: number; fieldId: number;
required: boolean; required: boolean;
sortCode?: number; sortCode?: number;
} }
export interface OnboardingMaterialRequirement { export interface OnboardingTemplateMaterialConfig {
id: number;
companyId: number;
positionId?: number;
materialTypeId: number; materialTypeId: number;
required: boolean; required: boolean;
sortCode?: number; sortCode?: number;
} }
export interface OnboardingTemplate {
id: number;
createUserId?: number | string;
templateCode: string;
templateName: string;
companyId: number;
companyName?: string;
departmentId: number;
departmentName?: string;
positionIds: number[];
positionNames?: string[];
contracts: OnboardingTemplateContractConfig[];
formFields: OnboardingTemplateFieldConfig[];
materials: OnboardingTemplateMaterialConfig[];
contractCount?: number;
formFieldCount?: number;
materialCount?: number;
status: string;
description?: string;
}
export const onboardingTaskApi = { export const onboardingTaskApi = {
page(params: Record<string, unknown>) { page(params: Record<string, unknown>) {
return taskHttp.get<ResPage<OnboardingTask>>("page", params); return taskHttp.get<OnboardingTaskPage>("page", params);
}, },
detail(params: ReqId) { detail(params: ReqId) {
return taskHttp.get("detail", params); return taskHttp.get("detail", params);
}, },
getTaskLink(params: ReqId) {
return taskHttp.get<any>("get-link", params);
},
/** 使用后台登录令牌读取文件流,供入职材料下载和图片预览使用。 */
async downloadFile(fileId: number) {
const response: any = await fileHttp.get<any>(
"download",
{ id: fileId },
{ responseType: "blob", loading: false, cancel: false }
);
const blob = response?.data;
const contentType = String(response?.headers?.["content-type"] || blob?.type || "");
if (blob instanceof Blob && contentType.toLowerCase().includes("application/json")) {
try {
const result = JSON.parse(await blob.text());
throw new Error(result?.msg || "文件下载失败");
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error("文件下载失败");
}
throw error;
}
}
return response;
},
/** 创建任务前生成并下载指定的一份临时 DOCX 合同预览。 */
async previewContractBeforeCreate(params: Record<string, unknown>) {
return requestContractPreview(
"preview-before-create",
params,
"合同预览生成失败"
);
},
/** 创建任务前生成全部临时 DOCX 合同预览,并以 ZIP 下载。 */
async previewAllContractsBeforeCreate(params: Record<string, unknown>) {
return requestContractPreview(
"preview-all-before-create",
params,
"合同预览打包生成失败"
);
},
create(params: Record<string, unknown>) { create(params: Record<string, unknown>) {
return taskHttp.post("create", params); return taskHttp.post("create", params);
}, },
copy(params: {
sourceTaskId: number;
count: number;
items: Array<{ remark: string }>;
}) {
return taskHttp.post<OnboardingTaskCopyResult>("copy", params);
},
approve(params: { approve(params: {
taskId: number; taskId: number;
auditOpinion?: string; auditOpinion?: string;
@@ -194,6 +477,67 @@ export const onboardingTaskApi = {
); );
}, },
/** 查询任务当前电子签流程和全部合同文件。 */
electronicSignDetail(taskId: number) {
return electronicSignHttp.get<OnboardingElectronicSignDetail>("detail", {
id: taskId
});
},
/** 手动发起;失败流程会从已保存的本地断点继续。 */
initiateElectronicSign(taskId: number) {
return electronicSignHttp.post<OnboardingElectronicSignDetail>("initiate", {
taskId
});
},
/** 保留旧流程记录,并以新的业务编号重新发起一次签约。 */
reinitiateElectronicSign(taskId: number, reason: string) {
return electronicSignHttp.post<OnboardingElectronicSignDetail>("reinitiate", {
taskId,
reason
});
},
/** 主动同步签署状态,完成时后端自动执行归档。 */
syncElectronicSign(taskId: number) {
return electronicSignHttp.post<OnboardingElectronicSignDetail>("sync", {
taskId
});
},
/** 获取有效的员工签署链接,旧链接失效时由后端重新生成。 */
getElectronicSignLink(taskId: number) {
return electronicSignHttp.post<OnboardingElectronicSignDetail>("get-link", {
taskId
});
},
/** 获取一份员工已签合同的契约锁只读浏览页面。 */
previewEmployeeSignedDocument(taskId: number, signDocumentId: number) {
return electronicSignHttp.post<OnboardingElectronicSignPreview>(
"preview-employee-signed",
{ taskId, signDocumentId }
);
},
/** 全部合同核对完成后,由后台管理员确认企业盖章。 */
companySealElectronicSign(taskId: number) {
return electronicSignHttp.post<OnboardingElectronicSignDetail>("company-seal", {
taskId
});
},
/** 催办当前待签员工。 */
noticeElectronicSign(taskId: number) {
return electronicSignHttp.post("notice", { taskId });
},
/** 作废尚未完成的电子签流程。 */
invalidElectronicSign(taskId: number, reason: string) {
return electronicSignHttp.post("invalid", { taskId, reason });
},
archive(params: { archive(params: {
taskId: number; taskId: number;
auditOpinion?: string; auditOpinion?: string;
@@ -208,6 +552,10 @@ export const onboardingTaskApi = {
return taskHttp.post("revoke", params); return taskHttp.post("revoke", params);
}, },
deleteTask(id: number) {
return taskHttp.post("delete", { id });
},
reviewMaterial(params: { reviewMaterial(params: {
taskMaterialId: number; taskMaterialId: number;
reviewStatus: string; reviewStatus: string;
@@ -225,6 +573,12 @@ export const onboardingTaskApi = {
>("list-enabled"); >("list-enabled");
}, },
listDepartments(companyId: number) {
return moduleRequest("/biz/onboarding/department/").get<
OnboardingDepartmentOption[]
>("list-enabled", { companyId });
},
listPositions(companyId: number) { listPositions(companyId: number) {
return moduleRequest("/biz/onboarding/position/").get< return moduleRequest("/biz/onboarding/position/").get<
OnboardingPositionOption[] OnboardingPositionOption[]
@@ -261,12 +615,48 @@ export const onboardingTaskApi = {
); );
}, },
departmentPage(params: Record<string, unknown>) {
return moduleRequest("/biz/onboarding/department/").get<
ResPage<OnboardingDepartment>
>("page", params);
},
departmentDetail(params: ReqId) {
return moduleRequest("/biz/onboarding/department/").get<OnboardingDepartment>(
"detail",
params
);
},
saveDepartment(
params: Partial<OnboardingDepartment>,
edit = false
) {
return moduleRequest("/biz/onboarding/department/").post(
edit ? "edit" : "add",
params
);
},
deleteDepartment(ids: number[]) {
return moduleRequest("/biz/onboarding/department/").post(
"delete",
{ ids }
);
},
positionPage(params: Record<string, unknown>) { positionPage(params: Record<string, unknown>) {
return moduleRequest("/biz/onboarding/position/").get< return moduleRequest("/biz/onboarding/position/").get<
ResPage<OnboardingPosition> ResPage<OnboardingPosition>
>("page", params); >("page", params);
}, },
listPositionsByDepartment(departmentId: number) {
return moduleRequest("/biz/onboarding/position/").get<
OnboardingPosition[]
>("list-by-department", { departmentId });
},
positionDetail(params: ReqId) { positionDetail(params: ReqId) {
return moduleRequest("/biz/onboarding/position/").get<OnboardingPosition>( return moduleRequest("/biz/onboarding/position/").get<OnboardingPosition>(
"detail", "detail",
@@ -291,6 +681,54 @@ export const onboardingTaskApi = {
); );
}, },
onboardingTemplatePage(params: Record<string, unknown>) {
return moduleRequest("/biz/onboarding/template/").get<
ResPage<OnboardingTemplate>
>("page", params);
},
onboardingTemplateDetail(params: ReqId) {
return moduleRequest("/biz/onboarding/template/").get<OnboardingTemplate>(
"detail",
params
);
},
saveOnboardingTemplate(params: Partial<OnboardingTemplate>) {
return moduleRequest("/biz/onboarding/template/").post<number>(
"save",
params
);
},
deleteOnboardingTemplate(ids: number[]) {
return moduleRequest("/biz/onboarding/template/").post(
"delete",
{ ids }
);
},
listEnabledOnboardingTemplates(
companyId: number,
departmentId: number,
positionId: number
) {
return moduleRequest("/biz/onboarding/template/").get<OnboardingTemplate[]>(
"list-enabled",
{ companyId, departmentId, positionId }
);
},
listAvailableContractTemplates(params: {
companyId: number;
departmentId: number;
positionIds: number[];
}) {
return moduleRequest("/biz/onboarding/contract-template/").post<
OnboardingContractTemplate[]
>("list-available", params);
},
templatePage(params: Record<string, unknown>) { templatePage(params: Record<string, unknown>) {
return moduleRequest("/biz/onboarding/contract-template/").get< return moduleRequest("/biz/onboarding/contract-template/").get<
ResPage<OnboardingContractTemplate> ResPage<OnboardingContractTemplate>
@@ -322,6 +760,7 @@ export const onboardingTaskApi = {
versionPage(params: { versionPage(params: {
templateId: number; templateId: number;
versionStatus?: string;
pageNum: number; pageNum: number;
pageSize: number; pageSize: number;
}) { }) {
@@ -347,6 +786,25 @@ export const onboardingTaskApi = {
); );
}, },
setVersionSeals(params: {
id: number;
companySealEnabled?: boolean;
acrossPageSealEnabled?: boolean;
}) {
return moduleRequest("/biz/onboarding/contract-template/").post(
"version/set-seals",
params,
{ loading: false }
);
},
deleteVersion(id: number) {
return moduleRequest("/biz/onboarding/contract-template/").post(
"version/delete",
{ id }
);
},
variablePage(params: { variablePage(params: {
templateVersionId: number; templateVersionId: number;
pageNum: number; pageNum: number;
@@ -357,47 +815,13 @@ export const onboardingTaskApi = {
>("variable/page", params); >("variable/page", params);
}, },
saveVariable( editVariable(params: Partial<OnboardingTemplateVariable>) {
params: Partial<OnboardingTemplateVariable>,
edit = false
) {
return moduleRequest("/biz/onboarding/contract-template/").post( return moduleRequest("/biz/onboarding/contract-template/").post(
edit ? "variable/edit" : "variable/add", "variable/edit",
params params
); );
}, },
deleteVariable(ids: number[]) {
return moduleRequest("/biz/onboarding/contract-template/").post(
"variable/delete",
{ ids }
);
},
scopePage(params: {
templateId: number;
pageNum: number;
pageSize: number;
}) {
return moduleRequest("/biz/onboarding/contract-template/").get<
ResPage<OnboardingContractScope>
>("scope/page", params);
},
addScope(params: Partial<OnboardingContractScope>) {
return moduleRequest("/biz/onboarding/contract-template/").post(
"scope/add",
params
);
},
deleteScope(ids: number[]) {
return moduleRequest("/biz/onboarding/contract-template/").post(
"scope/delete",
{ ids }
);
},
uploadDocx(params: FormData) { uploadDocx(params: FormData) {
return moduleRequest("/biz/onboarding/contract-template/").post<number>( return moduleRequest("/biz/onboarding/contract-template/").post<number>(
"file/upload-docx", "file/upload-docx",
@@ -425,9 +849,18 @@ export const onboardingTaskApi = {
}, },
employeePage(params: Record<string, unknown>) { employeePage(params: Record<string, unknown>) {
return moduleRequest("/biz/onboarding/employee/").get< return moduleRequest("/biz/onboarding/employee/").get<OnboardingEmployeePage>(
ResPage<OnboardingEmployee> "page",
>("page", params); params
);
},
employeeExport(params: Record<string, unknown>) {
return moduleRequest("/biz/onboarding/employee/").download(
"export",
params,
{ cancel: false }
);
}, },
employeeDetail(params: ReqId) { employeeDetail(params: ReqId) {
@@ -437,6 +870,49 @@ export const onboardingTaskApi = {
); );
}, },
updateEmployeeHrInfo(params: {
id: number;
employmentType: "full_time" | "part_time";
contractNature?: string;
socialInsuranceStatus: "unknown" | "not_purchased" | "purchased" | "stopped" | "not_applicable";
socialInsuranceCompany?: string;
}) {
return moduleRequest("/biz/onboarding/employee/").post(
"hr-info/update",
params
);
},
uploadEmployeeAttachment(params: FormData) {
return moduleRequest("/biz/onboarding/employee/").post<number>(
"attachment/upload",
params,
{ cancel: false, loading: false }
);
},
regularizeEmployee(params: {
id: number;
regularizationDate: string;
remark?: string;
fileIds: number[];
}) {
return moduleRequest("/biz/onboarding/employee/").post(
"regularization/confirm",
params
);
},
revokeEmployeeRegularization(params: {
id: number;
remark: string;
}) {
return moduleRequest("/biz/onboarding/employee/").post(
"regularization/revoke",
params
);
},
listFormFields() { listFormFields() {
return moduleRequest("/biz/onboarding/entry-config/").get< return moduleRequest("/biz/onboarding/entry-config/").get<
OnboardingFormField[] OnboardingFormField[]
@@ -477,45 +953,24 @@ export const onboardingTaskApi = {
); );
}, },
listFormRequirements(companyId: number) { // 合同变量字典定义
return moduleRequest("/biz/onboarding/entry-config/").get< variableDefinitionPage(params: { keyword?: string; variableCategory?: string; pageNum: number; pageSize: number }) {
OnboardingFormRequirement[] return moduleRequest("/biz/onboarding/variable-definition/").get<ResPage<any>>("page", params);
>("form-requirement/list", { companyId });
}, },
saveFormRequirement(params: Partial<OnboardingFormRequirement>) { variableDefinitionList() {
return moduleRequest("/biz/onboarding/entry-config/").post( return moduleRequest("/biz/onboarding/variable-definition/").get<any[]>("list");
"form-requirement/save",
params
);
}, },
deleteFormRequirements(ids: number[]) { addVariableDefinition(params: Record<string, unknown>) {
return moduleRequest("/biz/onboarding/entry-config/").post( return moduleRequest("/biz/onboarding/variable-definition/").post("add", params);
"form-requirement/delete",
{ ids }
);
}, },
listMaterialRequirements(companyId: number) { editVariableDefinition(params: Record<string, unknown>) {
return moduleRequest("/biz/onboarding/entry-config/").get< return moduleRequest("/biz/onboarding/variable-definition/").post("edit", params);
OnboardingMaterialRequirement[]
>("material-requirement/list", { companyId });
}, },
saveMaterialRequirement( deleteVariableDefinitions(ids: number[]) {
params: Partial<OnboardingMaterialRequirement> return moduleRequest("/biz/onboarding/variable-definition/").post("delete", { ids });
) {
return moduleRequest("/biz/onboarding/entry-config/").post(
"material-requirement/save",
params
);
},
deleteMaterialRequirements(ids: number[]) {
return moduleRequest("/biz/onboarding/entry-config/").post(
"material-requirement/delete",
{ ids }
);
} }
}; };
+92
View File
@@ -0,0 +1,92 @@
import {
publicDownload,
publicPost,
publicUpload,
type PublicFileResult
} from "@/api/request/publicRequest";
export interface OnboardingPublicTask {
id: number;
taskNo: string;
taskStatus: string;
rejectReason?: string;
linkExpireAt?: string;
}
export interface OnboardingPreparedContract {
fileId: number;
generatedAt?: string;
}
export const onboardingPublicApi = {
detail(params: { token: string }) {
return publicPost<any>("detail", params);
},
saveEmployeeInfo(params: Record<string, unknown>) {
// “下一步”需要将业务校验结果作为阻断弹窗展示,由页面统一提示一次。
return publicPost<never>("save-employee-info", params, { notifyError: false });
},
confirmContract(params: { token: string; taskContractId: number }) {
return publicPost<never>("confirm-contract", params);
},
submit(params: {
token: string;
identityInformationConfirmed: boolean;
confirmedEmployeeName: string;
confirmedIdCardNumber: string;
confirmedPhone: string;
}) {
return publicPost<never>("submit", params);
},
prepareContract(params: { token: string; taskContractId: number }) {
return publicPost<OnboardingPreparedContract>("prepare-contract", params);
},
downloadContract(params: {
token: string;
taskContractId: number;
fileVersion?: number;
}): Promise<PublicFileResult> {
return publicDownload("contract-source", {
token: params.token,
taskContractId: params.taskContractId,
version: params.fileVersion,
_t: params.fileVersion ? undefined : Date.now()
});
},
downloadMaterial(params: {
token: string;
taskMaterialId: number;
fileId?: number;
}, notifyError = true): Promise<PublicFileResult> {
return publicDownload("material-source", {
token: params.token,
taskMaterialId: params.taskMaterialId,
v: params.fileId
}, { notifyError });
},
uploadMaterial(params: {
token: string;
materialTypeId: number;
file: File;
taskMaterialId?: number;
}) {
const data = new FormData();
data.append("file", params.file);
return publicUpload<number>("upload-material", data, {
token: params.token,
materialTypeId: params.materialTypeId,
taskMaterialId: params.taskMaterialId
});
},
deleteMaterial(params: { token: string; taskMaterialId: number }) {
return publicPost<never>("delete-material", params);
}
};
+219
View File
@@ -0,0 +1,219 @@
import { ElMessage } from "element-plus";
const PUBLIC_ONBOARDING_PREFIX = "/api/open/onboarding/";
const DEFAULT_TIMEOUT = 90_000;
export interface PublicApiResult<T> {
code: number;
msg?: string;
data: T;
extras?: unknown;
time?: string;
}
export interface PublicFileResult {
blob: Blob;
fileName?: string;
}
interface PublicRequestOptions {
timeout?: number;
notifyError?: boolean;
}
export class PublicApiError extends Error {
code?: number;
status?: number;
msg: string;
reported: boolean;
constructor(message: string, options: {
code?: number;
status?: number;
reported?: boolean;
} = {}) {
super(message);
this.name = "PublicApiError";
this.msg = message;
this.code = options.code;
this.status = options.status ?? 0;
this.reported = Boolean(options.reported);
}
}
function assertPublicAction(action: string) {
if (!/^[a-z0-9-]+$/i.test(action)) {
throw new PublicApiError("公开接口地址不合法");
}
}
function buildPublicUrl(
action: string,
query?: Record<string, string | number | undefined>
) {
assertPublicAction(action);
const url = new URL(`${PUBLIC_ONBOARDING_PREFIX}${action}`, window.location.origin);
Object.entries(query || {}).forEach(([key, value]) => {
if (value !== undefined && value !== "") {
url.searchParams.set(key, String(value));
}
});
return `${url.pathname}${url.search}`;
}
function reportError(error: PublicApiError, notifyError: boolean) {
if (notifyError && !error.reported) {
ElMessage.error(error.message);
error.reported = true;
}
return error;
}
async function readErrorMessage(response: Response) {
if (response.status === 413) {
return "单个文件不能超过20MB,请压缩后重新上传";
}
try {
const payload = await response.clone().json();
return payload?.msg || payload?.message || `请求失败(${response.status})`;
} catch {
return `请求失败(${response.status})`;
}
}
async function requestRaw(
action: string,
init: RequestInit,
query?: Record<string, string | number | undefined>,
options: PublicRequestOptions = {}
) {
const notifyError = options.notifyError !== false;
const controller = new AbortController();
const timeoutId = window.setTimeout(
() => controller.abort(),
options.timeout ?? DEFAULT_TIMEOUT
);
try {
const response = await fetch(buildPublicUrl(action, query), {
...init,
credentials: "omit",
cache: "no-store",
redirect: "error",
signal: controller.signal
});
if (!response.ok) {
throw new PublicApiError(await readErrorMessage(response), {
status: response.status
});
}
return response;
} catch (error: any) {
const normalized = error instanceof PublicApiError
? error
: new PublicApiError(
error?.name === "AbortError"
? "请求超时,请检查网络后重试"
: "网络请求失败,请检查网络后重试"
);
throw reportError(normalized, notifyError);
} finally {
window.clearTimeout(timeoutId);
}
}
async function readApiResult<T>(
response: Response,
notifyError: boolean
): Promise<PublicApiResult<T>> {
let result: PublicApiResult<T>;
try {
result = await response.json();
} catch {
throw reportError(new PublicApiError("服务器返回了无法识别的数据"), notifyError);
}
if (result.code !== 0 && result.code !== 200) {
throw reportError(
new PublicApiError(result.msg || "请求失败", { code: result.code }),
notifyError
);
}
return result;
}
export async function publicPost<T>(
action: string,
body: unknown,
options: PublicRequestOptions = {}
) {
const response = await requestRaw(action, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}, undefined, options);
return readApiResult<T>(response, options.notifyError !== false);
}
export async function publicUpload<T>(
action: string,
formData: FormData,
query?: Record<string, string | number | undefined>,
options: PublicRequestOptions = {}
) {
const response = await requestRaw(action, {
method: "POST",
headers: { Accept: "application/json" },
body: formData
}, query, {
timeout: options.timeout ?? 300_000,
notifyError: options.notifyError
});
return readApiResult<T>(response, options.notifyError !== false);
}
export async function publicDownload(
action: string,
query: Record<string, string | number | undefined>,
options: PublicRequestOptions = {}
): Promise<PublicFileResult> {
const response = await requestRaw(action, {
method: "GET",
headers: { Accept: "application/octet-stream" }
}, query, {
timeout: options.timeout ?? 300_000,
notifyError: options.notifyError
});
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
if (contentType.includes("application/json")) {
const result = await readApiResult<never>(
response,
options.notifyError !== false
);
throw reportError(
new PublicApiError(result.msg || "文件下载失败", { code: result.code }),
options.notifyError !== false
);
}
const disposition = response.headers.get("content-disposition") || "";
const encodedName = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
|| disposition.match(/filename="?([^";]+)"?/i)?.[1];
let fileName: string | undefined;
if (encodedName) {
try {
fileName = decodeURIComponent(encodedName.replace(/\+/g, " "));
} catch {
fileName = encodedName;
}
}
return {
blob: await response.blob(),
fileName
};
}
+15
View File
@@ -173,6 +173,21 @@ const processTableData = computed(() => {
// 监听页面 initParam 改化,重新获取表格数据 // 监听页面 initParam 改化,重新获取表格数据
watch(() => props.initParam, getTableList, { deep: true }); watch(() => props.initParam, getTableList, { deep: true });
// 监听 props.data 变化,动态更新分页 total,并在超出页码时自动修正
watch(
() => props.data,
newData => {
if (newData) {
pageable.value.total = newData.length;
const maxPage = Math.ceil(newData.length / pageable.value.pageSize) || 1;
if (pageable.value.pageNum > maxPage) {
pageable.value.pageNum = 1;
}
}
},
{ deep: true, immediate: true }
);
// 接收 columns 并设置为响应式 // 接收 columns 并设置为响应式
const tableColumns = reactive<ColumnProps[]>(props.columns); const tableColumns = reactive<ColumnProps[]>(props.columns);
+3 -31
View File
@@ -1,7 +1,7 @@
<template> <template>
<div v-if="columns.length" class="card table-search"> <div v-if="columns.length" class="card table-search">
<el-form ref="formRef" :model="searchParam"> <el-form ref="formRef" :model="searchParam">
<Grid ref="gridRef" :collapsed="collapsed" :gap="[20, 0]" :cols="searchCol"> <Grid ref="gridRef" :collapsed="false" :gap="[20, 0]" :cols="searchCol">
<GridItem v-for="(item, index) in columns" :key="item.prop" v-bind="getResponsive(item)" :index="index"> <GridItem v-for="(item, index) in columns" :key="item.prop" v-bind="getResponsive(item)" :index="index">
<el-form-item> <el-form-item>
<template #label> <template #label>
@@ -20,12 +20,6 @@
<div class="operation"> <div class="operation">
<el-button type="primary" :icon="Search" @click="search"> 搜索 </el-button> <el-button type="primary" :icon="Search" @click="search"> 搜索 </el-button>
<el-button :icon="Delete" @click="reset"> 重置 </el-button> <el-button :icon="Delete" @click="reset"> 重置 </el-button>
<el-button v-if="showCollapse" type="primary" link class="search-isOpen" @click="collapsed = !collapsed">
{{ collapsed ? "展开" : "合并" }}
<el-icon class="el-icon--right">
<component :is="collapsed ? ArrowDown : ArrowUp"></component>
</el-icon>
</el-button>
</div> </div>
</GridItem> </GridItem>
</Grid> </Grid>
@@ -33,10 +27,10 @@
</div> </div>
</template> </template>
<script setup lang="ts" name="SearchForm"> <script setup lang="ts" name="SearchForm">
import { computed, ref } from "vue"; import { ref } from "vue";
import { ColumnProps } from "@/components/ProTable/interface"; import { ColumnProps } from "@/components/ProTable/interface";
import { BreakPoint } from "@/components/Grid/interface"; import { BreakPoint } from "@/components/Grid/interface";
import { Delete, Search, ArrowDown, ArrowUp } from "@element-plus/icons-vue"; import { Delete, Search } from "@element-plus/icons-vue";
import SearchFormItem from "./components/SearchFormItem.vue"; import SearchFormItem from "./components/SearchFormItem.vue";
import Grid from "@/components/Grid/index.vue"; import Grid from "@/components/Grid/index.vue";
import GridItem from "@/components/Grid/components/GridItem.vue"; import GridItem from "@/components/Grid/components/GridItem.vue";
@@ -68,27 +62,5 @@ const getResponsive = (item: ColumnProps) => {
}; };
}; };
// 是否默认折叠搜索项
const collapsed = ref(true);
// 获取响应式断点
const gridRef = ref(); const gridRef = ref();
const breakPoint = computed<BreakPoint>(() => gridRef.value?.breakPoint);
// 判断是否显示 展开/合并 按钮
const showCollapse = computed(() => {
let show = false;
props.columns.reduce((prev, current) => {
prev +=
(current.search![breakPoint.value]?.span ?? current.search?.span ?? 1) +
(current.search![breakPoint.value]?.offset ?? current.search?.offset ?? 0);
if (typeof props.searchCol !== "number") {
if (prev >= props.searchCol[breakPoint.value]) show = true;
} else {
if (prev >= props.searchCol) show = true;
}
return prev;
}, 0);
return show;
});
</script> </script>
+172 -91
View File
@@ -4,56 +4,62 @@
* @Date: 2023-12-15 15:40:39 * @Date: 2023-12-15 15:40:39
!--> !-->
<template> <template>
<form-container v-model="visible" title="角色选择" form-size="90%" v-bind="$attrs"> <form-container v-model="visible" title="角色选择" :form-size="formSize" v-bind="$attrs" @close="onClose">
<div class="-mt-15px min-h-350px"> <div class="role-selector-content">
<el-row :gutter="12" justify="space-between"> <!-- 组织树 -->
<el-col :span="4"> <div class="selector-tree-col">
<el-scrollbar max-height="650px"> <TreeFilter label="name" class="filterWidth" title="组织列表" top-name="全局" :default-expand-all="false"
<TreeFilter :request-api="orgTreeApi" @change="changeOrgTreeFilter" />
label="name" </div>
class="filterWidth"
title="组织列表" <!-- 待选角色 -->
top-name="全局" <div class="selector-table-col">
:default-expand-all="false" <ProTable ref="userTable" :columns="columns" :tool-button="false" :init-param="initParam"
:request-api="orgTreeApi" :request-api="roleSelectorApi" :search-col="{ xs: 1, sm: 2, md: 2, lg: 2, xl: 2 }" max-height="300px">
@change="changeOrgTreeFilter" <!-- 表格 header 按钮 -->
/></el-scrollbar> <template #tableHeader="scope">
</el-col> <el-button type="primary" @click="addRecords(userTable!.tableData)">添加当前</el-button>
<el-col :span="10"> <el-button type="primary" plain :disabled="!scope.isSelected"
<ProTable ref="userTable" :columns="columns" :tool-button="false" :init-param="initParam" :request-api="roleSelectorApi"> @click="addRecords(scope.selectedList)">添加选中</el-button>
<!-- 表格 header 按钮 --> </template>
<template #tableHeader="scope"> <!-- 操作 -->
<el-button type="primary" @click="addRecords(userTable!.tableData)">添加当前</el-button> <template #operation="scope">
<el-button type="primary" plain :disabled="!scope.isSelected" @click="addRecords(scope.selectedList)">添加选中</el-button> <el-button type="primary" link :icon="Plus" plain @click="addRecords([scope.row])">添加</el-button>
</template> </template>
<!-- 操作 --> </ProTable>
<template #operation="scope"> </div>
<el-button type="primary" link :icon="Plus" plain @click="addRecords([scope.row])">添加</el-button>
</template> <!-- 已选角色 -->
</ProTable> <div class="selector-table-col">
</el-col> <div class="card choose-header-bar">
<el-col :span="10"> <div class="choose-header-left">
<ProTable ref="chooseTable" :columns="columns" :tool-button="true" :data="chooseDataTmp" @search="searchRecords" @reset="resetRecords"> <span class="choose-title">已选角色清单</span>
<!-- 表格 header 按钮 --> <span v-if="maxCount" class="max-count-tip">(最多选择 {{ maxCount }} 个)</span>
<template #tableHeader="scope"> </div>
<el-button type="danger" @click="delRecords(chooseTable!.tableData)">删除当前</el-button> <el-tag type="primary" size="small" effect="plain">已选择 {{ chooseData.length }} 个角色</el-tag>
<el-button type="danger" plain :disabled="!scope.isSelected" @click="delRecords(scope.selectedList)">删除选中</el-button> </div>
</template> <ProTable ref="chooseTable" :columns="chooseColumns" :tool-button="false" :pagination="false"
<template #toolButton> :data="chooseDataTmp" max-height="300px">
<span>已选择:{{ chooseData.length }}人</span> <!-- 表格 header 按钮 -->
<span v-if="maxCount">,最多选择:{{ maxCount }}人</span> <template #tableHeader="scope">
</template> <el-button type="danger" @click="delRecords(chooseTable!.tableData)">删除当前</el-button>
<!-- 操作 --> <el-button type="danger" plain :disabled="!scope.isSelected"
<template #operation="scope"> @click="delRecords(scope.selectedList)">删除选中</el-button>
<el-button type="danger" link :icon="Delete" plain @click="delRecords([scope.row])">删除</el-button> </template>
</template> <!-- 操作 -->
</ProTable> <template #operation="scope">
</el-col> <el-button type="danger" link :icon="Delete" plain @click="delRecords([scope.row])">删除</el-button>
</el-row> </template>
</ProTable>
</div>
</div> </div>
<!-- 底部按钮区域 -->
<template #footer> <template #footer>
<el-button @click="onClose"> 取消 </el-button> <div class="role-selector-footer">
<el-button type="primary" @click="handleOk"> 确定 </el-button> <el-button @click="onClose"> 取消 </el-button>
<el-button type="primary" @click="handleOk"> 确定 </el-button>
</div>
</template> </template>
</form-container> </form-container>
</template> </template>
@@ -67,32 +73,41 @@ import { Plus, Delete } from "@element-plus/icons-vue";
const emit = defineEmits({ successful: null }); // 自定义事件 const emit = defineEmits({ successful: null }); // 自定义事件
const visible = ref(false); //是否显示 const visible = ref(false); // 是否显示
// 定义组件props // 定义组件 props
const props = withDefaults(defineProps<RoleSelectProps>(), { const props = withDefaults(defineProps<RoleSelectProps>(), {
permission: false, permission: false,
multiple: false multiple: false,
formSize: "1300px"
}); });
// 如果表格需要初始化请求参数,直接定义传给 ProTable(之后每次请求都会自动带上该参数,此参数更改之后也会一直带上,改变此参数会自动刷新表格数据) // 如果表格需要初始化请求参数,直接定义传给 ProTable
const initParam = reactive<RoleSelectTableInitParams>({}); const initParam = reactive<RoleSelectTableInitParams>({});
// 获取 userTable 元素,调用其获取刷新数据方法(还能获取到当前查询参数,方便导出携带参数) // 获取 userTable 元素
const userTable = ref<ProTableInstance>(); const userTable = ref<ProTableInstance>();
// 获取 chooseTable 元素,调用其获取刷新数据方法(还能获取到当前查询参数,方便导出携带参数) // 获取 chooseTable 元素
const chooseTable = ref<ProTableInstance>(); const chooseTable = ref<ProTableInstance>();
// 表格配置项
// 待选角色表格配置项(包含搜索项)
const columns: ColumnProps<SysRole.SysRoleInfo>[] = [ const columns: ColumnProps<SysRole.SysRoleInfo>[] = [
{ type: "selection", fixed: "left", width: 50 }, { type: "selection", fixed: "left", width: 50 },
{ prop: "operation", label: "操作", width: 80, fixed: "left" }, { prop: "operation", label: "操作", width: 80, fixed: "left" },
{ prop: "name", label: "角色名", search: { el: "input", span: 2 } } { prop: "name", label: "角色名", search: { el: "input" } }
];
// 已选角色表格配置项(不包含搜索,更轻量)
const chooseColumns: ColumnProps<SysRole.SysRoleInfo>[] = [
{ type: "selection", fixed: "left", width: 50 },
{ prop: "operation", label: "操作", width: 80, fixed: "left" },
{ prop: "name", label: "角色名" }
]; ];
/** 显示选择器 */ /** 显示选择器 */
function showSelector(data: SysRole.SysRoleInfo[] = []) { function showSelector(data: SysRole.SysRoleInfo[] = []) {
visible.value = true; visible.value = true;
chooseDataTmp.value = data; chooseDataTmp.value = data ? [...data] : [];
chooseData.value = data; chooseData.value = data ? [...data] : [];
} }
/** 关闭选择器 */ /** 关闭选择器 */
@@ -110,74 +125,140 @@ function handleOk() {
/** 部门切换 */ /** 部门切换 */
function changeOrgTreeFilter(val: number | string) { function changeOrgTreeFilter(val: number | string) {
userTable.value!.pageable.pageNum = 1; if (userTable.value?.pageable) {
if (val != "") { userTable.value.pageable.pageNum = 1;
// 如果传入的val不为空 }
if (val !== "") {
initParam.orgId = val; initParam.orgId = val;
} else { } else {
initParam.orgId = null; initParam.orgId = null;
} }
} }
const chooseData = ref<SysRole.SysRoleInfo[]>([]); //选择的数据 const chooseData = ref<SysRole.SysRoleInfo[]>([]); // 选择的数据
const chooseDataTmp = ref<SysRole.SysRoleInfo[]>([]); //临时选择的数据 const chooseDataTmp = ref<SysRole.SysRoleInfo[]>([]); // 临时选择的数据
/** 添加记录 */ /** 添加记录 */
function addRecords(records: any[]) { function addRecords(records: any[]) {
//如果不是多选,判断是否已经添加了 // 如果不是多选,判断是否已经添加了
if (!props.multiple) { if (!props.multiple) {
if (chooseData.value.length > 0 || records.length > 1) { if (chooseData.value.length > 0 || records.length > 1) {
ElMessage.warning("只可选择一条"); ElMessage.warning("只可选择一个角色");
return; return;
} }
chooseData.value = records; chooseData.value = records;
chooseDataTmp.value = chooseData.value; chooseDataTmp.value = chooseData.value;
} else { } else {
//如果是多选,先判断已添加列表是否有重复的id,有则过滤掉,没有则直接添加 // 如果是多选,先判断已添加列表是否有重复的id,有则过滤掉,没有则直接添加
records = records.filter(item => !chooseData.value.find(it => it.id == item.id)); records = records.filter(item => !chooseData.value.find(it => it.id === item.id));
if (props.maxCount && props.maxCount < records.length + chooseData.value.length) { if (props.maxCount && props.maxCount < records.length + chooseData.value.length) {
ElMessage.warning("最多选择" + props.maxCount + "条"); ElMessage.warning(`最多选择 ${props.maxCount} 个角色`);
return; return;
} }
chooseData.value = chooseData.value.concat(records); //添加到已选中列表 chooseData.value = chooseData.value.concat(records); // 添加到已选中列表
chooseDataTmp.value = chooseData.value; chooseDataTmp.value = chooseData.value;
} }
chooseTable.value?.refresh(); //刷新表格 chooseTable.value?.refresh(); // 刷新表格
} }
/** 删除记录 */ /** 删除记录 */
function delRecords(records: any[]) { function delRecords(records: any[]) {
chooseData.value = chooseData.value.filter(item => !records.includes(item)); //过滤掉已选中的 chooseData.value = chooseData.value.filter(item => !records.includes(item)); // 过滤掉已选中的
chooseDataTmp.value = chooseData.value; chooseDataTmp.value = chooseData.value;
chooseTable.value?.refresh(); //刷新表格 chooseTable.value?.refresh(); // 刷新表格
} }
/** 搜索记录 */
function searchRecords() {
if (chooseTable.value?.searchParam?.name) {
//搜索name符合的记录
chooseDataTmp.value = chooseDataTmp.value.filter(item => item.name.includes(chooseTable.value?.searchParam.name)); //过滤掉已选中的
chooseTable.value?.refresh(); //刷新表格
}
}
/** 重置记录 */
function resetRecords() {
chooseDataTmp.value = chooseData.value;
chooseTable.value?.refresh(); //刷新表格
}
// 暴露方法 // 暴露方法
defineExpose({ showSelector }); defineExpose({ showSelector });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.filterWidth { .role-selector-content {
width: 100%; display: flex;
gap: 12px;
align-items: stretch;
min-height: 460px;
} }
:deep(.el-tabs--border-card > .el-tabs__content) {
padding: 5px; .selector-tree-col {
width: 220px;
flex-shrink: 0;
height: 460px;
} }
.selector-table-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.choose-header-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
padding: 0 16px;
margin-bottom: 10px;
box-sizing: border-box;
.choose-header-left {
display: flex;
align-items: center;
}
.choose-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.max-count-tip {
margin-left: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.role-selector-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 14px;
border-top: 1px solid var(--el-border-color-lighter);
}
:deep(.filter) {
width: 100% !important;
height: 100% !important;
margin-right: 0 !important;
padding: 14px !important;
}
:deep(.card) {
padding: 14px;
}
:deep(.table-search) {
padding: 14px 14px 0 !important;
margin-bottom: 10px !important;
}
:deep(.table-main) { :deep(.table-main) {
height: 90%; flex: 1;
display: flex;
flex-direction: column;
}
:deep(.el-pagination) {
margin-top: 12px;
}
:deep(.table-header) {
.header-button-lf,
.header-button-ri {
margin-bottom: 10px;
}
} }
</style> </style>
@@ -27,6 +27,8 @@ export interface RoleSelectProps {
multiple?: boolean; multiple?: boolean;
/** 最大角色数 */ /** 最大角色数 */
maxCount?: number; maxCount?: number;
/** 弹窗宽度尺寸 */
formSize?: string;
} }
/** 角色选择器表格初始化参数 */ /** 角色选择器表格初始化参数 */
+1 -1
View File
@@ -26,7 +26,7 @@ export const USER_CENTER_URL: string = "/userCenter";
export const DEFAULT_PRIMARY: string = "#409EFF"; export const DEFAULT_PRIMARY: string = "#409EFF";
// 路由白名单地址(本地存在的路由 staticRouter.ts 中) // 路由白名单地址(本地存在的路由 staticRouter.ts 中)
export const ROUTER_WHITE_LIST: string[] = ["/500", "/onboarding/fill"]; export const ROUTER_WHITE_LIST: string[] = ["/500"];
// 高德地图 key // 高德地图 key
export const AMAP_MAP_KEY: string = ""; export const AMAP_MAP_KEY: string = "";
+3
View File
@@ -13,6 +13,7 @@
* @see https://gitee.com/dotnetmoyu/SimpleAdmin * @see https://gitee.com/dotnetmoyu/SimpleAdmin
*/ */
import { useAuthStore } from "@/stores/modules/auth"; import { useAuthStore } from "@/stores/modules/auth";
import { useUserStore } from "@/stores/modules/user";
import type { Directive, DirectiveBinding } from "vue"; import type { Directive, DirectiveBinding } from "vue";
/** /**
@@ -32,6 +33,8 @@ const auth: Directive = {
const { value, arg } = binding; const { value, arg } = binding;
// 获取authStore // 获取authStore
const authStore = useAuthStore(); const authStore = useAuthStore();
const userStore = useUserStore();
if ((userStore.userInfoGet?.roleCodeList || []).includes("superAdmin")) return;
// 获取用户权限按钮列表 // 获取用户权限按钮列表
const currentPageRoles = authStore.authButtonListGet ?? []; const currentPageRoles = authStore.authButtonListGet ?? [];
// 如果传入的值是数组,并且数组的长度大于0 // 如果传入的值是数组,并且数组的长度大于0
+59
View File
@@ -0,0 +1,59 @@
import { ref } from "vue";
import {
onboardingTaskApi,
type OnboardingDepartmentOption,
type OnboardingPositionOption
} from "@/api/modules/biz/onboarding";
type ScopeSelection = { companyId?: number; departmentId?: number; positionId?: number };
/** 公司、部门、岗位只按当前选择加载;过期响应不能覆盖新选择。 */
export function useOnboardingScopeOptions(
getSelection: () => ScopeSelection | undefined,
syncOptions?: () => void
) {
const departments = ref<OnboardingDepartmentOption[]>([]);
const positions = ref<OnboardingPositionOption[]>([]);
let companyRequest = 0;
let departmentRequest = 0;
function reset() {
companyRequest++;
departmentRequest++;
departments.value = [];
positions.value = [];
const selection = getSelection();
if (selection) {
selection.departmentId = undefined;
selection.positionId = undefined;
}
syncOptions?.();
}
async function onCompanyChange(companyId?: number) {
reset();
if (!companyId) return;
const request = companyRequest;
const { data } = await onboardingTaskApi.listDepartments(companyId).catch(() => ({ data: [] }));
if (request !== companyRequest || getSelection()?.companyId !== companyId) return;
departments.value = data || [];
syncOptions?.();
}
async function onDepartmentChange(departmentId?: number) {
const request = ++departmentRequest;
positions.value = [];
const selection = getSelection();
if (selection) selection.positionId = undefined;
syncOptions?.();
const companyId = selection?.companyId;
if (!companyId || !departmentId) return;
const { data } = await onboardingTaskApi.listPositionsByDepartment(departmentId).catch(() => ({ data: [] }));
const current = getSelection();
if (request !== departmentRequest || current?.companyId !== companyId || current?.departmentId !== departmentId) return;
positions.value = data || [];
syncOptions?.();
}
return { departments, positions, onCompanyChange, onDepartmentChange, reset };
}
+2
View File
@@ -18,6 +18,8 @@ import "element-plus/theme-chalk/dark/css-vars.css";
import "@/styles/element-dark.scss"; import "@/styles/element-dark.scss";
// custom element css // custom element css
import "@/styles/element.scss"; import "@/styles/element.scss";
// custom mobile feedback (Message / MessageBox / Dialog on mobile screens)
import "@/styles/mobile-feedback.scss";
// svg icons // svg icons
import "virtual:svg-icons-register"; import "virtual:svg-icons-register";
// element plus // element plus
+65
View File
@@ -0,0 +1,65 @@
import { createApp } from "vue";
import ElementPlus from "element-plus";
import {
Briefcase,
Calendar,
Check,
Delete,
Document,
Download,
FirstAidKit,
FolderChecked,
HomeFilled,
OfficeBuilding,
Phone,
Plus,
Reading,
Refresh,
School,
Tickets,
Upload,
User
} from "@element-plus/icons-vue";
import PublicApp from "./PublicApp.vue";
import publicRouter from "@/routers/public";
import errorHandler from "@/utils/errorHandler";
import "@/styles/reset.scss";
import "@/styles/common.scss";
import "element-plus/dist/index.css";
import "@/styles/mobile-feedback.scss";
import "virtual:uno.css";
import "default-passive-events";
if (typeof document !== "undefined") {
document.documentElement.classList.add("is-public-app");
}
const app = createApp(PublicApp);
const publicIcons = {
Briefcase,
Calendar,
Check,
Delete,
Document,
Download,
FirstAidKit,
FolderChecked,
HomeFilled,
OfficeBuilding,
Phone,
Plus,
Reading,
Refresh,
School,
Tickets,
Upload,
User
};
app.config.errorHandler = errorHandler;
Object.entries(publicIcons).forEach(([name, component]) => {
app.component(name, component);
});
app.use(ElementPlus).use(publicRouter).mount("#app");
+6 -1
View File
@@ -36,6 +36,7 @@ const routerMode = {
* @param meta ==> 路由菜单元信息 * @param meta ==> 路由菜单元信息
* @param meta.icon ==> 菜单和面包屑对应的图标 * @param meta.icon ==> 菜单和面包屑对应的图标
* @param meta.title ==> 路由标题 (用作 document.title || 菜单的名称) * @param meta.title ==> 路由标题 (用作 document.title || 菜单的名称)
* @param meta.standaloneTitle ==> 是否仅显示路由标题,不拼接系统名称
* @param meta.activeMenu ==> 当前路由为详情页时,需要高亮的菜单 * @param meta.activeMenu ==> 当前路由为详情页时,需要高亮的菜单
* @param meta.isLink ==> 路由外链时填写的访问地址 * @param meta.isLink ==> 路由外链时填写的访问地址
* @param meta.isHide ==> 是否在菜单中隐藏 (通常列表详情页需要隐藏) * @param meta.isHide ==> 是否在菜单中隐藏 (通常列表详情页需要隐藏)
@@ -62,7 +63,11 @@ router.beforeEach(async (to, from, next) => {
// 2.动态设置标题 // 2.动态设置标题
const title = import.meta.env.VITE_GLOB_APP_TITLE; const title = import.meta.env.VITE_GLOB_APP_TITLE;
document.title = to.meta.title ? `${to.meta.title} - ${title}` : title; document.title = to.meta.standaloneTitle && to.meta.title
? String(to.meta.title)
: to.meta.title
? `${to.meta.title} - ${title}`
: title;
// 3.判断是访问登陆页,有 Token 就在当前页面,没有 Token 重置路由到登陆页 // 3.判断是访问登陆页,有 Token 就在当前页面,没有 Token 重置路由到登陆页
if (to.path.toLocaleLowerCase() === LOGIN_URL) { if (to.path.toLocaleLowerCase() === LOGIN_URL) {
+4 -1
View File
@@ -19,7 +19,10 @@ import { useUserStore, useAuthStore, useDictStore, useConfigStore } from "@/stor
import router from "@/routers"; import router from "@/routers";
// 引入 views 文件夹下所有 vue 文件 // 引入 views 文件夹下所有 vue 文件
const modules = import.meta.glob("@/views/**/*.vue"); const modules = import.meta.glob([
"@/views/**/*.vue",
"!@/views/onboarding/**/*.vue"
]);
/** /**
* @description 初始化动态路由 * @description 初始化动态路由
-6
View File
@@ -31,12 +31,6 @@ export const staticRouter: RouteRecordRaw[] = [
title: "登录" title: "登录"
} }
}, },
{
path: "/onboarding/fill",
name: "onboardingFill",
component: () => import("@/views/onboarding/fill/index.vue"),
meta: { title: "入职资料填写" }
},
{ {
path: "/layout", path: "/layout",
name: "layout", name: "layout",
+55
View File
@@ -0,0 +1,55 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router";
const routes: RouteRecordRaw[] = [
{
path: "/",
name: "invalidOnboardingLink",
component: () => import("@/views/onboarding/public-status/index.vue"),
props: {
title: "入职链接无效",
description: "请联系招聘人员重新获取入职链接。"
},
meta: { title: "入职链接无效" }
},
{
path: "/onboarding/fill",
name: "onboardingFill",
component: () => import("@/views/onboarding/fill/index.vue"),
meta: { title: "入职服务" }
},
{
path: "/invalid-link",
redirect: "/"
},
{
path: "/404",
name: "publicNotFound",
component: () => import("@/views/onboarding/public-status/index.vue"),
props: {
title: "页面不存在",
description: "请检查入职链接是否完整,或联系招聘人员重新获取。"
},
meta: { title: "页面不存在" }
},
{
path: "/:pathMatch(.*)*",
redirect: "/404"
}
];
const router = createRouter({
history: createWebHashHistory(),
routes,
scrollBehavior: () => ({ left: 0, top: 0 })
});
router.beforeEach(to => {
if (to.path === "/onboarding/fill" && !String(to.query.token || "").trim()) {
return { name: "invalidOnboardingLink", replace: true };
}
document.title = String(to.meta.title || "入职服务");
return true;
});
export default router;
+403
View File
@@ -0,0 +1,403 @@
/**
* 移动端专属提示与弹窗交互体系
* 1. 在屏幕宽度 <= 768px 时对全局生效(手机端体验极致优化)
* 2. 在员工公开入职端 (html.is-public-app) 全面生效(无论手机真机或 PC 浏览器预览入职均享有一致的精致体验)
* 3. 严格隔离 PC 端后台管理系统,保证 PC 管理端在桌面大屏下 100% 保持标准桌面交互规范。
*/
@mixin mobile-feedback-rules {
/* ==========================================================================
1. ElMessage 移动端悬浮胶囊 Toast
========================================================================== */
.el-message {
min-width: unset !important;
width: max-content !important;
max-width: calc(100vw - 32px) !important;
padding: 10px 18px !important;
border-radius: 9999px !important;
box-shadow: 0 10px 25px -4px rgba(0, 0, 0, 0.12), 0 4px 10px -2px rgba(0, 0, 0, 0.05) !important;
backdrop-filter: blur(14px) !important;
-webkit-backdrop-filter: blur(14px) !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
box-sizing: border-box !important;
left: 50% !important;
transform: translateX(-50%) !important;
/* 适配刘海屏及微信导航栏安全区 */
margin-top: env(safe-area-inset-top, 14px) !important;
transition: top 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease, transform 0.25s ease !important;
pointer-events: auto !important;
.el-message__icon {
margin-right: 8px !important;
font-size: 18px !important;
flex-shrink: 0 !important;
}
.el-message__content {
font-size: 14px !important;
line-height: 1.45 !important;
font-weight: 500 !important;
word-break: break-word !important;
text-align: center !important;
padding: 0 !important;
}
.el-message__closeBtn {
margin-left: 10px !important;
font-size: 14px !important;
flex-shrink: 0 !important;
}
/* 品牌成功绿 (Emerald) */
&.el-message--success {
background: rgba(240, 253, 244, 0.96) !important;
border: 1px solid #86efac !important;
.el-message__icon {
color: #059669 !important;
}
.el-message__content {
color: #064e3b !important;
font-weight: 600 !important;
}
.el-message__closeBtn {
color: #059669 !important;
}
}
/* 品牌暖琥珀黄 (Amber) */
&.el-message--warning {
background: rgba(254, 252, 232, 0.96) !important;
border: 1px solid #fde047 !important;
.el-message__icon {
color: #d97706 !important;
}
.el-message__content {
color: #78350f !important;
font-weight: 600 !important;
}
.el-message__closeBtn {
color: #d97706 !important;
}
}
/* 柔和玫瑰红 (Rose) */
&.el-message--error {
background: rgba(255, 241, 242, 0.96) !important;
border: 1px solid #fca5a5 !important;
.el-message__icon {
color: #e11d48 !important;
}
.el-message__content {
color: #881337 !important;
font-weight: 600 !important;
}
.el-message__closeBtn {
color: #e11d48 !important;
}
}
/* 晨雾优雅蓝灰 (Slate) */
&.el-message--info {
background: rgba(248, 250, 252, 0.96) !important;
border: 1px solid #cbd5e1 !important;
.el-message__icon {
color: #059669 !important;
}
.el-message__content {
color: #1e293b !important;
font-weight: 600 !important;
}
.el-message__closeBtn {
color: #64748b !important;
}
}
}
/* ==========================================================================
2. ElMessageBox 移动端原生卡片弹框 (Confirm / Alert)
========================================================================== */
.el-overlay.is-message-box {
background-color: rgba(15, 23, 42, 0.52) !important;
backdrop-filter: blur(5px) !important;
-webkit-backdrop-filter: blur(5px) !important;
.el-overlay-message-box {
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 16px !important;
box-sizing: border-box !important;
/* 清除 PC 端 inline-block + vertical-align 伪类居中 */
&::after {
display: none !important;
}
}
}
.el-message-box {
display: flex !important;
flex-direction: column !important;
width: min(calc(100vw - 36px), 336px) !important;
max-width: 336px !important;
padding: 24px 20px 20px !important;
border-radius: 20px !important;
border: 1px solid rgba(255, 255, 255, 0.9) !important;
background: #ffffff !important;
box-shadow: 0 20px 45px -10px rgba(6, 78, 59, 0.18), 0 10px 20px -5px rgba(0, 0, 0, 0.08) !important;
text-align: center !important;
overflow: hidden !important;
box-sizing: border-box !important;
animation: mobileBoxPop 0.26s cubic-bezier(0.34, 1.56, 0.64, 1) !important;
/* 弹框头部 */
.el-message-box__header {
padding: 0 0 10px !important;
position: relative !important;
border-bottom: none !important;
.el-message-box__title {
justify-content: center !important;
text-align: center !important;
span {
font-size: 18px !important;
font-weight: 700 !important;
color: #064e3b !important;
letter-spacing: 0.3px !important;
}
}
/* 隐藏 PC 端右上角极小叉号,避免手机端误触 */
.el-message-box__headerbtn {
display: none !important;
}
}
/* 弹框内容区 */
.el-message-box__content {
padding: 6px 4px 20px !important;
text-align: center !important;
}
.el-message-box__container {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
gap: 12px !important;
}
/* 拟物状态大徽标 */
.el-message-box__status {
position: static !important;
transform: none !important;
margin: 0 auto 4px !important;
font-size: 30px !important;
width: 52px !important;
height: 52px !important;
border-radius: 50% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
&.el-message-box-icon--success {
color: #059669 !important;
background: #ecfdf5 !important;
border: 2px solid #a7f3d0 !important;
}
&.el-message-box-icon--warning {
color: #d97706 !important;
background: #fffbeb !important;
border: 2px solid #fde68a !important;
}
&.el-message-box-icon--error {
color: #e11d48 !important;
background: #fff1f2 !important;
border: 2px solid #fecdd3 !important;
}
&.el-message-box-icon--info {
color: #059669 !important;
background: #f0fdf7 !important;
border: 2px solid #d1fae5 !important;
}
}
.el-message-box__message {
padding: 0 !important;
text-align: center !important;
p {
margin: 0 !important;
font-size: 14px !important;
line-height: 1.65 !important;
color: #475569 !important;
word-break: break-word !important;
}
}
/* 按钮操作区 (44pt 移动端人机工效大按钮) */
.el-message-box__btns {
display: flex !important;
flex-direction: row !important;
gap: 10px !important;
padding: 0 !important;
justify-content: center !important;
.el-button {
flex: 1 1 0 !important;
min-width: 0 !important;
margin: 0 !important;
height: 44px !important;
min-height: 44px !important;
border-radius: 12px !important;
font-size: 15px !important;
font-weight: 600 !important;
box-sizing: border-box !important;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
cursor: pointer !important;
/* 点击微触觉缩放反馈 */
&:active {
transform: scale(0.97) !important;
}
}
/* 次要/取消按钮 ("再检查一下"、"取消") */
.el-button:not(.el-button--primary) {
background: #f1f5f9 !important;
border: 1px solid #e2e8f0 !important;
color: #475569 !important;
&:active {
background: #e2e8f0 !important;
color: #1e293b !important;
}
}
/* 主要/确认按钮 ("确认提交"、"我知道了") */
.el-button--primary {
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
border: none !important;
color: #ffffff !important;
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.3) !important;
&:active {
background: linear-gradient(135deg, #047857 0%, #065f46 100%) !important;
box-shadow: 0 2px 6px rgba(5, 150, 105, 0.2) !important;
}
}
}
}
/* ==========================================================================
3. ElAlert 移动端微卡片优化
========================================================================== */
.el-alert {
border-radius: 12px !important;
padding: 10px 14px !important;
box-sizing: border-box !important;
.el-alert__title {
font-size: 13px !important;
line-height: 1.5 !important;
}
.el-alert__description {
font-size: 12px !important;
line-height: 1.55 !important;
margin-top: 4px !important;
}
&.el-alert--info {
background-color: #f0fdf4 !important;
border: 1px solid #bbf7d0 !important;
color: #166534 !important;
.el-alert__icon {
color: #059669 !important;
}
}
&.el-alert--success {
background-color: #ecfdf5 !important;
border: 1px solid #a7f3d0 !important;
color: #065f46 !important;
.el-alert__icon {
color: #059669 !important;
}
}
&.el-alert--warning {
background-color: #fffbeb !important;
border: 1px solid #fde68a !important;
color: #92400e !important;
.el-alert__icon {
color: #d97706 !important;
}
}
&.el-alert--error {
background-color: #fff1f2 !important;
border: 1px solid #fecdd3 !important;
color: #9f1239 !important;
.el-alert__icon {
color: #e11d48 !important;
}
}
}
/* ==========================================================================
4. ElDialog 移动端通用轻量圆角抽屉/卡片
========================================================================== */
.el-dialog {
width: min(calc(100vw - 32px), 520px) !important;
max-width: calc(100vw - 32px) !important;
margin: 20px auto !important;
border-radius: 18px !important;
overflow: hidden !important;
box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.15) !important;
}
}
/* 屏幕宽度 <= 768px(手机、垂直平板)生效 */
@media screen and (max-width: 768px) {
@include mobile-feedback-rules;
}
/* 员工入职前端(无论手机真机或 PC 浏览器预览均享有该高级样式) */
html.is-public-app {
@include mobile-feedback-rules;
}
/* 移动端弹窗弹出平滑缩放进入动画 */
@keyframes mobileBoxPop {
0% {
opacity: 0;
transform: scale(0.92);
}
100% {
opacity: 1;
transform: scale(1);
}
}
+4
View File
@@ -57,6 +57,9 @@ declare type Recordable<T = any> = Record<string, T>;
declare interface ViteEnv { declare interface ViteEnv {
VITE_USER_NODE_ENV: "development" | "production" | "test"; VITE_USER_NODE_ENV: "development" | "production" | "test";
VITE_APP_VARIANT: "admin" | "public";
VITE_APP_ENTRY: string;
VITE_APP_FAVICON: string;
VITE_GLOB_APP_TITLE: string; VITE_GLOB_APP_TITLE: string;
VITE_PORT: number; VITE_PORT: number;
VITE_OPEN: boolean; VITE_OPEN: boolean;
@@ -69,6 +72,7 @@ declare interface ViteEnv {
VITE_DEVTOOLS: boolean; VITE_DEVTOOLS: boolean;
VITE_PUBLIC_PATH: string; VITE_PUBLIC_PATH: string;
VITE_API_URL: string; VITE_API_URL: string;
VITE_PUBLIC_ONBOARDING_URL: string;
VITE_PROXY: [string, string][]; VITE_PROXY: [string, string][];
VITE_CODEINSPECTOR: boolean; VITE_CODEINSPECTOR: boolean;
} }
+6 -4
View File
@@ -18,8 +18,10 @@ import { ElNotification } from "element-plus";
* @description 全局代码错误捕捉 * @description 全局代码错误捕捉
* */ * */
const errorHandler = (error: any) => { const errorHandler = (error: any) => {
// 过滤 HTTP 请求错误 // 过滤 HTTP 请求错误与接口业务返回错误(这类错误已在 Axios 拦截器中统一通过 ElMessage 弹出提示,无需重复告警)
if (error.status || error.status == 0) return false; if (!error || error.status !== undefined || error.code !== undefined || error.isAxiosError || error.config) {
return false;
}
let errorMap: { [key: string]: string } = { let errorMap: { [key: string]: string } = {
InternalError: "Javascript引擎内部错误", InternalError: "Javascript引擎内部错误",
ReferenceError: "未找到对象", ReferenceError: "未找到对象",
@@ -29,10 +31,10 @@ const errorHandler = (error: any) => {
EvalError: "错误的使用了Eval", EvalError: "错误的使用了Eval",
URIError: "URI错误" URIError: "URI错误"
}; };
let errorName = errorMap[error.name] || "未知错误"; let errorName = errorMap[error?.name] || "未知错误";
ElNotification({ ElNotification({
title: errorName, title: errorName,
message: error, message: error?.message || String(error),
type: "error", type: "error",
duration: 3000 duration: 3000
}); });
+41
View File
@@ -323,3 +323,44 @@ export function findItemNested(enumData: any, callValue: any, value: string, chi
if (current[children]) return findItemNested(current[children], callValue, value, children); if (current[children]) return findItemNested(current[children], callValue, value, children);
}, null); }, null);
} }
/**
* @description 复制文本到剪贴板(兼容安全上下文 HTTPS 与非安全上下文 HTTP/IP 环境)
* @param {String} text 要复制的文本
* @returns {Promise<boolean>} 是否成功复制
*/
export async function copyToClipboard(text: string): Promise<boolean> {
if (!text) return false;
// 1. 现代 Clipboard API (仅在 Secure Context 如 HTTPS 或 localhost 下可用)
if (navigator?.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.warn("navigator.clipboard.writeText failed, falling back to execCommand: ", err);
}
}
// 2. 降级方案:创建隐藏 textarea 并调用 document.execCommand('copy')
try {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.top = "0";
textArea.style.left = "-9999px";
textArea.style.opacity = "0";
textArea.setAttribute("readonly", "");
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
textArea.setSelectionRange(0, text.length);
const successful = document.execCommand("copy");
document.body.removeChild(textArea);
return successful;
} catch (fallbackErr) {
console.error("document.execCommand('copy') failed: ", fallbackErr);
return false;
}
}
+33
View File
@@ -0,0 +1,33 @@
/** 判断 Element Plus 弹窗是否由用户主动取消或关闭。 */
function isUserCancelled(action: unknown) {
return action === "cancel" || action === "close";
}
/**
* 执行确认弹窗。用户取消时返回 false,真正的异常继续抛出。
*/
export async function isMessageBoxConfirmed(confirmPromise: Promise<unknown>) {
try {
await confirmPromise;
return true;
} catch (action) {
if (isUserCancelled(action)) {
return false;
}
throw action;
}
}
/**
* 执行输入弹窗。用户取消时返回 null,真正的异常继续抛出。
*/
export async function getMessageBoxPromptResult<T>(promptPromise: Promise<T>) {
try {
return await promptPromise;
} catch (action) {
if (isUserCancelled(action)) {
return null;
}
throw action;
}
}
+45
View File
@@ -0,0 +1,45 @@
import { computed } from "vue";
import { useAuthStore } from "@/stores/modules/auth";
import { useUserStore } from "@/stores/modules/user";
export interface OnboardingOwnedRecord {
createUserId?: number | string | null;
}
/**
* 入职业务页面统一权限判断:按钮权限控制功能入口,创建人/管理全部权限控制数据操作范围。
*/
export function useOnboardingPermission(manageAllCode: string) {
const authStore = useAuthStore();
const userStore = useUserStore();
const isSuperAdmin = computed(() =>
(userStore.userInfoGet?.roleCodeList || []).includes("superAdmin")
);
const canManageAll = computed(
() =>
isSuperAdmin.value ||
authStore.authButtonListGet.includes(manageAllCode)
);
const hasAction = (code: string) =>
isSuperAdmin.value || authStore.authButtonListGet.includes(code);
const canManage = (record?: OnboardingOwnedRecord | null) => {
if (canManageAll.value) return true;
if (record?.createUserId == null || userStore.userInfoGet?.id == null)
return false;
return String(record.createUserId) === String(userStore.userInfoGet.id);
};
const disabledReason = (record?: OnboardingOwnedRecord | null) =>
canManage(record) ? "" : "只能操作本人创建的数据";
return {
isSuperAdmin,
canManageAll,
hasAction,
canManage,
disabledReason
};
}
+117
View File
@@ -0,0 +1,117 @@
/**
* 入职登记表固定字段校验工具。
*
* 前端校验用于即时提示,后端仍会执行同样的核心校验,避免绕过页面直接提交非法数据。
*/
const mobilePhonePattern = /^1[3-9]\d{9}$/;
const landlinePhonePattern = /^0\d{2,3}-?\d{7,8}$/;
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const bankCardPattern = /^\d{12,19}$/;
const idCardWeights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
const idCardCheckCodes = ["1", "0", "X", "9", "8", "7", "6", "5", "4", "3", "2"];
export function normalizeIdCardNumber(value: unknown) {
return String(value ?? "").trim().toUpperCase();
}
export function normalizeBankCardNumber(value: unknown) {
return String(value ?? "").replace(/[\s-]/g, "");
}
export function isMobilePhone(value: unknown) {
return mobilePhonePattern.test(String(value ?? "").trim());
}
export function isContactPhone(value: unknown) {
const phone = String(value ?? "").trim();
return mobilePhonePattern.test(phone) || landlinePhonePattern.test(phone);
}
export function isEmail(value: unknown) {
return emailPattern.test(String(value ?? "").trim());
}
export function isBankCardNumber(value: unknown) {
return bankCardPattern.test(normalizeBankCardNumber(value));
}
/**
* 校验中国大陆 18 位居民身份证,包括出生日期和校验位。
* 返回空字符串表示校验通过,否则返回适合直接展示给用户的错误信息。
*/
export function getIdCardValidationMessage(value: unknown) {
const idCardNumber = normalizeIdCardNumber(value);
if (!/^\d{17}[0-9X]$/.test(idCardNumber)) {
return "请输入正确的18位身份证号码";
}
const birthday = getIdCardBirthday(idCardNumber);
if (!birthday || !isValidDateText(birthday) || birthday > getTodayText()) {
return "身份证号码中的出生日期不正确";
}
const total = idCardWeights.reduce(
(sum, weight, index) => sum + Number(idCardNumber[index]) * weight,
0
);
if (idCardCheckCodes[total % 11] !== idCardNumber[17]) {
return "身份证号码校验位不正确";
}
return "";
}
export function getIdCardBirthday(value: unknown) {
const idCardNumber = normalizeIdCardNumber(value);
if (!/^\d{17}[0-9X]$/.test(idCardNumber)) {
return "";
}
const text = idCardNumber.slice(6, 14);
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
}
export function getIdCardGender(value: unknown) {
const idCardNumber = normalizeIdCardNumber(value);
if (!/^\d{17}[0-9X]$/.test(idCardNumber)) {
return "";
}
const genderCode = Number(idCardNumber.charAt(16));
return genderCode % 2 === 1 ? "男" : "女";
}
export function isNumberInRange(value: unknown, min: number, max: number) {
const text = String(value ?? "").trim();
if (!/^\d+(\.\d+)?$/.test(text)) {
return false;
}
const number = Number(text);
return Number.isFinite(number) && number >= min && number <= max;
}
function isValidDateText(value: string) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return false;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(year, month - 1, day);
return date.getFullYear() === year
&& date.getMonth() === month - 1
&& date.getDate() === day;
}
function getTodayText() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,813 @@
<template>
<div class="template-panel">
<div class="toolbar">
<el-input
v-model="query.keyword"
clearable
placeholder="搜索模板编码或名称"
class="keyword-input"
@keyup.enter="loadTemplates"
@clear="loadTemplates"
/>
<el-select
v-model="query.companyId"
clearable
filterable
placeholder="全部公司"
class="company-filter"
@change="onQueryCompanyChange"
>
<el-option v-for="item in companies" :key="item.id" :label="item.companyName" :value="item.id" />
</el-select>
<el-select
v-model="query.departmentId"
clearable
filterable
:placeholder="query.companyId ? '全部部门' : '请先选择所属公司'"
:disabled="!query.companyId"
class="department-filter"
@change="onQueryDepartmentChange"
>
<el-option v-for="item in queryDepartments" :key="item.id" :label="item.departmentName" :value="item.id" />
</el-select>
<el-select
v-model="query.positionId"
clearable
filterable
:placeholder="query.departmentId ? '全部岗位' : '请先选择所属部门'"
:disabled="!query.departmentId"
class="position-filter"
@change="loadTemplates"
>
<el-option v-for="item in queryPositions" :key="item.id" :label="item.positionName" :value="item.id" />
</el-select>
<el-select
v-model="query.status"
clearable
placeholder="模板状态"
class="status-filter"
@change="loadTemplates"
>
<el-option label="全部状态" value="" />
<el-option label="启用" value="enable" />
<el-option label="停用" value="disable" />
</el-select>
<el-button type="primary" icon="Search" @click="loadTemplates">查询</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
<div class="toolbar-spacer" />
<el-button v-auth="'onboardingEntryConfigAdd'" type="primary" icon="Plus" @click="openCreate">新增入职模板</el-button>
</div>
<el-alert title="入职模板统一管理公司、岗位、签署材料文件与员工上传材料;员工入职档案自动采用标准《入职登记表》。" type="info" :closable="false" show-icon
class="tip" />
<div class="table-container">
<el-table v-loading="loading" :data="rows" border stripe height="100%">
<el-table-column prop="templateCode" label="模板编码" min-width="150" />
<el-table-column prop="templateName" label="模板名称" min-width="180" />
<el-table-column prop="companyName" label="所属公司" min-width="160" />
<el-table-column prop="departmentName" label="所属部门" min-width="150" />
<el-table-column label="适用岗位" min-width="220">
<template #default="scope">
<el-space wrap>
<el-tag v-for="name in scope.row.positionNames" :key="name" size="small">{{ name }}</el-tag>
</el-space>
</template>
</el-table-column>
<el-table-column label="配置内容" min-width="200">
<template #default="scope">
签署材料 {{ scope.row.contractCount || 0 }} 份 / 材料 {{ scope.row.materialCount || 0 }} 项
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'enable' ? 'success' : 'info'">
{{ scope.row.status === "enable" ? "启用" : "停用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button
v-auth="'onboardingEntryConfigEdit'"
link
type="primary"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="openEdit(scope.row)"
>编辑</el-button>
<el-button
v-auth="'onboardingEntryConfigDelete'"
link
type="danger"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="removeTemplate(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination">
<el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize"
layout="total, sizes, prev, pager, next" :total="total" @current-change="loadTemplates"
@size-change="loadTemplates" />
</div>
<!-- 模板编辑大弹窗 -->
<el-dialog v-model="visible" :title="form.id ? '编辑入职模板' : '新增入职模板'" width="62%" top="3vh" destroy-on-close
append-to-body class="template-dialog">
<div class="template-dialog-body">
<el-form label-width="100px" label-position="top">
<!-- 1. 基本信息卡片 -->
<div class="form-block-card">
<div class="block-card-header">
<span class="block-title">基本信息</span>
<span class="block-subtitle">配置模板编码、名称、归属公司及适用岗位范围</span>
</div>
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item label="模板编码" required>
<el-input
v-model="form.templateCode"
maxlength="100"
show-word-limit
placeholder="如:FULLTIME_DEV(纯字母也可以)"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="模板名称" required>
<el-input v-model="form.templateName" placeholder="如:全职技术人员入职模板" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="状态" required>
<el-radio-group v-model="form.status">
<el-radio value="enable">启用</el-radio>
<el-radio value="disable">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="所属公司" required>
<el-select v-model="form.companyId" filterable class="full-width" placeholder="请选择公司"
@change="onCompanyChange">
<el-option v-for="item in companies" :key="item.id" :label="item.companyName" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="所属部门" required>
<el-select v-model="form.departmentId" filterable class="full-width" placeholder="请先选择公司"
:disabled="!form.companyId" @change="onDepartmentChange">
<el-option v-for="item in departments" :key="item.id" :label="item.departmentName"
:value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="适用岗位" required>
<el-select v-model="form.positionIds" multiple filterable collapse-tags collapse-tags-tooltip
class="full-width" :disabled="!form.departmentId" placeholder="请选择适用的岗位(可多选)"
@change="onPositionSelectionChange">
<el-option v-for="item in positions" :key="item.id" :label="item.positionName" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="模板说明">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="填写该入职模板的适用范围或注意事项" />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 2. 关联合同文件卡片 -->
<div class="form-block-card">
<div class="block-card-header flex-between">
<div>
<span class="block-title">关联合同文件</span>
<span class="block-subtitle">选择该岗位入职时需要员工确认与签署的合同模板及版本</span>
</div>
<el-button type="primary" plain icon="Plus" size="small" :disabled="!form.positionIds.length"
@click="addContract">
添加合同文件
</el-button>
</div>
<el-table :data="form.contracts" border empty-text="暂无关联合同,请点击右上角添加" class="mt-2">
<el-table-column label="合同模板" min-width="220">
<template #default="scope">
<el-select v-model="scope.row.contractTemplateId" filterable class="full-width" placeholder="选择签署材料模板"
@change="onContractTemplateChange(scope.row)">
<el-option v-for="item in contractTemplates" :key="item.id" :label="item.templateName"
:value="item.id" />
</el-select>
</template>
</el-table-column>
<el-table-column label="启用版本" min-width="160">
<template #default="scope">
<el-select v-model="scope.row.contractTemplateVersionId" class="full-width" placeholder="选择版本"
:disabled="!scope.row.contractTemplateId">
<el-option v-for="item in versionOptions[scope.row.contractTemplateId] || []" :key="item.id"
:label="item.versionNo" :value="item.id" />
</el-select>
</template>
</el-table-column>
<el-table-column label="必须确认" width="100" align="center">
<template #default="scope">
<el-switch v-model="scope.row.required" />
</template>
</el-table-column>
<el-table-column label="打印份数" width="130" align="center">
<template #default="scope">
<el-input-number v-model="scope.row.printCopies" :min="1" :max="10" controls-position="right"
style="width: 100px;" />
</template>
</el-table-column>
<el-table-column label="排序" width="130" align="center">
<template #default="scope">
<el-input-number v-model="scope.row.sortCode" :min="0" controls-position="right"
style="width: 100px;" />
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="scope">
<el-button link type="danger" icon="Delete" style="white-space: nowrap;"
@click="form.contracts.splice(scope.$index, 1)">
移除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 3. 员工上传材料卡片 -->
<div class="form-block-card">
<div class="block-card-header">
<span class="block-title">员工上传材料要求</span>
<span class="block-subtitle">勾选入职时要求候选人必须或可选上传的证件材料(如身份证、毕业证、体检表等)</span>
</div>
<el-table :data="enabledMaterialTypes" border stripe height="240px" class="mt-2">
<el-table-column label="选择要求" width="95" align="center">
<template #header>
<el-checkbox
:model-value="isAllMaterialsSelected"
:indeterminate="isMaterialsIndeterminate"
:disabled="!enabledMaterialTypes.length"
@change="handleSelectAllMaterials"
>
全选
</el-checkbox>
</template>
<template #default="scope">
<el-checkbox :model-value="hasMaterial(scope.row.id)"
@change="value => toggleMaterial(scope.row.id, Boolean(value))" />
</template>
</el-table-column>
<el-table-column prop="materialCode" label="材料编码" min-width="160" />
<el-table-column prop="materialName" label="材料名称" min-width="220" />
<el-table-column prop="maxFileCount" label="最多文件" width="100" align="center" />
<el-table-column label="必传" width="100" align="center">
<template #default="scope">
<el-switch v-if="hasMaterial(scope.row.id)" v-model="materialConfig(scope.row.id)!.required" />
<span v-else class="text-gray">-</span>
</template>
</el-table-column>
<el-table-column label="排序" width="130" align="center">
<template #default="scope">
<el-input-number v-if="hasMaterial(scope.row.id)" v-model="materialConfig(scope.row.id)!.sortCode"
:min="0" controls-position="right" style="width: 100px;" />
<span v-else class="text-gray">-</span>
</template>
</el-table-column>
</el-table>
</div>
</el-form>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存入职模板</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useOnboardingScopeOptions } from "@/hooks/useOnboardingScopeOptions";
import { ElMessage, ElMessageBox } from "element-plus";
import dayjs from "dayjs";
import {
OnboardingCompanyOption,
OnboardingContractTemplate,
OnboardingContractVersion,
OnboardingDepartmentOption,
OnboardingMaterialType,
OnboardingPosition,
OnboardingTemplate,
onboardingTaskApi
} from "@/api/modules/biz/onboarding";
import { isMessageBoxConfirmed } from "@/utils/messageBox";
import { useOnboardingPermission } from "@/utils/onboardingPermission";
const { canManage, disabledReason } = useOnboardingPermission("onboardingEntryConfigManageAll");
const loading = ref(false);
const saving = ref(false);
const visible = ref(false);
const rows = ref<OnboardingTemplate[]>([]);
const total = ref(0);
const props = defineProps<{ loadMaterials: () => Promise<OnboardingMaterialType[]> }>();
const companies = ref<OnboardingCompanyOption[]>([]);
const departments = ref<OnboardingDepartmentOption[]>([]);
const positions = ref<OnboardingPosition[]>([]);
const contractTemplates = ref<OnboardingContractTemplate[]>([]);
const materialTypes = ref<OnboardingMaterialType[]>([]);
const enabledMaterialTypes = computed(() =>
materialTypes.value.filter(item => item.status === "enable")
);
const versionOptions = reactive<Record<number, OnboardingContractVersion[]>>({});
const query = reactive({
keyword: "",
companyId: undefined as number | undefined,
departmentId: undefined as number | undefined,
positionId: undefined as number | undefined,
status: "enable" as string | undefined,
pageNum: 1,
pageSize: 20
});
const form = reactive<OnboardingTemplate>({
id: 0,
templateCode: "",
templateName: "",
companyId: 0,
departmentId: 0,
description: "",
status: "enable",
positionIds: [],
contracts: [],
formFields: [],
materials: []
});
const {
departments: queryDepartments,
positions: queryPositions,
onCompanyChange: changeQueryCompany,
onDepartmentChange: changeQueryDepartment,
reset: resetQueryScope
} = useOnboardingScopeOptions(() => query);
let companiesLoaded = false;
let companyRequest: Promise<void> | undefined;
async function loadCompanies() {
if (companiesLoaded) return;
if (!companyRequest) {
companyRequest = onboardingTaskApi.listCompanies().then(({ data }) => {
companies.value = data || [];
companiesLoaded = true;
}).finally(() => { companyRequest = undefined; });
}
await companyRequest;
}
async function loadBaseData() {
const [, materials] = await Promise.all([loadCompanies(), props.loadMaterials()]);
materialTypes.value = materials;
}
let templateRequest = 0;
async function loadTemplates() {
const request = ++templateRequest;
loading.value = true;
try {
const res = await onboardingTaskApi.onboardingTemplatePage({ ...query });
if (request !== templateRequest) return;
rows.value = res.data?.list || [];
total.value = res.data?.total || 0;
} finally {
if (request === templateRequest) loading.value = false;
}
}
async function onQueryCompanyChange(companyId?: number) {
query.pageNum = 1;
await Promise.all([changeQueryCompany(companyId), loadTemplates()]);
}
async function onQueryDepartmentChange(departmentId?: number) {
query.pageNum = 1;
await Promise.all([changeQueryDepartment(departmentId), loadTemplates()]);
}
function resetQuery() {
query.keyword = "";
query.companyId = undefined;
query.departmentId = undefined;
query.positionId = undefined;
query.status = "enable";
query.pageNum = 1;
resetQueryScope();
loadTemplates();
}
async function onCompanyChange(companyId: number) {
form.departmentId = undefined as any;
form.positionIds = [];
form.contracts = [];
departments.value = [];
contractTemplates.value = [];
positions.value = [];
if (!companyId) {
return;
}
const res = await onboardingTaskApi.listDepartments(companyId);
departments.value = res.data || [];
}
async function onDepartmentChange(departmentId: number) {
form.positionIds = [];
form.contracts = [];
positions.value = [];
contractTemplates.value = [];
if (!departmentId) return;
const res = await onboardingTaskApi.listPositionsByDepartment(departmentId);
positions.value = res.data || [];
}
async function onPositionSelectionChange() {
await loadAvailableContractTemplates();
}
async function loadAvailableContractTemplates() {
if (!form.companyId || !form.departmentId || !form.positionIds.length) {
contractTemplates.value = [];
form.contracts = [];
return;
}
const res = await onboardingTaskApi.listAvailableContractTemplates({
companyId: form.companyId,
departmentId: form.departmentId,
positionIds: form.positionIds
});
contractTemplates.value = res.data || [];
const availableIds = new Set(contractTemplates.value.map(item => item.id));
form.contracts = form.contracts.filter(item => availableIds.has(item.contractTemplateId));
}
async function onContractTemplateChange(row: any) {
row.contractTemplateVersionId = undefined;
if (!row.contractTemplateId) return;
await loadContractVersions(row.contractTemplateId);
}
function isVersionCurrentlyAvailable(version: OnboardingContractVersion) {
const now = dayjs();
const effectiveAt = version.effectiveDate ? dayjs(version.effectiveDate) : undefined;
const expiresAt = version.expiryDate ? dayjs(version.expiryDate) : undefined;
return version.versionStatus === "enabled"
&& (!effectiveAt || (effectiveAt.isValid() && !effectiveAt.isAfter(now)))
&& (!expiresAt || (expiresAt.isValid() && expiresAt.isAfter(now)));
}
async function loadContractVersions(templateId: number) {
const res = await onboardingTaskApi.versionPage({
templateId,
versionStatus: "enabled",
pageNum: 1,
pageSize: 100
});
versionOptions[templateId] = (res.data?.list || []).filter(isVersionCurrentlyAvailable);
}
function addContract() {
if (!form.positionIds.length) {
ElMessage.warning("请先选择适用岗位");
return;
}
if (!contractTemplates.value.length) {
ElMessage.warning("当前公司、部门和岗位没有可用的签署材料模板");
return;
}
form.contracts.push({
contractTemplateId: undefined as any,
contractTemplateVersionId: undefined as any,
required: true,
printCopies: 1,
sortCode: (form.contracts.length + 1) * 10
});
}
function hasMaterial(materialTypeId: number) {
return form.materials.some(item => item.materialTypeId === materialTypeId);
}
function materialConfig(materialTypeId: number) {
return form.materials.find(item => item.materialTypeId === materialTypeId);
}
function toggleMaterial(materialTypeId: number, checked: boolean) {
if (checked) {
if (!hasMaterial(materialTypeId)) {
form.materials.push({ materialTypeId, required: true, sortCode: (form.materials.length + 1) * 10 });
}
} else {
form.materials = form.materials.filter(item => item.materialTypeId !== materialTypeId);
}
}
const isAllMaterialsSelected = computed(() => {
if (!enabledMaterialTypes.value.length) return false;
return enabledMaterialTypes.value.every(item => hasMaterial(item.id));
});
const isMaterialsIndeterminate = computed(() => {
if (!enabledMaterialTypes.value.length) return false;
const selectedCount = enabledMaterialTypes.value.filter(item => hasMaterial(item.id)).length;
return selectedCount > 0 && selectedCount < enabledMaterialTypes.value.length;
});
function handleSelectAllMaterials(checked: boolean | string | number) {
const isChecked = Boolean(checked);
if (isChecked) {
enabledMaterialTypes.value.forEach(item => {
if (!hasMaterial(item.id)) {
form.materials.push({
materialTypeId: item.id,
required: true,
sortCode: (form.materials.length + 1) * 10
});
}
});
} else {
const enabledIds = new Set(enabledMaterialTypes.value.map(item => item.id));
form.materials = form.materials.filter(item => !enabledIds.has(item.materialTypeId));
}
}
async function openCreate() {
await loadBaseData();
Object.assign(form, {
id: undefined,
templateCode: "",
templateName: "",
companyId: undefined as any,
departmentId: undefined as any,
description: "",
status: "enable",
positionIds: [],
contracts: [],
formFields: [],
materials: []
});
departments.value = [];
positions.value = [];
contractTemplates.value = [];
visible.value = true;
}
async function openEdit(row: OnboardingTemplate) {
await loadBaseData();
const res = await onboardingTaskApi.onboardingTemplateDetail({ id: row.id! });
const detail = res.data;
Object.assign(form, {
id: detail.id,
templateCode: detail.templateCode,
templateName: detail.templateName,
companyId: detail.companyId,
departmentId: detail.departmentId,
description: detail.description,
status: detail.status,
positionIds: detail.positionIds || [],
contracts: (detail.contracts || []).map((item: any) => ({
contractTemplateId: item.contractTemplateId,
contractTemplateVersionId: item.contractTemplateVersionId,
required: item.required,
printCopies: item.printCopies || 1,
sortCode: item.sortCode || 10
})),
formFields: [],
materials: (detail.materials || []).map((item: any) => ({
materialTypeId: item.materialTypeId,
required: item.required,
sortCode: item.sortCode || 10
}))
});
const enabledMaterialIds = new Set(enabledMaterialTypes.value.map(item => item.id));
const disabledMaterials = materialTypes.value.filter(item =>
item.status !== "enable" && form.materials.some(config => config.materialTypeId === item.id)
);
form.materials = form.materials.filter(item => enabledMaterialIds.has(item.materialTypeId));
if (disabledMaterials.length > 0) {
ElMessage.warning(
`该模板原来关联的停用资料“${disabledMaterials.map(item => item.materialName).join("、")}”已从本次配置中移除,保存后生效`
);
}
if (form.companyId) {
const departmentRes = await onboardingTaskApi.listDepartments(form.companyId);
departments.value = departmentRes.data || [];
}
if (form.departmentId) {
const positionRes = await onboardingTaskApi.listPositionsByDepartment(form.departmentId);
positions.value = positionRes.data || [];
}
const configuredContractCount = form.contracts.length;
await loadAvailableContractTemplates();
const removedTemplateCount = configuredContractCount - form.contracts.length;
let clearedVersionCount = 0;
for (const contract of form.contracts) {
if (contract.contractTemplateId) {
await loadContractVersions(contract.contractTemplateId);
const availableVersions = versionOptions[contract.contractTemplateId] || [];
if (!availableVersions.some(item => item.id === contract.contractTemplateVersionId)) {
contract.contractTemplateVersionId = undefined as any;
clearedVersionCount++;
}
}
}
if (removedTemplateCount > 0 || clearedVersionCount > 0) {
ElMessage.warning(
"该入职模板原来关联了已停用、未生效或已失效的签署材料,已从当前选择中移除或清空,请重新选择后保存"
);
}
visible.value = true;
}
async function save() {
form.templateCode = form.templateCode.trim();
if (
!form.templateCode
|| !form.templateName
|| !form.companyId
|| !form.departmentId
|| !form.positionIds.length
) {
return ElMessage.warning("请完整填写模板编码、名称、公司、部门及适用岗位");
}
if (!/^[A-Za-z][A-Za-z0-9._-]{0,99}$/.test(form.templateCode)) {
return ElMessage.warning("模板编码须以字母开头,最长 100 位,仅支持字母、数字、点、下划线和短横线");
}
if (!form.contracts.length || form.contracts.some(item => !item.contractTemplateId || !item.contractTemplateVersionId)) {
return ElMessage.warning("请完整选择至少一份签署材料模板及版本");
}
const containsUnavailableVersion = form.contracts.some(contract => {
const versions = versionOptions[contract.contractTemplateId] || [];
const selected = versions.find(item => item.id === contract.contractTemplateVersionId);
return !selected || !isVersionCurrentlyAvailable(selected);
});
if (containsUnavailableVersion) {
return ElMessage.warning("所选签署材料版本已停用、尚未生效或已经失效,请重新选择");
}
saving.value = true;
try {
await onboardingTaskApi.saveOnboardingTemplate(form);
ElMessage.success("入职模板保存成功");
visible.value = false;
await loadTemplates();
} finally {
saving.value = false;
}
}
async function removeTemplate(row: OnboardingTemplate) {
const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm(`确认删除入职模板“${row.templateName}”吗?`, "删除确认", { type: "warning" })
);
if (!confirmed) return;
await onboardingTaskApi.deleteOnboardingTemplate([row.id!]);
ElMessage.success("删除成功");
query.pageNum = 1;
await loadTemplates();
}
onMounted(async () => {
await Promise.all([loadCompanies(), loadTemplates()]);
});
</script>
<style scoped>
.template-panel {
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-shrink: 0;
}
.keyword-input {
width: 220px;
}
.company-filter {
width: 170px;
}
.department-filter {
width: 160px;
}
.position-filter {
width: 160px;
}
.status-filter {
width: 120px;
}
.toolbar-spacer {
flex: 1;
}
.tip {
margin-bottom: 10px;
flex-shrink: 0;
}
.table-container {
flex: 1;
height: 0;
overflow: hidden;
}
.pagination {
margin-top: 12px;
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
.full-width {
width: 100%;
}
.mt-2 {
margin-top: 8px;
}
.text-gray {
color: #94a3b8;
}
/* 弹窗内部卡片样式 */
.template-dialog-body {
max-height: 72vh;
overflow-y: auto;
padding-right: 6px;
}
.form-block-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px 18px;
margin-bottom: 16px;
}
.block-card-header {
margin-bottom: 14px;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.block-title {
font-size: 15px;
font-weight: 700;
color: #0f172a;
margin-right: 10px;
}
.block-subtitle {
font-size: 12px;
color: #64748b;
}
</style>
@@ -0,0 +1,382 @@
<template>
<div class="variable-panel">
<div class="toolbar">
<el-input
v-model="query.keyword"
placeholder="搜索变量编码 / 变量名称"
clearable
style="width: 240px"
@keyup.enter="loadData"
@clear="loadData"
/>
<el-select
v-model="query.variableCategory"
placeholder="变量分类"
clearable
style="width: 180px"
@change="loadData"
>
<el-option label="全部变量分类" value="" />
<el-option label="员工登记表变量 (employee)" value="employee" />
<el-option label="人事预设参数 (hr)" value="hr" />
<el-option label="公司信息变量 (company)" value="company" />
<el-option label="系统预置变量 (system)" value="system" />
</el-select>
<el-button type="primary" icon="Search" @click="loadData">查询</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
<div class="spacer"></div>
<el-button v-auth="'onboardingEntryConfigAdd'" type="primary" icon="Plus" @click="openCreate">新增自定义变量</el-button>
</div>
<el-alert
title="变量字典库定义了签署材料 DOCX 中所有合法的占位符(如 {{employee.EmployeeName}})。上传 Word 时系统会自动校验占位符是否在此字典中,防止拼写错误。"
type="info"
show-icon
:closable="false"
class="tip-alert"
/>
<div class="table-container">
<el-table :data="tableData" border stripe height="100%" v-loading="loading">
<el-table-column prop="variableCode" label="变量全编码 (DOCX占位符)" min-width="260">
<template #default="{ row }">
<div class="code-col">
<el-tag :type="categoryTag(row.variableCategory)" effect="light" size="small" class="tag-badge">
{{ categoryText(row.variableCategory) }}
</el-tag>
<code class="code-font">{{ formatPlaceholder(row.variableCode) }}</code>
</div>
</template>
</el-table-column>
<el-table-column prop="variableName" label="中文显示名称" min-width="160" />
<el-table-column prop="sortCode" label="排序" width="80" align="center" sortable />
<el-table-column prop="dataType" label="数据类型" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.dataType || 'string' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="isBuiltIn" label="内置标准" width="120" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.isBuiltIn ? 'success' : 'warning'">
{{ row.isBuiltIn ? '入职登记表' : '自定义' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="required" label="必填" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.required ? 'danger' : 'info'">
{{ row.required ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="说明备注" min-width="140" show-overflow-tooltip />
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<div style="display: flex; align-items: center; justify-content: center; gap: 8px; white-space: nowrap;">
<el-button
v-auth="'onboardingEntryConfigEdit'"
link
type="primary"
icon="Edit"
:disabled="!canManage(row)"
:title="disabledReason(row)"
@click="openEdit(row)"
>编辑</el-button>
<el-button
v-auth="'onboardingEntryConfigDelete'"
v-if="!row.isBuiltIn"
link
type="danger"
icon="Delete"
:disabled="!canManage(row)"
:title="disabledReason(row)"
@click="remove(row)"
>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination-box">
<el-pagination
v-model:current-page="query.pageNum"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData"
@current-change="loadData"
/>
</div>
<!-- 弹窗 -->
<el-dialog v-model="visible" :title="form.id ? '编辑变量' : '新增自定义变量'" width="560px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="变量分类" prop="variableCategory" required>
<el-select v-model="form.variableCategory" :disabled="form.isBuiltIn" style="width: 100%">
<el-option label="员工变量 (employee.)" value="employee" />
<el-option label="人事预设参数 (hr.)" value="hr" />
<el-option label="公司信息 (company.)" value="company" />
<el-option label="系统变量 (system.)" value="system" />
</el-select>
</el-form-item>
<el-form-item label="变量全编码" prop="variableCode" required>
<el-input
v-model="form.variableCode"
placeholder="如 employee.WeChat 或 hr.SpecialSubsidy"
:disabled="Boolean(form.id)"
/>
<div class="form-tip">规范格式:分类.变量名,例如 employee.CarNo</div>
</el-form-item>
<el-form-item label="中文名称" prop="variableName" required>
<el-input v-model="form.variableName" placeholder="如 微信号、特殊津贴" />
</el-form-item>
<el-form-item label="数据类型">
<el-select v-model="form.dataType" style="width: 100%">
<el-option label="文本 (string)" value="string" />
<el-option label="数字 / 金额 (number)" value="number" />
<el-option label="日期 (date)" value="date" />
</el-select>
</el-form-item>
<el-form-item label="默认值">
<el-input v-model="form.defaultValue" placeholder="未填写时的默认值" />
</el-form-item>
<el-form-item label="是否必填">
<el-switch v-model="form.required" />
</el-form-item>
<el-form-item label="排序码">
<el-input-number v-model="form.sortCode" :min="1" :max="9999" />
</el-form-item>
<el-form-item label="说明备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="变量业务说明" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { onboardingTaskApi } from "@/api/modules/biz/onboarding";
import { isMessageBoxConfirmed } from "@/utils/messageBox";
import { useOnboardingPermission } from "@/utils/onboardingPermission";
const { canManage, disabledReason } = useOnboardingPermission("onboardingEntryConfigManageAll");
const loading = ref(false);
const saving = ref(false);
const visible = ref(false);
const tableData = ref<any[]>([]);
const total = ref(0);
const formRef = ref<FormInstance>();
const query = reactive({
keyword: "",
variableCategory: "",
pageNum: 1,
pageSize: 50
});
const form = reactive<any>({
id: undefined,
variableCode: "",
variableName: "",
variableCategory: "employee",
dataType: "string",
defaultValue: "",
isBuiltIn: false,
required: false,
sortCode: 100,
remark: ""
});
const rules: FormRules = {
variableCode: [{ required: true, message: "请输入变量编码", trigger: "blur" }],
variableName: [{ required: true, message: "请输入中文显示名称", trigger: "blur" }],
variableCategory: [{ required: true, message: "请选择分类", trigger: "change" }]
};
function categoryTag(cat: string) {
switch (cat) {
case "employee": return "primary";
case "hr": return "warning";
case "company": return "success";
default: return "info";
}
}
function categoryText(cat: string) {
switch (cat) {
case "employee": return "员工变量";
case "hr": return "人事参数";
case "company": return "公司信息";
default: return "系统变量";
}
}
function formatPlaceholder(code?: string) {
return code ? `{{${code}}}` : "";
}
async function loadData() {
loading.value = true;
try {
const res = await onboardingTaskApi.variableDefinitionPage(query);
tableData.value = res.data?.list || [];
total.value = res.data?.total || 0;
} finally {
loading.value = false;
}
}
function resetQuery() {
query.keyword = "";
query.variableCategory = "";
query.pageNum = 1;
loadData();
}
function openCreate() {
Object.assign(form, {
id: undefined,
variableCode: "",
variableName: "",
variableCategory: "employee",
dataType: "string",
defaultValue: "",
isBuiltIn: false,
required: false,
sortCode: 100,
remark: ""
});
visible.value = true;
}
function openEdit(row: any) {
Object.assign(form, {
id: row.id,
variableCode: row.variableCode,
variableName: row.variableName,
variableCategory: row.variableCategory,
dataType: row.dataType || "string",
defaultValue: row.defaultValue || "",
isBuiltIn: row.isBuiltIn || false,
required: row.required || false,
sortCode: row.sortCode || 100,
remark: row.remark || ""
});
visible.value = true;
}
async function save() {
await formRef.value?.validate();
saving.value = true;
try {
if (form.id) {
await onboardingTaskApi.editVariableDefinition(form);
ElMessage.success("变量修改成功");
} else {
await onboardingTaskApi.addVariableDefinition(form);
ElMessage.success("自定义变量添加成功");
}
visible.value = false;
loadData();
} finally {
saving.value = false;
}
}
async function remove(row: any) {
const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm(`确认删除自定义变量“${row.variableCode}”?`, "删除确认", { type: "warning" })
);
if (!confirmed) return;
await onboardingTaskApi.deleteVariableDefinitions([row.id]);
ElMessage.success("删除成功");
loadData();
}
onMounted(loadData);
</script>
<style scoped>
.variable-panel {
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-shrink: 0;
}
.spacer {
flex: 1;
}
.tip-alert {
margin-bottom: 10px;
flex-shrink: 0;
}
.table-container {
flex: 1;
height: 0;
overflow: hidden;
}
.code-col {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.tag-badge {
flex-shrink: 0;
}
.code-font {
font-family: Consolas, Monaco, monospace;
font-weight: 600;
color: #b45309;
background: #fef3c7;
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
}
.pagination-box {
display: flex;
justify-content: flex-end;
margin-top: 12px;
flex-shrink: 0;
}
.form-tip {
font-size: 12px;
color: #94a3b8;
margin-top: 4px;
}
</style>
+117 -504
View File
@@ -1,58 +1,24 @@
<template> <template>
<div class="main-box config-container"> <div class="main-box config-container">
<el-tabs v-model="activeTab" class="config-tabs"> <el-tabs v-model="activeTab" class="config-tabs">
<el-tab-pane label="动态填写字段" name="fields"> <el-tab-pane lazy label="合同变量字典" name="variables">
<div class="tab-card-wrapper"> <div class="tab-card-wrapper">
<div class="toolbar"> <VariableDefinitionPanel />
<el-button type="primary" icon="Plus" @click="openFieldCreate">
新增字段
</el-button>
</div>
<el-table :data="fields" border stripe>
<el-table-column prop="fieldCode" label="字段编码" min-width="140" />
<el-table-column prop="fieldName" label="字段名称" min-width="150" />
<el-table-column prop="dataType" label="数据类型" width="120" align="center">
<template #default="scope">
<el-tag size="small" type="info">{{ scope.row.dataType }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="placeholder" label="提示语" min-width="160" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag size="small" :type="scope.row.status === 'enable' ? 'success' : 'info'">
{{ scope.row.status === 'enable' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button link type="primary" icon="Edit" @click="openFieldEdit(scope.row)">
编辑
</el-button>
<el-button link type="danger" icon="Delete" @click="removeField(scope.row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="上传资料类型" name="materials"> <el-tab-pane lazy label="上传资料类型" name="materials">
<div class="tab-card-wrapper"> <div class="tab-card-wrapper material-tab-wrapper">
<div class="toolbar"> <div class="toolbar">
<el-button type="primary" icon="Plus" @click="openMaterialCreate"> <el-button v-auth="'onboardingEntryConfigAdd'" type="primary" icon="Plus" @click="openMaterialCreate">
新增资料类型 新增资料类型
</el-button> </el-button>
</div> </div>
<el-table :data="materialTypes" border stripe> <el-table :data="materialTypes" border stripe height="100%">
<el-table-column prop="materialCode" label="资料编码" min-width="140" /> <el-table-column prop="materialCode" label="资料编码" min-width="140" />
<el-table-column prop="materialName" label="资料名称" min-width="150" /> <el-table-column prop="materialName" label="资料名称" min-width="150" />
<el-table-column prop="allowedExtensions" label="允许扩展名" min-width="140"> <el-table-column prop="allowedExtensions" label="允许扩展名" min-width="160">
<template #default="scope"> <template #default="scope">
<code>{{ scope.row.allowedExtensions || '不限' }}</code> <code>{{ scope.row.allowedExtensions || '不限' }}</code>
</template> </template>
@@ -67,13 +33,29 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="150" fixed="right"> <el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" icon="Edit" @click="openMaterialEdit(scope.row)"> <el-button
v-auth="'onboardingEntryConfigEdit'"
link
type="primary"
icon="Edit"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="openMaterialEdit(scope.row)"
>
编辑 编辑
</el-button> </el-button>
<el-button link type="danger" icon="Delete" @click="removeMaterial(scope.row)"> <el-button
v-auth="'onboardingEntryConfigDelete'"
link
type="danger"
icon="Delete"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="removeMaterial(scope.row)"
>
删除 删除
</el-button> </el-button>
</template> </template>
@@ -82,161 +64,16 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="公司岗位要求" name="requirements"> <el-tab-pane lazy label="入职模板" name="templates">
<div class="tab-card-wrapper"> <div class="tab-card-wrapper">
<el-alert title="请先选择公司,再为全公司或某个岗位分别配置员工必填信息和上传资料。" type="info" :closable="false" class="hint" /> <OnboardingTemplatePanel :load-materials="loadMaterials" />
<el-select v-model="selectedCompanyId" placeholder="请选择公司" filterable @change="loadRequirements">
<el-option v-for="company in companies" :key="company.id" :label="company.companyName" :value="company.id" />
</el-select>
<template v-if="selectedCompanyId">
<el-divider />
<el-tabs v-model="requirementTab">
<el-tab-pane label="填写字段要求" name="form">
<div class="toolbar">
<el-button type="primary" @click="openRequirementCreate('form')">
新增填写要求
</el-button>
</div>
<el-table :data="formRequirements" border>
<el-table-column label="字段">
<template #default="scope">
{{ fieldName(scope.row.fieldId) }}
</template>
</el-table-column>
<el-table-column label="岗位">
<template #default="scope">
{{ positionName(scope.row.positionId) }}
</template>
</el-table-column>
<el-table-column label="必填">
<template #default="scope">
{{ scope.row.required ? "是" : "否" }}
</template>
</el-table-column>
<el-table-column prop="sortCode" label="排序" />
<el-table-column label="操作" width="140">
<template #default="scope">
<el-button link type="primary" @click="openRequirementEdit('form', scope.row)">
编辑
</el-button>
<el-button link type="danger" @click="removeRequirement('form', scope.row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="上传资料要求" name="material">
<div class="toolbar">
<el-button type="primary" @click="openRequirementCreate('material')">
新增资料要求
</el-button>
</div>
<el-table :data="materialRequirements" border>
<el-table-column label="资料类型">
<template #default="scope">
{{ materialName(scope.row.materialTypeId) }}
</template>
</el-table-column>
<el-table-column label="岗位">
<template #default="scope">
{{ positionName(scope.row.positionId) }}
</template>
</el-table-column>
<el-table-column label="必传">
<template #default="scope">
{{ scope.row.required ? "是" : "否" }}
</template>
</el-table-column>
<el-table-column prop="sortCode" label="排序" />
<el-table-column label="操作" width="140">
<template #default="scope">
<el-button link type="primary" @click="openRequirementEdit('material', scope.row)">
编辑
</el-button>
<el-button link type="danger" @click="removeRequirement('material', scope.row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</template>
</div> </div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<el-dialog v-model="fieldVisible" :title="fieldForm.id ? '编辑字段' : '新增字段'" width="560px" destroy-on-close> <!-- 资料类型弹窗 -->
<el-form :model="fieldForm" label-width="110px">
<el-form-item label="字段编码" required>
<el-input v-model="fieldForm.fieldCode" />
</el-form-item>
<el-form-item label="字段名称" required>
<el-input v-model="fieldForm.fieldName" />
</el-form-item>
<el-form-item label="数据类型" required>
<el-select v-model="fieldForm.dataType">
<el-option label="文本" value="string" />
<el-option label="日期" value="date" />
<el-option label="整数" value="int" />
<el-option label="数字" value="number" />
<el-option label="金额" value="decimal" />
<el-option label="下拉选择" value="select" />
</el-select>
</el-form-item>
<el-form-item label="提示语">
<el-input v-model="fieldForm.placeholder" />
</el-form-item>
<el-form-item label="选项JSON">
<el-input v-model="fieldForm.optionsJson" type="textarea" placeholder="下拉/多选字段可填写选项JSON" />
</el-form-item>
<el-form-item label="校验规则">
<el-input v-model="fieldForm.validationRule" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="fieldForm.status">
<el-option label="启用" value="enable" />
<el-option label="停用" value="disable" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="fieldVisible = false">
取消
</el-button>
<el-button type="primary" @click="saveField">
保存
</el-button>
</template>
</el-dialog>
<el-dialog v-model="materialVisible" :title="materialForm.id ? '编辑资料类型' : '新增资料类型'" width="560px" destroy-on-close> <el-dialog v-model="materialVisible" :title="materialForm.id ? '编辑资料类型' : '新增资料类型'" width="560px" destroy-on-close>
<el-form :model="materialForm" label-width="120px"> <el-form :model="materialForm" label-width="120px" class="material-form">
<el-form-item label="资料编码" required> <el-form-item label="资料编码" required>
<el-input v-model="materialForm.materialCode" /> <el-input v-model="materialForm.materialCode" />
</el-form-item> </el-form-item>
@@ -246,219 +83,84 @@
</el-form-item> </el-form-item>
<el-form-item label="允许扩展名"> <el-form-item label="允许扩展名">
<el-input v-model="materialForm.allowedExtensions" placeholder="如:jpg,png,pdf" /> <el-input v-model="materialForm.allowedExtensions" placeholder="逗号分隔,如:png,jpg,jpeg,pdf" />
</el-form-item> </el-form-item>
<el-form-item label="最大文件数量"> <el-form-item label="最大文件数">
<el-input-number v-model="materialForm.maxFileCount" :min="1" /> <el-input-number v-model="materialForm.maxFileCount" :min="1" :max="10" />
<span class="unit-suffix">个</span>
</el-form-item> </el-form-item>
<el-form-item label="最大大小(MB)"> <el-form-item label="单文件大小">
<el-input-number v-model="materialForm.maxFileSizeMb" :min="1" /> <el-input-number v-model="materialForm.maxFileSizeMb" :min="1" :max="20" />
<span class="unit-suffix">MB</span>
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item label="状态">
<el-select v-model="materialForm.status"> <el-radio-group v-model="materialForm.status">
<el-option label="启用" value="enable" /> <el-radio label="enable">启用</el-radio>
<el-option label="停用" value="disable" /> <el-radio label="disable">停用</el-radio>
</el-select> </el-radio-group>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="materialVisible = false"> <el-button @click="materialVisible = false">取消</el-button>
取消 <el-button type="primary" @click="saveMaterial">保存</el-button>
</el-button>
<el-button type="primary" @click="saveMaterial">
保存
</el-button>
</template>
</el-dialog>
<el-dialog v-model="requirementVisible" :title="requirementKind === 'form' ? '填写字段要求' : '上传资料要求'" width="560px"
destroy-on-close>
<el-form :model="requirementForm" label-width="110px">
<el-form-item label="所属公司">
<el-select v-model="requirementForm.companyId" disabled>
<el-option v-for="company in companies" :key="company.id" :label="company.companyName"
:value="company.id" />
</el-select>
</el-form-item>
<el-form-item label="适用岗位">
<el-select v-model="requirementForm.positionId" clearable placeholder="不选表示全公司">
<el-option v-for="position in positions" :key="position.id" :label="position.positionName"
:value="position.id" />
</el-select>
</el-form-item>
<el-form-item v-if="requirementKind === 'form'" label="填写字段" required>
<el-select v-model="requirementForm.fieldId">
<el-option v-for="field in enabledFields" :key="field.id" :label="field.fieldName" :value="field.id" />
</el-select>
</el-form-item>
<el-form-item v-else label="资料类型" required>
<el-select v-model="requirementForm.materialTypeId">
<el-option v-for="material in enabledMaterials" :key="material.id" :label="material.materialName"
:value="material.id" />
</el-select>
</el-form-item>
<el-form-item label="是否必填/必传">
<el-switch v-model="requirementForm.required" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="requirementForm.sortCode" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="requirementVisible = false">
取消
</el-button>
<el-button type="primary" @click="saveRequirement">
保存
</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts" name="onboardingEntryConfig"> <script setup lang="ts" name="onboardingEntryConfig">
import { reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { import {
onboardingTaskApi, OnboardingMaterialType,
type OnboardingCompanyOption, onboardingTaskApi
type OnboardingFormField,
type OnboardingFormRequirement,
type OnboardingMaterialRequirement,
type OnboardingMaterialType,
type OnboardingPositionOption
} from "@/api/modules/biz/onboarding"; } from "@/api/modules/biz/onboarding";
import { isMessageBoxConfirmed } from "@/utils/messageBox";
import OnboardingTemplatePanel from "./components/OnboardingTemplatePanel.vue";
import VariableDefinitionPanel from "./components/VariableDefinitionPanel.vue";
import { useOnboardingPermission } from "@/utils/onboardingPermission";
const activeTab = ref("fields"); const { canManage, disabledReason } = useOnboardingPermission("onboardingEntryConfigManageAll");
const requirementTab = ref("form");
const fields = ref<OnboardingFormField[]>([]); const activeTab = ref("variables");
const materialTypes = ref<OnboardingMaterialType[]>([]); const materialTypes = ref<OnboardingMaterialType[]>([]);
const companies = ref<OnboardingCompanyOption[]>([]);
const positions = ref<OnboardingPositionOption[]>([]);
const formRequirements = ref<OnboardingFormRequirement[]>([]);
const materialRequirements = ref<OnboardingMaterialRequirement[]>([]);
const selectedCompanyId = ref<number>();
const fieldVisible = ref(false);
const materialVisible = ref(false); const materialVisible = ref(false);
const requirementVisible = ref(false);
const requirementKind = ref<"form" | "material">("form"); const materialForm = reactive<Partial<OnboardingMaterialType>>({
materialCode: "",
materialName: "",
allowedExtensions: "png,jpg,jpeg,pdf",
maxFileCount: 3,
maxFileSizeMb: 10,
status: "enable"
});
const fieldForm = reactive<Partial<OnboardingFormField>>({}); let materialRequest: Promise<OnboardingMaterialType[]> | undefined;
const materialForm = reactive<Partial<OnboardingMaterialType>>({}); async function loadMaterials() {
const requirementForm = reactive<any>({}); if (!materialRequest) {
materialRequest = onboardingTaskApi.listMaterialTypes().then(({ data }) => {
const enabledFields = computed(() => materialTypes.value = data || [];
fields.value.filter(item => item.status === "enable") return materialTypes.value;
); }).finally(() => { materialRequest = undefined; });
}
const enabledMaterials = computed(() => return materialRequest;
materialTypes.value.filter(item => item.status === "enable")
);
async function loadBase() {
const [
fieldResult,
materialResult,
companyResult
] = await Promise.all([
onboardingTaskApi.listFormFields(),
onboardingTaskApi.listMaterialTypes(),
onboardingTaskApi.listCompanies()
]);
fields.value = fieldResult.data;
materialTypes.value = materialResult.data;
companies.value = companyResult.data;
} }
async function loadRequirements() { function openMaterialCreate() {
if (!selectedCompanyId.value) return;
const [
formResult,
materialResult,
positionResult
] = await Promise.all([
onboardingTaskApi.listFormRequirements(selectedCompanyId.value),
onboardingTaskApi.listMaterialRequirements(selectedCompanyId.value),
onboardingTaskApi.listPositions(selectedCompanyId.value)
]);
formRequirements.value = formResult.data;
materialRequirements.value = materialResult.data;
positions.value = positionResult.data;
}
function resetField() {
Object.assign(fieldForm, {
id: undefined,
fieldCode: "",
fieldName: "",
dataType: "string",
placeholder: "",
optionsJson: "",
validationRule: "",
status: "enable"
});
}
function resetMaterial() {
Object.assign(materialForm, { Object.assign(materialForm, {
id: undefined, id: undefined,
materialCode: "", materialCode: "",
materialName: "", materialName: "",
allowedExtensions: "", allowedExtensions: "png,jpg,jpeg,pdf",
maxFileCount: 1, maxFileCount: 3,
maxFileSizeMb: undefined, maxFileSizeMb: 10,
status: "enable" status: "enable"
}); });
}
function openFieldCreate() {
resetField();
fieldVisible.value = true;
}
function openFieldEdit(row: OnboardingFormField) {
Object.assign(fieldForm, row);
fieldVisible.value = true;
}
async function saveField() {
if (
!fieldForm.fieldCode ||
!fieldForm.fieldName ||
!fieldForm.dataType
) {
return ElMessage.warning(
"请填写字段编码、名称和数据类型"
);
}
await onboardingTaskApi.saveFormField(fieldForm);
fieldVisible.value = false;
await loadBase();
}
function openMaterialCreate() {
resetMaterial();
materialVisible.value = true; materialVisible.value = true;
} }
@@ -468,141 +170,29 @@ function openMaterialEdit(row: OnboardingMaterialType) {
} }
async function saveMaterial() { async function saveMaterial() {
if ( if (!materialForm.materialCode || !materialForm.materialName) {
!materialForm.materialCode || return ElMessage.warning("请填写资料编码和资料名称");
!materialForm.materialName
) {
return ElMessage.warning(
"请填写资料编码和名称"
);
} }
await onboardingTaskApi.saveMaterialType(materialForm); await onboardingTaskApi.saveMaterialType(materialForm);
ElMessage.success("资料类型保存成功");
materialVisible.value = false; materialVisible.value = false;
await loadMaterials();
await loadBase();
}
async function removeField(row: OnboardingFormField) {
await ElMessageBox.confirm(
`确认删除字段“${row.fieldName}”?`,
"删除确认"
);
await onboardingTaskApi.deleteFormFields([row.id]);
await loadBase();
} }
async function removeMaterial(row: OnboardingMaterialType) { async function removeMaterial(row: OnboardingMaterialType) {
await ElMessageBox.confirm( const confirmed = await isMessageBoxConfirmed(
`确认删除资料类型“${row.materialName}”?`, ElMessageBox.confirm(`确认删除资料类型“${row.materialName}”吗?`, "删除确认", { type: "warning" })
"删除确认"
); );
if (!confirmed) return;
await onboardingTaskApi.deleteMaterialTypes([row.id]); await onboardingTaskApi.deleteMaterialTypes([row.id!]);
ElMessage.success("删除成功");
await loadBase(); await loadMaterials();
} }
function resetRequirement() { watch(activeTab, tab => {
Object.assign(requirementForm, { if (tab === "materials") void loadMaterials();
id: undefined, });
companyId: selectedCompanyId.value,
positionId: undefined,
fieldId: undefined,
materialTypeId: undefined,
required: true,
sortCode: 0
});
}
function openRequirementCreate(
kind: "form" | "material"
) {
requirementKind.value = kind;
resetRequirement();
requirementVisible.value = true;
}
function openRequirementEdit(
kind: "form" | "material",
row: any
) {
requirementKind.value = kind;
Object.assign(requirementForm, row);
requirementVisible.value = true;
}
async function saveRequirement() {
if (
requirementKind.value === "form" &&
!requirementForm.fieldId
) {
return ElMessage.warning("请选择填写字段");
}
if (
requirementKind.value === "material" &&
!requirementForm.materialTypeId
) {
return ElMessage.warning("请选择资料类型");
}
if (requirementKind.value === "form") {
await onboardingTaskApi.saveFormRequirement(
requirementForm
);
} else {
await onboardingTaskApi.saveMaterialRequirement(
requirementForm
);
}
requirementVisible.value = false;
await loadRequirements();
}
async function removeRequirement(
kind: "form" | "material",
row: any
) {
await ElMessageBox.confirm(
"确认删除该要求?",
"删除确认"
);
if (kind === "form") {
await onboardingTaskApi.deleteFormRequirements([
row.id
]);
} else {
await onboardingTaskApi.deleteMaterialRequirements([
row.id
]);
}
await loadRequirements();
}
const fieldName = (id: number) =>
fields.value.find(item => item.id === id)?.fieldName || id;
const materialName = (id: number) =>
materialTypes.value.find(item => item.id === id)?.materialName || id;
const positionName = (id?: number) =>
!id
? "全公司"
: positions.value.find(item => item.id === id)?.positionName || id;
onMounted(loadBase);
</script> </script>
<style scoped> <style scoped>
@@ -632,21 +222,44 @@ onMounted(loadBase);
.config-tabs :deep(.el-tabs__content) { .config-tabs :deep(.el-tabs__content) {
flex: 1; flex: 1;
overflow: auto; height: 0;
overflow: hidden;
}
.config-tabs :deep(.el-tab-pane) {
height: 100%;
display: flex;
flex-direction: column;
} }
.tab-card-wrapper { .tab-card-wrapper {
height: 100%;
width: 100%;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
.material-tab-wrapper {
display: flex;
flex-direction: column;
} }
.toolbar { .toolbar {
margin-bottom: 14px; margin-bottom: 12px;
} }
.hint { .material-form :deep(.el-form-item__label) {
margin-bottom: 14px; white-space: nowrap;
} }
</style>
.unit-suffix {
margin-left: 10px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
</style>
+584 -67
View File
@@ -2,7 +2,7 @@
<div class="main-box setup-container"> <div class="main-box setup-container">
<el-tabs v-model="activeTab" class="setup-tabs"> <el-tabs v-model="activeTab" class="setup-tabs">
<!-- 1. 入职公司管理 --> <!-- 1. 入职公司管理 -->
<el-tab-pane label="入职公司" name="company"> <el-tab-pane lazy label="入职公司" name="company">
<div class="table-wrapper"> <div class="table-wrapper">
<ProTable <ProTable
ref="companyTable" ref="companyTable"
@@ -12,7 +12,7 @@
:search-col="5" :search-col="5"
> >
<template #tableHeader> <template #tableHeader>
<el-button type="primary" icon="Plus" @click="openCompanyCreate"> <el-button v-auth="'onboardingSetupAdd'" type="primary" icon="Plus" @click="openCompanyCreate">
新增入职公司 新增入职公司
</el-button> </el-button>
</template> </template>
@@ -23,12 +23,37 @@
</el-tag> </el-tag>
</template> </template>
<template #qiyuesuoConfig="scope">
<el-tag
size="small"
:type="scope.row.qiyuesuoCategoryId && scope.row.qiyuesuoSealId ? 'success' : 'warning'"
>
{{ scope.row.qiyuesuoCategoryId && scope.row.qiyuesuoSealId ? "已配置" : "未配置" }}
</el-tag>
</template>
<template #operation="scope"> <template #operation="scope">
<el-button link type="primary" icon="Edit" @click="openCompanyEdit(scope.row)"> <el-button
v-auth="'onboardingSetupEdit'"
link
type="primary"
icon="Edit"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="openCompanyEdit(scope.row)"
>
编辑 编辑
</el-button> </el-button>
<el-button link type="danger" icon="Delete" @click="removeCompany(scope.row)"> <el-button
v-auth="'onboardingSetupDelete'"
link
type="danger"
icon="Delete"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="removeCompany(scope.row)"
>
删除 删除
</el-button> </el-button>
</template> </template>
@@ -36,26 +61,34 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<!-- 2. 入职岗位管理 --> <!-- 2. 入职部门管理 -->
<el-tab-pane label="入职岗位" name="position"> <el-tab-pane lazy label="入职部门" name="department">
<div class="table-wrapper"> <div class="table-wrapper">
<ProTable <ProTable
ref="positionTable" ref="departmentTable"
title="入职岗位列表" title="入职部门列表"
:columns="positionColumns" :columns="departmentColumns"
:request-api="onboardingTaskApi.positionPage" :request-api="onboardingTaskApi.departmentPage"
:search-col="5" :search-col="5"
> >
<template #tableHeader> <template #tableHeader>
<el-button type="primary" icon="Plus" @click="openPositionCreate"> <el-button v-auth="'onboardingSetupAdd'" type="primary" icon="Plus" @click="openDepartmentCreate">
新增入职岗位 新增入职部门
</el-button> </el-button>
</template> </template>
<template #companyId="scope"> <template #companyId="scope">
<el-tag effect="plain" type="primary"> <el-tooltip
{{ getCompanyName(scope.row.companyId) }} v-if="scope.row.companyName || getCompanyName(scope.row.companyId)"
</el-tag> :content="scope.row.companyName || getCompanyName(scope.row.companyId)"
placement="top"
:show-after="300"
>
<el-tag effect="plain" type="primary" class="truncate-tag">
{{ scope.row.companyName || getCompanyName(scope.row.companyId) }}
</el-tag>
</el-tooltip>
<span v-else class="text-gray-400">-</span>
</template> </template>
<template #status="scope"> <template #status="scope">
@@ -65,11 +98,106 @@
</template> </template>
<template #operation="scope"> <template #operation="scope">
<el-button link type="primary" icon="Edit" @click="openPositionEdit(scope.row)"> <el-button
v-auth="'onboardingSetupEdit'"
link
type="primary"
icon="Edit"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="openDepartmentEdit(scope.row)"
>
编辑 编辑
</el-button> </el-button>
<el-button link type="danger" icon="Delete" @click="removePosition(scope.row)"> <el-button
v-auth="'onboardingSetupDelete'"
link
type="danger"
icon="Delete"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="removeDepartment(scope.row)"
>
删除
</el-button>
</template>
</ProTable>
</div>
</el-tab-pane>
<!-- 3. 入职岗位管理 -->
<el-tab-pane lazy label="入职岗位" name="position">
<div class="table-wrapper">
<ProTable
ref="positionTable"
title="入职岗位列表"
:columns="positionColumns"
:request-api="onboardingTaskApi.positionPage"
:search-col="5"
>
<template #tableHeader>
<el-button v-auth="'onboardingSetupAdd'" type="primary" icon="Plus" @click="openPositionCreate">
新增入职岗位
</el-button>
</template>
<template #companyId="scope">
<el-tooltip
v-if="scope.row.companyName || getCompanyName(scope.row.companyId)"
:content="scope.row.companyName || getCompanyName(scope.row.companyId)"
placement="top"
:show-after="300"
>
<el-tag effect="plain" type="primary" class="truncate-tag">
{{ scope.row.companyName || getCompanyName(scope.row.companyId) }}
</el-tag>
</el-tooltip>
<span v-else class="text-gray-400">-</span>
</template>
<template #departmentName="scope">
<el-tooltip
v-if="scope.row.departmentName"
:content="scope.row.departmentName"
placement="top"
:show-after="300"
>
<el-tag effect="light" type="warning" class="truncate-tag">
{{ scope.row.departmentName }}
</el-tag>
</el-tooltip>
<span v-else class="text-gray-400">-</span>
</template>
<template #status="scope">
<el-tag size="small" :type="scope.row.status === 'enable' ? 'success' : 'info'">
{{ scope.row.status === "enable" ? "启用" : "停用" }}
</el-tag>
</template>
<template #operation="scope">
<el-button
v-auth="'onboardingSetupEdit'"
link
type="primary"
icon="Edit"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="openPositionEdit(scope.row)"
>
编辑
</el-button>
<el-button
v-auth="'onboardingSetupDelete'"
link
type="danger"
icon="Delete"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="removePosition(scope.row)"
>
删除 删除
</el-button> </el-button>
</template> </template>
@@ -82,49 +210,65 @@
<el-dialog <el-dialog
v-model="dialogVisible" v-model="dialogVisible"
:title="dialogTitle" :title="dialogTitle"
width="680px" width="820px"
top="6vh"
destroy-on-close destroy-on-close
append-to-body append-to-body
> >
<el-form v-if="dialogKind === 'company'" :model="companyForm" label-width="140px"> <!-- 1. 公司表单 -->
<el-form
v-if="dialogKind === 'company'"
ref="companyFormRef"
:model="companyForm"
:rules="companyRules"
label-width="140px"
class="setup-form"
>
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="公司编码" required> <el-form-item label="公司编码" prop="companyCode">
<el-input v-model="companyForm.companyCode" placeholder="如:TECH_CORP" /> <el-input v-model.trim="companyForm.companyCode" placeholder="如:TECH_CORP" maxlength="50" show-word-limit clearable />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="公司简称/名称" required> <el-form-item label="公司简称/名称" prop="companyName">
<el-input v-model="companyForm.companyName" placeholder="如:总公司 / 科技分部" /> <el-input v-model.trim="companyForm.companyName" placeholder="如:总公司 / 科技分部" maxlength="50" show-word-limit clearable />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-form-item label="合同甲方全称" required> <el-form-item label="签署材料甲方全称" prop="legalName">
<el-input v-model="companyForm.legalName" placeholder="用于合同填写的法定主体全称" /> <el-input v-model.trim="companyForm.legalName" placeholder="必须与契约锁中的企业名称完全一致" maxlength="100" show-word-limit clearable />
</el-form-item> </el-form-item>
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="统一社会信用代码"> <el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode">
<el-input v-model="companyForm.unifiedSocialCreditCode" placeholder="18位统一信用代码" /> <el-input
v-model.trim="companyForm.unifiedSocialCreditCode"
placeholder="18位统一社会信用代码"
maxlength="18"
show-word-limit
clearable
@input="val => companyForm.unifiedSocialCreditCode = (val || '').replace(/[^0-9A-Za-z]/g, '').toUpperCase()"
/>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="法定代表人"> <el-form-item label="法定代表人" prop="legalRepresentative">
<el-input v-model="companyForm.legalRepresentative" placeholder="法定代表人姓名" /> <el-input v-model.trim="companyForm.legalRepresentative" placeholder="法定代表人姓名" maxlength="20" show-word-limit clearable />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="联系电话"> <el-form-item label="联系电话" prop="contactPhone">
<el-input v-model="companyForm.contactPhone" placeholder="企业联系电话" /> <el-input v-model.trim="companyForm.contactPhone" placeholder="企业联系电话(选填,手机或固话)" maxlength="20" show-word-limit clearable />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="状态" required> <el-form-item label="状态" prop="status">
<el-select v-model="companyForm.status" class="w-full"> <el-select v-model="companyForm.status" class="w-full">
<el-option label="启用" value="enable" /> <el-option label="启用" value="enable" />
<el-option label="停用" value="disable" /> <el-option label="停用" value="disable" />
@@ -133,18 +277,54 @@
</el-col> </el-col>
</el-row> </el-row>
<el-form-item label="注册地址"> <el-form-item label="注册地址" prop="registeredAddress">
<el-input v-model="companyForm.registeredAddress" placeholder="企业法定注册地址" /> <el-input v-model.trim="companyForm.registeredAddress" placeholder="企业法定注册地址" maxlength="150" show-word-limit clearable />
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item label="备注" prop="remark">
<el-input v-model="companyForm.remark" type="textarea" :rows="2" placeholder="备注信息" /> <el-input v-model="companyForm.remark" type="textarea" :rows="3" placeholder="备注信息(选填)" maxlength="300" show-word-limit />
</el-form-item> </el-form-item>
<el-divider content-position="left">契约锁电子签配置(由技术人员添加)</el-divider>
<el-alert
title="说明:默认业务分类 Id 与企业印章 Id 由技术人员添加配置,非技术人员请勿随意修改。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 18px"
/>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="默认业务分类 Id" prop="qiyuesuoCategoryId">
<el-input
v-model.trim="companyForm.qiyuesuoCategoryId"
placeholder="契约锁当前公司的分类 Id"
maxlength="19"
show-word-limit
clearable
@input="val => companyForm.qiyuesuoCategoryId = (val || '').replace(/\D/g, '')"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="企业印章 Id" prop="qiyuesuoSealId">
<el-input
v-model.trim="companyForm.qiyuesuoSealId"
placeholder="契约锁当前公司的印章 Id"
maxlength="19"
show-word-limit
clearable
@input="val => companyForm.qiyuesuoSealId = (val || '').replace(/\D/g, '')"
/>
</el-form-item>
</el-col>
</el-row>
</el-form> </el-form>
<el-form v-else :model="positionForm" label-width="140px"> <!-- 2. 部门表单 -->
<el-form v-else-if="dialogKind === 'department'" :model="departmentForm" label-width="140px">
<el-form-item label="所属公司" required> <el-form-item label="所属公司" required>
<el-select v-model="positionForm.companyId" placeholder="请选择所属公司" filterable class="w-full"> <el-select v-model="departmentForm.companyId" placeholder="请选择所属公司" filterable class="w-full">
<el-option <el-option
v-for="company in companies" v-for="company in companies"
:key="company.id" :key="company.id"
@@ -156,20 +336,91 @@
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="岗位编码" required> <el-form-item label="部门编码" required>
<el-input v-model="positionForm.positionCode" placeholder="如:DEV_FRONTEND" /> <el-input v-model.trim="departmentForm.departmentCode" placeholder="如:DEPT_TECH" maxlength="50" show-word-limit clearable />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="岗位名称" required> <el-form-item label="部门名称" required>
<el-input v-model="positionForm.positionName" placeholder="如:高级前端开发工程师" /> <el-input v-model.trim="departmentForm.departmentName" placeholder="如:技术研发部" maxlength="50" show-word-limit clearable />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="默认合同年限"> <el-form-item label="显示排序">
<el-input-number v-model="departmentForm.sortCode" :min="1" :max="999" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" required>
<el-select v-model="departmentForm.status" class="w-full">
<el-option label="启用" value="enable" />
<el-option label="停用" value="disable" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注">
<el-input v-model="departmentForm.remark" type="textarea" :rows="2" placeholder="备注信息" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<!-- 3. 岗位表单 -->
<el-form v-else :model="positionForm" label-width="140px">
<el-form-item label="所属公司" required>
<el-select
v-model="positionForm.companyId"
placeholder="请选择所属公司"
filterable
class="w-full"
@change="onPositionCompanyChange"
>
<el-option
v-for="company in companies"
:key="company.id"
:label="company.companyName"
:value="company.id"
/>
</el-select>
</el-form-item>
<el-form-item label="所属部门" required>
<el-select
v-model="positionForm.departmentId"
placeholder="请先选择所属公司"
filterable
clearable
class="w-full"
:disabled="!positionForm.companyId"
>
<el-option
v-for="dept in formDepartments"
:key="dept.id"
:label="dept.departmentName"
:value="dept.id"
/>
</el-select>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="岗位编码" required>
<el-input v-model.trim="positionForm.positionCode" placeholder="如:DEV_FRONTEND" maxlength="50" show-word-limit clearable />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="岗位名称" required>
<el-input v-model.trim="positionForm.positionName" placeholder="如:高级前端开发工程师" maxlength="50" show-word-limit clearable />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="默认签署材料年限">
<el-input-number v-model="positionForm.defaultContractYears" :min="1" :max="20" class="w-full" /> <el-input-number v-model="positionForm.defaultContractYears" :min="1" :max="20" class="w-full" />
</el-form-item> </el-form-item>
</el-col> </el-col>
@@ -188,7 +439,7 @@
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item label="备注">
<el-input v-model="positionForm.remark" type="textarea" :rows="2" placeholder="备注信息" /> <el-input v-model="positionForm.remark" type="textarea" :rows="2" placeholder="备注信息" maxlength="200" show-word-limit />
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -201,23 +452,32 @@
</template> </template>
<script setup lang="ts" name="onboardingSetup"> <script setup lang="ts" name="onboardingSetup">
import { ref, reactive, computed, onMounted } from "vue"; import { ref, reactive, computed, watch, nextTick } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus, Edit, Delete } from "@element-plus/icons-vue"; import { Plus, Edit, Delete } from "@element-plus/icons-vue";
import { import {
onboardingTaskApi, onboardingTaskApi,
type OnboardingCompany, type OnboardingCompany,
type OnboardingCompanyOption, type OnboardingCompanyOption,
type OnboardingDepartment,
type OnboardingDepartmentOption,
type OnboardingPosition type OnboardingPosition
} from "@/api/modules/biz/onboarding"; } from "@/api/modules/biz/onboarding";
import type { ColumnProps, ProTableInstance, EnumProps } from "@/components/ProTable/interface"; import type { ColumnProps, ProTableInstance, EnumProps } from "@/components/ProTable/interface";
import { isMessageBoxConfirmed } from "@/utils/messageBox";
import { useOnboardingPermission } from "@/utils/onboardingPermission";
const { canManage, disabledReason } = useOnboardingPermission("onboardingSetupManageAll");
const activeTab = ref("company"); const activeTab = ref("company");
const companyTable = ref<ProTableInstance>(); const companyTable = ref<ProTableInstance>();
const departmentTable = ref<ProTableInstance>();
const positionTable = ref<ProTableInstance>(); const positionTable = ref<ProTableInstance>();
const companies = ref<OnboardingCompanyOption[]>([]); const companies = ref<OnboardingCompanyOption[]>([]);
const formDepartments = ref<OnboardingDepartmentOption[]>([]);
const companyOptions = computed<EnumProps[]>(() => const companyOptions = computed<EnumProps[]>(() =>
companies.value.map(c => ({ companies.value.map(c => ({
label: c.companyName, label: c.companyName,
@@ -232,7 +492,7 @@ const getCompanyName = (companyId?: number) => {
}; };
const dialogVisible = ref(false); const dialogVisible = ref(false);
const dialogKind = ref<"company" | "position">("company"); const dialogKind = ref<"company" | "department" | "position">("company");
const editing = ref(false); const editing = ref(false);
const emptyCompany = (): Partial<OnboardingCompany> => ({ const emptyCompany = (): Partial<OnboardingCompany> => ({
@@ -243,12 +503,24 @@ const emptyCompany = (): Partial<OnboardingCompany> => ({
legalRepresentative: "", legalRepresentative: "",
registeredAddress: "", registeredAddress: "",
contactPhone: "", contactPhone: "",
qiyuesuoCategoryId: "",
qiyuesuoSealId: "",
status: "enable",
remark: ""
});
const emptyDepartment = (): Partial<OnboardingDepartment> => ({
companyId: undefined,
departmentCode: "",
departmentName: "",
sortCode: 99,
status: "enable", status: "enable",
remark: "" remark: ""
}); });
const emptyPosition = (): Partial<OnboardingPosition> => ({ const emptyPosition = (): Partial<OnboardingPosition> => ({
companyId: undefined, companyId: undefined,
departmentId: undefined,
positionCode: "", positionCode: "",
positionName: "", positionName: "",
defaultContractYears: 3, defaultContractYears: 3,
@@ -257,12 +529,85 @@ const emptyPosition = (): Partial<OnboardingPosition> => ({
remark: "" remark: ""
}); });
const companyFormRef = ref<FormInstance>();
const companyForm = reactive<Partial<OnboardingCompany>>(emptyCompany()); const companyForm = reactive<Partial<OnboardingCompany>>(emptyCompany());
const companyRules = reactive<FormRules>({
companyCode: [
{ required: true, message: "请输入公司编码", trigger: "blur" },
{
pattern: /^[a-zA-Z0-9_-]{2,50}$/,
message: "支持2-50位字母、数字、下划线或短横线",
trigger: "blur"
}
],
companyName: [
{ required: true, message: "请输入公司简称/名称", trigger: "blur" },
{ min: 2, max: 50, message: "公司名称长度需在 2 到 50 个字符之间", trigger: "blur" }
],
legalName: [
{ required: true, message: "请输入签署材料甲方全称", trigger: "blur" },
{ min: 2, max: 100, message: "甲方全称长度需在 2 到 100 个字符之间", trigger: "blur" }
],
unifiedSocialCreditCode: [
{ required: true, message: "请输入统一社会信用代码", trigger: "blur" },
{
pattern: /^[0-9A-Za-z]{18}$/,
message: "请输入18位统一社会信用代码",
trigger: "blur"
}
],
legalRepresentative: [
{ required: true, message: "请输入法定代表人姓名", trigger: "blur" },
{
pattern: /^[\u4e00-\u9fa5a-zA-Z·.\s]{2,20}$/,
message: "请输入2-20位中英文姓名",
trigger: "blur"
}
],
contactPhone: [
{
pattern: /^(1[3-9]\d{9}|(0\d{2,3}-?)?\d{7,8}|400(-?\d{3,4}){2})$/,
message: "请输入有效的手机号或固话号码",
trigger: "blur"
}
],
status: [
{ required: true, message: "请选择公司启用状态", trigger: "change" }
],
registeredAddress: [
{ required: true, message: "请输入企业法定注册地址", trigger: "blur" },
{ min: 4, max: 150, message: "注册地址需在 4 到 150 个字符之间", trigger: "blur" }
],
remark: [
{ max: 300, message: "备注不能超过 300 个字符", trigger: "blur" }
],
qiyuesuoCategoryId: [
{
pattern: /^\d*$/,
message: "业务分类 Id 必须为纯数字",
trigger: "blur"
},
{ max: 19, message: "业务分类 Id 不能超过 19 位数字", trigger: "blur" }
],
qiyuesuoSealId: [
{
pattern: /^\d*$/,
message: "企业印章 Id 必须为纯数字",
trigger: "blur"
},
{ max: 19, message: "企业印章 Id 不能超过 19 位数字", trigger: "blur" }
]
});
const departmentForm = reactive<Partial<OnboardingDepartment>>(emptyDepartment());
const positionForm = reactive<Partial<OnboardingPosition>>(emptyPosition()); const positionForm = reactive<Partial<OnboardingPosition>>(emptyPosition());
const dialogTitle = computed( const dialogTitle = computed(() => {
() => `${editing.value ? "编辑" : "新增"}${dialogKind.value === "company" ? "入职公司" : "入职岗位"}` const prefix = editing.value ? "编辑" : "新增";
); if (dialogKind.value === "company") return `${prefix}入职公司`;
if (dialogKind.value === "department") return `${prefix}入职部门`;
return `${prefix}入职岗位`;
});
const companyColumns: ColumnProps<OnboardingCompany>[] = [ const companyColumns: ColumnProps<OnboardingCompany>[] = [
{ {
@@ -279,7 +624,7 @@ const companyColumns: ColumnProps<OnboardingCompany>[] = [
}, },
{ {
prop: "legalName", prop: "legalName",
label: "合同甲方全称", label: "签署材料甲方全称",
minWidth: 220 minWidth: 220
}, },
{ {
@@ -297,6 +642,61 @@ const companyColumns: ColumnProps<OnboardingCompany>[] = [
label: "联系电话", label: "联系电话",
width: 140 width: 140
}, },
{
prop: "qiyuesuoConfig",
label: "契约锁配置",
width: 120,
align: "center"
},
{
prop: "status",
label: "状态",
width: 100,
align: "center",
enum: [
{ label: "启用", value: "enable" },
{ label: "停用", value: "disable" }
],
search: { el: "select" }
},
{
prop: "operation",
label: "操作",
fixed: "right",
width: 160
}
];
const departmentColumns: ColumnProps<OnboardingDepartment>[] = [
{
prop: "companyId",
label: "所属公司",
minWidth: 200,
enum: companyOptions,
search: {
el: "select",
props: { filterable: true, placeholder: "请选择所属公司" }
}
},
{
prop: "departmentCode",
label: "部门编码",
width: 160,
search: { el: "input" }
},
{
prop: "departmentName",
label: "部门名称",
minWidth: 180,
search: { el: "input" },
showOverflowTooltip: true
},
{
prop: "sortCode",
label: "显示排序",
width: 110,
align: "center"
},
{ {
prop: "status", prop: "status",
label: "状态", label: "状态",
@@ -320,28 +720,35 @@ const positionColumns: ColumnProps<OnboardingPosition>[] = [
{ {
prop: "companyId", prop: "companyId",
label: "所属公司", label: "所属公司",
minWidth: 200, minWidth: 180,
enum: companyOptions, enum: companyOptions,
search: { search: {
el: "select", el: "select",
props: { filterable: true, placeholder: "请选择所属公司" } props: { filterable: true, placeholder: "请选择所属公司" }
} }
}, },
{
prop: "departmentName",
label: "所属部门",
minWidth: 180
},
{ {
prop: "positionCode", prop: "positionCode",
label: "岗位编码", label: "岗位编码",
width: 150, width: 150,
search: { el: "input" } search: { el: "input" },
showOverflowTooltip: true
}, },
{ {
prop: "positionName", prop: "positionName",
label: "岗位名称", label: "岗位名称",
minWidth: 180, minWidth: 180,
search: { el: "input" } search: { el: "input" },
showOverflowTooltip: true
}, },
{ {
prop: "defaultContractYears", prop: "defaultContractYears",
label: "默认合同年限", label: "默认签署材料年限",
width: 130, width: 130,
align: "center", align: "center",
render: scope => `${scope.row.defaultContractYears || 3} 年` render: scope => `${scope.row.defaultContractYears || 3} 年`
@@ -372,15 +779,38 @@ const positionColumns: ColumnProps<OnboardingPosition>[] = [
} }
]; ];
let companyRequest: Promise<void> | undefined;
async function loadCompanies() { async function loadCompanies() {
const { data } = await onboardingTaskApi.listCompanies(); if (!companyRequest) {
companies.value = data; companyRequest = onboardingTaskApi.listCompanies().then(({ data }) => {
companies.value = data || [];
}).finally(() => { companyRequest = undefined; });
}
await companyRequest;
}
async function loadDepartmentsForCompany(companyId?: number) {
if (!companyId) {
formDepartments.value = [];
return;
}
const { data } = await onboardingTaskApi.listDepartments(companyId);
formDepartments.value = data || [];
}
async function onPositionCompanyChange(companyId?: number) {
positionForm.departmentId = undefined;
await loadDepartmentsForCompany(companyId);
} }
function resetCompany() { function resetCompany() {
Object.assign(companyForm, emptyCompany()); Object.assign(companyForm, emptyCompany());
} }
function resetDepartment() {
Object.assign(departmentForm, emptyDepartment());
}
function resetPosition() { function resetPosition() {
Object.assign(positionForm, emptyPosition()); Object.assign(positionForm, emptyPosition());
} }
@@ -390,12 +820,23 @@ function openCompanyCreate() {
editing.value = false; editing.value = false;
resetCompany(); resetCompany();
dialogVisible.value = true; dialogVisible.value = true;
nextTick(() => {
companyFormRef.value?.clearValidate();
});
}
function openDepartmentCreate() {
dialogKind.value = "department";
editing.value = false;
resetDepartment();
dialogVisible.value = true;
} }
function openPositionCreate() { function openPositionCreate() {
dialogKind.value = "position"; dialogKind.value = "position";
editing.value = false; editing.value = false;
resetPosition(); resetPosition();
formDepartments.value = [];
dialogVisible.value = true; dialogVisible.value = true;
} }
@@ -405,6 +846,17 @@ async function openCompanyEdit(row: OnboardingCompany) {
editing.value = true; editing.value = true;
Object.assign(companyForm, data); Object.assign(companyForm, data);
dialogVisible.value = true; dialogVisible.value = true;
nextTick(() => {
companyFormRef.value?.clearValidate();
});
}
async function openDepartmentEdit(row: OnboardingDepartment) {
const { data } = await onboardingTaskApi.departmentDetail({ id: row.id });
dialogKind.value = "department";
editing.value = true;
Object.assign(departmentForm, data);
dialogVisible.value = true;
} }
async function openPositionEdit(row: OnboardingPosition) { async function openPositionEdit(row: OnboardingPosition) {
@@ -412,23 +864,46 @@ async function openPositionEdit(row: OnboardingPosition) {
dialogKind.value = "position"; dialogKind.value = "position";
editing.value = true; editing.value = true;
Object.assign(positionForm, data); Object.assign(positionForm, data);
if (data?.companyId) {
await loadDepartmentsForCompany(data.companyId);
}
dialogVisible.value = true; dialogVisible.value = true;
} }
async function save() { async function save() {
if (dialogKind.value === "company") { if (dialogKind.value === "company") {
if (!companyForm.companyCode || !companyForm.companyName || !companyForm.legalName) { const valid = await companyFormRef.value?.validate().catch(() => false);
return ElMessage.warning("请填写公司编码、公司名称和合同甲方全称"); if (!valid) return;
}
const categoryId = companyForm.qiyuesuoCategoryId?.trim() || "";
const sealId = companyForm.qiyuesuoSealId?.trim() || "";
if (Boolean(categoryId) !== Boolean(sealId)) {
return ElMessage.warning("契约锁默认业务分类 Id 和企业印章 Id 必须同时填写(或均不填)");
}
if (categoryId && (!/^\d+$/.test(categoryId) || !/^\d+$/.test(sealId))) {
return ElMessage.warning("契约锁默认业务分类 Id 和企业印章 Id 只能填写纯数字");
}
if (categoryId && (categoryId.length > 19 || sealId.length > 19)) {
return ElMessage.warning("契约锁默认业务分类 Id 和企业印章 Id 不能超过 19 位数字");
}
companyForm.qiyuesuoCategoryId = categoryId;
companyForm.qiyuesuoSealId = sealId;
if (companyForm.unifiedSocialCreditCode) {
companyForm.unifiedSocialCreditCode = companyForm.unifiedSocialCreditCode.trim().toUpperCase();
}
await onboardingTaskApi.saveCompany(companyForm, editing.value); await onboardingTaskApi.saveCompany(companyForm, editing.value);
companyTable.value?.refresh(); companyTable.value?.refresh();
await loadCompanies(); await loadCompanies();
} else if (dialogKind.value === "department") {
if (!departmentForm.companyId || !departmentForm.departmentCode || !departmentForm.departmentName) {
return ElMessage.warning("请填写所属公司、部门编码和部门名称");
}
await onboardingTaskApi.saveDepartment(departmentForm, editing.value);
departmentTable.value?.refresh();
} else { } else {
if (!positionForm.companyId || !positionForm.positionCode || !positionForm.positionName) { if (!positionForm.companyId || !positionForm.departmentId || !positionForm.positionCode || !positionForm.positionName) {
return ElMessage.warning("请填写所属公司、岗位编码和岗位名称"); return ElMessage.warning("请填写所属公司、所属部门、岗位编码和岗位名称");
} }
await onboardingTaskApi.savePosition(positionForm, editing.value); await onboardingTaskApi.savePosition(positionForm, editing.value);
positionTable.value?.refresh(); positionTable.value?.refresh();
} }
@@ -438,19 +913,39 @@ async function save() {
} }
async function removeCompany(row: OnboardingCompany) { async function removeCompany(row: OnboardingCompany) {
await ElMessageBox.confirm(`确认删除公司“${row.companyName}”?`, "删除确认", { type: "warning" }); const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm(`确认删除公司“${row.companyName}”?`, "删除确认", { type: "warning" })
);
if (!confirmed) return;
await onboardingTaskApi.deleteCompany([row.id]); await onboardingTaskApi.deleteCompany([row.id]);
companyTable.value?.refresh(); companyTable.value?.refresh();
await loadCompanies(); await loadCompanies();
} }
async function removeDepartment(row: OnboardingDepartment) {
const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm(`确认删除部门“${row.departmentName}”?`, "删除确认", { type: "warning" })
);
if (!confirmed) return;
await onboardingTaskApi.deleteDepartment([row.id]);
departmentTable.value?.refresh();
}
async function removePosition(row: OnboardingPosition) { async function removePosition(row: OnboardingPosition) {
await ElMessageBox.confirm(`确认删除岗位“${row.positionName}”?`, "删除确认", { type: "warning" }); const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm(`确认删除岗位“${row.positionName}”?`, "删除确认", { type: "warning" })
);
if (!confirmed) return;
await onboardingTaskApi.deletePosition([row.id]); await onboardingTaskApi.deletePosition([row.id]);
positionTable.value?.refresh(); positionTable.value?.refresh();
} }
onMounted(loadCompanies); watch(activeTab, tab => {
if (tab !== "company" && !companies.value.length) void loadCompanies();
});
</script> </script>
<style scoped> <style scoped>
@@ -522,4 +1017,26 @@ onMounted(loadCompanies);
.w-full { .w-full {
width: 100%; width: 100%;
} }
</style>
.setup-form :deep(.el-form-item) {
margin-bottom: 22px;
}
.setup-form :deep(.el-form-item__error) {
padding-top: 3px;
line-height: 1.25;
}
.truncate-tag {
max-width: 140px;
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.truncate-tag :deep(.el-tag__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,8 @@
<template> <template>
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" size="large"> <el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" size="large">
<s-form-item v-if="tenantOption == TenantEnum.CHOSE" prop="orgId" class="-ml-12px"> <s-form-item v-if="tenantOption == TenantEnum.CHOSE" prop="orgId" class="-ml-12px">
<s-select v-model="loginForm.tenantId" :options="tenantOptions" label="name" value="id" placeholder="请选择租户"></s-select> <s-select v-model="loginForm.tenantId" :options="tenantOptions" label="name" value="id"
placeholder="请选择租户"></s-select>
</s-form-item> </s-form-item>
<el-form-item prop="account"> <el-form-item prop="account">
<el-input v-model="loginForm.account" placeholder="请输入用户名"> <el-input v-model="loginForm.account" placeholder="请输入用户名">
@@ -14,7 +15,8 @@
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item prop="password"> <el-form-item prop="password">
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码" show-password autocomplete="new-password"> <el-input v-model="loginForm.password" type="password" placeholder="请输入密码" show-password
autocomplete="new-password">
<template #prefix> <template #prefix>
<el-icon class="el-input__icon"> <el-icon class="el-input__icon">
<lock /> <lock />
@@ -38,7 +40,8 @@
</el-form> </el-form>
<div class="login-btn"> <div class="login-btn">
<el-button :icon="CircleClose" round size="large" @click="resetForm(loginFormRef)"> 重置 </el-button> <el-button :icon="CircleClose" round size="large" @click="resetForm(loginFormRef)"> 重置 </el-button>
<el-button :icon="UserFilled" round size="large" type="primary" :loading="auth.loginLoading" @click="handleSubmit(loginFormRef)"> <el-button :icon="UserFilled" round size="large" type="primary" :loading="auth.loginLoading"
@click="handleSubmit(loginFormRef)">
登录 登录
</el-button> </el-button>
</div> </div>
@@ -90,8 +93,8 @@ watch(
const loginFormRef = ref<FormInstance>(); // 表单实例 const loginFormRef = ref<FormInstance>(); // 表单实例
// 表单数据 // 表单数据
const loginForm = reactive<Login.LoginForm>({ const loginForm = reactive<Login.LoginForm>({
account: "superAdmin", //用户名 account: "", //用户名
password: "123456", // 密码 password: "", // 密码
validCode: "", // 验证码 validCode: "", // 验证码
validCodeReqNo: "", // 验证码请求号 validCodeReqNo: "", // 验证码请求号
tenantId: config.tenantIdGet tenantId: config.tenantIdGet
+1 -1
View File
@@ -62,7 +62,7 @@ interface LoginProps {
//默认值 //默认值
const props = reactive<LoginProps>({ const props = reactive<LoginProps>({
sysName: "未病局管理系统", sysName: "员工管理系统",
sysVersion: "", sysVersion: "",
sysLogo: "", sysLogo: "",
sysTenantOption: TenantEnum.CLOSE, sysTenantOption: TenantEnum.CLOSE,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,68 @@
<template>
<main class="public-status-page">
<section class="status-card">
<div class="status-mark">!</div>
<h1>{{ title }}</h1>
<p>{{ description }}</p>
</section>
</main>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
title?: string;
description?: string;
}>(), {
title: "入职链接无效",
description: "请联系招聘人员重新获取入职链接。"
});
</script>
<style scoped>
.public-status-page {
display: flex;
min-height: 100vh;
padding: 24px;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #f0fdf7;
}
.status-card {
width: min(100%, 420px);
padding: 42px 28px;
text-align: center;
background: #ffffff;
border: 1px solid #d1fae5;
border-radius: 16px;
box-shadow: 0 12px 36px rgb(5 150 105 / 10%);
}
.status-mark {
display: flex;
width: 56px;
height: 56px;
margin: 0 auto 20px;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 30px;
font-weight: 700;
background: #059669;
border-radius: 50%;
}
h1 {
margin: 0;
color: #064e3b;
font-size: 24px;
}
p {
margin: 14px 0 0;
color: #64748b;
font-size: 15px;
line-height: 1.8;
}
</style>
+307 -47
View File
@@ -1,43 +1,133 @@
<!-- <!--
* @Description: 详情 * @Description: 操作日志详情
* @Author: huguodong * @Author: huguodong
* @Date: 2023-12-15 15:44:16 * @Date: 2023-12-15 15:44:16
!--> !-->
<template> <template>
<form-container v-model="visible" title="日志详情" form-size="650px"> <form-container v-model="visible" title="操作日志详情" form-size="980px">
<el-descriptions :column="1" border class="mb-2"> <div class="log-detail-box" v-loading="loading">
<el-descriptions-item label="名称">{{ logInfo.name }}</el-descriptions-item> <!-- 异常告警条(仅异常日志显示) -->
<el-descriptions-item label="IP地址">{{ logInfo.opIp }}</el-descriptions-item> <div v-if="logInfo.category === 'EXCEPTION' || logInfo.exeMessage" class="exception-alert-box">
<el-descriptions-item label="地址">{{ logInfo.opAddress }}</el-descriptions-item> <div class="exception-header">
<el-descriptions-item label="请求地址">{{ logInfo.reqUrl }}</el-descriptions-item> <el-icon color="#ef4444" :size="18"><WarningFilled /></el-icon>
<el-descriptions-item label="操作类">{{ logInfo.className }}</el-descriptions-item> <span class="exception-title">异常堆栈 / 错误信息</span>
<el-descriptions-item label="操作方法">{{ logInfo.methodName }}</el-descriptions-item> <el-button link type="danger" size="small" :icon="CopyDocument" @click="copyText(logInfo.exeMessage || '')">复制异常</el-button>
<el-descriptions-item label="浏览器">{{ logInfo.opBrowser }}</el-descriptions-item> </div>
<el-descriptions-item label="设备">{{ logInfo.opOs }}</el-descriptions-item> <pre class="exception-content">{{ logInfo.exeMessage || '发生未知异常' }}</pre>
<el-descriptions-item label="时间">{{ logInfo.opTime }}</el-descriptions-item> </div>
<el-descriptions-item label="用户">{{ logInfo.opUser }}</el-descriptions-item>
<el-descriptions-item label="账号">{{ logInfo.opAccount }}</el-descriptions-item> <!-- 1. 基本与环境信息 -->
</el-descriptions> <el-card shadow="never" class="detail-card">
<el-space direction="vertical" class="mb-2 w-full"> <template #header>
<span>请求参数:</span> <div class="card-title">基本信息与客户端</div>
<code-high-light language="JSON" class="w-600px" :code="paramJson" /> </template>
</el-space> <el-descriptions :column="3" border size="small">
<el-space direction="vertical" class="mb-2" style="width: 100%"> <el-descriptions-item label="操作名称" :span="2">
返回结果: <span class="font-bold text-slate-800">{{ logInfo.name || '-' }}</span>
<code-high-light autodetect class="w-600px" :code="resultJson"></code-high-light> </el-descriptions-item>
</el-space> <el-descriptions-item label="执行状态">
<el-tag :type="logInfo.category === 'EXCEPTION' ? 'danger' : 'success'" size="small">
{{ logInfo.category === 'EXCEPTION' ? '执行异常' : '执行成功' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="操作人员">
{{ logInfo.opUser || '-' }}
</el-descriptions-item>
<el-descriptions-item label="操作账号">
{{ logInfo.opAccount || '-' }}
</el-descriptions-item>
<el-descriptions-item label="操作时间">
{{ logInfo.opTime || '-' }}
</el-descriptions-item>
<el-descriptions-item label="IP 地址">
<code class="code-badge">{{ logInfo.opIp || '-' }}</code>
</el-descriptions-item>
<el-descriptions-item label="地理位置">
{{ logInfo.opAddress || '-' }}
</el-descriptions-item>
<el-descriptions-item label="客户端环境">
{{ logInfo.opBrowser || '-' }} / {{ logInfo.opOs || '-' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 2. 请求与调用定位 -->
<el-card shadow="never" class="detail-card mt-3">
<template #header>
<div class="card-title">请求与类方法</div>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="请求 URL" :span="2">
<div class="req-url-box">
<el-tag size="small" :type="reqMethodTag(logInfo.reqMethod)" effect="dark" class="mr-2">
{{ logInfo.reqMethod || 'POST' }}
</el-tag>
<span class="url-text">{{ logInfo.reqUrl || '-' }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="操作类">
<span class="code-font" :title="logInfo.className">{{ logInfo.className || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="操作方法">
<span class="code-font font-bold text-primary">{{ logInfo.methodName || '-' }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 3. 请求参数 与 返回结果(左右双栏并排) -->
<el-row :gutter="12" class="mt-3">
<el-col :span="12">
<el-card shadow="never" class="detail-card code-card">
<template #header>
<div class="card-header-flex">
<span class="card-title">请求参数 (ParamJson)</span>
<el-button link type="primary" size="small" :icon="CopyDocument" :disabled="!paramFormatted" @click="copyText(paramFormatted)">
复制参数
</el-button>
</div>
</template>
<div class="code-wrapper">
<code-high-light v-if="paramFormatted" language="json" :code="paramFormatted" />
<div v-else class="text-placeholder">无请求参数</div>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never" class="detail-card code-card">
<template #header>
<div class="card-header-flex">
<span class="card-title">返回结果 (ResultJson)</span>
<el-button link type="primary" size="small" :icon="CopyDocument" :disabled="!resultFormatted" @click="copyText(resultFormatted)">
复制结果
</el-button>
</div>
</template>
<div class="code-wrapper">
<code-high-light v-if="resultFormatted" language="json" :code="resultFormatted" />
<div v-else class="text-placeholder">无返回结果</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
<template #footer> <template #footer>
<el-button type="primary" @click="onClose"> 确定 </el-button> <el-button @click="onClose">关 闭</el-button>
</template> </template>
</form-container> </form-container>
</template> </template>
<script setup lang="ts" name="detail"> <script setup lang="ts" name="detail">
import { ref } from "vue";
import { WarningFilled, CopyDocument } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { OpLog, opLogApi } from "@/api"; import { OpLog, opLogApi } from "@/api";
const visible = ref(false); //是否显示表单 const visible = ref(false);
const loading = ref(false);
// 表单参数
const logInfo = ref<OpLog.OpLogInfo>({ const logInfo = ref<OpLog.OpLogInfo>({
name: "", name: "",
opIp: "", opIp: "",
@@ -60,40 +150,210 @@ const logInfo = ref<OpLog.OpLogInfo>({
resultJson: "" resultJson: ""
}); });
const paramJson = ref<string>(); const paramFormatted = ref<string>("");
const resultJson = ref<string>(); const resultFormatted = ref<string>("");
/** function safeJsonFormat(raw?: string | null): string {
* 打开表单 if (!raw || typeof raw !== "string" || !raw.trim()) return "";
* @param props 表单参数 try {
*/ const parsed = JSON.parse(raw);
function onOpen(record: OpLog.OpLogInfo) { return JSON.stringify(parsed, null, 2);
logInfo.value = record; } catch {
visible.value = true; //显示表单 return raw;
opLogApi.detail({ id: record.id }).then(res => { }
logInfo.value = res.data; }
if (res.data.paramJson) paramJson.value = JSON.stringify(JSON.parse(res.data.paramJson), null, 2);
if (res.data.exeMessage) { function reqMethodTag(method?: string) {
resultJson.value = res.data.exeMessage; const m = (method || "").toUpperCase();
} else { if (m === "POST") return "success";
if (res.data.resultJson) resultJson.value = JSON.stringify(JSON.parse(res.data.resultJson), null, 2); if (m === "PUT") return "warning";
if (m === "DELETE") return "danger";
return "info";
}
async function onOpen(record: OpLog.OpLogInfo) {
logInfo.value = { ...record };
paramFormatted.value = safeJsonFormat(record.paramJson);
resultFormatted.value = safeJsonFormat(record.resultJson);
visible.value = true;
loading.value = true;
try {
const res = await opLogApi.detail({ id: record.id });
if (res.data) {
logInfo.value = res.data;
paramFormatted.value = safeJsonFormat(res.data.paramJson);
resultFormatted.value = safeJsonFormat(res.data.resultJson);
} }
}); } catch (err: any) {
ElMessage.error(err?.message || "获取日志详情失败");
} finally {
loading.value = false;
}
}
function copyText(text: string) {
if (!text) return;
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success("已复制到剪贴板");
});
} else {
const input = document.createElement("textarea");
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
ElMessage.success("已复制到剪贴板");
}
} }
/** 关闭表单*/
function onClose() { function onClose() {
visible.value = false; visible.value = false;
} }
// 暴露给父组件的方法
defineExpose({ defineExpose({
onOpen onOpen
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.el-space__item) { .log-detail-box {
width: 100% !important; display: flex;
flex-direction: column;
max-height: calc(86vh - 120px);
overflow-y: auto;
padding: 4px 6px;
}
.detail-card {
border-radius: 8px;
border: 1px solid #e2e8f0;
:deep(.el-card__header) {
padding: 10px 14px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
:deep(.el-card__body) {
padding: 12px;
}
}
.code-card {
display: flex;
flex-direction: column;
:deep(.el-card__body) {
padding: 8px;
flex: 1;
}
}
.card-title {
font-weight: 600;
font-size: 13px;
color: #1e293b;
}
.card-header-flex {
display: flex;
align-items: center;
justify-content: space-between;
}
.exception-alert-box {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 12px;
}
.exception-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.exception-title {
font-weight: 600;
color: #b91c1c;
font-size: 13px;
flex: 1;
}
.exception-content {
margin: 0;
font-family: Consolas, Monaco, monospace;
font-size: 12px;
color: #991b1b;
white-space: pre-wrap;
word-break: break-all;
max-height: 180px;
overflow-y: auto;
background: #fff;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid #fee2e2;
}
.font-bold {
font-weight: 600;
}
.code-badge {
font-family: monospace;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
.code-font {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
}
.text-primary {
color: var(--el-color-primary);
}
.req-url-box {
display: flex;
align-items: center;
word-break: break-all;
}
.url-text {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
color: #334155;
}
.code-wrapper {
height: 280px;
overflow-y: auto;
border-radius: 6px;
background: #1e1e1e;
}
.text-placeholder {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 13px;
background: #f8fafc;
border-radius: 6px;
}
.mt-3 {
margin-top: 12px;
}
.mr-2 {
margin-right: 8px;
} }
</style> </style>
+167 -69
View File
@@ -4,74 +4,116 @@
* @Date: 2023-12-15 15:44:27 * @Date: 2023-12-15 15:44:27
!--> !-->
<template> <template>
<div> <div class="log-page-container">
<el-row :gutter="10" class="mb-2"> <el-row :gutter="12" class="mb-3">
<el-col :span="16"> <el-col :span="16">
<el-card header="周统计" shadow="never"> <el-card header="近七日操作趋势" shadow="never" class="chart-card">
<column-chat /> <column-chat />
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-card header="总比例" shadow="never"> <el-card header="日志类型占比" shadow="never" class="chart-card">
<pie-chat /> <pie-chat />
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
<el-row>
<div class="table-box min-h-400px">
<ProTable
ref="proTable"
title="系统字典"
class="table"
:tool-button="false"
:columns="columns"
:request-api="opLogApi.page"
:init-param="initParam"
>
<!-- 表格 header 按钮 -->
<template #tableHeader>
<el-space>
<el-radio-group v-model="initParam.category" class="mb-15px">
<el-radio-button v-for="(item, index) in visLogTypeOptions" :key="index" :label="item.value">{{ item.label }}</el-radio-button>
</el-radio-group>
<el-input v-model="initParam.searchKey" placeholder="请输入字典名称" class="mb-15px">
<template #append>
<el-button :icon="Search" class="el-input-button" />
</template>
</el-input>
<s-button type="danger" plain :opt="FormOptEnum.DELETE" @click="onDelete('清空当前日志')">清空</s-button>
</el-space>
</template>
<!-- 操作 --> <div class="table-box">
<template #operation="scope"> <ProTable
<s-button link :opt="FormOptEnum.VIEW" @click="onOpen(scope.row)"> 详情 </s-button> ref="proTable"
</template> title="操作日志列表"
</ProTable> class="table"
<!-- 日志详情 --> :tool-button="false"
<Detail ref="detailRef" /> :columns="columns"
</div> :request-api="opLogApi.page"
</el-row> :init-param="initParam"
>
<!-- 表格 header 工具栏 -->
<template #tableHeader>
<div class="header-tools">
<el-radio-group v-model="initParam.category" @change="onCategoryChange">
<el-radio-button v-for="(item, index) in visLogTypeOptions" :key="index" :label="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
<el-input
v-model="initParam.searchKey"
placeholder="搜索操作名称 / IP / 账号"
clearable
style="width: 260px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #append>
<el-button :icon="Search" class="search-btn" @click="handleSearch" />
</template>
</el-input>
<el-button :icon="Refresh" @click="handleSearch">刷新</el-button>
<s-button type="danger" plain :opt="FormOptEnum.DELETE" @click="onDelete('清空当前日志')">清空日志</s-button>
</div>
</template>
<!-- 操作名称 -->
<template #name="scope">
<span class="font-medium text-slate-800">{{ scope.row.name || '-' }}</span>
</template>
<!-- 分类/状态 -->
<template #category="scope">
<el-tag :type="scope.row.category === 'EXCEPTION' ? 'danger' : 'success'" size="small" effect="light">
{{ scope.row.category === 'EXCEPTION' ? '异常' : '正常' }}
</el-tag>
</template>
<!-- 操作用户 -->
<template #opUser="scope">
<div class="user-cell">
<span class="user-name">{{ scope.row.opUser || '-' }}</span>
<span v-if="scope.row.opAccount" class="user-account text-secondary">({{ scope.row.opAccount }})</span>
</div>
</template>
<!-- IP与地点 -->
<template #opIp="scope">
<el-tag size="small" type="info" class="ip-tag">{{ scope.row.opIp || '-' }}</el-tag>
</template>
<!-- 类与方法 -->
<template #methodName="scope">
<code class="method-code">{{ scope.row.methodName || '-' }}</code>
</template>
<!-- 操作 -->
<template #operation="scope">
<el-button link type="primary" :icon="View" @click="onOpen(scope.row)"> 详情 </el-button>
</template>
</ProTable>
<!-- 日志详情 -->
<Detail ref="detailRef" />
</div>
</div> </div>
</template> </template>
<script setup lang="ts" name="sysOpLog"> <script setup lang="tsx" name="sysOpLog">
import { reactive, ref } from "vue";
import ColumnChat from "./components/columnChat.vue"; import ColumnChat from "./components/columnChat.vue";
import PieChat from "./components/pieChat.vue"; import PieChat from "./components/pieChat.vue";
import Detail from "./components/detail.vue"; import Detail from "./components/detail.vue";
import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface"; import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
import { Search } from "@element-plus/icons-vue"; import { Search, Refresh, View } from "@element-plus/icons-vue";
import { OpLog, opLogApi } from "@/api"; import { OpLog, opLogApi } from "@/api";
import { FormOptEnum } from "@/enums"; import { FormOptEnum } from "@/enums";
import { useHandleData } from "@/hooks/useHandleData"; import { useHandleData } from "@/hooks/useHandleData";
// 左侧表格初始化条件
interface InitParam { interface InitParam {
category?: string; //字典分类 category?: string;
searchKey?: string; //关键字 searchKey?: string;
} }
// 访问日志类型
let visLogTypeOptions = [ const visLogTypeOptions = [
{ {
label: "操作日志", label: "操作日志",
value: "OPERATE" value: "OPERATE"
@@ -81,47 +123,103 @@ let visLogTypeOptions = [
value: "EXCEPTION" value: "EXCEPTION"
} }
]; ];
// 如果表格需要初始化请求参数,直接定义传给 ProTable(之后每次请求都会自动带上该参数,此参数更改之后也会一直带上,改变此参数会自动刷新表格数据)
const initParam = reactive<InitParam>({ category: visLogTypeOptions[0].value }); //主表格初始化参数
// 获取 ProTable 元素,调用其获取刷新数据方法(还能获取到当前查询参数,方便导出携带参数) const initParam = reactive<InitParam>({ category: visLogTypeOptions[0].value, searchKey: "" });
const proTable = ref<ProTableInstance>(); const proTable = ref<ProTableInstance>();
// 表格配置项 // 表格配置项
const columns: ColumnProps<OpLog.OpLogInfo>[] = [ const columns: ColumnProps<OpLog.OpLogInfo>[] = [
{ prop: "name", label: "名称" }, { prop: "name", label: "操作名称", minWidth: 150, align: "left" },
{ prop: "opIp", label: "IP地址" }, { prop: "category", label: "状态", width: 90, align: "center" },
{ prop: "opAddress", label: "地址" }, { prop: "opUser", label: "操作人员", minWidth: 140, align: "left" },
{ prop: "className", label: "类名称" }, { prop: "opIp", label: "IP地址", width: 140, align: "center" },
{ prop: "methodName", label: "方法名称" }, { prop: "opAddress", label: "归属地", minWidth: 130, align: "left" },
{ prop: "opUser", label: "用户" }, { prop: "className", label: "操作类", minWidth: 180, showOverflowTooltip: true, align: "left" },
{ prop: "opAccount", label: "账号" }, { prop: "methodName", label: "操作方法", minWidth: 130, showOverflowTooltip: true, align: "left" },
{ prop: "opTime", label: "操作时间" }, { prop: "opTime", label: "操作时间", width: 170, align: "center" },
{ prop: "operation", label: "操作", fixed: "right", width: 100 } { prop: "operation", label: "操作", fixed: "right", width: 90, align: "center" }
]; ];
const detailRef = ref<InstanceType<typeof Detail> | null>(null); //详情引用 const detailRef = ref<InstanceType<typeof Detail> | null>(null);
function onCategoryChange() {
handleSearch();
}
function handleSearch() {
proTable.value?.pageable && (proTable.value.pageable.pageNum = 1);
proTable.value?.refresh();
}
/**
* 打开表单
* @param record 记录
*/
function onOpen(record: OpLog.OpLogInfo) { function onOpen(record: OpLog.OpLogInfo) {
detailRef.value?.onOpen(record); detailRef.value?.onOpen(record);
} }
/**
* 清空日志
*/
async function onDelete(msg: string) { async function onDelete(msg: string) {
// 二次确认 => 请求api => 刷新表格
await useHandleData(opLogApi.delete, initParam.category, msg); await useHandleData(opLogApi.delete, initParam.category, msg);
proTable.value?.refresh(); //刷新主表格 proTable.value?.refresh();
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.el-card__header) { .log-page-container {
padding: 9px; display: flex;
flex-direction: column;
gap: 8px;
}
.chart-card {
border-radius: 8px;
:deep(.el-card__header) {
padding: 12px 16px;
font-weight: 600;
font-size: 14px;
color: #1e293b;
border-bottom: 1px solid #f1f5f9;
}
}
.header-tools {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.search-btn {
cursor: pointer;
}
.font-medium {
font-weight: 500;
}
.user-cell {
display: flex;
align-items: center;
gap: 4px;
}
.user-name {
font-weight: 500;
}
.text-secondary {
color: #64748b;
font-size: 12px;
}
.ip-tag {
font-family: monospace;
}
.method-code {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
color: #0f172a;
} }
</style> </style>
+126 -24
View File
@@ -1,32 +1,76 @@
<!-- <!--
* @Description: 详情 * @Description: 访问日志详情
* @Author: huguodong * @Author: huguodong
* @Date: 2023-12-15 15:44:38 * @Date: 2023-12-15 15:44:38
!--> !-->
<template> <template>
<form-container v-model="visible" title="日志详情" form-size="600px"> <form-container v-model="visible" title="访问日志详情" form-size="820px">
<el-descriptions :column="1" border class="mb-2"> <div class="log-detail-box">
<el-descriptions-item label="名称">{{ logInfo.name }}</el-descriptions-item> <!-- 1. 访问事件与用户信息 -->
<el-descriptions-item label="IP地址">{{ logInfo.opIp }}</el-descriptions-item> <el-card shadow="never" class="detail-card">
<el-descriptions-item label="地址">{{ logInfo.opAddress }}</el-descriptions-item> <template #header>
<el-descriptions-item label="浏览器">{{ logInfo.opBrowser }}</el-descriptions-item> <div class="card-title">事件与访问主体</div>
<el-descriptions-item label="设备">{{ logInfo.opOs }}</el-descriptions-item> </template>
<el-descriptions-item label="时间">{{ logInfo.opTime }}</el-descriptions-item> <el-descriptions :column="2" border size="small">
<el-descriptions-item label="用户">{{ logInfo.opUser }}</el-descriptions-item> <el-descriptions-item label="事件名称" :span="2">
<el-descriptions-item label="账号">{{ logInfo.opAccount }}</el-descriptions-item> <span class="font-bold text-slate-800">{{ logInfo.name || '-' }}</span>
</el-descriptions> </el-descriptions-item>
<el-descriptions-item label="事件类型">
<el-tag :type="logInfo.category === 'LOGIN' ? 'primary' : 'warning'" size="small">
{{ logInfo.category === 'LOGIN' ? '用户登录' : '用户登出' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="访问时间">
{{ logInfo.opTime || '-' }}
</el-descriptions-item>
<el-descriptions-item label="访问人员">
{{ logInfo.opUser || '-' }}
</el-descriptions-item>
<el-descriptions-item label="访问账号">
{{ logInfo.opAccount || '-' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 2. 网络与设备环境 -->
<el-card shadow="never" class="detail-card mt-3">
<template #header>
<div class="card-title">网络与终端环境</div>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="IP 地址">
<div class="ip-cell">
<code class="code-badge">{{ logInfo.opIp || '-' }}</code>
<el-button v-if="logInfo.opIp" link type="primary" size="small" :icon="CopyDocument" @click="copyText(logInfo.opIp)">复制</el-button>
</div>
</el-descriptions-item>
<el-descriptions-item label="地理位置">
{{ logInfo.opAddress || '-' }}
</el-descriptions-item>
<el-descriptions-item label="客户端浏览器">
{{ logInfo.opBrowser || '-' }}
</el-descriptions-item>
<el-descriptions-item label="操作系统">
{{ logInfo.opOs || '-' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
<template #footer> <template #footer>
<el-button type="primary" @click="onClose"> 确定 </el-button> <el-button @click="onClose">关 闭</el-button>
</template> </template>
</form-container> </form-container>
</template> </template>
<script setup lang="ts" name="detail"> <script setup lang="ts" name="detail">
import { ref } from "vue";
import { CopyDocument } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { VisLog } from "@/api"; import { VisLog } from "@/api";
const visible = ref(false); //是否显示表单 const visible = ref(false);
// 表单参数
const logInfo = ref<VisLog.VisLogInfo>({ const logInfo = ref<VisLog.VisLogInfo>({
name: "", name: "",
opIp: "", opIp: "",
@@ -42,24 +86,82 @@ const logInfo = ref<VisLog.VisLogInfo>({
createTime: "" createTime: ""
}); });
/**
* 打开表单
* @param props 表单参数
*/
function onOpen(record: VisLog.VisLogInfo) { function onOpen(record: VisLog.VisLogInfo) {
logInfo.value = record; logInfo.value = { ...record };
visible.value = true; //显示表单 visible.value = true;
}
function copyText(text: string) {
if (!text) return;
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success("已复制到剪贴板");
});
} else {
const input = document.createElement("textarea");
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
ElMessage.success("已复制到剪贴板");
}
} }
/** 关闭表单*/
function onClose() { function onClose() {
visible.value = false; visible.value = false;
} }
// 暴露给父组件的方法
defineExpose({ defineExpose({
onOpen onOpen
}); });
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped>
.log-detail-box {
display: flex;
flex-direction: column;
padding: 4px 6px;
}
.detail-card {
border-radius: 8px;
border: 1px solid #e2e8f0;
:deep(.el-card__header) {
padding: 10px 14px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
:deep(.el-card__body) {
padding: 12px;
}
}
.card-title {
font-weight: 600;
font-size: 13px;
color: #1e293b;
}
.font-bold {
font-weight: 600;
}
.ip-cell {
display: flex;
align-items: center;
gap: 8px;
}
.code-badge {
font-family: monospace;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
.mt-3 {
margin-top: 12px;
}
</style>
+167 -69
View File
@@ -4,74 +4,120 @@
* @Date: 2023-12-15 15:44:33 * @Date: 2023-12-15 15:44:33
!--> !-->
<template> <template>
<div> <div class="log-page-container">
<el-row :gutter="10" class="mb-2"> <el-row :gutter="12" class="mb-3">
<el-col :span="16"> <el-col :span="16">
<el-card header="周统计" shadow="never"> <el-card header="近七日访问趋势" shadow="never" class="chart-card">
<line-chat /> <line-chat />
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-card header="总比例" shadow="never"> <el-card header="访问类型占比" shadow="never" class="chart-card">
<pie-chat /> <pie-chat />
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
<el-row>
<div class="table-box min-h-400px">
<ProTable
ref="proTable"
title="系统字典"
class="table"
:tool-button="false"
:columns="columns"
:request-api="visLogApi.page"
:init-param="initParam"
>
<!-- 表格 header 按钮 -->
<template #tableHeader>
<el-space>
<el-radio-group v-model="initParam.category" class="mb-15px">
<el-radio-button v-for="(item, index) in visLogTypeOptions" :key="index" :label="item.value">{{ item.label }}</el-radio-button>
</el-radio-group>
<el-input v-model="initParam.searchKey" placeholder="请输入字典名称" class="mb-15px">
<template #append>
<el-button :icon="Search" class="el-input-button" />
</template>
</el-input>
<s-button type="danger" plain :opt="FormOptEnum.DELETE" @click="onDelete('清空当前日志')">清空</s-button>
</el-space>
</template>
<!-- 操作 --> <div class="table-box">
<template #operation="scope"> <ProTable
<s-button link :opt="FormOptEnum.VIEW" @click="onOpen(scope.row)"> 详情 </s-button> ref="proTable"
</template> title="访问日志列表"
</ProTable> class="table"
<!-- 日志详情 --> :tool-button="false"
<Detail ref="detailRef" /> :columns="columns"
</div> :request-api="visLogApi.page"
</el-row> :init-param="initParam"
>
<!-- 表格 header 工具栏 -->
<template #tableHeader>
<div class="header-tools">
<el-radio-group v-model="initParam.category" @change="onCategoryChange">
<el-radio-button v-for="(item, index) in visLogTypeOptions" :key="index" :label="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
<el-input
v-model="initParam.searchKey"
placeholder="搜索日志名称 / 访问IP / 账号"
clearable
style="width: 260px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #append>
<el-button :icon="Search" class="search-btn" @click="handleSearch" />
</template>
</el-input>
<el-button :icon="Refresh" @click="handleSearch">刷新</el-button>
<s-button type="danger" plain :opt="FormOptEnum.DELETE" @click="onDelete('清空当前日志')">清空日志</s-button>
</div>
</template>
<!-- 名称 -->
<template #name="scope">
<span class="font-medium text-slate-800">{{ scope.row.name || '-' }}</span>
</template>
<!-- 分类/类型 -->
<template #category="scope">
<el-tag :type="scope.row.category === 'LOGIN' ? 'primary' : 'warning'" size="small" effect="light">
{{ scope.row.category === 'LOGIN' ? '登录' : '登出' }}
</el-tag>
</template>
<!-- 操作人员 -->
<template #opUser="scope">
<div class="user-cell">
<span class="user-name">{{ scope.row.opUser || '-' }}</span>
<span v-if="scope.row.opAccount" class="user-account text-secondary">({{ scope.row.opAccount }})</span>
</div>
</template>
<!-- IP与地点 -->
<template #opIp="scope">
<el-tag size="small" type="info" class="ip-tag">{{ scope.row.opIp || '-' }}</el-tag>
</template>
<!-- 浏览器与系统 -->
<template #opBrowser="scope">
<span class="env-text">{{ scope.row.opBrowser || '-' }}</span>
</template>
<template #opOs="scope">
<span class="env-text">{{ scope.row.opOs || '-' }}</span>
</template>
<!-- 操作 -->
<template #operation="scope">
<el-button link type="primary" :icon="View" @click="onOpen(scope.row)"> 详情 </el-button>
</template>
</ProTable>
<!-- 日志详情 -->
<Detail ref="detailRef" />
</div>
</div> </div>
</template> </template>
<script setup lang="ts" name="sysVisLog"> <script setup lang="tsx" name="sysVisLog">
import { reactive, ref } from "vue";
import LineChat from "./components/lineChat.vue"; import LineChat from "./components/lineChat.vue";
import PieChat from "./components/pieChat.vue"; import PieChat from "./components/pieChat.vue";
import Detail from "./components/detail.vue"; import Detail from "./components/detail.vue";
import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface"; import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
import { Search } from "@element-plus/icons-vue"; import { Search, Refresh, View } from "@element-plus/icons-vue";
import { VisLog, visLogApi } from "@/api"; import { VisLog, visLogApi } from "@/api";
import { FormOptEnum } from "@/enums"; import { FormOptEnum } from "@/enums";
import { useHandleData } from "@/hooks/useHandleData"; import { useHandleData } from "@/hooks/useHandleData";
// 左侧表格初始化条件
interface InitParam { interface InitParam {
category?: string; //字典分类 category?: string;
searchKey?: string; //关键字 searchKey?: string;
} }
// 访问日志类型
let visLogTypeOptions = [ const visLogTypeOptions = [
{ {
label: "登录日志", label: "登录日志",
value: "LOGIN" value: "LOGIN"
@@ -81,47 +127,99 @@ let visLogTypeOptions = [
value: "LOGOUT" value: "LOGOUT"
} }
]; ];
// 如果表格需要初始化请求参数,直接定义传给 ProTable(之后每次请求都会自动带上该参数,此参数更改之后也会一直带上,改变此参数会自动刷新表格数据)
const initParam = reactive<InitParam>({ category: visLogTypeOptions[0].value }); //主表格初始化参数
// 获取 ProTable 元素,调用其获取刷新数据方法(还能获取到当前查询参数,方便导出携带参数) const initParam = reactive<InitParam>({ category: visLogTypeOptions[0].value, searchKey: "" });
const proTable = ref<ProTableInstance>(); const proTable = ref<ProTableInstance>();
// 表格配置项 // 表格配置项
const columns: ColumnProps<VisLog.VisLogInfo>[] = [ const columns: ColumnProps<VisLog.VisLogInfo>[] = [
{ prop: "name", label: "名称" }, { prop: "name", label: "日志名称", minWidth: 150, align: "left" },
{ prop: "opIp", label: "IP地址" }, { prop: "category", label: "事件类型", width: 100, align: "center" },
{ prop: "opAddress", label: "地址" }, { prop: "opUser", label: "访问人员", minWidth: 140, align: "left" },
{ prop: "opBrowser", label: "浏览器" }, { prop: "opIp", label: "访问IP", width: 140, align: "center" },
{ prop: "opOs", label: "操作系统" }, { prop: "opAddress", label: "登录地点", minWidth: 130, align: "left" },
{ prop: "opUser", label: "用户" }, { prop: "opBrowser", label: "浏览器", minWidth: 130, showOverflowTooltip: true, align: "left" },
{ prop: "opAccount", label: "账号" }, { prop: "opOs", label: "操作系统", minWidth: 130, showOverflowTooltip: true, align: "left" },
{ prop: "opTime", label: "操作时间" }, { prop: "opTime", label: "访问时间", width: 170, align: "center" },
{ prop: "operation", label: "操作", fixed: "right", width: 100 } { prop: "operation", label: "操作", fixed: "right", width: 90, align: "center" }
]; ];
const detailRef = ref<InstanceType<typeof Detail> | null>(null); //详情引用 const detailRef = ref<InstanceType<typeof Detail> | null>(null);
function onCategoryChange() {
handleSearch();
}
function handleSearch() {
proTable.value?.pageable && (proTable.value.pageable.pageNum = 1);
proTable.value?.refresh();
}
/**
* 打开表单
* @param record 记录
*/
function onOpen(record: VisLog.VisLogInfo) { function onOpen(record: VisLog.VisLogInfo) {
detailRef.value?.onOpen(record); detailRef.value?.onOpen(record);
} }
/**
* 清空日志
*/
async function onDelete(msg: string) { async function onDelete(msg: string) {
// 二次确认 => 请求api => 刷新表格
await useHandleData(visLogApi.delete, initParam.category, msg); await useHandleData(visLogApi.delete, initParam.category, msg);
proTable.value?.refresh(); //刷新主表格 proTable.value?.refresh();
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.el-card__header) { .log-page-container {
padding: 9px; display: flex;
flex-direction: column;
gap: 8px;
}
.chart-card {
border-radius: 8px;
:deep(.el-card__header) {
padding: 12px 16px;
font-weight: 600;
font-size: 14px;
color: #1e293b;
border-bottom: 1px solid #f1f5f9;
}
}
.header-tools {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.search-btn {
cursor: pointer;
}
.font-medium {
font-weight: 500;
}
.user-cell {
display: flex;
align-items: center;
gap: 4px;
}
.user-name {
font-weight: 500;
}
.text-secondary {
color: #64748b;
font-size: 12px;
}
.ip-tag {
font-family: monospace;
}
.env-text {
font-size: 13px;
color: #475569;
} }
</style> </style>
@@ -68,6 +68,11 @@ function onOpen(props: FormProps.Base<Button.ButtonInfo>) {
if (props.record.id) { if (props.record.id) {
//如果传了id,就去请求api获取record //如果传了id,就去请求api获取record
buttonApi.detail({ id: props.record.id }).then(res => { buttonApi.detail({ id: props.record.id }).then(res => {
if (!res.data) {
ElMessage.error("按钮详情不存在或按钮ID无效,请刷新列表后重试");
onClose();
return;
}
buttonProps.record = res.data; buttonProps.record = res.data;
}); });
} }
@@ -5,31 +5,40 @@
!--> !-->
<template> <template>
<form-container v-model="visible" :title="`授权权限->${grantProps.name}`" form-size="1200px" @close="onClose"> <form-container v-model="visible" :title="`授权权限->${grantProps.name}`" form-size="1200px" @close="onClose">
<el-alert <div class="grant-permission-container">
title="注:此功能界面需要与代码查询条件配合使用,并非所有接口都需设置数据范围,多用于业务模块!" <el-alert
type="warning" title="注:此功能界面需要与代码查询条件配合使用,并非所有接口都需设置数据范围,多用于业务模块!"
show-icon type="warning"
style="margin-bottom: 10px" show-icon
/> style="margin-bottom: 10px"
<ProTable ref="proTable" :columns="columns" :tool-button="false" :data="grantProps.tableData" row-key="api"> />
<!-- 自定义api表头 --> <ProTable
<template #apiHeader> ref="proTable"
<el-row class="row-bg" justify="space-between"> :columns="columns"
<el-col :span="12"><el-checkbox @change="onCheckAllChange"> 接口 </el-checkbox></el-col> :tool-button="false"
<el-col :span="12"> <el-input v-model="searchValue" @input="searchApi" placeholder="请输入接口名称" /></el-col> :data="grantProps.tableData"
</el-row> row-key="api"
</template> max-height="560px"
<!-- 接口 --> >
<template #api="scope"> <!-- 自定义api表头 -->
<el-checkbox v-model="scope.row.check" @change="val => changeApi(scope.row, val)"> <template #apiHeader>
{{ scope.row.api }} <el-row class="row-bg" justify="space-between">
</el-checkbox> <el-col :span="12"><el-checkbox @change="onCheckAllChange"> 接口 </el-checkbox></el-col>
</template> <el-col :span="12"> <el-input v-model="searchValue" clearable @input="searchApi" placeholder="请输入接口名称" /></el-col>
<!-- 数据范围 --> </el-row>
<template #dataScope="scope"> </template>
<DataScopeSelector v-if="scope.row.check" v-model="scope.row.dataScope"></DataScopeSelector> <!-- 接口 -->
</template> <template #api="scope">
</ProTable> <el-checkbox v-model="scope.row.check" @change="val => changeApi(scope.row, val)">
{{ scope.row.api }}
</el-checkbox>
</template>
<!-- 数据范围 -->
<template #dataScope="scope">
<DataScopeSelector v-if="scope.row.check" v-model="scope.row.dataScope"></DataScopeSelector>
</template>
</ProTable>
</div>
<template #footer> <template #footer>
<el-button @click="onClose"> 取消 </el-button> <el-button @click="onClose"> 取消 </el-button>
<el-button type="primary" @click="handleSubmit"> 确定 </el-button> <el-button type="primary" @click="handleSubmit"> 确定 </el-button>
@@ -89,8 +98,11 @@ const columns: ColumnProps<SysRole.ResTreeSelector>[] = [
async function onOpen(props: FormProps.Base<SysRole.SysRoleInfo | SysUser.SysUserInfo>) { async function onOpen(props: FormProps.Base<SysRole.SysRoleInfo | SysUser.SysUserInfo>) {
grantProps.id = props.record.id || 0; grantProps.id = props.record.id || 0;
grantProps.name = props.record.name || ""; grantProps.name = props.record.name || "";
await loadData(); grantProps.tableData = [];
grantProps.permissionData = [];
searchValue.value = "";
visible.value = true; visible.value = true;
await loadData();
} }
/** 加载数据 */ /** 加载数据 */
@@ -110,6 +122,7 @@ async function loadData() {
* @param ownPermission 已拥有的权限 * @param ownPermission 已拥有的权限
*/ */
function echoModuleData(apiList: string[], ownPermission: SysRole.RoleOwnPermission) { function echoModuleData(apiList: string[], ownPermission: SysRole.RoleOwnPermission) {
grantProps.tableData = [];
//遍历api列表 //遍历api列表
apiList.forEach(item => { apiList.forEach(item => {
let data: SysRole.RoleGrantPermission = { let data: SysRole.RoleGrantPermission = {
@@ -132,23 +145,37 @@ function echoModuleData(apiList: string[], ownPermission: SysRole.RoleOwnPermiss
} }
grantProps.tableData.push(data); grantProps.tableData.push(data);
}); });
grantProps.permissionData = grantProps.tableData; //保存权限数据 grantProps.permissionData = [...grantProps.tableData]; //保存权限数据
} }
/** 搜索api /** 搜索api
* @param value 搜索值 * @param value 搜索值
*/ */
function searchApi(value: string) { function searchApi(value: string) {
console.log("[ value ] >", value); // 先把当前当前页面的修改同步到 permissionData 中
//如果搜索值为空,就显示全部 grantProps.permissionData.forEach(item => {
if (!value) { let data = grantProps.tableData.find(data => data.api === item.api);
grantProps.tableData = grantProps.permissionData; if (data) {
return; item.check = data.check;
} item.dataScope = data.dataScope;
//过滤api }
grantProps.tableData = grantProps.permissionData.filter(item => {
return item.api.indexOf(value) > -1;
}); });
const query = value ? value.trim().toLowerCase() : "";
//如果搜索值为空,就显示全部
if (!query) {
grantProps.tableData = [...grantProps.permissionData];
} else {
//过滤api (支持不区分大小写)
grantProps.tableData = grantProps.permissionData.filter(item => {
return item.api.toLowerCase().includes(query);
});
}
// 筛选后重置页码至第1页
if (proTable.value?.pageable) {
proTable.value.pageable.pageNum = 1;
}
} }
/** 提交之前转换数据 */ /** 提交之前转换数据 */
@@ -236,4 +263,15 @@ defineExpose({
}); });
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped>
.grant-permission-container {
display: flex;
flex-direction: column;
max-height: calc(85vh - 120px);
overflow-y: auto;
padding-right: 4px;
}
:deep(.el-table__body-wrapper) {
overflow-y: auto;
}
</style>
+278
View File
@@ -0,0 +1,278 @@
// Offline checks: execute the real composable and page setup code with stubbed APIs.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import vm from "node:vm";
import test from "node:test";
import { compileScript, parse } from "@vue/compiler-sfc";
import ts from "typescript";
import * as vue from "vue";
const require = createRequire(import.meta.url);
const root = new URL("../", import.meta.url);
const read = path => readFileSync(new URL(path, root), "utf8");
const deferred = () => {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
};
function harness(overrides = {}) {
const calls = [];
const api = new Proxy(overrides, {
get(target, method) {
return (...args) => {
calls.push({ method, args });
if (method in target) return target[method](...args);
if (method === "listCompanies") return Promise.resolve({ data: [{ id: 1, companyName: "A" }, { id: 2, companyName: "B" }] });
if (method === "listDepartments") return Promise.resolve({ data: [{ id: args[0] * 10, departmentName: "部门" }] });
if (method === "listPositionsByDepartment") return Promise.resolve({ data: [{ id: args[0] * 10, positionName: "岗位" }] });
if (method === "listMaterialTypes") return Promise.resolve({ data: [{ id: 1, status: "enable" }] });
if (method === "onboardingTemplatePage") return Promise.resolve({ data: { list: [], total: 0 } });
throw new Error("Unexpected API: " + method);
};
}
});
function evaluate(source, imports = {}) {
const output = ts.transpileModule(source.replaceAll("import.meta.env", "({})"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true }
}).outputText;
const module = { exports: {} };
vm.runInNewContext(output, {
module, exports: module.exports, console,
require(name) {
if (name in imports) return imports[name];
if (name === "vue") return vue;
if (name === "@/api/modules/biz/onboarding") return { onboardingTaskApi: api };
if (name === "@/hooks/useOnboardingScopeOptions") return scopeModule;
if (name === "@/utils/onboardingPermission") return {
useOnboardingPermission: () => ({ canManage: () => true, disabledReason: () => "", hasAction: () => true })
};
if (name === "@/utils/messageBox" || name === "@/utils") return {};
if (name === "element-plus") return { ElMessage: { success() {}, warning() {}, error() {} }, ElMessageBox: {} };
if (name === "@element-plus/icons-vue" || name.endsWith(".vue")) return {};
return require(name);
}
});
return module.exports;
}
const scopeModule = evaluate(read("src/hooks/useOnboardingScopeOptions.ts"));
function page(path, props = {}) {
const mounted = [];
const descriptor = parse(read(path), { filename: path }).descriptor;
const code = compileScript(descriptor, { id: path }).content;
const component = evaluate(code, { vue: { ...vue, onMounted: callback => mounted.push(callback), onBeforeUnmount() {} } }).default;
const effects = vue.effectScope();
const state = effects.run(() => component.setup(props, { expose() {} }));
return { state, mount: () => Promise.all(mounted.map(callback => callback())), stop: () => effects.stop() };
}
return { api, calls, scopeModule, page };
}
test("dependent options load only after selecting their parent, and reset makes no request", async () => {
const h = harness();
const selection = vue.reactive({});
const options = h.scopeModule.useOnboardingScopeOptions(() => selection);
assert.equal(h.calls.length, 0);
await options.onDepartmentChange(10);
assert.equal(h.calls.length, 0);
selection.companyId = 1;
await options.onCompanyChange(1);
assert.equal(h.calls.length, 1);
assert.equal(h.calls[0].method, "listDepartments");
assert.equal(options.positions.value.length, 0);
selection.departmentId = 10;
await options.onDepartmentChange(10);
assert.equal(h.calls[1].method, "listPositionsByDepartment");
selection.positionId = 100;
selection.companyId = undefined;
options.reset();
assert.equal(selection.departmentId, undefined);
assert.equal(selection.positionId, undefined);
assert.equal(options.departments.value.length, 0);
assert.equal(options.positions.value.length, 0);
assert.equal(h.calls.length, 2);
});
test("slow department responses cannot restore a previous company or survive reset", async () => {
const a = deferred(), b = deferred(), c = deferred();
const h = harness({ listDepartments: id => ({ 1: a, 2: b, 3: c })[id].promise });
const selection = { companyId: 1 };
const options = h.scopeModule.useOnboardingScopeOptions(() => selection);
const first = options.onCompanyChange(1);
selection.companyId = 2;
const second = options.onCompanyChange(2);
b.resolve({ data: [{ id: 20 }] });
await second;
a.resolve({ data: [{ id: 10 }] });
await first;
assert.equal(options.departments.value[0].id, 20);
selection.companyId = 3;
const third = options.onCompanyChange(3);
options.reset();
c.resolve({ data: [{ id: 30 }] });
await third;
assert.equal(options.departments.value.length, 0);
});
test("slow position responses cannot overwrite a new department; failure leaves options empty", async () => {
const a = deferred(), b = deferred();
const h = harness({ listPositionsByDepartment: id => (id === 10 ? a : b).promise });
const selection = { companyId: 1, departmentId: 10 };
const options = h.scopeModule.useOnboardingScopeOptions(() => selection);
const first = options.onDepartmentChange(10);
selection.departmentId = 20;
const second = options.onDepartmentChange(20);
b.resolve({ data: [{ id: 200 }] });
await second;
a.resolve({ data: [{ id: 100 }] });
await first;
assert.equal(options.positions.value[0].id, 200);
const failed = harness({ listDepartments: () => Promise.reject(new Error("offline")) });
const empty = failed.scopeModule.useOnboardingScopeOptions(() => ({ companyId: 1 }));
await empty.onCompanyChange(1);
assert.equal(empty.departments.value.length, 0);
});
for (const [name, tableName, columnName] of [
["contract", "templateTable", "templateColumns"],
["task", "proTable", "columns"],
["employee", "proTable", "columns"]
]) {
test(name + ": initialization, filter cascade and reset use only necessary requests", async () => {
const h = harness();
const page = h.page("src/views/biz/onboarding/" + name + "/index.vue");
const state = page.state;
state[tableName].value = { searchParam: {}, enumMap: new Map() };
await page.mount();
assert.equal(h.calls.length, 1);
assert.equal(h.calls[0].method, "listCompanies");
const department = state[columnName].find(item => item.prop === "departmentId");
const position = state[columnName].find(item => item.prop === "positionId");
assert.equal(department.search.props.disabled, true);
assert.equal(position.search.props.disabled, true);
state[tableName].value.searchParam.companyId = 1;
await state.onSearchCompanyChange(1);
assert.equal(department.search.props.disabled, false);
assert.equal(position.search.props.disabled, true);
assert.equal(state[tableName].value.enumMap.get("departmentId")[0].value, 10);
state[tableName].value.searchParam.departmentId = 10;
await state.onSearchDepartmentChange(10);
assert.equal(position.search.props.disabled, false);
assert.equal(state[tableName].value.enumMap.get("positionId")[0].value, 100);
state[tableName].value.searchParam = {};
state.resetSearchScope();
assert.equal(h.calls.length, 3);
assert.equal(state[tableName].value.enumMap.get("departmentId").length, 0);
assert.equal(state[tableName].value.enumMap.get("positionId").length, 0);
page.stop();
});
}
test("entry config loads no hidden data; material loads share one in-flight request", async () => {
const pending = deferred();
const h = harness({ listMaterialTypes: () => pending.promise });
const page = h.page("src/views/biz/onboarding/entry-config/index.vue");
await page.mount();
assert.equal(h.calls.length, 0);
page.state.activeTab.value = "materials";
await vue.nextTick();
const shared = page.state.loadMaterials();
assert.equal(h.calls.length, 1);
pending.resolve({ data: [{ id: 5 }] });
await shared;
assert.equal(page.state.materialTypes.value[0].id, 5);
page.stop();
});
test("task form fetches only the selected template's materials and preserves names and HR fields", async () => {
const h = harness({
onboardingTemplateDetail: () => Promise.resolve({ data: {
contracts: [{ contractTemplateId: 11, contractTemplateVersionId: 12, required: true }]
} }),
templateDetail: () => Promise.resolve({ data: {
id: 11, companyId: 1, departmentId: 10, status: "enable", templateName: "劳动合同"
} }),
variablePage: () => Promise.resolve({ data: { list: [{
variableCode: "hr.Salary", variableName: "薪资", variableType: "contract",
hrEditable: true, defaultValue: "5000", dataType: "number", required: true
}] } })
});
const page = h.page("src/views/biz/onboarding/task/index.vue");
await page.mount();
page.state.form.companyId = 1;
page.state.form.departmentId = 10;
await page.state.onOnboardingTemplateChange(7);
assert.equal(h.calls.length, 4);
assert.equal(h.calls[1].method, "onboardingTemplateDetail");
assert.equal(h.calls[2].method, "templateDetail");
assert.equal(h.calls[2].args[0].id, 11);
assert.equal(h.calls[3].method, "variablePage");
assert.equal(page.state.contractPreviews.value[0].contractName, "劳动合同");
assert.equal(page.state.form.contractVariables[0].variableValue, "5000");
assert.equal(page.state.form.contractVariables[0].contractName, "劳动合同");
assert.equal(page.state.contractConfigurationError.value, "");
page.stop();
});
test("entry template list loads just companies and its page; form data is deferred", async () => {
const h = harness();
let materials = 0;
const page = h.page("src/views/biz/onboarding/entry-config/components/OnboardingTemplatePanel.vue", {
loadMaterials: async () => { materials++; return [{ id: 7, status: "enable" }]; }
});
await page.mount();
assert.equal(h.calls.length, 2);
assert.equal(materials, 0);
page.state.query.companyId = 1;
await page.state.onQueryCompanyChange(1);
assert.equal(h.calls.length, 4);
assert.equal(h.calls[2].method, "listDepartments");
assert.equal(h.calls[3].method, "onboardingTemplatePage");
page.state.resetQuery();
await vue.nextTick();
assert.equal(h.calls.length, 5);
assert.equal(page.state.queryDepartments.value.length, 0);
await page.state.openCreate();
assert.equal(materials, 1);
assert.equal(page.state.materialTypes.value[0].id, 7);
assert.equal(h.calls.length, 5);
page.stop();
});
test("entry template list ignores older search responses", async () => {
const a = deferred(), b = deferred();
let count = 0;
const h = harness({ onboardingTemplatePage: () => (++count === 1 ? a : b).promise });
const page = h.page("src/views/biz/onboarding/entry-config/components/OnboardingTemplatePanel.vue", { loadMaterials: async () => [] });
const first = page.state.loadTemplates();
page.state.query.keyword = "new";
const second = page.state.loadTemplates();
b.resolve({ data: { list: [{ id: 2 }], total: 1 } });
await second;
a.resolve({ data: { list: [{ id: 1 }], total: 1 } });
await first;
assert.equal(page.state.rows.value[0].id, 2);
assert.equal(page.state.loading.value, false);
page.stop();
});
test("company setup defers options until needed and shares loading across fast tab changes", async () => {
const pending = deferred();
const h = harness({ listCompanies: () => pending.promise });
const page = h.page("src/views/biz/onboarding/setup/index.vue");
await page.mount();
assert.equal(h.calls.length, 0);
page.state.activeTab.value = "department";
await vue.nextTick();
page.state.activeTab.value = "position";
await vue.nextTick();
assert.equal(h.calls.length, 1);
pending.resolve({ data: [{ id: 1, companyName: "A" }] });
await page.state.loadCompanies();
page.state.activeTab.value = "department";
await vue.nextTick();
assert.equal(h.calls.length, 1);
page.stop();
});