前端双部署
This commit is contained in:
@@ -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,6 +1,13 @@
|
|||||||
# 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
|
||||||
@@ -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,13 @@
|
|||||||
|
<template>
|
||||||
|
<el-config-provider :locale="zhCn" :button="buttonConfig">
|
||||||
|
<router-view />
|
||||||
|
</el-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive } from "vue";
|
||||||
|
import { ElConfigProvider } from "element-plus";
|
||||||
|
import zhCn from "element-plus/es/locale/lang/zh-cn";
|
||||||
|
|
||||||
|
const buttonConfig = reactive({ autoInsertSpace: false });
|
||||||
|
</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 {
|
||||||
@@ -964,40 +957,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;
|
|
||||||
identityInformationConfirmed: boolean;
|
|
||||||
confirmedEmployeeName: string;
|
|
||||||
confirmedIdCardNumber: string;
|
|
||||||
confirmedPhone: 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,91 @@
|
|||||||
|
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);
|
||||||
|
},
|
||||||
|
|
||||||
|
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,216 @@
|
|||||||
|
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) {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
+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,60 @@
|
|||||||
|
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 "virtual:uno.css";
|
||||||
|
import "default-passive-events";
|
||||||
|
|
||||||
|
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");
|
||||||
@@ -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: "入职服务", standaloneTitle: true }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
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;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2516,6 +2516,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()) {
|
||||||
@@ -2532,7 +2542,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 || "";
|
||||||
@@ -2571,7 +2581,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,
|
||||||
|
|||||||
@@ -514,8 +514,8 @@
|
|||||||
<div v-for="f in getUploadedMaterials(mat.materialTypeId)" :key="`${f.id}-${f.fileId}`"
|
<div v-for="f in getUploadedMaterials(mat.materialTypeId)" :key="`${f.id}-${f.fileId}`"
|
||||||
class="file-badge">
|
class="file-badge">
|
||||||
<!-- 图片点击缩略图即可在手机端放大预览,非图片继续显示文件图标。 -->
|
<!-- 图片点击缩略图即可在手机端放大预览,非图片继续显示文件图标。 -->
|
||||||
<el-image v-if="isImageFile(f)" class="material-thumbnail" :src="getMaterialSourceUrl(f)"
|
<el-image v-if="isImageFile(f) && getMaterialSourceUrl(f)" class="material-thumbnail"
|
||||||
:preview-src-list="[getMaterialSourceUrl(f)]" fit="cover" preview-teleported />
|
:src="getMaterialSourceUrl(f)" :preview-src-list="[getMaterialSourceUrl(f)]" fit="cover" preview-teleported />
|
||||||
<el-icon v-else class="material-file-icon" color="#059669">
|
<el-icon v-else class="material-file-icon" color="#059669">
|
||||||
<Document />
|
<Document />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
@@ -529,8 +529,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="canEdit" class="file-actions">
|
<div v-if="canEdit" class="file-actions">
|
||||||
<el-upload :action="getMaterialUploadUrl(mat.materialTypeId, f.id)" :accept="getUploadAccept(mat)"
|
<el-upload action="#" :accept="getUploadAccept(mat)"
|
||||||
:disabled="isMaterialUploading(mat.materialTypeId)" :show-file-list="false"
|
:disabled="isMaterialUploading(mat.materialTypeId)" :show-file-list="false"
|
||||||
|
:http-request="(options: any) => uploadMaterial(options, mat.materialTypeId, f.id)"
|
||||||
:on-success="(response: any) => onUploadSuccess(response, mat.materialTypeId)"
|
:on-success="(response: any) => onUploadSuccess(response, mat.materialTypeId)"
|
||||||
:on-error="(error: Error) => onUploadError(error, mat.materialTypeId)"
|
:on-error="(error: Error) => onUploadError(error, mat.materialTypeId)"
|
||||||
:before-upload="(file: File) => beforeUpload(file, mat)">
|
:before-upload="(file: File) => beforeUpload(file, mat)">
|
||||||
@@ -547,8 +548,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="canEdit && getRemainingFileCount(mat) > 0" class="upload-action-box">
|
<div v-if="canEdit && getRemainingFileCount(mat) > 0" class="upload-action-box">
|
||||||
<el-upload :action="getMaterialUploadUrl(mat.materialTypeId)" :accept="getUploadAccept(mat)"
|
<el-upload action="#" :accept="getUploadAccept(mat)"
|
||||||
:disabled="isMaterialUploading(mat.materialTypeId)" :show-file-list="false"
|
:disabled="isMaterialUploading(mat.materialTypeId)" :show-file-list="false"
|
||||||
|
:http-request="(options: any) => uploadMaterial(options, mat.materialTypeId)"
|
||||||
:on-success="(response: any) => onUploadSuccess(response, mat.materialTypeId)"
|
:on-success="(response: any) => onUploadSuccess(response, mat.materialTypeId)"
|
||||||
:on-error="(error: Error) => onUploadError(error, mat.materialTypeId)"
|
:on-error="(error: Error) => onUploadError(error, mat.materialTypeId)"
|
||||||
:before-upload="(file: File) => beforeUpload(file, mat)">
|
:before-upload="(file: File) => beforeUpload(file, mat)">
|
||||||
@@ -642,8 +644,8 @@
|
|||||||
|
|
||||||
<div class="contract-card-bottom">
|
<div class="contract-card-bottom">
|
||||||
<el-button :type="isContractPrepareFailed(c.id) ? 'danger' : 'primary'" plain
|
<el-button :type="isContractPrepareFailed(c.id) ? 'danger' : 'primary'" plain
|
||||||
:icon="isContractPrepareFailed(c.id) ? 'Refresh' : 'Download'" :loading="isContractPreparing(c.id)"
|
:icon="isContractPrepareFailed(c.id) ? 'Refresh' : 'Download'" :loading="isContractBusy(c.id)"
|
||||||
:disabled="!isContractPrepared(c.id) && !isContractPrepareFailed(c.id)"
|
:disabled="isContractDownloading(c.id) || (!isContractPrepared(c.id) && !isContractPrepareFailed(c.id))"
|
||||||
@click="handleContractAction(c)">
|
@click="handleContractAction(c)">
|
||||||
{{ getContractActionText(c.id) }}
|
{{ getContractActionText(c.id) }}
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -682,9 +684,9 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch }
|
|||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
||||||
import {
|
import {
|
||||||
OnboardingTask,
|
OnboardingPublicTask,
|
||||||
onboardingPublicApi
|
onboardingPublicApi
|
||||||
} from "@/api/modules/biz/onboarding";
|
} from "@/api/modules/open/onboarding";
|
||||||
import {
|
import {
|
||||||
getIdCardBirthday,
|
getIdCardBirthday,
|
||||||
getIdCardGender,
|
getIdCardGender,
|
||||||
@@ -713,8 +715,11 @@ const uploadingMaterials = reactive<Record<string, boolean>>({});
|
|||||||
type ContractPrepareStatus = "idle" | "preparing" | "ready" | "error";
|
type ContractPrepareStatus = "idle" | "preparing" | "ready" | "error";
|
||||||
const contractPrepareStatus = reactive<Record<string, ContractPrepareStatus>>({});
|
const contractPrepareStatus = reactive<Record<string, ContractPrepareStatus>>({});
|
||||||
const contractPrepareErrors = reactive<Record<string, string>>({});
|
const contractPrepareErrors = reactive<Record<string, string>>({});
|
||||||
|
const contractDownloading = reactive<Record<string, boolean>>({});
|
||||||
|
const materialPreviewUrls = reactive<Record<string, string>>({});
|
||||||
|
let materialPreviewGeneration = 0;
|
||||||
|
|
||||||
const task = ref<OnboardingTask | null>(null);
|
const task = ref<OnboardingPublicTask | null>(null);
|
||||||
const contracts = ref<any[]>([]);
|
const contracts = ref<any[]>([]);
|
||||||
const materialRequirements = ref<any[]>([]);
|
const materialRequirements = ref<any[]>([]);
|
||||||
const uploadedMaterials = ref<any[]>([]);
|
const uploadedMaterials = ref<any[]>([]);
|
||||||
@@ -1405,6 +1410,7 @@ async function load(silent = false) {
|
|||||||
try { Object.assign(customVariables, JSON.parse(data.employeeInfo.customVariablesJson)); } catch { }
|
try { Object.assign(customVariables, JSON.parse(data.employeeInfo.customVariablesJson)); } catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await refreshMaterialPreviews();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.message || "无法加载入职任务";
|
error.value = err.message || "无法加载入职任务";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1492,23 +1498,60 @@ async function nextStep() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMaterialUploadUrl(materialTypeId: number, taskMaterialId?: number) {
|
function materialPreviewKey(file: any) {
|
||||||
const query = new URLSearchParams({
|
return `${file.id}-${file.fileId || 0}`;
|
||||||
token: token.value,
|
|
||||||
materialTypeId: String(materialTypeId)
|
|
||||||
});
|
|
||||||
if (taskMaterialId) {
|
|
||||||
query.set("taskMaterialId", String(taskMaterialId));
|
|
||||||
}
|
|
||||||
return `/api/open/onboarding/upload-material?${query.toString()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMaterialSourceUrl(file: any) {
|
function getMaterialSourceUrl(file: any) {
|
||||||
return onboardingPublicApi.materialSourceUrl({
|
return materialPreviewUrls[materialPreviewKey(file)] || "";
|
||||||
token: token.value,
|
}
|
||||||
taskMaterialId: file.id,
|
|
||||||
fileId: file.fileId
|
function revokeMaterialPreviewUrls() {
|
||||||
});
|
Object.values(materialPreviewUrls).forEach(url => URL.revokeObjectURL(url));
|
||||||
|
Object.keys(materialPreviewUrls).forEach(key => delete materialPreviewUrls[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMaterialPreviews() {
|
||||||
|
const generation = ++materialPreviewGeneration;
|
||||||
|
revokeMaterialPreviewUrls();
|
||||||
|
|
||||||
|
await Promise.all(uploadedMaterials.value
|
||||||
|
.filter(file => isImageFile(file))
|
||||||
|
.map(async file => {
|
||||||
|
try {
|
||||||
|
const result = await onboardingPublicApi.downloadMaterial({
|
||||||
|
token: token.value,
|
||||||
|
taskMaterialId: file.id,
|
||||||
|
fileId: file.fileId
|
||||||
|
}, false);
|
||||||
|
const objectUrl = URL.createObjectURL(result.blob);
|
||||||
|
if (generation !== materialPreviewGeneration) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
materialPreviewUrls[materialPreviewKey(file)] = objectUrl;
|
||||||
|
} catch {
|
||||||
|
// 单张历史图片损坏时保留文件信息,不阻断整个入职任务。
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadMaterial(
|
||||||
|
options: any,
|
||||||
|
materialTypeId: number,
|
||||||
|
taskMaterialId?: number
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const response = await onboardingPublicApi.uploadMaterial({
|
||||||
|
token: token.value,
|
||||||
|
materialTypeId,
|
||||||
|
taskMaterialId,
|
||||||
|
file: options.file
|
||||||
|
});
|
||||||
|
options.onSuccess(response);
|
||||||
|
} catch (error: any) {
|
||||||
|
options.onError(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMaterialUploading(materialTypeId: number) {
|
function isMaterialUploading(materialTypeId: number) {
|
||||||
@@ -1547,9 +1590,11 @@ async function onUploadSuccess(res: any, materialTypeId: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUploadError(error: Error, materialTypeId: number) {
|
function onUploadError(error: any, materialTypeId: number) {
|
||||||
setMaterialUploading(materialTypeId, false);
|
setMaterialUploading(materialTypeId, false);
|
||||||
ElMessage.error(error.message || "材料上传失败,请稍后重试");
|
if (!error?.reported) {
|
||||||
|
ElMessage.error(error?.message || "材料上传失败,请稍后重试");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteFile(id: number) {
|
async function deleteFile(id: number) {
|
||||||
@@ -1594,6 +1639,14 @@ function isContractPreparing(taskContractId: number) {
|
|||||||
return getContractPrepareStatus(taskContractId) === "preparing";
|
return getContractPrepareStatus(taskContractId) === "preparing";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isContractDownloading(taskContractId: number) {
|
||||||
|
return Boolean(contractDownloading[String(taskContractId)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContractBusy(taskContractId: number) {
|
||||||
|
return isContractPreparing(taskContractId) || isContractDownloading(taskContractId);
|
||||||
|
}
|
||||||
|
|
||||||
function isContractPrepared(taskContractId: number) {
|
function isContractPrepared(taskContractId: number) {
|
||||||
return getContractPrepareStatus(taskContractId) === "ready";
|
return getContractPrepareStatus(taskContractId) === "ready";
|
||||||
}
|
}
|
||||||
@@ -1603,6 +1656,9 @@ function isContractPrepareFailed(taskContractId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getContractActionText(taskContractId: number) {
|
function getContractActionText(taskContractId: number) {
|
||||||
|
if (isContractDownloading(taskContractId)) {
|
||||||
|
return "正在下载签署材料";
|
||||||
|
}
|
||||||
const status = getContractPrepareStatus(taskContractId);
|
const status = getContractPrepareStatus(taskContractId);
|
||||||
if (status === "preparing") {
|
if (status === "preparing") {
|
||||||
return "正在准备签署材料";
|
return "正在准备签署材料";
|
||||||
@@ -1670,23 +1726,105 @@ async function handleContractAction(contract: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isContractPrepared(contract.id)) {
|
if (isContractPrepared(contract.id)) {
|
||||||
previewAndDownloadContract(contract);
|
await previewAndDownloadContract(contract);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewAndDownloadContract(c: any) {
|
function normalizeDownloadFileName(fileName: string | undefined, fallback: string) {
|
||||||
const url = onboardingPublicApi.contractSourceUrl({
|
const normalized = String(fileName || fallback)
|
||||||
token: token.value,
|
.replace(/[\\/:*?"<>|]/g, "_")
|
||||||
taskContractId: c.id,
|
.trim();
|
||||||
fileVersion: c.generatedDocxFileId
|
return normalized || fallback;
|
||||||
});
|
}
|
||||||
|
|
||||||
c.hasViewed = true;
|
function triggerContractDownload(blob: Blob, fileName: string) {
|
||||||
c.isAgreed = false;
|
if (!blob.size) {
|
||||||
// 下载地址必须在用户点击事件中直接打开,微信及部分手机浏览器会拦截异步跳转空白页。
|
throw new Error("服务器返回的合同文件为空");
|
||||||
const downloadWindow = window.open(url, "_blank");
|
}
|
||||||
if (!downloadWindow) {
|
|
||||||
window.location.assign(url);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = fileName;
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
link.style.display = "none";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
|
||||||
|
// 安卓 WebView 可能延迟接管 Blob 下载,不能过早释放。
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDirectContractDownloadUrl(c: any) {
|
||||||
|
const url = new URL("/api/open/onboarding/contract-source", window.location.origin);
|
||||||
|
url.searchParams.set("token", token.value);
|
||||||
|
url.searchParams.set("taskContractId", String(c.id));
|
||||||
|
if (c.generatedDocxFileId) {
|
||||||
|
url.searchParams.set("version", String(c.generatedDocxFileId));
|
||||||
|
}
|
||||||
|
url.searchParams.set("_t", String(Date.now()));
|
||||||
|
return `${url.pathname}${url.search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDirectContractDownload(url: string) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
link.style.display = "none";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewAndDownloadContract(c: any) {
|
||||||
|
const key = String(c.id);
|
||||||
|
if (contractDownloading[key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contractDownloading[key] = true;
|
||||||
|
try {
|
||||||
|
const userAgent = window.navigator.userAgent;
|
||||||
|
if (/Android/i.test(userAgent)) {
|
||||||
|
// 安卓 WebView 通常不支持 blob: 下载,直接交给系统的 HTTP 下载能力处理。
|
||||||
|
// 此处在用户点击事件后的第一个同步分支触发,避免被 WebView 当成弹窗拦截。
|
||||||
|
triggerDirectContractDownload(buildDirectContractDownloadUrl(c));
|
||||||
|
c.hasViewed = true;
|
||||||
|
c.isAgreed = false;
|
||||||
|
ElMessage.success("签署材料下载已发起,请使用 Word/WPS 打开核对");
|
||||||
|
if (/MicroMessenger/i.test(userAgent)) {
|
||||||
|
void ElMessageBox.alert(
|
||||||
|
"如果微信中没有出现文件,请点击右上角“…”选择“在浏览器打开”,然后再次点击下载。",
|
||||||
|
"安卓微信下载提示",
|
||||||
|
{ confirmButtonText: "我知道了" }
|
||||||
|
).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await onboardingPublicApi.downloadContract({
|
||||||
|
token: token.value,
|
||||||
|
taskContractId: c.id,
|
||||||
|
fileVersion: c.generatedDocxFileId
|
||||||
|
});
|
||||||
|
const fallbackName = `${c.contractName || "入职签署材料"}.docx`;
|
||||||
|
triggerContractDownload(
|
||||||
|
result.blob,
|
||||||
|
normalizeDownloadFileName(result.fileName, fallbackName)
|
||||||
|
);
|
||||||
|
|
||||||
|
c.hasViewed = true;
|
||||||
|
c.isAgreed = false;
|
||||||
|
ElMessage.success("签署材料已开始下载,请使用 Word/WPS 打开核对");
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!err?.reported) {
|
||||||
|
ElMessage.error(err?.message || "签署材料下载失败,请稍后重试");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
contractDownloading[key] = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1773,6 +1911,8 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
materialPreviewGeneration++;
|
||||||
|
revokeMaterialPreviewUrls();
|
||||||
if (linkExpiryTimer !== undefined) {
|
if (linkExpiryTimer !== undefined) {
|
||||||
window.clearInterval(linkExpiryTimer);
|
window.clearInterval(linkExpiryTimer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user