Compare commits
29
Commits
446e83eed1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f183bfe508 | ||
|
|
b79034311b | ||
|
|
a2a163425c | ||
|
|
54ac8369b5 | ||
|
|
30ac8b51b5 | ||
|
|
6be0d4f54a | ||
|
|
ea2c823b75 | ||
|
|
c812071d8a | ||
|
|
2f0d04b982 | ||
|
|
7cffe57e4d | ||
|
|
04c621961c | ||
|
|
a0a181af57 | ||
|
|
d407f1c80b | ||
|
|
70bf1907b5 | ||
|
|
56e6d5a4d7 | ||
|
|
cbedb66bff | ||
|
|
d6d4fb2885 | ||
|
|
763c27d5c6 | ||
|
|
52c8ddc09e | ||
|
|
fd5b85e20f | ||
|
|
8d4bee03e8 | ||
|
|
c4daaae117 | ||
|
|
aa4961cd6e | ||
|
|
0e8fe2cbfe | ||
|
|
7de33e5f13 | ||
|
|
40010b5668 | ||
|
|
0624710136 | ||
|
|
5d1ec4b137 | ||
|
|
cdf2b0776a |
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = /
|
||||||
|
|
||||||
|
|||||||
@@ -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 = /
|
||||||
|
|
||||||
|
|||||||
+28
@@ -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
|
||||||
@@ -9,6 +9,7 @@ lerna-debug.log*
|
|||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
dist-*
|
||||||
dist-ssr
|
dist-ssr
|
||||||
stats.html
|
stats.html
|
||||||
*.local
|
*.local
|
||||||
|
|||||||
@@ -26,16 +26,16 @@ RUN pnpm install --frozen-lockfile --fetch-timeout 300000
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 构建项目
|
# 构建项目
|
||||||
RUN npm run build:pro
|
RUN npm run build:admin
|
||||||
|
|
||||||
# 部署阶段
|
# 部署阶段
|
||||||
FROM registry-vpc.cn-chengdu.aliyuncs.com/zhixueli-prod/nginx:alpine
|
FROM registry-vpc.cn-chengdu.aliyuncs.com/zhixueli-prod/nginx:alpine
|
||||||
|
|
||||||
# 复制构建产物到 Nginx 目录
|
# 复制构建产物到 Nginx 目录
|
||||||
COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html
|
COPY --from=builder --chown=nginx:nginx /app/dist-admin /usr/share/nginx/html
|
||||||
|
|
||||||
# 复制 Nginx 配置
|
# 复制 Nginx 配置
|
||||||
COPY Dockerfiles/default.conf /etc/nginx/conf.d/default.conf
|
COPY Dockerfiles/Dockerfile-admin/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;"]
|
||||||
@@ -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;"]
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
+3
-2
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -104,13 +104,6 @@ export interface OnboardingTaskCopyResult {
|
|||||||
tasks: OnboardingTaskCreatedResult[];
|
tasks: OnboardingTaskCreatedResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 员工端完成合同准备后返回的当前文件版本。 */
|
|
||||||
export interface OnboardingPreparedContract {
|
|
||||||
/** 当前生成合同对应的系统文件 Id,同时作为下载缓存版本。 */
|
|
||||||
fileId: number;
|
|
||||||
/** 当前合同文件生成时间。 */
|
|
||||||
generatedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 一次电子签流程中的单份合同文件。 */
|
/** 一次电子签流程中的单份合同文件。 */
|
||||||
export interface OnboardingElectronicSignDocument {
|
export interface OnboardingElectronicSignDocument {
|
||||||
@@ -122,6 +115,8 @@ export interface OnboardingElectronicSignDocument {
|
|||||||
originalDocxFileId?: number;
|
originalDocxFileId?: number;
|
||||||
signedPdfFileId?: number;
|
signedPdfFileId?: number;
|
||||||
employeeSignedPreviewed: boolean;
|
employeeSignedPreviewed: boolean;
|
||||||
|
companySealEnabled: boolean;
|
||||||
|
acrossPageSealEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 员工已签、企业未盖章文件的契约锁只读预览。 */
|
/** 员工已签、企业未盖章文件的契约锁只读预览。 */
|
||||||
@@ -252,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,6 +760,7 @@ export const onboardingTaskApi = {
|
|||||||
|
|
||||||
versionPage(params: {
|
versionPage(params: {
|
||||||
templateId: number;
|
templateId: number;
|
||||||
|
versionStatus?: string;
|
||||||
pageNum: number;
|
pageNum: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -788,6 +786,18 @@ 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) {
|
deleteVersion(id: number) {
|
||||||
return moduleRequest("/biz/onboarding/contract-template/").post(
|
return moduleRequest("/biz/onboarding/contract-template/").post(
|
||||||
"version/delete",
|
"version/delete",
|
||||||
@@ -964,34 +974,3 @@ export const onboardingTaskApi = {
|
|||||||
return moduleRequest("/biz/onboarding/variable-definition/").post("delete", { ids });
|
return moduleRequest("/biz/onboarding/variable-definition/").post("delete", { ids });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const publicHttp = moduleRequest("/open/onboarding/");
|
|
||||||
|
|
||||||
export const onboardingPublicApi = {
|
|
||||||
detail(params: { token: string }) {
|
|
||||||
return publicHttp.post<any>("detail", params);
|
|
||||||
},
|
|
||||||
saveEmployeeInfo(params: Record<string, unknown>) {
|
|
||||||
return publicHttp.post("save-employee-info", params);
|
|
||||||
},
|
|
||||||
confirmContract(params: { token: string; taskContractId: number }) {
|
|
||||||
return publicHttp.post("confirm-contract", params);
|
|
||||||
},
|
|
||||||
submit(params: { token: string }) {
|
|
||||||
return publicHttp.post("submit", params);
|
|
||||||
},
|
|
||||||
prepareContract(params: { token: string; taskContractId: number }) {
|
|
||||||
return publicHttp.post<OnboardingPreparedContract>("prepare-contract", params);
|
|
||||||
},
|
|
||||||
contractSourceUrl(params: { token: string; taskContractId: number; fileVersion?: number }) {
|
|
||||||
const version = params.fileVersion ? `&version=${params.fileVersion}` : `&_t=${Date.now()}`;
|
|
||||||
return `/api/open/onboarding/contract-source?token=${encodeURIComponent(params.token)}&taskContractId=${params.taskContractId}${version}`;
|
|
||||||
},
|
|
||||||
materialSourceUrl(params: { token: string; taskMaterialId: number; fileId?: number }) {
|
|
||||||
const version = params.fileId ? `&v=${params.fileId}` : "";
|
|
||||||
return `/api/open/onboarding/material-source?token=${encodeURIComponent(params.token)}&taskMaterialId=${params.taskMaterialId}${version}`;
|
|
||||||
},
|
|
||||||
deleteMaterial(params: { token: string; taskMaterialId: number }) {
|
|
||||||
return publicHttp.post("delete-material", params);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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 = "";
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 初始化动态路由
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+4
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ export function getIdCardBirthday(value: unknown) {
|
|||||||
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
|
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) {
|
export function isNumberInRange(value: unknown, min: number, max: number) {
|
||||||
const text = String(value ?? "").trim();
|
const text = String(value ?? "").trim();
|
||||||
if (!/^\d+(\.\d+)?$/.test(text)) {
|
if (!/^\d+(\.\d+)?$/.test(text)) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
title="签署材料模板列表"
|
title="签署材料模板列表"
|
||||||
:columns="templateColumns"
|
:columns="templateColumns"
|
||||||
:request-api="onboardingTaskApi.templatePage"
|
:request-api="onboardingTaskApi.templatePage"
|
||||||
|
@reset="resetSearchScope"
|
||||||
>
|
>
|
||||||
<template #tableHeader>
|
<template #tableHeader>
|
||||||
<el-button v-auth="'onboardingContractAdd'" type="primary" icon="Plus" @click="openTemplateCreate">
|
<el-button v-auth="'onboardingContractAdd'" type="primary" icon="Plus" @click="openTemplateCreate">
|
||||||
@@ -174,7 +175,7 @@
|
|||||||
<el-drawer
|
<el-drawer
|
||||||
v-model="drawerVisible"
|
v-model="drawerVisible"
|
||||||
:title="`签署材料模板管理 · ${selectedTemplate?.templateName || ''}`"
|
:title="`签署材料模板管理 · ${selectedTemplate?.templateName || ''}`"
|
||||||
size="1150px"
|
size="min(1500px, 96vw)"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
<div v-if="selectedTemplate" class="drawer-content-box">
|
<div v-if="selectedTemplate" class="drawer-content-box">
|
||||||
@@ -211,7 +212,7 @@
|
|||||||
上传新版本 DOCX
|
上传新版本 DOCX
|
||||||
</el-button>
|
</el-button>
|
||||||
<span class="pane-tip-text">
|
<span class="pane-tip-text">
|
||||||
支持多版本迭代。启用某个版本后,该版本的 DOCX 将用于生成新入职员工的签署材料。
|
支持多版本迭代。启用版本后用于生成新入职员工的签署材料;盖章开关的修改仅影响后续新建任务。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -230,8 +231,37 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="effectiveDate" label="生效日期" width="130" align="center" />
|
<el-table-column label="公司盖章" width="100" align="center">
|
||||||
<el-table-column prop="expiryDate" label="失效日期" width="130" align="center" />
|
<template #default="scope">
|
||||||
|
<el-switch
|
||||||
|
:model-value="scope.row.companySealEnabled === true"
|
||||||
|
:loading="sealUpdating[scope.row.id] === 'companySealEnabled'"
|
||||||
|
:disabled="!canEditVersionSeals || !!sealUpdating[scope.row.id]"
|
||||||
|
:title="versionSealDisabledReason"
|
||||||
|
aria-label="公司盖章"
|
||||||
|
@change="value => changeVersionSeal(scope.row, 'companySealEnabled', value)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="骑缝章" width="100" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-switch
|
||||||
|
:model-value="scope.row.acrossPageSealEnabled === true"
|
||||||
|
:loading="sealUpdating[scope.row.id] === 'acrossPageSealEnabled'"
|
||||||
|
:disabled="!canEditVersionSeals || !!sealUpdating[scope.row.id]"
|
||||||
|
:title="versionSealDisabledReason"
|
||||||
|
aria-label="骑缝章"
|
||||||
|
@change="value => changeVersionSeal(scope.row, 'acrossPageSealEnabled', value)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="生效时间" width="175" align="center">
|
||||||
|
<template #default="scope">{{ formatVersionTime(scope.row.effectiveDate) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="失效时间" width="175" align="center">
|
||||||
|
<template #default="scope">{{ formatVersionTime(scope.row.expiryDate) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="remark" label="版本说明" min-width="150" />
|
<el-table-column prop="remark" label="版本说明" min-width="150" />
|
||||||
|
|
||||||
<el-table-column label="操作" width="340" fixed="right" align="center">
|
<el-table-column label="操作" width="340" fixed="right" align="center">
|
||||||
@@ -311,6 +341,7 @@
|
|||||||
v-model="versionVisible"
|
v-model="versionVisible"
|
||||||
title="上传新版本 DOCX"
|
title="上传新版本 DOCX"
|
||||||
width="600px"
|
width="600px"
|
||||||
|
top="5vh"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
append-to-body
|
append-to-body
|
||||||
>
|
>
|
||||||
@@ -344,14 +375,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="生效日期">
|
<el-form-item label="生效时间">
|
||||||
<el-date-picker v-model="versionForm.effectiveDate" value-format="YYYY-MM-DD" class="w-full" />
|
<el-date-picker
|
||||||
|
v-model="versionForm.effectiveDate"
|
||||||
|
type="datetime"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
placeholder="请选择生效时间"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="失效日期">
|
<el-form-item label="失效时间">
|
||||||
<el-date-picker v-model="versionForm.expiryDate" value-format="YYYY-MM-DD" class="w-full" />
|
<el-date-picker
|
||||||
|
v-model="versionForm.expiryDate"
|
||||||
|
type="datetime"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
placeholder="请选择失效时间"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="公司盖章">
|
||||||
|
<el-switch v-model="versionForm.companySealEnabled" aria-label="公司盖章" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="骑缝章">
|
||||||
|
<el-switch v-model="versionForm.acrossPageSealEnabled" aria-label="骑缝章" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-alert
|
||||||
|
title="所有材料均需员工签字。公司盖章和骑缝章可在版本列表调整,修改仅影响后续新建任务,已有任务和签约保持原配置。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
/>
|
||||||
|
|
||||||
<el-form-item label="版本备注">
|
<el-form-item label="版本备注">
|
||||||
<el-input v-model="versionForm.remark" type="textarea" :rows="2" placeholder="本次版本修订说明" />
|
<el-input v-model="versionForm.remark" type="textarea" :rows="2" placeholder="本次版本修订说明" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -476,7 +534,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="变量类型" required>
|
<el-form-item label="变量类型" required>
|
||||||
<el-select v-model="variableForm.variableType" class="w-full">
|
<el-select v-model="variableForm.variableType" disabled class="w-full">
|
||||||
<el-option label="员工填写(从新员工基本信息/动态字段读取)" value="employee" />
|
<el-option label="员工填写(从新员工基本信息/动态字段读取)" value="employee" />
|
||||||
<el-option label="公司信息(从公司档案自动读取)" value="company" />
|
<el-option label="公司信息(从公司档案自动读取)" value="company" />
|
||||||
<el-option label="人事预设填写(人事发起时录入)" value="contract" />
|
<el-option label="人事预设填写(人事发起时录入)" value="contract" />
|
||||||
@@ -485,7 +543,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="数据类型" required>
|
<el-form-item label="数据类型" required>
|
||||||
<el-select v-model="variableForm.dataType" class="w-full">
|
<el-select v-model="variableForm.dataType" disabled class="w-full">
|
||||||
<el-option label="文本" value="string" />
|
<el-option label="文本" value="string" />
|
||||||
<el-option label="日期" value="date" />
|
<el-option label="日期" value="date" />
|
||||||
<el-option label="数字" value="number" />
|
<el-option label="数字" value="number" />
|
||||||
@@ -494,7 +552,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item v-if="['employee', 'company'].includes(variableForm.variableType || '')" label="取值字段">
|
<el-form-item v-if="['employee', 'company'].includes(variableForm.variableType || '')" label="取值字段">
|
||||||
<el-input v-model="variableForm.employeeField" placeholder="如:EmployeeName、IdCardNumber、Phone" />
|
<el-input v-model="variableForm.employeeField" disabled placeholder="如:EmployeeName、IdCardNumber、Phone" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="默认值">
|
<el-form-item label="默认值">
|
||||||
@@ -527,8 +585,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="onboardingContract">
|
<script setup lang="ts" name="onboardingContract">
|
||||||
|
import { useOnboardingScopeOptions } from "@/hooks/useOnboardingScopeOptions";
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import { Check } from "@element-plus/icons-vue";
|
import { Check } from "@element-plus/icons-vue";
|
||||||
import {
|
import {
|
||||||
onboardingTaskApi,
|
onboardingTaskApi,
|
||||||
@@ -542,8 +602,9 @@ import {
|
|||||||
import type { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
|
import type { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
|
||||||
import { isMessageBoxConfirmed } from "@/utils/messageBox";
|
import { isMessageBoxConfirmed } from "@/utils/messageBox";
|
||||||
import { useOnboardingPermission } from "@/utils/onboardingPermission";
|
import { useOnboardingPermission } from "@/utils/onboardingPermission";
|
||||||
|
import { copyToClipboard } from "@/utils";
|
||||||
|
|
||||||
const { canManage, disabledReason } = useOnboardingPermission("onboardingContractManageAll");
|
const { canManage, disabledReason, hasAction } = useOnboardingPermission("onboardingContractManageAll");
|
||||||
|
|
||||||
const templateTable = ref<ProTableInstance>();
|
const templateTable = ref<ProTableInstance>();
|
||||||
const selectedTemplate = ref<OnboardingContractTemplate>();
|
const selectedTemplate = ref<OnboardingContractTemplate>();
|
||||||
@@ -556,10 +617,19 @@ const companies = ref<OnboardingCompanyOption[]>([]);
|
|||||||
const departments = ref<OnboardingDepartmentOption[]>([]);
|
const departments = ref<OnboardingDepartmentOption[]>([]);
|
||||||
const positions = ref<OnboardingPositionOption[]>([]);
|
const positions = ref<OnboardingPositionOption[]>([]);
|
||||||
const searchCompanies = ref<OnboardingCompanyOption[]>([]);
|
const searchCompanies = ref<OnboardingCompanyOption[]>([]);
|
||||||
const searchDepartments = ref<OnboardingDepartmentOption[]>([]);
|
const {
|
||||||
const searchPositions = ref<OnboardingPositionOption[]>([]);
|
departments: searchDepartments,
|
||||||
const allSearchDepartments = ref<OnboardingDepartmentOption[]>([]);
|
positions: searchPositions,
|
||||||
const allSearchPositions = ref<OnboardingPositionOption[]>([]);
|
onCompanyChange: onSearchCompanyChange,
|
||||||
|
onDepartmentChange: onSearchDepartmentChange,
|
||||||
|
reset: resetSearchScope
|
||||||
|
} = useOnboardingScopeOptions(
|
||||||
|
() => templateTable.value?.searchParam,
|
||||||
|
() => {
|
||||||
|
templateTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
|
||||||
|
templateTable.value?.enumMap.set("positionId", positionSearchOptions.value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const companySearchOptions = computed(() =>
|
const companySearchOptions = computed(() =>
|
||||||
searchCompanies.value.map(c => ({
|
searchCompanies.value.map(c => ({
|
||||||
@@ -582,59 +652,17 @@ const positionSearchOptions = computed(() =>
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
async function onSearchCompanyChange(companyId?: number) {
|
|
||||||
if (companyId) {
|
|
||||||
const [depRes, posRes] = await Promise.all([
|
|
||||||
onboardingTaskApi.listDepartments(companyId).catch(() => ({ data: [] })),
|
|
||||||
onboardingTaskApi.listPositions(companyId).catch(() => ({ data: [] }))
|
|
||||||
]);
|
|
||||||
searchDepartments.value = depRes.data || [];
|
|
||||||
searchPositions.value = posRes.data || [];
|
|
||||||
} else {
|
|
||||||
searchDepartments.value = allSearchDepartments.value;
|
|
||||||
searchPositions.value = allSearchPositions.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
templateTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
|
|
||||||
templateTable.value?.enumMap.set("positionId", positionSearchOptions.value);
|
|
||||||
|
|
||||||
if (templateTable.value?.searchParam) {
|
|
||||||
const currentDept = templateTable.value.searchParam.departmentId;
|
|
||||||
if (currentDept && !searchDepartments.value.some(d => d.id === currentDept)) {
|
|
||||||
templateTable.value.searchParam.departmentId = undefined;
|
|
||||||
}
|
|
||||||
const currentPos = templateTable.value.searchParam.positionId;
|
|
||||||
if (currentPos && !searchPositions.value.some(p => p.id === currentPos)) {
|
|
||||||
templateTable.value.searchParam.positionId = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSearchDepartmentChange(departmentId?: number) {
|
|
||||||
if (departmentId) {
|
|
||||||
const posRes = await onboardingTaskApi.listPositionsByDepartment(departmentId).catch(() => ({ data: [] }));
|
|
||||||
searchPositions.value = posRes.data || [];
|
|
||||||
} else {
|
|
||||||
const selectedCompanyId = templateTable.value?.searchParam?.companyId;
|
|
||||||
if (selectedCompanyId) {
|
|
||||||
const posRes = await onboardingTaskApi.listPositions(selectedCompanyId).catch(() => ({ data: [] }));
|
|
||||||
searchPositions.value = posRes.data || [];
|
|
||||||
} else {
|
|
||||||
searchPositions.value = allSearchPositions.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templateTable.value?.enumMap.set("positionId", positionSearchOptions.value);
|
|
||||||
|
|
||||||
if (templateTable.value?.searchParam) {
|
|
||||||
const currentPos = templateTable.value.searchParam.positionId;
|
|
||||||
if (currentPos && !searchPositions.value.some(p => p.id === currentPos)) {
|
|
||||||
templateTable.value.searchParam.positionId = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const versions = ref<OnboardingContractVersion[]>([]);
|
const versions = ref<OnboardingContractVersion[]>([]);
|
||||||
|
type VersionSealField = "companySealEnabled" | "acrossPageSealEnabled";
|
||||||
|
const sealUpdating = reactive<Record<number, VersionSealField | undefined>>({});
|
||||||
|
const canEditVersionSeals = computed(() =>
|
||||||
|
canManage(selectedTemplate.value) && hasAction("onboardingContractEdit")
|
||||||
|
);
|
||||||
|
const versionSealDisabledReason = computed(() =>
|
||||||
|
!hasAction("onboardingContractEdit")
|
||||||
|
? "没有编辑签署材料模板的权限"
|
||||||
|
: disabledReason(selectedTemplate.value)
|
||||||
|
);
|
||||||
const versionVisible = ref(false);
|
const versionVisible = ref(false);
|
||||||
const variablesVisible = ref(false);
|
const variablesVisible = ref(false);
|
||||||
const variables = ref<OnboardingTemplateVariable[]>([]);
|
const variables = ref<OnboardingTemplateVariable[]>([]);
|
||||||
@@ -670,11 +698,18 @@ const emptyTemplate = (): Partial<OnboardingContractTemplate> => ({
|
|||||||
description: ""
|
description: ""
|
||||||
});
|
});
|
||||||
|
|
||||||
const emptyVersion = (): Partial<OnboardingContractVersion> => ({
|
type OnboardingVersionForm = Partial<OnboardingContractVersion> & {
|
||||||
|
companySealEnabled: boolean;
|
||||||
|
acrossPageSealEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyVersion = (): OnboardingVersionForm => ({
|
||||||
versionNo: "",
|
versionNo: "",
|
||||||
sourceDocxFileId: undefined,
|
sourceDocxFileId: undefined,
|
||||||
effectiveDate: undefined,
|
effectiveDate: undefined,
|
||||||
expiryDate: undefined,
|
expiryDate: undefined,
|
||||||
|
companySealEnabled: true,
|
||||||
|
acrossPageSealEnabled: true,
|
||||||
remark: ""
|
remark: ""
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -692,7 +727,7 @@ const emptyVariable = (): Partial<OnboardingTemplateVariable> => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const templateForm = reactive<Partial<OnboardingContractTemplate>>(emptyTemplate());
|
const templateForm = reactive<Partial<OnboardingContractTemplate>>(emptyTemplate());
|
||||||
const versionForm = reactive<Partial<OnboardingContractVersion>>(emptyVersion());
|
const versionForm = reactive<OnboardingVersionForm>(emptyVersion());
|
||||||
const variableForm = reactive<Partial<OnboardingTemplateVariable>>(emptyVariable());
|
const variableForm = reactive<Partial<OnboardingTemplateVariable>>(emptyVariable());
|
||||||
|
|
||||||
const templateColumns: ColumnProps<OnboardingContractTemplate>[] = [
|
const templateColumns: ColumnProps<OnboardingContractTemplate>[] = [
|
||||||
@@ -721,7 +756,12 @@ const templateColumns: ColumnProps<OnboardingContractTemplate>[] = [
|
|||||||
props: {
|
props: {
|
||||||
filterable: true,
|
filterable: true,
|
||||||
clearable: true,
|
clearable: true,
|
||||||
placeholder: "请选择所属部门",
|
get placeholder() {
|
||||||
|
return templateTable.value?.searchParam?.companyId ? "请选择所属部门" : "请先选择所属公司";
|
||||||
|
},
|
||||||
|
get disabled() {
|
||||||
|
return !templateTable.value?.searchParam?.companyId;
|
||||||
|
},
|
||||||
onChange: (val?: number) => onSearchDepartmentChange(val)
|
onChange: (val?: number) => onSearchDepartmentChange(val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -733,7 +773,16 @@ const templateColumns: ColumnProps<OnboardingContractTemplate>[] = [
|
|||||||
enum: positionSearchOptions,
|
enum: positionSearchOptions,
|
||||||
search: {
|
search: {
|
||||||
el: "select",
|
el: "select",
|
||||||
props: { filterable: true, clearable: true, placeholder: "请选择适用岗位" }
|
props: {
|
||||||
|
filterable: true,
|
||||||
|
clearable: true,
|
||||||
|
get placeholder() {
|
||||||
|
return templateTable.value?.searchParam?.departmentId ? "请选择适用岗位" : "请先选择所属部门";
|
||||||
|
},
|
||||||
|
get disabled() {
|
||||||
|
return !templateTable.value?.searchParam?.departmentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ prop: "templateCode", label: "模板编码", minWidth: 160, search: { el: "input" } },
|
{ prop: "templateCode", label: "模板编码", minWidth: 160, search: { el: "input" } },
|
||||||
@@ -891,10 +940,21 @@ async function uploadDocx(options: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatVersionTime(value?: string) {
|
||||||
|
return value ? dayjs(value).format("YYYY-MM-DD HH:mm:ss") : "--";
|
||||||
|
}
|
||||||
|
|
||||||
async function saveVersion() {
|
async function saveVersion() {
|
||||||
if (!selectedTemplate.value || !versionForm.versionNo || !versionForm.sourceDocxFileId) {
|
if (!selectedTemplate.value || !versionForm.versionNo || !versionForm.sourceDocxFileId) {
|
||||||
return ElMessage.warning("请填写版本号并上传 DOCX 文件");
|
return ElMessage.warning("请填写版本号并上传 DOCX 文件");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
versionForm.effectiveDate
|
||||||
|
&& versionForm.expiryDate
|
||||||
|
&& !dayjs(versionForm.expiryDate).isAfter(dayjs(versionForm.effectiveDate))
|
||||||
|
) {
|
||||||
|
return ElMessage.warning("失效时间必须晚于生效时间");
|
||||||
|
}
|
||||||
await onboardingTaskApi.addVersion({ ...versionForm, templateId: selectedTemplate.value.id });
|
await onboardingTaskApi.addVersion({ ...versionForm, templateId: selectedTemplate.value.id });
|
||||||
versionVisible.value = false;
|
versionVisible.value = false;
|
||||||
await loadVersions();
|
await loadVersions();
|
||||||
@@ -907,6 +967,27 @@ async function changeVersionStatus(row: OnboardingContractVersion, status: strin
|
|||||||
ElMessage.success(`版本状态已更新为:${versionText(status)}`);
|
ElMessage.success(`版本状态已更新为:${versionText(status)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeVersionSeal(
|
||||||
|
row: OnboardingContractVersion,
|
||||||
|
field: VersionSealField,
|
||||||
|
enabled: string | number | boolean
|
||||||
|
) {
|
||||||
|
if (typeof enabled !== "boolean" || !canEditVersionSeals.value || sealUpdating[row.id]) return;
|
||||||
|
|
||||||
|
sealUpdating[row.id] = field;
|
||||||
|
try {
|
||||||
|
// 只提交当前开关;另一个开关和历史任务快照均保持不变。
|
||||||
|
await onboardingTaskApi.setVersionSeals({ id: row.id, [field]: enabled });
|
||||||
|
const currentRow = versions.value.find(item => item.id === row.id);
|
||||||
|
if (currentRow) currentRow[field] = enabled;
|
||||||
|
ElMessage.success(`${field === "companySealEnabled" ? "公司盖章" : "骑缝章"}已${enabled ? "开启" : "关闭"},仅对后续新建任务生效`);
|
||||||
|
} catch {
|
||||||
|
// 请求层统一显示错误;保存失败时保留原开关状态。
|
||||||
|
} finally {
|
||||||
|
delete sealUpdating[row.id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeVersion(row: OnboardingContractVersion) {
|
async function removeVersion(row: OnboardingContractVersion) {
|
||||||
const confirmed = await isMessageBoxConfirmed(
|
const confirmed = await isMessageBoxConfirmed(
|
||||||
ElMessageBox.confirm(`确认删除签署材料版本“${row.versionNo}”?此操作不可撤销。`, "删除确认", { type: "warning" })
|
ElMessageBox.confirm(`确认删除签署材料版本“${row.versionNo}”?此操作不可撤销。`, "删除确认", { type: "warning" })
|
||||||
@@ -997,9 +1078,13 @@ function formatPlaceholder(code: string) {
|
|||||||
return `{{${code}}}`;
|
return `{{${code}}}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyPlaceholder(code: string) {
|
async function copyPlaceholder(code: string) {
|
||||||
navigator.clipboard.writeText(`{{${code}}}`);
|
const ok = await copyToClipboard(`{{${code}}}`);
|
||||||
ElMessage.success(`占位符 {{${code}}} 已复制到剪贴板`);
|
if (ok) {
|
||||||
|
ElMessage.success(`占位符 {{${code}}} 已复制到剪贴板`);
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(`自动复制失败,请手动复制 {{${code}}}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openVariables(row: OnboardingContractVersion) {
|
async function openVariables(row: OnboardingContractVersion) {
|
||||||
@@ -1026,43 +1111,9 @@ async function saveVariable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadSearchOptions() {
|
async function loadSearchOptions() {
|
||||||
try {
|
await loadCompanies();
|
||||||
await loadCompanies();
|
searchCompanies.value = companies.value;
|
||||||
searchCompanies.value = companies.value;
|
templateTable.value?.enumMap.set("companyId", companySearchOptions.value);
|
||||||
|
|
||||||
const deptList: OnboardingDepartmentOption[] = [];
|
|
||||||
const posList: OnboardingPositionOption[] = [];
|
|
||||||
|
|
||||||
if (companies.value && companies.value.length > 0) {
|
|
||||||
const promises = companies.value.map(async comp => {
|
|
||||||
const [depRes, posRes] = await Promise.all([
|
|
||||||
onboardingTaskApi.listDepartments(comp.id).catch(() => ({ data: [] })),
|
|
||||||
onboardingTaskApi.listPositions(comp.id).catch(() => ({ data: [] }))
|
|
||||||
]);
|
|
||||||
if (depRes.data) deptList.push(...depRes.data);
|
|
||||||
if (posRes.data) posList.push(...posRes.data);
|
|
||||||
});
|
|
||||||
await Promise.all(promises);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueDeptMap = new Map<number, OnboardingDepartmentOption>();
|
|
||||||
deptList.forEach(d => uniqueDeptMap.set(d.id, d));
|
|
||||||
const finalDepts = Array.from(uniqueDeptMap.values());
|
|
||||||
allSearchDepartments.value = finalDepts;
|
|
||||||
searchDepartments.value = finalDepts;
|
|
||||||
|
|
||||||
const uniquePosMap = new Map<number, OnboardingPositionOption>();
|
|
||||||
posList.forEach(p => uniquePosMap.set(p.id, p));
|
|
||||||
const finalPositions = Array.from(uniquePosMap.values());
|
|
||||||
allSearchPositions.value = finalPositions;
|
|
||||||
searchPositions.value = finalPositions;
|
|
||||||
|
|
||||||
templateTable.value?.enumMap.set("companyId", companySearchOptions.value);
|
|
||||||
templateTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
|
|
||||||
templateTable.value?.enumMap.set("positionId", positionSearchOptions.value);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("加载签署材料搜索选项失败", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadSearchOptions);
|
onMounted(loadSearchOptions);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="table-box">
|
<div class="table-box">
|
||||||
<ProTable ref="proTable" title="员工档案" :columns="columns" :request-api="getTableData">
|
<ProTable ref="proTable" title="员工档案" :columns="columns" :request-api="getTableData" @reset="resetSearchScope">
|
||||||
<template #tableHeader>
|
<template #tableHeader>
|
||||||
<div class="employee-table-toolbar">
|
<div class="employee-table-toolbar">
|
||||||
<el-button v-auth="'onboardingEmployeeExport'" type="primary" plain icon="Download" :loading="exporting" @click="exportEmployees">
|
<el-button v-auth="'onboardingEmployeeExport'" type="primary" plain icon="Download" :loading="exporting" @click="exportEmployees">
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
<el-descriptions-item label="所属岗位">{{
|
<el-descriptions-item label="所属岗位">{{
|
||||||
detail.employee.positionName || employeeInfo.onboardingPosition || "-"
|
detail.employee.positionName || employeeInfo.onboardingPosition || "-"
|
||||||
}}</el-descriptions-item>
|
}}</el-descriptions-item>
|
||||||
<el-descriptions-item label="员工编号">{{ detail.employee.employeeNo || employeeInfo.employeeNo || "-" }}</el-descriptions-item>
|
<el-descriptions-item label="员工编号">{{ resolvedEmployeeNo }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="入职日期">{{ formatDate(detail.employee.entryDate) }}</el-descriptions-item>
|
<el-descriptions-item label="入职日期">{{ formatDate(detail.employee.entryDate) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="员工司龄">{{ formatSeniority(detail.employee.entryDate) }}</el-descriptions-item>
|
<el-descriptions-item label="员工司龄">{{ formatSeniority(detail.employee.entryDate) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="用工类型">{{ employmentTypeText(detail.employee.employmentType) }}</el-descriptions-item>
|
<el-descriptions-item label="用工类型">{{ employmentTypeText(detail.employee.employmentType) }}</el-descriptions-item>
|
||||||
@@ -243,19 +243,54 @@
|
|||||||
:key="record.id"
|
:key="record.id"
|
||||||
:timestamp="formatTime(record.createTime)"
|
:timestamp="formatTime(record.createTime)"
|
||||||
placement="top"
|
placement="top"
|
||||||
type="primary"
|
:type="changeTimelineType(record.changeType)"
|
||||||
>
|
>
|
||||||
<div class="change-record-card">
|
<div class="change-record-card">
|
||||||
|
<!-- 顶部信息:操作类型标签 + 生效日期 + 操作人 -->
|
||||||
<div class="change-record-header">
|
<div class="change-record-header">
|
||||||
<strong>{{ changeTypeText(record.changeType) }}</strong>
|
<div class="change-record-title-wrap">
|
||||||
<span>{{ record.createUser || "系统" }}</span>
|
<el-tag :type="changeTypeTagType(record.changeType)" size="default" effect="light" class="change-type-tag">
|
||||||
|
{{ changeTypeText(record.changeType) }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="record.effectiveDate" class="change-effective-badge">
|
||||||
|
<el-icon class="mr-1"><Calendar /></el-icon>生效于 {{ formatDate(record.effectiveDate) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="change-operator">
|
||||||
|
<el-icon class="operator-icon"><User /></el-icon>
|
||||||
|
<span class="operator-name">{{ record.createUser || "系统" }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="record.effectiveDate" class="change-record-line">
|
|
||||||
生效日期:{{ formatDate(record.effectiveDate) }}
|
<!-- 变更明细:轻量 Diff 流向列表 -->
|
||||||
|
<div v-if="hasChangeSnapshot(record)" class="change-diff-flow">
|
||||||
|
<div
|
||||||
|
v-for="diff in changeDiffRows(record)"
|
||||||
|
:key="diff.key"
|
||||||
|
class="change-diff-item"
|
||||||
|
>
|
||||||
|
<span class="diff-field-label">{{ diff.label }}</span>
|
||||||
|
<div class="diff-values">
|
||||||
|
<span class="diff-val-before">{{ diff.beforeText }}</span>
|
||||||
|
<el-icon class="diff-arrow"><Right /></el-icon>
|
||||||
|
<span class="diff-val-after">{{ diff.afterText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!changeDiffRows(record).length" class="change-diff-none">
|
||||||
|
前后字段值无实质差异
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="record.remark" class="change-record-line">
|
<div v-else class="change-record-empty">
|
||||||
备注:{{ record.remark }}
|
该记录未保存修改前后快照
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 真实备注(过滤掉无意义系统默认同义反复文案) -->
|
||||||
|
<div v-if="shouldShowRemark(record)" class="change-remark-box">
|
||||||
|
<el-icon class="remark-icon"><ChatDotRound /></el-icon>
|
||||||
|
<span class="remark-text">{{ record.remark }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 附件列表 -->
|
||||||
<div v-if="record.attachments?.length" class="change-attachments">
|
<div v-if="record.attachments?.length" class="change-attachments">
|
||||||
<el-button
|
<el-button
|
||||||
v-for="file in record.attachments"
|
v-for="file in record.attachments"
|
||||||
@@ -319,7 +354,7 @@
|
|||||||
<span v-if="isImageMaterial(item)" class="preview-hint">
|
<span v-if="isImageMaterial(item)" class="preview-hint">
|
||||||
{{ materialPreviewUrls[item.fileId] ? "点击图片可放大预览" : materialPreviewText(item) }}
|
{{ materialPreviewUrls[item.fileId] ? "点击图片可放大预览" : materialPreviewText(item) }}
|
||||||
</span>
|
</span>
|
||||||
<el-button link type="primary" icon="Download" @click="download(item.fileId, materialFileName(item))"> 下载原件 </el-button>
|
<el-button link type="primary" icon="Download" @click="download(item.fileId, materialDownloadName(item))"> 下载原件 </el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,12 +404,7 @@
|
|||||||
plain
|
plain
|
||||||
size="small"
|
size="small"
|
||||||
icon="Download"
|
icon="Download"
|
||||||
@click="
|
@click="download(contract.generatedDocxFileId, contractDownloadName(contract, 'docx'))"
|
||||||
download(
|
|
||||||
contract.generatedDocxFileId,
|
|
||||||
`${detail?.employee?.employeeName || '员工'}_${contract.contractName || '入职合同'}.docx`
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
下载生成 DOCX
|
下载生成 DOCX
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -461,6 +491,7 @@
|
|||||||
filterable
|
filterable
|
||||||
allow-create
|
allow-create
|
||||||
clearable
|
clearable
|
||||||
|
:value-on-clear="''"
|
||||||
default-first-option
|
default-first-option
|
||||||
placeholder="请选择或输入合同性质"
|
placeholder="请选择或输入合同性质"
|
||||||
class="full-width"
|
class="full-width"
|
||||||
@@ -523,6 +554,7 @@
|
|||||||
v-model:file-list="regularizationFiles"
|
v-model:file-list="regularizationFiles"
|
||||||
multiple
|
multiple
|
||||||
:limit="10"
|
:limit="10"
|
||||||
|
:on-exceed="handleRegularizationExceed"
|
||||||
:http-request="uploadRegularizationFile"
|
:http-request="uploadRegularizationFile"
|
||||||
:before-upload="beforeRegularizationUpload"
|
:before-upload="beforeRegularizationUpload"
|
||||||
:on-remove="removeRegularizationFile"
|
:on-remove="removeRegularizationFile"
|
||||||
@@ -544,14 +576,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="onboardingEmployee">
|
<script setup lang="ts" name="onboardingEmployee">
|
||||||
|
import { useOnboardingScopeOptions } from "@/hooks/useOnboardingScopeOptions";
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||||
import {
|
import {
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
Calendar,
|
||||||
|
ChatDotRound,
|
||||||
Clock,
|
Clock,
|
||||||
Connection,
|
Connection,
|
||||||
Document,
|
Document,
|
||||||
Download,
|
Download,
|
||||||
FirstAidKit,
|
FirstAidKit,
|
||||||
|
Right,
|
||||||
User,
|
User,
|
||||||
UserFilled,
|
UserFilled,
|
||||||
WarningFilled
|
WarningFilled
|
||||||
@@ -561,9 +597,7 @@ import type { UploadFile, UploadRequestOptions, UploadUserFile } from "element-p
|
|||||||
import {
|
import {
|
||||||
onboardingTaskApi,
|
onboardingTaskApi,
|
||||||
type OnboardingEmployee,
|
type OnboardingEmployee,
|
||||||
type OnboardingCompanyOption,
|
type OnboardingCompanyOption
|
||||||
type OnboardingDepartmentOption,
|
|
||||||
type OnboardingPositionOption
|
|
||||||
} from "@/api/modules/biz/onboarding";
|
} from "@/api/modules/biz/onboarding";
|
||||||
import type { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
|
import type { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
|
||||||
import { getMessageBoxPromptResult, isMessageBoxConfirmed } from "@/utils/messageBox";
|
import { getMessageBoxPromptResult, isMessageBoxConfirmed } from "@/utils/messageBox";
|
||||||
@@ -729,6 +763,34 @@ const regularizationForm = reactive({
|
|||||||
remark: ""
|
remark: ""
|
||||||
});
|
});
|
||||||
const employeeInfo = computed<Record<string, any>>(() => detail.value?.employeeInfo || {});
|
const employeeInfo = computed<Record<string, any>>(() => detail.value?.employeeInfo || {});
|
||||||
|
|
||||||
|
const resolvedEmployeeNo = computed(() => {
|
||||||
|
if (detail.value?.employee?.employeeNo && String(detail.value.employee.employeeNo).trim()) {
|
||||||
|
return String(detail.value.employee.employeeNo).trim();
|
||||||
|
}
|
||||||
|
const snapshots = detail.value?.contractSnapshots || [];
|
||||||
|
for (const item of snapshots) {
|
||||||
|
if (item?.employeeNo && String(item.employeeNo).trim()) {
|
||||||
|
return String(item.employeeNo).trim();
|
||||||
|
}
|
||||||
|
if (item?.variablesJson) {
|
||||||
|
try {
|
||||||
|
const vars = typeof item.variablesJson === "string" ? JSON.parse(item.variablesJson) : item.variablesJson;
|
||||||
|
const target = vars?.["hr.EmployeeNo"] || vars?.["hr.employeeno"];
|
||||||
|
const val = typeof target === "object" ? target?.variableValue : target;
|
||||||
|
if (val && String(val).trim()) {
|
||||||
|
return String(val).trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略变量快照反序列化异常
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (employeeInfo.value?.employeeNo && String(employeeInfo.value.employeeNo).trim()) {
|
||||||
|
return String(employeeInfo.value.employeeNo).trim();
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
});
|
||||||
const materialPreviewUrls = reactive<Record<number, string>>({});
|
const materialPreviewUrls = reactive<Record<number, string>>({});
|
||||||
const materialPreviewLoading = reactive<Record<number, boolean>>({});
|
const materialPreviewLoading = reactive<Record<number, boolean>>({});
|
||||||
const materialPreviewFailed = reactive<Record<number, boolean>>({});
|
const materialPreviewFailed = reactive<Record<number, boolean>>({});
|
||||||
@@ -743,10 +805,19 @@ const imageMimeTypes: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const searchCompanies = ref<OnboardingCompanyOption[]>([]);
|
const searchCompanies = ref<OnboardingCompanyOption[]>([]);
|
||||||
const searchDepartments = ref<OnboardingDepartmentOption[]>([]);
|
const {
|
||||||
const searchPositions = ref<OnboardingPositionOption[]>([]);
|
departments: searchDepartments,
|
||||||
const allSearchDepartments = ref<OnboardingDepartmentOption[]>([]);
|
positions: searchPositions,
|
||||||
const allSearchPositions = ref<OnboardingPositionOption[]>([]);
|
onCompanyChange: onSearchCompanyChange,
|
||||||
|
onDepartmentChange: onSearchDepartmentChange,
|
||||||
|
reset: resetSearchScope
|
||||||
|
} = useOnboardingScopeOptions(
|
||||||
|
() => proTable.value?.searchParam,
|
||||||
|
() => {
|
||||||
|
proTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
|
||||||
|
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const companySearchOptions = computed(() =>
|
const companySearchOptions = computed(() =>
|
||||||
searchCompanies.value.map(c => ({
|
searchCompanies.value.map(c => ({
|
||||||
@@ -769,58 +840,6 @@ const positionSearchOptions = computed(() =>
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
async function onSearchCompanyChange(companyId?: number) {
|
|
||||||
if (companyId) {
|
|
||||||
const [depRes, posRes] = await Promise.all([
|
|
||||||
onboardingTaskApi.listDepartments(companyId).catch(() => ({ data: [] })),
|
|
||||||
onboardingTaskApi.listPositions(companyId).catch(() => ({ data: [] }))
|
|
||||||
]);
|
|
||||||
searchDepartments.value = depRes.data || [];
|
|
||||||
searchPositions.value = posRes.data || [];
|
|
||||||
} else {
|
|
||||||
searchDepartments.value = allSearchDepartments.value;
|
|
||||||
searchPositions.value = allSearchPositions.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
proTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
|
|
||||||
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
|
|
||||||
|
|
||||||
if (proTable.value?.searchParam) {
|
|
||||||
const currentDept = proTable.value.searchParam.departmentId;
|
|
||||||
if (currentDept && !searchDepartments.value.some(d => d.id === currentDept)) {
|
|
||||||
proTable.value.searchParam.departmentId = undefined;
|
|
||||||
}
|
|
||||||
const currentPos = proTable.value.searchParam.positionId;
|
|
||||||
if (currentPos && !searchPositions.value.some(p => p.id === currentPos)) {
|
|
||||||
proTable.value.searchParam.positionId = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSearchDepartmentChange(departmentId?: number) {
|
|
||||||
if (departmentId) {
|
|
||||||
const posRes = await onboardingTaskApi.listPositionsByDepartment(departmentId).catch(() => ({ data: [] }));
|
|
||||||
searchPositions.value = posRes.data || [];
|
|
||||||
} else {
|
|
||||||
const selectedCompanyId = proTable.value?.searchParam?.companyId;
|
|
||||||
if (selectedCompanyId) {
|
|
||||||
const posRes = await onboardingTaskApi.listPositions(selectedCompanyId).catch(() => ({ data: [] }));
|
|
||||||
searchPositions.value = posRes.data || [];
|
|
||||||
} else {
|
|
||||||
searchPositions.value = allSearchPositions.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
|
|
||||||
|
|
||||||
if (proTable.value?.searchParam) {
|
|
||||||
const currentPos = proTable.value.searchParam.positionId;
|
|
||||||
if (currentPos && !searchPositions.value.some(p => p.id === currentPos)) {
|
|
||||||
proTable.value.searchParam.positionId = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonList(value?: string) {
|
function parseJsonList(value?: string) {
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
|
|
||||||
@@ -894,19 +913,69 @@ function contractStatusText(status?: string) {
|
|||||||
return map[status || ""] || status || "未知状态";
|
return map[status || ""] || status || "未知状态";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeDecodeFileName(name: string): string {
|
||||||
|
let decoded = String(name || "").trim();
|
||||||
|
try {
|
||||||
|
while (/%[0-9a-fA-F]{2}/.test(decoded)) {
|
||||||
|
const next = decodeURIComponent(decoded.replace(/\+/g, "%20"));
|
||||||
|
if (next === decoded) break;
|
||||||
|
decoded = next;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略异常,保留当前值
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
function safeDownloadName(value: string) {
|
function safeDownloadName(value: string) {
|
||||||
return value.replace(/[\\/:*?"<>|]/g, "_");
|
return safeDecodeFileName(value)
|
||||||
|
.replace(/[\/\\:*?"<>|]+/g, "_")
|
||||||
|
.replace(/\s+/g, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmployeeDownloadPrefix(): string {
|
||||||
|
const empNo = resolvedEmployeeNo.value !== "-" ? resolvedEmployeeNo.value : "";
|
||||||
|
const taskNo = String(
|
||||||
|
detail.value?.task?.taskNo
|
||||||
|
|| empNo
|
||||||
|
|| ""
|
||||||
|
).trim();
|
||||||
|
const employeeName = String(
|
||||||
|
detail.value?.employee?.employeeName
|
||||||
|
|| detail.value?.employeeInfo?.employeeName
|
||||||
|
|| "员工"
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const safeNo = safeDownloadName(taskNo);
|
||||||
|
const safeName = safeDownloadName(employeeName);
|
||||||
|
|
||||||
|
if (safeNo) {
|
||||||
|
return `${safeNo}_${safeName}`;
|
||||||
|
}
|
||||||
|
return safeName;
|
||||||
}
|
}
|
||||||
|
|
||||||
function contractDownloadName(contract: any, extension: "pdf" | "docx") {
|
function contractDownloadName(contract: any, extension: "pdf" | "docx") {
|
||||||
const employeeName = detail.value?.employee?.employeeName || "员工";
|
const prefix = getEmployeeDownloadPrefix();
|
||||||
const contractName = contract?.contractName || "入职合同";
|
const docType = String(
|
||||||
return safeDownloadName(employeeName + "_" + contractName) + "." + extension;
|
contract?.contractType
|
||||||
|
|| contract?.contractName
|
||||||
|
|| "入职合同"
|
||||||
|
).trim();
|
||||||
|
return `${prefix}_${safeDownloadName(docType)}.${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function materialDownloadName(item: any) {
|
||||||
|
const prefix = getEmployeeDownloadPrefix();
|
||||||
|
const materialType = String(item?.materialName || "资料文件").trim();
|
||||||
|
const suffix = materialFileSuffix(item) || "png";
|
||||||
|
return `${prefix}_${safeDownloadName(materialType)}.${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function electronicSignArchiveName() {
|
function electronicSignArchiveName() {
|
||||||
const employeeName = detail.value?.employee?.employeeName || "员工";
|
const prefix = getEmployeeDownloadPrefix();
|
||||||
return safeDownloadName(employeeName + "_电子签完整归档") + ".zip";
|
return `${prefix}_电子签完整归档.zip`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearMaterialPreviews() {
|
function clearMaterialPreviews() {
|
||||||
@@ -970,7 +1039,12 @@ const columns: ColumnProps<OnboardingEmployee>[] = [
|
|||||||
props: {
|
props: {
|
||||||
filterable: true,
|
filterable: true,
|
||||||
clearable: true,
|
clearable: true,
|
||||||
placeholder: "请选择所属部门",
|
get placeholder() {
|
||||||
|
return proTable.value?.searchParam?.companyId ? "请选择所属部门" : "请先选择所属公司";
|
||||||
|
},
|
||||||
|
get disabled() {
|
||||||
|
return !proTable.value?.searchParam?.companyId;
|
||||||
|
},
|
||||||
onChange: (val?: number) => onSearchDepartmentChange(val)
|
onChange: (val?: number) => onSearchDepartmentChange(val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -982,7 +1056,16 @@ const columns: ColumnProps<OnboardingEmployee>[] = [
|
|||||||
enum: positionSearchOptions,
|
enum: positionSearchOptions,
|
||||||
search: {
|
search: {
|
||||||
el: "select",
|
el: "select",
|
||||||
props: { filterable: true, clearable: true, placeholder: "请选择入职岗位" }
|
props: {
|
||||||
|
filterable: true,
|
||||||
|
clearable: true,
|
||||||
|
get placeholder() {
|
||||||
|
return proTable.value?.searchParam?.departmentId ? "请选择入职岗位" : "请先选择所属部门";
|
||||||
|
},
|
||||||
|
get disabled() {
|
||||||
|
return !proTable.value?.searchParam?.departmentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1115,6 +1198,128 @@ function changeTypeText(value?: string) {
|
|||||||
return map[value || ""] || value || "人事变更";
|
return map[value || ""] || value || "人事变更";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeTypeTagType(value?: string): "primary" | "success" | "warning" | "danger" | "info" {
|
||||||
|
switch (value) {
|
||||||
|
case "regularization":
|
||||||
|
return "success";
|
||||||
|
case "regularization_revoke":
|
||||||
|
return "warning";
|
||||||
|
case "hr_info_update":
|
||||||
|
return "primary";
|
||||||
|
default:
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeTimelineType(value?: string): "primary" | "success" | "warning" | "info" {
|
||||||
|
switch (value) {
|
||||||
|
case "regularization":
|
||||||
|
return "success";
|
||||||
|
case "regularization_revoke":
|
||||||
|
return "warning";
|
||||||
|
default:
|
||||||
|
return "primary";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowRemark(record: any) {
|
||||||
|
if (!record?.remark || !String(record.remark).trim()) return false;
|
||||||
|
const remark = String(record.remark).trim();
|
||||||
|
const defaultRemarks = [
|
||||||
|
"编辑员工人事信息",
|
||||||
|
"办理员工转正",
|
||||||
|
"撤销员工转正",
|
||||||
|
"编辑人事信息",
|
||||||
|
"办理转正",
|
||||||
|
"撤销转正"
|
||||||
|
];
|
||||||
|
return !defaultRemarks.includes(remark) && remark !== changeTypeText(record?.changeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmployeeChangeFieldKey =
|
||||||
|
| "employmentType"
|
||||||
|
| "regularizationStatus"
|
||||||
|
| "regularizationDate"
|
||||||
|
| "contractNature"
|
||||||
|
| "socialInsuranceStatus"
|
||||||
|
| "socialInsuranceCompany";
|
||||||
|
|
||||||
|
type EmployeeChangeDiffRow = {
|
||||||
|
key: EmployeeChangeFieldKey;
|
||||||
|
label: string;
|
||||||
|
beforeText: string;
|
||||||
|
afterText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const employeeChangeFields: Array<{ key: EmployeeChangeFieldKey; label: string }> = [
|
||||||
|
{ key: "employmentType", label: "用工类型" },
|
||||||
|
{ key: "regularizationStatus", label: "转正状态" },
|
||||||
|
{ key: "regularizationDate", label: "转正日期" },
|
||||||
|
{ key: "contractNature", label: "合同性质" },
|
||||||
|
{ key: "socialInsuranceStatus", label: "社保状态" },
|
||||||
|
{ key: "socialInsuranceCompany", label: "合同社保单位" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseChangeSnapshot(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
if (typeof value !== "string" || !value.trim()) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? parsed as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(record: any, field: "beforeJson" | "afterJson") {
|
||||||
|
const pascalField = field === "beforeJson" ? "BeforeJson" : "AfterJson";
|
||||||
|
return parseChangeSnapshot(record?.[field] ?? record?.[pascalField]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshotValue(snapshot: Record<string, unknown>, key: string) {
|
||||||
|
const matchedKey = Object.keys(snapshot).find(item => item.toLowerCase() === key.toLowerCase());
|
||||||
|
return matchedKey ? snapshot[matchedKey] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChangeValue(value: unknown) {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
return typeof value === "string" ? value.trim() : JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChangeValue(key: EmployeeChangeFieldKey, value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === "") return "—";
|
||||||
|
const text = String(value);
|
||||||
|
if (key === "employmentType") return employmentTypeText(text);
|
||||||
|
if (key === "regularizationStatus") return regularizationText(text);
|
||||||
|
if (key === "regularizationDate") return formatDate(text);
|
||||||
|
if (key === "socialInsuranceStatus") return socialInsuranceText(text);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasChangeSnapshot(record: any) {
|
||||||
|
return Boolean(getSnapshot(record, "beforeJson") || getSnapshot(record, "afterJson"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeDiffRows(record: any): EmployeeChangeDiffRow[] {
|
||||||
|
const before = getSnapshot(record, "beforeJson") || {};
|
||||||
|
const after = getSnapshot(record, "afterJson") || {};
|
||||||
|
return employeeChangeFields.flatMap(field => {
|
||||||
|
const beforeValue = getSnapshotValue(before, field.key);
|
||||||
|
const afterValue = getSnapshotValue(after, field.key);
|
||||||
|
if (normalizeChangeValue(beforeValue) === normalizeChangeValue(afterValue)) return [];
|
||||||
|
return [{
|
||||||
|
key: field.key,
|
||||||
|
label: field.label,
|
||||||
|
beforeText: formatChangeValue(field.key, beforeValue),
|
||||||
|
afterText: formatChangeValue(field.key, afterValue)
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function canRegularize(employee?: OnboardingEmployee) {
|
function canRegularize(employee?: OnboardingEmployee) {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
employee
|
employee
|
||||||
@@ -1201,18 +1406,18 @@ async function showDetail(row: OnboardingEmployee) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveDownloadFileName(response: any, fallbackFileName?: string) {
|
function resolveDownloadFileName(response: any, fallbackFileName?: string) {
|
||||||
|
if (fallbackFileName) {
|
||||||
|
return safeDecodeFileName(fallbackFileName);
|
||||||
|
}
|
||||||
|
|
||||||
const disposition = String(response?.headers?.["content-disposition"] || "");
|
const disposition = String(response?.headers?.["content-disposition"] || "");
|
||||||
const encodedName = disposition.match(/filename\*=UTF-8\'\'([^;]+)/i)?.[1] || disposition.match(/filename="?([^";]+)"?/i)?.[1];
|
const encodedName = disposition.match(/filename\*=UTF-8\'\'([^;]+)/i)?.[1] || disposition.match(/filename="?([^";]+)"?/i)?.[1];
|
||||||
|
|
||||||
if (encodedName) {
|
if (encodedName) {
|
||||||
try {
|
return safeDecodeFileName(encodedName);
|
||||||
return decodeURIComponent(encodedName.replace(/\+/g, "%20"));
|
|
||||||
} catch {
|
|
||||||
return encodedName;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallbackFileName || "员工资料文件";
|
return "员工资料文件";
|
||||||
}
|
}
|
||||||
|
|
||||||
function openHrEdit(row: OnboardingEmployee) {
|
function openHrEdit(row: OnboardingEmployee) {
|
||||||
@@ -1256,9 +1461,9 @@ async function saveHrInfo() {
|
|||||||
await onboardingTaskApi.updateEmployeeHrInfo({
|
await onboardingTaskApi.updateEmployeeHrInfo({
|
||||||
id: hrForm.id,
|
id: hrForm.id,
|
||||||
employmentType: hrForm.employmentType,
|
employmentType: hrForm.employmentType,
|
||||||
contractNature: hrForm.contractNature.trim() || undefined,
|
contractNature: String(hrForm.contractNature ?? "").trim() || undefined,
|
||||||
socialInsuranceStatus: hrForm.socialInsuranceStatus,
|
socialInsuranceStatus: hrForm.socialInsuranceStatus,
|
||||||
socialInsuranceCompany: hrForm.socialInsuranceCompany.trim() || undefined
|
socialInsuranceCompany: String(hrForm.socialInsuranceCompany ?? "").trim() || undefined
|
||||||
});
|
});
|
||||||
hrEditVisible.value = false;
|
hrEditVisible.value = false;
|
||||||
await refreshEmployee(hrForm.id);
|
await refreshEmployee(hrForm.id);
|
||||||
@@ -1292,7 +1497,15 @@ function openRegularization(row: OnboardingEmployee) {
|
|||||||
regularizationVisible.value = true;
|
regularizationVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRegularizationExceed() {
|
||||||
|
ElMessage.warning("文件总数不超过 10");
|
||||||
|
}
|
||||||
|
|
||||||
function beforeRegularizationUpload(file: File) {
|
function beforeRegularizationUpload(file: File) {
|
||||||
|
if (regularizationFiles.value.length >= 10) {
|
||||||
|
ElMessage.warning("文件总数不超过 10");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const extension = "." + (file.name.split(".").pop() || "").toLowerCase();
|
const extension = "." + (file.name.split(".").pop() || "").toLowerCase();
|
||||||
const allowed = [
|
const allowed = [
|
||||||
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
||||||
@@ -1331,6 +1544,9 @@ async function submitRegularization() {
|
|||||||
if (!regularizationForm.regularizationDate) {
|
if (!regularizationForm.regularizationDate) {
|
||||||
return ElMessage.warning("请选择转正日期");
|
return ElMessage.warning("请选择转正日期");
|
||||||
}
|
}
|
||||||
|
if (regularizationFiles.value.length > 10) {
|
||||||
|
return ElMessage.warning("文件总数不超过 10");
|
||||||
|
}
|
||||||
if (regularizationFiles.value.some(file => file.status === "uploading")) {
|
if (regularizationFiles.value.some(file => file.status === "uploading")) {
|
||||||
return ElMessage.warning("材料仍在上传,请稍候");
|
return ElMessage.warning("材料仍在上传,请稍候");
|
||||||
}
|
}
|
||||||
@@ -1338,6 +1554,9 @@ async function submitRegularization() {
|
|||||||
const fileIds = regularizationFiles.value
|
const fileIds = regularizationFiles.value
|
||||||
.map(file => Number((file.response as any)?.fileId))
|
.map(file => Number((file.response as any)?.fileId))
|
||||||
.filter(fileId => Number.isFinite(fileId) && fileId > 0);
|
.filter(fileId => Number.isFinite(fileId) && fileId > 0);
|
||||||
|
if (fileIds.length > 10) {
|
||||||
|
return ElMessage.warning("文件总数不超过 10");
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
regularizationFiles.value.some(file => file.status === "fail")
|
regularizationFiles.value.some(file => file.status === "fail")
|
||||||
|| fileIds.length !== regularizationFiles.value.length
|
|| fileIds.length !== regularizationFiles.value.length
|
||||||
@@ -1464,43 +1683,9 @@ async function download(fileId: number, fallbackFileName?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadSearchOptions() {
|
async function loadSearchOptions() {
|
||||||
try {
|
const { data } = await onboardingTaskApi.listCompanies();
|
||||||
const { data } = await onboardingTaskApi.listCompanies().catch(() => ({ data: [] }));
|
searchCompanies.value = data || [];
|
||||||
searchCompanies.value = data || [];
|
proTable.value?.enumMap.set("companyId", companySearchOptions.value);
|
||||||
|
|
||||||
const deptList: OnboardingDepartmentOption[] = [];
|
|
||||||
const posList: OnboardingPositionOption[] = [];
|
|
||||||
|
|
||||||
if (data && data.length > 0) {
|
|
||||||
const promises = data.map(async (comp: any) => {
|
|
||||||
const [depRes, posRes] = await Promise.all([
|
|
||||||
onboardingTaskApi.listDepartments(comp.id).catch(() => ({ data: [] })),
|
|
||||||
onboardingTaskApi.listPositions(comp.id).catch(() => ({ data: [] }))
|
|
||||||
]);
|
|
||||||
if (depRes.data) deptList.push(...depRes.data);
|
|
||||||
if (posRes.data) posList.push(...posRes.data);
|
|
||||||
});
|
|
||||||
await Promise.all(promises);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueDeptMap = new Map<number, OnboardingDepartmentOption>();
|
|
||||||
deptList.forEach(d => uniqueDeptMap.set(d.id, d));
|
|
||||||
const finalDepts = Array.from(uniqueDeptMap.values());
|
|
||||||
allSearchDepartments.value = finalDepts;
|
|
||||||
searchDepartments.value = finalDepts;
|
|
||||||
|
|
||||||
const uniquePosMap = new Map<number, OnboardingPositionOption>();
|
|
||||||
posList.forEach(p => uniquePosMap.set(p.id, p));
|
|
||||||
const finalPositions = Array.from(uniquePosMap.values());
|
|
||||||
allSearchPositions.value = finalPositions;
|
|
||||||
searchPositions.value = finalPositions;
|
|
||||||
|
|
||||||
proTable.value?.enumMap.set("companyId", companySearchOptions.value);
|
|
||||||
proTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
|
|
||||||
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("加载员工档案搜索选项失败", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadSearchOptions);
|
onMounted(loadSearchOptions);
|
||||||
@@ -1578,35 +1763,175 @@ onBeforeUnmount(clearMaterialPreviews);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.change-record-card {
|
.change-record-card {
|
||||||
padding: 12px 14px;
|
padding: 14px 16px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
background: #f8fafc;
|
background: #ffffff;
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-record-card:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.change-record-header {
|
.change-record-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
flex-wrap: wrap;
|
||||||
color: #1e293b;
|
gap: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px dashed #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.change-record-header span,
|
.change-record-title-wrap {
|
||||||
.change-record-line {
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-type-tag {
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-effective-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-operator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 变更 Diff 标签流 */
|
||||||
|
.change-diff-flow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-diff-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-label {
|
||||||
|
min-width: 90px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-values {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-val-before {
|
||||||
|
color: #64748b;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-arrow {
|
||||||
|
color: #94a3b8;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.change-record-line {
|
.diff-val-after {
|
||||||
margin-top: 6px;
|
color: #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-diff-none {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-record-empty {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 真实备注气泡框 */
|
||||||
|
.change-remark-box {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-left: 3px solid #cbd5e1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remark-icon {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #94a3b8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remark-text {
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.change-attachments {
|
.change-attachments {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-card {
|
.section-card {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
placeholder="全部公司"
|
placeholder="全部公司"
|
||||||
class="company-filter"
|
class="company-filter"
|
||||||
@change="onQueryCompanyChange"
|
@change="onQueryCompanyChange"
|
||||||
@clear="onQueryCompanyChange"
|
|
||||||
>
|
>
|
||||||
<el-option v-for="item in companies" :key="item.id" :label="item.companyName" :value="item.id" />
|
<el-option v-for="item in companies" :key="item.id" :label="item.companyName" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -24,10 +23,10 @@
|
|||||||
v-model="query.departmentId"
|
v-model="query.departmentId"
|
||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
placeholder="全部部门"
|
:placeholder="query.companyId ? '全部部门' : '请先选择所属公司'"
|
||||||
|
:disabled="!query.companyId"
|
||||||
class="department-filter"
|
class="department-filter"
|
||||||
@change="onQueryDepartmentChange"
|
@change="onQueryDepartmentChange"
|
||||||
@clear="onQueryDepartmentChange"
|
|
||||||
>
|
>
|
||||||
<el-option v-for="item in queryDepartments" :key="item.id" :label="item.departmentName" :value="item.id" />
|
<el-option v-for="item in queryDepartments" :key="item.id" :label="item.departmentName" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -35,10 +34,10 @@
|
|||||||
v-model="query.positionId"
|
v-model="query.positionId"
|
||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
placeholder="全部岗位"
|
:placeholder="query.departmentId ? '全部岗位' : '请先选择所属部门'"
|
||||||
|
:disabled="!query.departmentId"
|
||||||
class="position-filter"
|
class="position-filter"
|
||||||
@change="loadTemplates"
|
@change="loadTemplates"
|
||||||
@clear="loadTemplates"
|
|
||||||
>
|
>
|
||||||
<el-option v-for="item in queryPositions" :key="item.id" :label="item.positionName" :value="item.id" />
|
<el-option v-for="item in queryPositions" :key="item.id" :label="item.positionName" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -48,7 +47,6 @@
|
|||||||
placeholder="模板状态"
|
placeholder="模板状态"
|
||||||
class="status-filter"
|
class="status-filter"
|
||||||
@change="loadTemplates"
|
@change="loadTemplates"
|
||||||
@clear="loadTemplates"
|
|
||||||
>
|
>
|
||||||
<el-option label="全部状态" value="" />
|
<el-option label="全部状态" value="" />
|
||||||
<el-option label="启用" value="enable" />
|
<el-option label="启用" value="enable" />
|
||||||
@@ -256,8 +254,18 @@
|
|||||||
<span class="block-subtitle">勾选入职时要求候选人必须或可选上传的证件材料(如身份证、毕业证、体检表等)</span>
|
<span class="block-subtitle">勾选入职时要求候选人必须或可选上传的证件材料(如身份证、毕业证、体检表等)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="materialTypes" border stripe height="240px" class="mt-2">
|
<el-table :data="enabledMaterialTypes" border stripe height="240px" class="mt-2">
|
||||||
<el-table-column label="选择要求" width="85" align="center">
|
<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">
|
<template #default="scope">
|
||||||
<el-checkbox :model-value="hasMaterial(scope.row.id)"
|
<el-checkbox :model-value="hasMaterial(scope.row.id)"
|
||||||
@change="value => toggleMaterial(scope.row.id, Boolean(value))" />
|
@change="value => toggleMaterial(scope.row.id, Boolean(value))" />
|
||||||
@@ -293,16 +301,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
import { useOnboardingScopeOptions } from "@/hooks/useOnboardingScopeOptions";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import {
|
import {
|
||||||
OnboardingCompany,
|
OnboardingCompanyOption,
|
||||||
OnboardingContractTemplate,
|
OnboardingContractTemplate,
|
||||||
OnboardingContractVersion,
|
OnboardingContractVersion,
|
||||||
OnboardingDepartmentOption,
|
OnboardingDepartmentOption,
|
||||||
OnboardingMaterialType,
|
OnboardingMaterialType,
|
||||||
OnboardingPosition,
|
OnboardingPosition,
|
||||||
OnboardingPositionOption,
|
|
||||||
OnboardingTemplate,
|
OnboardingTemplate,
|
||||||
onboardingTaskApi
|
onboardingTaskApi
|
||||||
} from "@/api/modules/biz/onboarding";
|
} from "@/api/modules/biz/onboarding";
|
||||||
@@ -317,13 +326,15 @@ const visible = ref(false);
|
|||||||
const rows = ref<OnboardingTemplate[]>([]);
|
const rows = ref<OnboardingTemplate[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
|
|
||||||
const companies = ref<OnboardingCompany[]>([]);
|
const props = defineProps<{ loadMaterials: () => Promise<OnboardingMaterialType[]> }>();
|
||||||
const queryDepartments = ref<OnboardingDepartmentOption[]>([]);
|
const companies = ref<OnboardingCompanyOption[]>([]);
|
||||||
const queryPositions = ref<OnboardingPositionOption[]>([]);
|
|
||||||
const departments = ref<OnboardingDepartmentOption[]>([]);
|
const departments = ref<OnboardingDepartmentOption[]>([]);
|
||||||
const positions = ref<OnboardingPosition[]>([]);
|
const positions = ref<OnboardingPosition[]>([]);
|
||||||
const contractTemplates = ref<OnboardingContractTemplate[]>([]);
|
const contractTemplates = ref<OnboardingContractTemplate[]>([]);
|
||||||
const materialTypes = ref<OnboardingMaterialType[]>([]);
|
const materialTypes = ref<OnboardingMaterialType[]>([]);
|
||||||
|
const enabledMaterialTypes = computed(() =>
|
||||||
|
materialTypes.value.filter(item => item.status === "enable")
|
||||||
|
);
|
||||||
const versionOptions = reactive<Record<number, OnboardingContractVersion[]>>({});
|
const versionOptions = reactive<Record<number, OnboardingContractVersion[]>>({});
|
||||||
|
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
@@ -350,96 +361,54 @@ const form = reactive<OnboardingTemplate>({
|
|||||||
materials: []
|
materials: []
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadAllQueryOptions() {
|
const {
|
||||||
if (!companies.value || companies.value.length === 0) return;
|
departments: queryDepartments,
|
||||||
const deptList: OnboardingDepartmentOption[] = [];
|
positions: queryPositions,
|
||||||
const posList: OnboardingPositionOption[] = [];
|
onCompanyChange: changeQueryCompany,
|
||||||
|
onDepartmentChange: changeQueryDepartment,
|
||||||
|
reset: resetQueryScope
|
||||||
|
} = useOnboardingScopeOptions(() => query);
|
||||||
|
|
||||||
const promises = companies.value.map(async comp => {
|
let companiesLoaded = false;
|
||||||
const [depRes, posRes] = await Promise.all([
|
let companyRequest: Promise<void> | undefined;
|
||||||
onboardingTaskApi.listDepartments(comp.id).catch(() => ({ data: [] })),
|
async function loadCompanies() {
|
||||||
onboardingTaskApi.listPositions(comp.id).catch(() => ({ data: [] }))
|
if (companiesLoaded) return;
|
||||||
]);
|
if (!companyRequest) {
|
||||||
if (depRes.data) deptList.push(...depRes.data);
|
companyRequest = onboardingTaskApi.listCompanies().then(({ data }) => {
|
||||||
if (posRes.data) posList.push(...posRes.data);
|
companies.value = data || [];
|
||||||
});
|
companiesLoaded = true;
|
||||||
await Promise.all(promises);
|
}).finally(() => { companyRequest = undefined; });
|
||||||
|
}
|
||||||
const uniqueDeptMap = new Map<number, OnboardingDepartmentOption>();
|
await companyRequest;
|
||||||
deptList.forEach(d => uniqueDeptMap.set(d.id, d));
|
|
||||||
queryDepartments.value = Array.from(uniqueDeptMap.values());
|
|
||||||
|
|
||||||
const uniquePosMap = new Map<number, OnboardingPositionOption>();
|
|
||||||
posList.forEach(p => uniquePosMap.set(p.id, p));
|
|
||||||
queryPositions.value = Array.from(uniquePosMap.values());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBaseData() {
|
async function loadBaseData() {
|
||||||
const [companyRes, materialRes] = await Promise.all([
|
const [, materials] = await Promise.all([loadCompanies(), props.loadMaterials()]);
|
||||||
onboardingTaskApi.companyPage({ pageNum: 1, pageSize: 100, status: "enable" }).catch(() => ({ data: { list: [] } })),
|
materialTypes.value = materials;
|
||||||
onboardingTaskApi.listMaterialTypes().catch(() => ({ data: [] }))
|
|
||||||
]);
|
|
||||||
companies.value = companyRes.data?.list || [];
|
|
||||||
materialTypes.value = materialRes.data || [];
|
|
||||||
await loadAllQueryOptions();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let templateRequest = 0;
|
||||||
async function loadTemplates() {
|
async function loadTemplates() {
|
||||||
|
const request = ++templateRequest;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await onboardingTaskApi.onboardingTemplatePage(query);
|
const res = await onboardingTaskApi.onboardingTemplatePage({ ...query });
|
||||||
|
if (request !== templateRequest) return;
|
||||||
rows.value = res.data?.list || [];
|
rows.value = res.data?.list || [];
|
||||||
total.value = res.data?.total || 0;
|
total.value = res.data?.total || 0;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (request === templateRequest) loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onQueryCompanyChange(companyId?: number) {
|
async function onQueryCompanyChange(companyId?: number) {
|
||||||
query.departmentId = undefined;
|
query.pageNum = 1;
|
||||||
query.positionId = undefined;
|
await Promise.all([changeQueryCompany(companyId), loadTemplates()]);
|
||||||
if (!companyId) {
|
|
||||||
await loadAllQueryOptions();
|
|
||||||
} else {
|
|
||||||
const [depRes, posRes] = await Promise.all([
|
|
||||||
onboardingTaskApi.listDepartments(companyId).catch(() => ({ data: [] })),
|
|
||||||
onboardingTaskApi.listPositions(companyId).catch(() => ({ data: [] }))
|
|
||||||
]);
|
|
||||||
queryDepartments.value = depRes.data || [];
|
|
||||||
queryPositions.value = (posRes.data || []).map(p => ({
|
|
||||||
id: p.id,
|
|
||||||
positionName: p.positionName,
|
|
||||||
companyId: companyId,
|
|
||||||
departmentId: p.departmentId
|
|
||||||
} as any));
|
|
||||||
}
|
|
||||||
loadTemplates();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onQueryDepartmentChange(departmentId?: number) {
|
async function onQueryDepartmentChange(departmentId?: number) {
|
||||||
query.positionId = undefined;
|
query.pageNum = 1;
|
||||||
if (!departmentId) {
|
await Promise.all([changeQueryDepartment(departmentId), loadTemplates()]);
|
||||||
if (query.companyId) {
|
|
||||||
const posRes = await onboardingTaskApi.listPositions(query.companyId).catch(() => ({ data: [] }));
|
|
||||||
queryPositions.value = (posRes.data || []).map(p => ({
|
|
||||||
id: p.id,
|
|
||||||
positionName: p.positionName,
|
|
||||||
companyId: query.companyId!,
|
|
||||||
departmentId: p.departmentId
|
|
||||||
} as any));
|
|
||||||
} else {
|
|
||||||
await loadAllQueryOptions();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const posRes = await onboardingTaskApi.listPositionsByDepartment(departmentId).catch(() => ({ data: [] }));
|
|
||||||
queryPositions.value = (posRes.data || []).map(p => ({
|
|
||||||
id: p.id,
|
|
||||||
positionName: p.positionName,
|
|
||||||
companyId: p.companyId,
|
|
||||||
departmentId: departmentId
|
|
||||||
} as any));
|
|
||||||
}
|
|
||||||
loadTemplates();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetQuery() {
|
function resetQuery() {
|
||||||
@@ -449,7 +418,7 @@ function resetQuery() {
|
|||||||
query.positionId = undefined;
|
query.positionId = undefined;
|
||||||
query.status = "enable";
|
query.status = "enable";
|
||||||
query.pageNum = 1;
|
query.pageNum = 1;
|
||||||
loadAllQueryOptions();
|
resetQueryScope();
|
||||||
loadTemplates();
|
loadTemplates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,10 +474,23 @@ async function onContractTemplateChange(row: any) {
|
|||||||
await loadContractVersions(row.contractTemplateId);
|
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) {
|
async function loadContractVersions(templateId: number) {
|
||||||
if (versionOptions[templateId]) return;
|
const res = await onboardingTaskApi.versionPage({
|
||||||
const res = await onboardingTaskApi.versionPage({ templateId, pageNum: 1, pageSize: 100 });
|
templateId,
|
||||||
versionOptions[templateId] = (res.data?.list || []).filter((it: any) => it.versionStatus === "enabled");
|
versionStatus: "enabled",
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 100
|
||||||
|
});
|
||||||
|
versionOptions[templateId] = (res.data?.list || []).filter(isVersionCurrentlyAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addContract() {
|
function addContract() {
|
||||||
@@ -548,6 +530,35 @@ function toggleMaterial(materialTypeId: number, checked: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function openCreate() {
|
||||||
await loadBaseData();
|
await loadBaseData();
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
@@ -597,6 +608,17 @@ async function openEdit(row: OnboardingTemplate) {
|
|||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
if (form.companyId) {
|
||||||
const departmentRes = await onboardingTaskApi.listDepartments(form.companyId);
|
const departmentRes = await onboardingTaskApi.listDepartments(form.companyId);
|
||||||
departments.value = departmentRes.data || [];
|
departments.value = departmentRes.data || [];
|
||||||
@@ -605,12 +627,25 @@ async function openEdit(row: OnboardingTemplate) {
|
|||||||
const positionRes = await onboardingTaskApi.listPositionsByDepartment(form.departmentId);
|
const positionRes = await onboardingTaskApi.listPositionsByDepartment(form.departmentId);
|
||||||
positions.value = positionRes.data || [];
|
positions.value = positionRes.data || [];
|
||||||
}
|
}
|
||||||
|
const configuredContractCount = form.contracts.length;
|
||||||
await loadAvailableContractTemplates();
|
await loadAvailableContractTemplates();
|
||||||
|
const removedTemplateCount = configuredContractCount - form.contracts.length;
|
||||||
|
let clearedVersionCount = 0;
|
||||||
for (const contract of form.contracts) {
|
for (const contract of form.contracts) {
|
||||||
if (contract.contractTemplateId) {
|
if (contract.contractTemplateId) {
|
||||||
await loadContractVersions(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;
|
visible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,6 +666,14 @@ async function save() {
|
|||||||
if (!form.contracts.length || form.contracts.some(item => !item.contractTemplateId || !item.contractTemplateVersionId)) {
|
if (!form.contracts.length || form.contracts.some(item => !item.contractTemplateId || !item.contractTemplateVersionId)) {
|
||||||
return ElMessage.warning("请完整选择至少一份签署材料模板及版本");
|
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;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -656,7 +699,7 @@ async function removeTemplate(row: OnboardingTemplate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadBaseData(), loadTemplates()]);
|
await Promise.all([loadCompanies(), loadTemplates()]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<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="variables">
|
<el-tab-pane lazy label="合同变量字典" name="variables">
|
||||||
<div class="tab-card-wrapper">
|
<div class="tab-card-wrapper">
|
||||||
<VariableDefinitionPanel />
|
<VariableDefinitionPanel />
|
||||||
</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 material-tab-wrapper">
|
<div class="tab-card-wrapper material-tab-wrapper">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button v-auth="'onboardingEntryConfigAdd'" type="primary" icon="Plus" @click="openMaterialCreate">
|
<el-button v-auth="'onboardingEntryConfigAdd'" type="primary" icon="Plus" @click="openMaterialCreate">
|
||||||
@@ -64,9 +64,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="入职模板" name="templates">
|
<el-tab-pane lazy label="入职模板" name="templates">
|
||||||
<div class="tab-card-wrapper">
|
<div class="tab-card-wrapper">
|
||||||
<OnboardingTemplatePanel />
|
<OnboardingTemplatePanel :load-materials="loadMaterials" />
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="单文件大小">
|
<el-form-item label="单文件大小">
|
||||||
<el-input-number v-model="materialForm.maxFileSizeMb" :min="1" :max="100" />
|
<el-input-number v-model="materialForm.maxFileSizeMb" :min="1" :max="20" />
|
||||||
<span class="unit-suffix">MB</span>
|
<span class="unit-suffix">MB</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="onboardingEntryConfig">
|
<script setup lang="ts" name="onboardingEntryConfig">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { reactive, ref, watch } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import {
|
import {
|
||||||
OnboardingMaterialType,
|
OnboardingMaterialType,
|
||||||
@@ -140,9 +140,15 @@ const materialForm = reactive<Partial<OnboardingMaterialType>>({
|
|||||||
status: "enable"
|
status: "enable"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let materialRequest: Promise<OnboardingMaterialType[]> | undefined;
|
||||||
async function loadMaterials() {
|
async function loadMaterials() {
|
||||||
const res = await onboardingTaskApi.listMaterialTypes();
|
if (!materialRequest) {
|
||||||
materialTypes.value = res.data || [];
|
materialRequest = onboardingTaskApi.listMaterialTypes().then(({ data }) => {
|
||||||
|
materialTypes.value = data || [];
|
||||||
|
return materialTypes.value;
|
||||||
|
}).finally(() => { materialRequest = undefined; });
|
||||||
|
}
|
||||||
|
return materialRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMaterialCreate() {
|
function openMaterialCreate() {
|
||||||
@@ -184,8 +190,8 @@ async function removeMaterial(row: OnboardingMaterialType) {
|
|||||||
await loadMaterials();
|
await loadMaterials();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
watch(activeTab, tab => {
|
||||||
loadMaterials();
|
if (tab === "materials") void loadMaterials();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 2. 入职部门管理 -->
|
<!-- 2. 入职部门管理 -->
|
||||||
<el-tab-pane label="入职部门" name="department">
|
<el-tab-pane lazy label="入职部门" name="department">
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<ProTable
|
<ProTable
|
||||||
ref="departmentTable"
|
ref="departmentTable"
|
||||||
@@ -78,9 +78,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #companyId="scope">
|
<template #companyId="scope">
|
||||||
<el-tag effect="plain" type="primary">
|
<el-tooltip
|
||||||
{{ scope.row.companyName || 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">
|
||||||
@@ -119,7 +127,7 @@
|
|||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 3. 入职岗位管理 -->
|
<!-- 3. 入职岗位管理 -->
|
||||||
<el-tab-pane label="入职岗位" name="position">
|
<el-tab-pane lazy label="入职岗位" name="position">
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<ProTable
|
<ProTable
|
||||||
ref="positionTable"
|
ref="positionTable"
|
||||||
@@ -135,15 +143,30 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #companyId="scope">
|
<template #companyId="scope">
|
||||||
<el-tag effect="plain" type="primary">
|
<el-tooltip
|
||||||
{{ scope.row.companyName || 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 #departmentName="scope">
|
<template #departmentName="scope">
|
||||||
<el-tag v-if="scope.row.departmentName" effect="light" type="warning">
|
<el-tooltip
|
||||||
{{ scope.row.departmentName }}
|
v-if="scope.row.departmentName"
|
||||||
</el-tag>
|
: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>
|
<span v-else class="text-gray-400">-</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -204,18 +227,18 @@
|
|||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="公司编码" prop="companyCode">
|
<el-form-item label="公司编码" prop="companyCode">
|
||||||
<el-input v-model.trim="companyForm.companyCode" placeholder="如:TECH_CORP" clearable />
|
<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="公司简称/名称" prop="companyName">
|
<el-form-item label="公司简称/名称" prop="companyName">
|
||||||
<el-input v-model.trim="companyForm.companyName" placeholder="如:总公司 / 科技分部" clearable />
|
<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="签署材料甲方全称" prop="legalName">
|
<el-form-item label="签署材料甲方全称" prop="legalName">
|
||||||
<el-input v-model.trim="companyForm.legalName" placeholder="必须与契约锁中的企业名称完全一致" clearable />
|
<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">
|
||||||
@@ -225,14 +248,15 @@
|
|||||||
v-model.trim="companyForm.unifiedSocialCreditCode"
|
v-model.trim="companyForm.unifiedSocialCreditCode"
|
||||||
placeholder="18位统一社会信用代码"
|
placeholder="18位统一社会信用代码"
|
||||||
maxlength="18"
|
maxlength="18"
|
||||||
|
show-word-limit
|
||||||
clearable
|
clearable
|
||||||
@input="val => companyForm.unifiedSocialCreditCode = (val || '').toUpperCase()"
|
@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="法定代表人" prop="legalRepresentative">
|
<el-form-item label="法定代表人" prop="legalRepresentative">
|
||||||
<el-input v-model.trim="companyForm.legalRepresentative" placeholder="法定代表人姓名" clearable />
|
<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>
|
||||||
@@ -240,7 +264,7 @@
|
|||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="联系电话" prop="contactPhone">
|
<el-form-item label="联系电话" prop="contactPhone">
|
||||||
<el-input v-model.trim="companyForm.contactPhone" placeholder="企业联系电话(手机或固话)" clearable />
|
<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">
|
||||||
@@ -254,11 +278,11 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-form-item label="注册地址" prop="registeredAddress">
|
<el-form-item label="注册地址" prop="registeredAddress">
|
||||||
<el-input v-model.trim="companyForm.registeredAddress" placeholder="企业法定注册地址" clearable />
|
<el-input v-model.trim="companyForm.registeredAddress" placeholder="企业法定注册地址" maxlength="150" show-word-limit clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="备注" prop="remark">
|
<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-divider content-position="left">契约锁电子签配置(由技术人员添加)</el-divider>
|
||||||
@@ -272,12 +296,26 @@
|
|||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="默认业务分类 Id" prop="qiyuesuoCategoryId">
|
<el-form-item label="默认业务分类 Id" prop="qiyuesuoCategoryId">
|
||||||
<el-input v-model.trim="companyForm.qiyuesuoCategoryId" placeholder="契约锁当前公司的分类 Id" clearable />
|
<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-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="企业印章 Id" prop="qiyuesuoSealId">
|
<el-form-item label="企业印章 Id" prop="qiyuesuoSealId">
|
||||||
<el-input v-model.trim="companyForm.qiyuesuoSealId" placeholder="契约锁当前公司的印章 Id" clearable />
|
<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-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -299,12 +337,12 @@
|
|||||||
<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="departmentForm.departmentCode" placeholder="如:DEPT_TECH" />
|
<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="departmentForm.departmentName" 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>
|
||||||
@@ -326,7 +364,7 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-form-item label="备注">
|
<el-form-item label="备注">
|
||||||
<el-input v-model="departmentForm.remark" type="textarea" :rows="2" placeholder="备注信息" />
|
<el-input v-model="departmentForm.remark" type="textarea" :rows="2" placeholder="备注信息" maxlength="200" show-word-limit />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
@@ -370,12 +408,12 @@
|
|||||||
<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="positionForm.positionCode" placeholder="如:DEV_FRONTEND" 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="positionForm.positionName" placeholder="如:高级前端开发工程师" maxlength="50" show-word-limit clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -401,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>
|
||||||
|
|
||||||
@@ -414,7 +452,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="onboardingSetup">
|
<script setup lang="ts" name="onboardingSetup">
|
||||||
import { ref, reactive, computed, onMounted, nextTick } from "vue";
|
import { ref, reactive, computed, watch, nextTick } from "vue";
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } 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 {
|
||||||
@@ -505,7 +543,7 @@ const companyRules = reactive<FormRules>({
|
|||||||
],
|
],
|
||||||
companyName: [
|
companyName: [
|
||||||
{ required: true, message: "请输入公司简称/名称", trigger: "blur" },
|
{ required: true, message: "请输入公司简称/名称", trigger: "blur" },
|
||||||
{ min: 2, max: 100, message: "公司名称长度需在 2 到 100 个字符之间", trigger: "blur" }
|
{ min: 2, max: 50, message: "公司名称长度需在 2 到 50 个字符之间", trigger: "blur" }
|
||||||
],
|
],
|
||||||
legalName: [
|
legalName: [
|
||||||
{ required: true, message: "请输入签署材料甲方全称", trigger: "blur" },
|
{ required: true, message: "请输入签署材料甲方全称", trigger: "blur" },
|
||||||
@@ -528,7 +566,6 @@ const companyRules = reactive<FormRules>({
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
contactPhone: [
|
contactPhone: [
|
||||||
{ required: true, message: "请输入企业联系电话", trigger: "blur" },
|
|
||||||
{
|
{
|
||||||
pattern: /^(1[3-9]\d{9}|(0\d{2,3}-?)?\d{7,8}|400(-?\d{3,4}){2})$/,
|
pattern: /^(1[3-9]\d{9}|(0\d{2,3}-?)?\d{7,8}|400(-?\d{3,4}){2})$/,
|
||||||
message: "请输入有效的手机号或固话号码",
|
message: "请输入有效的手机号或固话号码",
|
||||||
@@ -540,21 +577,26 @@ const companyRules = reactive<FormRules>({
|
|||||||
],
|
],
|
||||||
registeredAddress: [
|
registeredAddress: [
|
||||||
{ required: true, message: "请输入企业法定注册地址", trigger: "blur" },
|
{ required: true, message: "请输入企业法定注册地址", trigger: "blur" },
|
||||||
{ min: 4, max: 200, message: "注册地址请至少输入4个字符", trigger: "blur" }
|
{ min: 4, max: 150, message: "注册地址需在 4 到 150 个字符之间", trigger: "blur" }
|
||||||
|
],
|
||||||
|
remark: [
|
||||||
|
{ max: 300, message: "备注不能超过 300 个字符", trigger: "blur" }
|
||||||
],
|
],
|
||||||
qiyuesuoCategoryId: [
|
qiyuesuoCategoryId: [
|
||||||
{
|
{
|
||||||
pattern: /^\d*$/,
|
pattern: /^\d*$/,
|
||||||
message: "业务分类 Id 必须为纯数字",
|
message: "业务分类 Id 必须为纯数字",
|
||||||
trigger: "blur"
|
trigger: "blur"
|
||||||
}
|
},
|
||||||
|
{ max: 19, message: "业务分类 Id 不能超过 19 位数字", trigger: "blur" }
|
||||||
],
|
],
|
||||||
qiyuesuoSealId: [
|
qiyuesuoSealId: [
|
||||||
{
|
{
|
||||||
pattern: /^\d*$/,
|
pattern: /^\d*$/,
|
||||||
message: "企业印章 Id 必须为纯数字",
|
message: "企业印章 Id 必须为纯数字",
|
||||||
trigger: "blur"
|
trigger: "blur"
|
||||||
}
|
},
|
||||||
|
{ max: 19, message: "企业印章 Id 不能超过 19 位数字", trigger: "blur" }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
const departmentForm = reactive<Partial<OnboardingDepartment>>(emptyDepartment());
|
const departmentForm = reactive<Partial<OnboardingDepartment>>(emptyDepartment());
|
||||||
@@ -646,7 +688,8 @@ const departmentColumns: ColumnProps<OnboardingDepartment>[] = [
|
|||||||
prop: "departmentName",
|
prop: "departmentName",
|
||||||
label: "部门名称",
|
label: "部门名称",
|
||||||
minWidth: 180,
|
minWidth: 180,
|
||||||
search: { el: "input" }
|
search: { el: "input" },
|
||||||
|
showOverflowTooltip: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
prop: "sortCode",
|
prop: "sortCode",
|
||||||
@@ -693,13 +736,15 @@ const positionColumns: ColumnProps<OnboardingPosition>[] = [
|
|||||||
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",
|
||||||
@@ -734,9 +779,14 @@ 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) {
|
async function loadDepartmentsForCompany(companyId?: number) {
|
||||||
@@ -831,7 +881,10 @@ async function save() {
|
|||||||
return ElMessage.warning("契约锁默认业务分类 Id 和企业印章 Id 必须同时填写(或均不填)");
|
return ElMessage.warning("契约锁默认业务分类 Id 和企业印章 Id 必须同时填写(或均不填)");
|
||||||
}
|
}
|
||||||
if (categoryId && (!/^\d+$/.test(categoryId) || !/^\d+$/.test(sealId))) {
|
if (categoryId && (!/^\d+$/.test(categoryId) || !/^\d+$/.test(sealId))) {
|
||||||
return ElMessage.warning("契约锁默认业务分类 Id 和企业印章 Id 只能填写数字");
|
return ElMessage.warning("契约锁默认业务分类 Id 和企业印章 Id 只能填写纯数字");
|
||||||
|
}
|
||||||
|
if (categoryId && (categoryId.length > 19 || sealId.length > 19)) {
|
||||||
|
return ElMessage.warning("契约锁默认业务分类 Id 和企业印章 Id 不能超过 19 位数字");
|
||||||
}
|
}
|
||||||
companyForm.qiyuesuoCategoryId = categoryId;
|
companyForm.qiyuesuoCategoryId = categoryId;
|
||||||
companyForm.qiyuesuoSealId = sealId;
|
companyForm.qiyuesuoSealId = sealId;
|
||||||
@@ -890,7 +943,9 @@ async function removePosition(row: OnboardingPosition) {
|
|||||||
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>
|
||||||
@@ -971,4 +1026,17 @@ onMounted(loadCompanies);
|
|||||||
padding-top: 3px;
|
padding-top: 3px;
|
||||||
line-height: 1.25;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
title="入职任务列表"
|
title="入职任务列表"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:request-api="getTableData"
|
:request-api="getTableData"
|
||||||
|
@reset="resetSearchScope"
|
||||||
>
|
>
|
||||||
<template #tableHeader>
|
<template #tableHeader>
|
||||||
<div class="task-table-toolbar">
|
<div class="task-table-toolbar">
|
||||||
@@ -346,6 +347,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="contractConfigurationError"
|
||||||
|
:title="contractConfigurationError"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
<!-- 3. 合同逐份预览与确认 -->
|
<!-- 3. 合同逐份预览与确认 -->
|
||||||
<div v-if="contractPreviews.length" class="form-card-section">
|
<div v-if="contractPreviews.length" class="form-card-section">
|
||||||
<div class="section-title contract-preview-section-title">
|
<div class="section-title contract-preview-section-title">
|
||||||
@@ -541,38 +550,75 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 任务创建成功与分享弹窗 -->
|
<!-- 任务创建成功与分享弹窗 -->
|
||||||
<el-dialog v-model="shareVisible" title="入职任务已创建" width="560px" destroy-on-close>
|
<el-dialog
|
||||||
<el-result icon="success" title="任务创建成功!" sub-title="请先确认签署材料人事参数,再将专属链接发送给候选人">
|
v-model="shareVisible"
|
||||||
|
title="入职任务已创建"
|
||||||
|
width="600px"
|
||||||
|
destroy-on-close
|
||||||
|
class="task-share-dialog"
|
||||||
|
>
|
||||||
|
<el-result
|
||||||
|
icon="success"
|
||||||
|
title="任务创建成功!"
|
||||||
|
sub-title="请先确认签署材料人事参数,再将专属链接发送给候选人"
|
||||||
|
class="task-share-result"
|
||||||
|
>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<div class="share-box">
|
<div class="share-card">
|
||||||
<div class="share-item">
|
<div class="share-info-header">
|
||||||
<span class="share-label">任务编号:</span>
|
<div class="share-meta-item">
|
||||||
<span class="share-value font-bold">{{ createdTaskInfo?.taskNo }}</span>
|
<span class="share-meta-label">任务编号</span>
|
||||||
|
<span class="share-meta-val font-mono">{{ createdTaskInfo?.taskNo }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="createdTaskInfo?.positionName" class="share-meta-item text-right">
|
||||||
|
<span class="share-meta-label">入职岗位</span>
|
||||||
|
<span class="share-meta-val">{{ createdTaskInfo.positionName }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="share-item">
|
|
||||||
<span class="share-label">专属链接:</span>
|
<div class="share-link-section">
|
||||||
<el-input :model-value="createdTaskInfo?.linkUrl" readonly size="small">
|
<div class="share-section-title">专属填报链接</div>
|
||||||
|
<el-input
|
||||||
|
:model-value="createdTaskInfo?.linkUrl"
|
||||||
|
readonly
|
||||||
|
class="share-link-input"
|
||||||
|
>
|
||||||
<template #append>
|
<template #append>
|
||||||
<el-button icon="CopyDocument" @click="copyText(createdTaskInfo?.linkUrl, '专属链接已复制')">
|
<el-button
|
||||||
复制
|
type="primary"
|
||||||
|
icon="CopyDocument"
|
||||||
|
@click="copyText(createdTaskInfo?.linkUrl, '专属链接已复制')"
|
||||||
|
>
|
||||||
|
复制链接
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="share-item">
|
|
||||||
<span class="share-label">有效期至:</span>
|
<div class="share-expiry-row">
|
||||||
<span class="share-value text-secondary">{{ createdTaskInfo?.expiresAt }}</span>
|
<el-icon class="expiry-icon"><Clock /></el-icon>
|
||||||
|
<span>链接有效期至:</span>
|
||||||
|
<span class="expiry-time">{{ createdTaskInfo?.expiresAt }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="share-actions">
|
|
||||||
<el-button type="primary" plain icon="ChatDotRound" @click="copyInviteMessage">
|
<div class="share-action-section">
|
||||||
复制短信/微信邀请文案
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
icon="ChatDotRound"
|
||||||
|
class="invite-copy-btn"
|
||||||
|
@click="copyInviteMessage"
|
||||||
|
>
|
||||||
|
复制短信 / 微信邀请文案
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-result>
|
</el-result>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button type="primary" @click="shareVisible = false">完成</el-button>
|
<div class="dialog-footer-center">
|
||||||
|
<el-button type="primary" @click="shareVisible = false">完成</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
@@ -769,7 +815,7 @@
|
|||||||
<el-descriptions-item
|
<el-descriptions-item
|
||||||
v-for="item in detailCustomVars"
|
v-for="item in detailCustomVars"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
:label="item.key"
|
:label="item.label"
|
||||||
>
|
>
|
||||||
{{ item.value || '-' }}
|
{{ item.value || '-' }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
@@ -779,7 +825,15 @@
|
|||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 2. 证件与材料复核画廊 -->
|
<!-- 2. 证件与材料复核画廊 -->
|
||||||
<el-tab-pane label="证件与材料复核" name="materials">
|
<el-tab-pane name="materials">
|
||||||
|
<template #label>
|
||||||
|
<span class="tab-label-with-badge">
|
||||||
|
<span>证件与材料复核</span>
|
||||||
|
<span v-if="unreviewedMaterialCount > 0" class="tab-badge-pill">
|
||||||
|
{{ unreviewedMaterialCount > 99 ? '99+' : unreviewedMaterialCount }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<el-empty v-if="!detail.materials || !detail.materials.length" description="候选人尚未上传任何材料" />
|
<el-empty v-if="!detail.materials || !detail.materials.length" description="候选人尚未上传任何材料" />
|
||||||
<div v-else class="materials-gallery">
|
<div v-else class="materials-gallery">
|
||||||
<div
|
<div
|
||||||
@@ -843,21 +897,10 @@
|
|||||||
plain
|
plain
|
||||||
:disabled="item.reviewStatus === 'passed' || !canManage(detail.task)"
|
:disabled="item.reviewStatus === 'passed' || !canManage(detail.task)"
|
||||||
:title="disabledReason(detail.task)"
|
:title="disabledReason(detail.task)"
|
||||||
@click="reviewMaterial(item.id, 'passed')"
|
@click="reviewMaterial(item.id)"
|
||||||
>
|
>
|
||||||
合格
|
合格
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
|
||||||
v-auth="'onboardingTaskMaterialReview'"
|
|
||||||
size="small"
|
|
||||||
type="danger"
|
|
||||||
plain
|
|
||||||
:disabled="item.reviewStatus === 'need_supplement' || !canManage(detail.task)"
|
|
||||||
:title="disabledReason(detail.task)"
|
|
||||||
@click="reviewMaterial(item.id, 'need_supplement')"
|
|
||||||
>
|
|
||||||
需补充
|
|
||||||
</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -900,7 +943,7 @@
|
|||||||
plain
|
plain
|
||||||
size="small"
|
size="small"
|
||||||
icon="Download"
|
icon="Download"
|
||||||
@click="download(item.generatedDocxFileId)"
|
@click="download(item.generatedDocxFileId, getContractDocxDownloadName(item))"
|
||||||
>
|
>
|
||||||
下载已生成DOCX
|
下载已生成DOCX
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -1268,6 +1311,11 @@
|
|||||||
<div class="font-bold">
|
<div class="font-bold">
|
||||||
{{ documentItem.documentSort }}. {{ documentItem.documentName }}
|
{{ documentItem.documentSort }}. {{ documentItem.documentName }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="document-state-text">
|
||||||
|
<el-tag size="small" effect="plain">员工签字</el-tag>
|
||||||
|
<el-tag v-if="documentItem.companySealEnabled" size="small" type="success" effect="plain">公司盖章</el-tag>
|
||||||
|
<el-tag v-if="documentItem.acrossPageSealEnabled" size="small" type="warning" effect="plain">骑缝章</el-tag>
|
||||||
|
</div>
|
||||||
<div class="text-secondary document-state-text">
|
<div class="text-secondary document-state-text">
|
||||||
{{ documentItem.providerDocumentId ? '已上传契约锁' : '发起时自动生成并上传' }}
|
{{ documentItem.providerDocumentId ? '已上传契约锁' : '发起时自动生成并上传' }}
|
||||||
</div>
|
</div>
|
||||||
@@ -1474,6 +1522,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="onboardingTask">
|
<script setup lang="ts" name="onboardingTask">
|
||||||
|
import { useOnboardingScopeOptions } from "@/hooks/useOnboardingScopeOptions";
|
||||||
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from "vue";
|
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import {
|
import {
|
||||||
@@ -1509,6 +1558,7 @@ import {
|
|||||||
import type { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
|
import type { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
|
||||||
import { getMessageBoxPromptResult, isMessageBoxConfirmed } from "@/utils/messageBox";
|
import { getMessageBoxPromptResult, isMessageBoxConfirmed } from "@/utils/messageBox";
|
||||||
import { useOnboardingPermission } from "@/utils/onboardingPermission";
|
import { useOnboardingPermission } from "@/utils/onboardingPermission";
|
||||||
|
import { copyToClipboard } from "@/utils";
|
||||||
|
|
||||||
const { canManage, disabledReason } = useOnboardingPermission("onboardingTaskManageAll");
|
const { canManage, disabledReason } = useOnboardingPermission("onboardingTaskManageAll");
|
||||||
|
|
||||||
@@ -1518,10 +1568,19 @@ const companies = ref<OnboardingCompanyOption[]>([]);
|
|||||||
const departments = ref<OnboardingDepartmentOption[]>([]);
|
const departments = ref<OnboardingDepartmentOption[]>([]);
|
||||||
const positions = ref<OnboardingPositionOption[]>([]);
|
const positions = ref<OnboardingPositionOption[]>([]);
|
||||||
const searchCompanies = ref<OnboardingCompanyOption[]>([]);
|
const searchCompanies = ref<OnboardingCompanyOption[]>([]);
|
||||||
const searchDepartments = ref<OnboardingDepartmentOption[]>([]);
|
const {
|
||||||
const searchPositions = ref<OnboardingPositionOption[]>([]);
|
departments: searchDepartments,
|
||||||
const allSearchDepartments = ref<OnboardingDepartmentOption[]>([]);
|
positions: searchPositions,
|
||||||
const allSearchPositions = ref<OnboardingPositionOption[]>([]);
|
onCompanyChange: onSearchCompanyChange,
|
||||||
|
onDepartmentChange: onSearchDepartmentChange,
|
||||||
|
reset: resetSearchScope
|
||||||
|
} = useOnboardingScopeOptions(
|
||||||
|
() => proTable.value?.searchParam,
|
||||||
|
() => {
|
||||||
|
proTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
|
||||||
|
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const companySearchOptions = computed(() =>
|
const companySearchOptions = computed(() =>
|
||||||
searchCompanies.value.map(c => ({
|
searchCompanies.value.map(c => ({
|
||||||
@@ -1544,58 +1603,6 @@ const positionSearchOptions = computed(() =>
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
async function onSearchCompanyChange(companyId?: number) {
|
|
||||||
if (companyId) {
|
|
||||||
const [depRes, posRes] = await Promise.all([
|
|
||||||
onboardingTaskApi.listDepartments(companyId).catch(() => ({ data: [] })),
|
|
||||||
onboardingTaskApi.listPositions(companyId).catch(() => ({ data: [] }))
|
|
||||||
]);
|
|
||||||
searchDepartments.value = depRes.data || [];
|
|
||||||
searchPositions.value = posRes.data || [];
|
|
||||||
} else {
|
|
||||||
searchDepartments.value = allSearchDepartments.value;
|
|
||||||
searchPositions.value = allSearchPositions.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
proTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
|
|
||||||
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
|
|
||||||
|
|
||||||
if (proTable.value?.searchParam) {
|
|
||||||
const currentDept = proTable.value.searchParam.departmentId;
|
|
||||||
if (currentDept && !searchDepartments.value.some(d => d.id === currentDept)) {
|
|
||||||
proTable.value.searchParam.departmentId = undefined;
|
|
||||||
}
|
|
||||||
const currentPos = proTable.value.searchParam.positionId;
|
|
||||||
if (currentPos && !searchPositions.value.some(p => p.id === currentPos)) {
|
|
||||||
proTable.value.searchParam.positionId = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSearchDepartmentChange(departmentId?: number) {
|
|
||||||
if (departmentId) {
|
|
||||||
const posRes = await onboardingTaskApi.listPositionsByDepartment(departmentId).catch(() => ({ data: [] }));
|
|
||||||
searchPositions.value = posRes.data || [];
|
|
||||||
} else {
|
|
||||||
const selectedCompanyId = proTable.value?.searchParam?.companyId;
|
|
||||||
if (selectedCompanyId) {
|
|
||||||
const posRes = await onboardingTaskApi.listPositions(selectedCompanyId).catch(() => ({ data: [] }));
|
|
||||||
searchPositions.value = posRes.data || [];
|
|
||||||
} else {
|
|
||||||
searchPositions.value = allSearchPositions.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
|
|
||||||
|
|
||||||
if (proTable.value?.searchParam) {
|
|
||||||
const currentPos = proTable.value.searchParam.positionId;
|
|
||||||
if (currentPos && !searchPositions.value.some(p => p.id === currentPos)) {
|
|
||||||
proTable.value.searchParam.positionId = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onboardingTemplates = ref<OnboardingTemplate[]>([]);
|
const onboardingTemplates = ref<OnboardingTemplate[]>([]);
|
||||||
const contractTemplates = ref<OnboardingContractTemplate[]>([]);
|
const contractTemplates = ref<OnboardingContractTemplate[]>([]);
|
||||||
|
|
||||||
@@ -1615,6 +1622,7 @@ interface ContractPreviewState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contractPreviews = ref<ContractPreviewState[]>([]);
|
const contractPreviews = ref<ContractPreviewState[]>([]);
|
||||||
|
const contractConfigurationError = ref("");
|
||||||
const confirmedContractCount = computed(
|
const confirmedContractCount = computed(
|
||||||
() => contractPreviews.value.filter(item => item.confirmed).length
|
() => contractPreviews.value.filter(item => item.confirmed).length
|
||||||
);
|
);
|
||||||
@@ -1684,10 +1692,36 @@ const detailFamilyList = computed(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unreviewedMaterialCount = computed(() => {
|
||||||
|
const materials = detail.value?.materials;
|
||||||
|
if (!Array.isArray(materials) || materials.length === 0) return 0;
|
||||||
|
return materials.filter(item => item.reviewStatus !== "passed").length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const registrationEmployeeFieldCodes = new Set([
|
||||||
|
"employeename", "name", "username", "gender", "nation", "birthday", "registeredresidence",
|
||||||
|
"height", "weight", "healthcondition", "maritalstatus", "education", "major", "graduatedschool",
|
||||||
|
"college", "vocationalqualification", "qualificationlevel", "technicalspecialty", "phone",
|
||||||
|
"mobile", "email", "idcardnumber", "idcard", "currentaddress", "contactaddress", "landlordcontact",
|
||||||
|
"bankcardnumber", "bankcard", "bankname", "bankaccountname", "emergencycontact", "emergencyphone",
|
||||||
|
"emergencyaddress", "onboardingdepartment", "onboardingdate", "onboardingposition", "otherremarks"
|
||||||
|
]);
|
||||||
|
const detailContractVariableNames = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
function isRegistrationEmployeeVariable(variableCode: string) {
|
||||||
|
const fieldCode = (String(variableCode || "").split(".")[1] || variableCode || "").toLowerCase();
|
||||||
|
return registrationEmployeeFieldCodes.has(fieldCode);
|
||||||
|
}
|
||||||
const detailCustomVars = computed(() => {
|
const detailCustomVars = computed(() => {
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(detail.value?.employeeInfo?.customVariablesJson || "{}");
|
const obj = JSON.parse(detail.value?.employeeInfo?.customVariablesJson || "{}");
|
||||||
return Object.entries(obj).map(([k, v]) => ({ key: k, value: String(v) }));
|
return Object.entries(obj)
|
||||||
|
.filter(([key]) => !isRegistrationEmployeeVariable(key))
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
label: detailContractVariableNames.value[key] || key,
|
||||||
|
value: value == null ? "" : String(value)
|
||||||
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -1877,7 +1911,12 @@ const columns: ColumnProps<OnboardingTask>[] = [
|
|||||||
props: {
|
props: {
|
||||||
filterable: true,
|
filterable: true,
|
||||||
clearable: true,
|
clearable: true,
|
||||||
placeholder: "请选择所属部门",
|
get placeholder() {
|
||||||
|
return proTable.value?.searchParam?.companyId ? "请选择所属部门" : "请先选择所属公司";
|
||||||
|
},
|
||||||
|
get disabled() {
|
||||||
|
return !proTable.value?.searchParam?.companyId;
|
||||||
|
},
|
||||||
onChange: (val?: number) => onSearchDepartmentChange(val)
|
onChange: (val?: number) => onSearchDepartmentChange(val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1889,7 +1928,16 @@ const columns: ColumnProps<OnboardingTask>[] = [
|
|||||||
enum: positionSearchOptions,
|
enum: positionSearchOptions,
|
||||||
search: {
|
search: {
|
||||||
el: "select",
|
el: "select",
|
||||||
props: { filterable: true, clearable: true, placeholder: "请选择入职岗位" }
|
props: {
|
||||||
|
filterable: true,
|
||||||
|
clearable: true,
|
||||||
|
get placeholder() {
|
||||||
|
return proTable.value?.searchParam?.departmentId ? "请选择入职岗位" : "请先选择所属部门";
|
||||||
|
},
|
||||||
|
get disabled() {
|
||||||
|
return !proTable.value?.searchParam?.departmentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2122,20 +2170,57 @@ async function loadMaterialPreviews(materials: any[]) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeDecodeFileName(name: string): string {
|
||||||
|
let decoded = String(name || "").trim();
|
||||||
|
try {
|
||||||
|
// 循环解码,应对多重 URL 编码(例如 %25e5... 或 %e5...)
|
||||||
|
while (/%[0-9a-fA-F]{2}/.test(decoded)) {
|
||||||
|
const next = decodeURIComponent(decoded.replace(/\+/g, "%20"));
|
||||||
|
if (next === decoded) break;
|
||||||
|
decoded = next;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略异常,保留当前值
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContractDocxDownloadName(item: any) {
|
||||||
|
const taskNo = String(detail.value?.task?.taskNo || "").trim();
|
||||||
|
const employeeName = String(
|
||||||
|
detail.value?.employeeInfo?.employeeName
|
||||||
|
|| detail.value?.task?.employeeName
|
||||||
|
|| "待填员工"
|
||||||
|
).trim();
|
||||||
|
const docType = String(
|
||||||
|
item?.contractType
|
||||||
|
|| item?.contractName
|
||||||
|
|| "劳动合同"
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const clean = (val: string) =>
|
||||||
|
safeDecodeFileName(val)
|
||||||
|
.replace(/[\/\\:*?"<>|]+/g, "_")
|
||||||
|
.replace(/\s+/g, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return `${clean(taskNo)}_${clean(employeeName)}_${clean(docType)}.docx`;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveDownloadFileName(response: any, fallbackFileName?: string) {
|
function resolveDownloadFileName(response: any, fallbackFileName?: string) {
|
||||||
|
if (fallbackFileName) {
|
||||||
|
return safeDecodeFileName(fallbackFileName);
|
||||||
|
}
|
||||||
|
|
||||||
const disposition = String(response?.headers?.["content-disposition"] || "");
|
const disposition = String(response?.headers?.["content-disposition"] || "");
|
||||||
const encodedName = disposition.match(/filename\*=UTF-8\'\'([^;]+)/i)?.[1]
|
const encodedName = disposition.match(/filename\*=UTF-8\'\'([^;]+)/i)?.[1]
|
||||||
|| disposition.match(/filename="?([^";]+)"?/i)?.[1];
|
|| disposition.match(/filename="?([^";]+)"?/i)?.[1];
|
||||||
|
|
||||||
if (encodedName) {
|
if (encodedName) {
|
||||||
try {
|
return safeDecodeFileName(encodedName);
|
||||||
return decodeURIComponent(encodedName.replace(/\+/g, "%20"));
|
|
||||||
} catch {
|
|
||||||
return encodedName;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallbackFileName || "入职材料";
|
return "入职材料";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 列表查询并统计
|
// 列表查询并统计
|
||||||
@@ -2155,48 +2240,10 @@ async function getTableData(params: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCompanies() {
|
async function loadCompanies() {
|
||||||
try {
|
const { data } = await onboardingTaskApi.listCompanies();
|
||||||
const [companyResult, contractResult] = await Promise.all([
|
companies.value = data || [];
|
||||||
onboardingTaskApi.listCompanies().catch(() => ({ data: [] })),
|
searchCompanies.value = data || [];
|
||||||
onboardingTaskApi.templatePage({ pageNum: 1, pageSize: 100, status: "enable" }).catch(() => ({ data: { list: [] } }))
|
proTable.value?.enumMap.set("companyId", companySearchOptions.value);
|
||||||
]);
|
|
||||||
companies.value = companyResult.data || [];
|
|
||||||
searchCompanies.value = companyResult.data || [];
|
|
||||||
contractTemplates.value = contractResult.data?.list || [];
|
|
||||||
|
|
||||||
const deptList: OnboardingDepartmentOption[] = [];
|
|
||||||
const posList: OnboardingPositionOption[] = [];
|
|
||||||
|
|
||||||
if (companies.value && companies.value.length > 0) {
|
|
||||||
const promises = companies.value.map(async comp => {
|
|
||||||
const [depRes, posRes] = await Promise.all([
|
|
||||||
onboardingTaskApi.listDepartments(comp.id).catch(() => ({ data: [] })),
|
|
||||||
onboardingTaskApi.listPositions(comp.id).catch(() => ({ data: [] }))
|
|
||||||
]);
|
|
||||||
if (depRes.data) deptList.push(...depRes.data);
|
|
||||||
if (posRes.data) posList.push(...posRes.data);
|
|
||||||
});
|
|
||||||
await Promise.all(promises);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueDeptMap = new Map<number, OnboardingDepartmentOption>();
|
|
||||||
deptList.forEach(d => uniqueDeptMap.set(d.id, d));
|
|
||||||
const finalDepts = Array.from(uniqueDeptMap.values());
|
|
||||||
allSearchDepartments.value = finalDepts;
|
|
||||||
searchDepartments.value = finalDepts;
|
|
||||||
|
|
||||||
const uniquePosMap = new Map<number, OnboardingPositionOption>();
|
|
||||||
posList.forEach(p => uniquePosMap.set(p.id, p));
|
|
||||||
const finalPositions = Array.from(uniquePosMap.values());
|
|
||||||
allSearchPositions.value = finalPositions;
|
|
||||||
searchPositions.value = finalPositions;
|
|
||||||
|
|
||||||
proTable.value?.enumMap.set("companyId", companySearchOptions.value);
|
|
||||||
proTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
|
|
||||||
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("加载公司与搜索选项失败", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onCompanyChange(companyId: number) {
|
async function onCompanyChange(companyId: number) {
|
||||||
@@ -2252,6 +2299,42 @@ async function onOnboardingTemplateChange(templateId: number) {
|
|||||||
const sortResult = (left.sortCode ?? 100) - (right.sortCode ?? 100);
|
const sortResult = (left.sortCode ?? 100) - (right.sortCode ?? 100);
|
||||||
return sortResult || left.contractTemplateVersionId - right.contractTemplateVersionId;
|
return sortResult || left.contractTemplateVersionId - right.contractTemplateVersionId;
|
||||||
});
|
});
|
||||||
|
const matchesCurrentScope = (item: OnboardingContractTemplate, contractTemplateId: number) =>
|
||||||
|
item.id === contractTemplateId
|
||||||
|
&& item.status === "enable"
|
||||||
|
&& item.companyId === form.companyId
|
||||||
|
&& item.departmentId === form.departmentId;
|
||||||
|
const unresolvedTemplateIds = contracts
|
||||||
|
.map(contract => contract.contractTemplateId)
|
||||||
|
.filter(contractTemplateId =>
|
||||||
|
!contractTemplates.value.some(item => matchesCurrentScope(item, contractTemplateId))
|
||||||
|
)
|
||||||
|
.filter((contractTemplateId, index, values) => values.indexOf(contractTemplateId) === index);
|
||||||
|
if (unresolvedTemplateIds.length) {
|
||||||
|
const detailResults = await Promise.all(
|
||||||
|
unresolvedTemplateIds.map(id =>
|
||||||
|
onboardingTaskApi.templateDetail({ id }).catch(() => ({ data: undefined }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
detailResults.forEach(result => {
|
||||||
|
const item = result.data;
|
||||||
|
if (
|
||||||
|
item
|
||||||
|
&& matchesCurrentScope(item, item.id)
|
||||||
|
&& !contractTemplates.value.some(existing => existing.id === item.id)
|
||||||
|
) {
|
||||||
|
contractTemplates.value.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const unavailableContract = contracts.find(contract =>
|
||||||
|
!contractTemplates.value.some(item => matchesCurrentScope(item, contract.contractTemplateId))
|
||||||
|
);
|
||||||
|
if (unavailableContract) {
|
||||||
|
contractConfigurationError.value = "原签署材料已失效,请重新配置入职模板";
|
||||||
|
ElMessage.error(contractConfigurationError.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const variableResults = await Promise.all(
|
const variableResults = await Promise.all(
|
||||||
contracts.map(contract => onboardingTaskApi.variablePage({
|
contracts.map(contract => onboardingTaskApi.variablePage({
|
||||||
templateVersionId: contract.contractTemplateVersionId,
|
templateVersionId: contract.contractTemplateVersionId,
|
||||||
@@ -2279,12 +2362,12 @@ async function onOnboardingTemplateChange(templateId: number) {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
contractPreviews.value = contracts.map((contract, index) => ({
|
contractPreviews.value = contracts.map(contract => ({
|
||||||
contractTemplateId: contract.contractTemplateId,
|
contractTemplateId: contract.contractTemplateId,
|
||||||
contractTemplateVersionId: contract.contractTemplateVersionId,
|
contractTemplateVersionId: contract.contractTemplateVersionId,
|
||||||
contractName:
|
contractName:
|
||||||
contractTemplates.value.find(item => item.id === contract.contractTemplateId)
|
contractTemplates.value.find(item => item.id === contract.contractTemplateId)
|
||||||
?.templateName || `合同${index + 1}`,
|
?.templateName || "原签署材料已失效,请重新配置入职模板",
|
||||||
required: contract.required,
|
required: contract.required,
|
||||||
sortCode: contract.sortCode ?? 100,
|
sortCode: contract.sortCode ?? 100,
|
||||||
downloaded: false,
|
downloaded: false,
|
||||||
@@ -2400,6 +2483,7 @@ function buildTaskCreatePayload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetContractPreviewState(clearContracts = false) {
|
function resetContractPreviewState(clearContracts = false) {
|
||||||
|
contractConfigurationError.value = "";
|
||||||
if (clearContracts) {
|
if (clearContracts) {
|
||||||
contractPreviews.value = [];
|
contractPreviews.value = [];
|
||||||
return;
|
return;
|
||||||
@@ -2478,6 +2562,16 @@ async function downloadAllContractPreviews() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPublicOnboardingLink(linkToken: string) {
|
||||||
|
const publicOrigin = String(import.meta.env.VITE_PUBLIC_ONBOARDING_URL || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/\/+$/, "");
|
||||||
|
if (!publicOrigin) {
|
||||||
|
throw new Error("未配置员工公开入职域名 VITE_PUBLIC_ONBOARDING_URL");
|
||||||
|
}
|
||||||
|
return `${publicOrigin}/#/onboarding/fill?token=${encodeURIComponent(linkToken)}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function createTask() {
|
async function createTask() {
|
||||||
if (!validateContractPreviewForm()) return;
|
if (!validateContractPreviewForm()) return;
|
||||||
if (!form.remark.trim()) {
|
if (!form.remark.trim()) {
|
||||||
@@ -2494,7 +2588,7 @@ async function createTask() {
|
|||||||
createVisible.value = false;
|
createVisible.value = false;
|
||||||
proTable.value?.refresh();
|
proTable.value?.refresh();
|
||||||
|
|
||||||
const linkUrl = `${location.origin}/#/onboarding/fill?token=${result.linkToken}`;
|
const linkUrl = buildPublicOnboardingLink(result.linkToken);
|
||||||
const selectedCompany = companies.value.find(c => c.id === form.companyId)?.companyName || "";
|
const selectedCompany = companies.value.find(c => c.id === form.companyId)?.companyName || "";
|
||||||
const selectedDepartment = departments.value.find(d => d.id === form.departmentId)?.departmentName || "";
|
const selectedDepartment = departments.value.find(d => d.id === form.departmentId)?.departmentName || "";
|
||||||
const selectedPosition = positions.value.find(p => p.id === form.positionId)?.positionName || "";
|
const selectedPosition = positions.value.find(p => p.id === form.positionId)?.positionName || "";
|
||||||
@@ -2514,10 +2608,14 @@ async function createTask() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyText(text?: string, successMsg = "复制成功") {
|
async function copyText(text?: string, successMsg = "复制成功") {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
navigator.clipboard.writeText(text);
|
const ok = await copyToClipboard(text);
|
||||||
ElMessage.success(successMsg);
|
if (ok) {
|
||||||
|
ElMessage.success(successMsg);
|
||||||
|
} else {
|
||||||
|
ElMessage.warning("自动复制失败,请在弹窗中长按或手动复制");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyInviteMessage() {
|
function copyInviteMessage() {
|
||||||
@@ -2529,7 +2627,7 @@ function copyInviteMessage() {
|
|||||||
async function copyOrShareTaskLink(row: OnboardingTask) {
|
async function copyOrShareTaskLink(row: OnboardingTask) {
|
||||||
try {
|
try {
|
||||||
const { data }: any = await onboardingTaskApi.getTaskLink({ id: row.id });
|
const { data }: any = await onboardingTaskApi.getTaskLink({ id: row.id });
|
||||||
const linkUrl = `${location.origin}/#/onboarding/fill?token=${data.linkToken}`;
|
const linkUrl = buildPublicOnboardingLink(data.linkToken);
|
||||||
|
|
||||||
createdTaskInfo.value = {
|
createdTaskInfo.value = {
|
||||||
taskNo: row.taskNo,
|
taskNo: row.taskNo,
|
||||||
@@ -2543,7 +2641,7 @@ async function copyOrShareTaskLink(row: OnboardingTask) {
|
|||||||
row.linkExpireAt = data.expiresAt;
|
row.linkExpireAt = data.expiresAt;
|
||||||
proTable.value?.refresh();
|
proTable.value?.refresh();
|
||||||
|
|
||||||
copyText(linkUrl, "专属入职链接已复制到剪贴板!");
|
await copyText(linkUrl, "专属入职链接已复制到剪贴板!");
|
||||||
shareVisible.value = true;
|
shareVisible.value = true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ElMessage.error(err?.message || "获取专属链接失败");
|
ElMessage.error(err?.message || "获取专属链接失败");
|
||||||
@@ -2552,7 +2650,16 @@ async function copyOrShareTaskLink(row: OnboardingTask) {
|
|||||||
|
|
||||||
async function showDetail(row: OnboardingTask, preserveTab: boolean = false) {
|
async function showDetail(row: OnboardingTask, preserveTab: boolean = false) {
|
||||||
const currentTab = activeDetailTab.value;
|
const currentTab = activeDetailTab.value;
|
||||||
const { data }: any = await onboardingTaskApi.detail({ id: row.id });
|
const [{ data }, variableResponse]: any[] = await Promise.all([
|
||||||
|
onboardingTaskApi.detail({ id: row.id }),
|
||||||
|
onboardingTaskApi.taskContractVariables(row.id).catch(() => ({ data: [] }))
|
||||||
|
]);
|
||||||
|
detailContractVariableNames.value = Object.fromEntries(
|
||||||
|
(variableResponse.data || [])
|
||||||
|
.flatMap((group: OnboardingTaskContractVariableGroup) => group.variables || [])
|
||||||
|
.filter((variable: any) => variable.variableType === "employee" && !isRegistrationEmployeeVariable(variable.variableCode))
|
||||||
|
.map((variable: any) => [variable.variableCode, variable.variableName || variable.variableCode])
|
||||||
|
);
|
||||||
detail.value = data;
|
detail.value = data;
|
||||||
if (!preserveTab) {
|
if (!preserveTab) {
|
||||||
activeDetailTab.value = "profile";
|
activeDetailTab.value = "profile";
|
||||||
@@ -2705,21 +2812,9 @@ async function download(fileId: number, fallbackFileName?: string) {
|
|||||||
|
|
||||||
onBeforeUnmount(clearMaterialPreviews);
|
onBeforeUnmount(clearMaterialPreviews);
|
||||||
|
|
||||||
async function reviewMaterial(taskMaterialId: number, reviewStatus: string) {
|
async function reviewMaterial(taskMaterialId: number) {
|
||||||
let reviewRemark = "";
|
await onboardingTaskApi.reviewMaterial({ taskMaterialId, reviewStatus: "passed", reviewRemark: "" });
|
||||||
if (reviewStatus === "need_supplement") {
|
ElMessage.success("该材料已标记为合格");
|
||||||
const result = await getMessageBoxPromptResult(
|
|
||||||
ElMessageBox.prompt("请输入该材料退回补充说明:", "材料不合格退回", {
|
|
||||||
inputPattern: /\S+/,
|
|
||||||
inputErrorMessage: "请填写说明"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if (!result) return;
|
|
||||||
reviewRemark = result.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
await onboardingTaskApi.reviewMaterial({ taskMaterialId, reviewStatus, reviewRemark });
|
|
||||||
ElMessage.success(reviewStatus === "passed" ? "该材料已标记为合格" : "已标记需补充");
|
|
||||||
|
|
||||||
if (detail.value?.task) {
|
if (detail.value?.task) {
|
||||||
await showDetail(detail.value.task, true);
|
await showDetail(detail.value.task, true);
|
||||||
@@ -3610,6 +3705,27 @@ onMounted(loadCompanies);
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-label-with-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-badge-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 18px;
|
||||||
|
min-width: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--el-color-danger, #f56c6c);
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.detail-tabs > .el-tabs__header) {
|
:deep(.detail-tabs > .el-tabs__header) {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -3988,12 +4104,85 @@ onMounted(loadCompanies);
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contracts-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 2px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-card-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 18px 24px;
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-card-item:hover {
|
||||||
|
border-color: var(--el-color-primary-light-5);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-sub-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmed-text {
|
||||||
|
font-size: 13px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.copy-count-row {
|
.copy-count-row {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contract-card-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-right {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.electronic-sign-document,
|
.electronic-sign-document,
|
||||||
.electronic-sign-link-card,
|
.electronic-sign-link-card,
|
||||||
.electronic-sign-footer {
|
.electronic-sign-footer {
|
||||||
@@ -4005,4 +4194,145 @@ onMounted(loadCompanies);
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 任务创建分享弹窗样式优化 */
|
||||||
|
.task-share-result {
|
||||||
|
padding: 12px 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-share-result :deep(.el-result__icon svg) {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-share-result :deep(.el-result__title) {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-share-result :deep(.el-result__subtitle) {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-share-result :deep(.el-result__extra) {
|
||||||
|
margin-top: 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-info-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 1px dashed #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-meta-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-meta-val {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1e293b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-section {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-input :deep(.el-input__inner) {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #334155;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-input :deep(.el-input-group__append) {
|
||||||
|
background-color: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
font-weight: 500;
|
||||||
|
border-color: #dbeafe;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-input :deep(.el-input-group__append:hover) {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-expiry-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiry-icon {
|
||||||
|
margin-right: 6px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiry-time {
|
||||||
|
color: #e11d48;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-action-section {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-copy-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer-center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+1465
-495
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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user