From 70bf1907b53ad78cc116e4c10504dd262eebb329 Mon Sep 17 00:00:00 2001 From: fuenmao <980740792@qq.com> Date: Tue, 15 Sep 2026 13:43:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=8F=8C=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 15 ++ .env | 7 + .env.development | 3 + .env.production | 3 + .env.public | 28 +++ Dockerfiles/Dockerfile.admin | 23 ++ Dockerfiles/Dockerfile.public | 23 ++ Dockerfiles/admin.conf | 34 +++ Dockerfiles/api-public-callback.conf.example | 22 ++ Dockerfiles/edge-domains.conf.example | 82 +++++++ Dockerfiles/public.conf | 45 ++++ Dockerfiles/双前端域名部署.md | 117 ++++++++++ build/plugins.ts | 17 +- index.html | 5 +- package.json | 3 + src/PublicApp.vue | 13 ++ src/api/modules/biz/onboarding.ts | 44 ---- src/api/modules/open/onboarding.ts | 91 ++++++++ src/api/request/publicRequest.ts | 216 +++++++++++++++++++ src/config/index.ts | 2 +- src/public-main.ts | 60 ++++++ src/routers/modules/dynamicRouter.ts | 5 +- src/routers/modules/staticRouter.ts | 6 - src/routers/public.ts | 55 +++++ src/typings/global.d.ts | 4 + src/views/biz/onboarding/task/index.vue | 14 +- src/views/onboarding/fill/index.vue | 216 +++++++++++++++---- src/views/onboarding/public-status/index.vue | 68 ++++++ 28 files changed, 1125 insertions(+), 96 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.public create mode 100644 Dockerfiles/Dockerfile.admin create mode 100644 Dockerfiles/Dockerfile.public create mode 100644 Dockerfiles/admin.conf create mode 100644 Dockerfiles/api-public-callback.conf.example create mode 100644 Dockerfiles/edge-domains.conf.example create mode 100644 Dockerfiles/public.conf create mode 100644 Dockerfiles/双前端域名部署.md create mode 100644 src/PublicApp.vue create mode 100644 src/api/modules/open/onboarding.ts create mode 100644 src/api/request/publicRequest.ts create mode 100644 src/public-main.ts create mode 100644 src/routers/public.ts create mode 100644 src/views/onboarding/public-status/index.vue diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8de817f --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env b/.env index a78f9e6..3b49cfb 100644 --- a/.env +++ b/.env @@ -1,6 +1,13 @@ # 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 diff --git a/.env.development b/.env.development index 005b91e..25bdd5b 100644 --- a/.env.development +++ b/.env.development @@ -1,6 +1,9 @@ # 本地环境 VITE_USER_NODE_ENV = development +# 本地后台生成的员工链接固定指向独立公开前端开发端口 +VITE_PUBLIC_ONBOARDING_URL = http://localhost:8849 + # 公共基础路径 VITE_PUBLIC_PATH = / diff --git a/.env.production b/.env.production index c87dc05..e1d0b8c 100644 --- a/.env.production +++ b/.env.production @@ -1,6 +1,9 @@ # 线上环境 VITE_USER_NODE_ENV = production +# 后台生成员工入职链接时使用的固定公开域名 +VITE_PUBLIC_ONBOARDING_URL = https://onboardx.cnzhyx.cn + # 公共基础路径 VITE_PUBLIC_PATH = / diff --git a/.env.public b/.env.public new file mode 100644 index 0000000..a81fa93 --- /dev/null +++ b/.env.public @@ -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 diff --git a/Dockerfiles/Dockerfile.admin b/Dockerfiles/Dockerfile.admin new file mode 100644 index 0000000..c06d02f --- /dev/null +++ b/Dockerfiles/Dockerfile.admin @@ -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;"] diff --git a/Dockerfiles/Dockerfile.public b/Dockerfiles/Dockerfile.public new file mode 100644 index 0000000..c8a1dfc --- /dev/null +++ b/Dockerfiles/Dockerfile.public @@ -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;"] diff --git a/Dockerfiles/admin.conf b/Dockerfiles/admin.conf new file mode 100644 index 0000000..fb38486 --- /dev/null +++ b/Dockerfiles/admin.conf @@ -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; + } +} diff --git a/Dockerfiles/api-public-callback.conf.example b/Dockerfiles/api-public-callback.conf.example new file mode 100644 index 0000000..52cc282 --- /dev/null +++ b/Dockerfiles/api-public-callback.conf.example @@ -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; + } +} diff --git a/Dockerfiles/edge-domains.conf.example b/Dockerfiles/edge-domains.conf.example new file mode 100644 index 0000000..24cafc0 --- /dev/null +++ b/Dockerfiles/edge-domains.conf.example @@ -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; + } +} diff --git a/Dockerfiles/public.conf b/Dockerfiles/public.conf new file mode 100644 index 0000000..e07c21c --- /dev/null +++ b/Dockerfiles/public.conf @@ -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; + } +} diff --git a/Dockerfiles/双前端域名部署.md b/Dockerfiles/双前端域名部署.md new file mode 100644 index 0000000..5e9d173 --- /dev/null +++ b/Dockerfiles/双前端域名部署.md @@ -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 镜像。 diff --git a/build/plugins.ts b/build/plugins.ts index afa7d89..3d973d9 100644 --- a/build/plugins.ts +++ b/build/plugins.ts @@ -129,12 +129,25 @@ const createCompression = (viteEnv: ViteEnv): PluginOption | PluginOption[] => { * @param viteEnv */ 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({ registerType: "autoUpdate", workbox: { // 添加此项配置,增加需要缓存的最大文件大小 - maximumFileSizeToCacheInBytes: 4 * 1024 * 1024 + maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, + // API、下载和回调均不能回退到 SPA 的 index.html。 + navigateFallbackDenylist: [/^\/api\//] }, manifest: { name: VITE_GLOB_APP_TITLE, diff --git a/index.html b/index.html index 5aea4eb..e56d367 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,8 @@ - + + <%- title %> @@ -100,6 +101,6 @@ if (globalState.isDark) html.style.background = "#141414"; } - + diff --git a/package.json b/package.json index a55f4a4..633db21 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,13 @@ }, "scripts": { "dev": "vite", + "dev:public": "vite --mode public", "serve": "vite", "build:dev": "vue-tsc && vite build --mode development", "build:test": "vue-tsc && vite build --mode test", "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", "preview": "pnpm run build:dev && vite preview", "lint:eslint": "eslint --fix --ext .js,.ts,.vue ./src", diff --git a/src/PublicApp.vue b/src/PublicApp.vue new file mode 100644 index 0000000..3e07928 --- /dev/null +++ b/src/PublicApp.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/api/modules/biz/onboarding.ts b/src/api/modules/biz/onboarding.ts index f7cb82d..c02b9b8 100644 --- a/src/api/modules/biz/onboarding.ts +++ b/src/api/modules/biz/onboarding.ts @@ -104,13 +104,6 @@ export interface OnboardingTaskCopyResult { tasks: OnboardingTaskCreatedResult[]; } -/** 员工端完成合同准备后返回的当前文件版本。 */ -export interface OnboardingPreparedContract { - /** 当前生成合同对应的系统文件 Id,同时作为下载缓存版本。 */ - fileId: number; - /** 当前合同文件生成时间。 */ - generatedAt?: string; -} /** 一次电子签流程中的单份合同文件。 */ export interface OnboardingElectronicSignDocument { @@ -964,40 +957,3 @@ export const onboardingTaskApi = { return moduleRequest("/biz/onboarding/variable-definition/").post("delete", { ids }); } }; - -const publicHttp = moduleRequest("/open/onboarding/"); - -export const onboardingPublicApi = { - detail(params: { token: string }) { - return publicHttp.post("detail", params); - }, - saveEmployeeInfo(params: Record) { - 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("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); - } -}; diff --git a/src/api/modules/open/onboarding.ts b/src/api/modules/open/onboarding.ts new file mode 100644 index 0000000..8917d45 --- /dev/null +++ b/src/api/modules/open/onboarding.ts @@ -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("detail", params); + }, + + saveEmployeeInfo(params: Record) { + return publicPost("save-employee-info", params); + }, + + confirmContract(params: { token: string; taskContractId: number }) { + return publicPost("confirm-contract", params); + }, + + submit(params: { + token: string; + identityInformationConfirmed: boolean; + confirmedEmployeeName: string; + confirmedIdCardNumber: string; + confirmedPhone: string; + }) { + return publicPost("submit", params); + }, + + prepareContract(params: { token: string; taskContractId: number }) { + return publicPost("prepare-contract", params); + }, + + downloadContract(params: { + token: string; + taskContractId: number; + fileVersion?: number; + }): Promise { + 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 { + 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("upload-material", data, { + token: params.token, + materialTypeId: params.materialTypeId, + taskMaterialId: params.taskMaterialId + }); + }, + + deleteMaterial(params: { token: string; taskMaterialId: number }) { + return publicPost("delete-material", params); + } +}; diff --git a/src/api/request/publicRequest.ts b/src/api/request/publicRequest.ts new file mode 100644 index 0000000..729ceaa --- /dev/null +++ b/src/api/request/publicRequest.ts @@ -0,0 +1,216 @@ +import { ElMessage } from "element-plus"; + +const PUBLIC_ONBOARDING_PREFIX = "/api/open/onboarding/"; +const DEFAULT_TIMEOUT = 90_000; + +export interface PublicApiResult { + 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 +) { + 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, + 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( + response: Response, + notifyError: boolean +): Promise> { + let result: PublicApiResult; + 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( + 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(response, options.notifyError !== false); +} + +export async function publicUpload( + action: string, + formData: FormData, + query?: Record, + 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(response, options.notifyError !== false); +} + +export async function publicDownload( + action: string, + query: Record, + options: PublicRequestOptions = {} +): Promise { + 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( + 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 + }; +} diff --git a/src/config/index.ts b/src/config/index.ts index 48b7092..f1c477c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -26,7 +26,7 @@ export const USER_CENTER_URL: string = "/userCenter"; export const DEFAULT_PRIMARY: string = "#409EFF"; // 路由白名单地址(本地存在的路由 staticRouter.ts 中) -export const ROUTER_WHITE_LIST: string[] = ["/500", "/onboarding/fill"]; +export const ROUTER_WHITE_LIST: string[] = ["/500"]; // 高德地图 key export const AMAP_MAP_KEY: string = ""; diff --git a/src/public-main.ts b/src/public-main.ts new file mode 100644 index 0000000..1b738df --- /dev/null +++ b/src/public-main.ts @@ -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"); diff --git a/src/routers/modules/dynamicRouter.ts b/src/routers/modules/dynamicRouter.ts index 1f8dd63..fb9637b 100644 --- a/src/routers/modules/dynamicRouter.ts +++ b/src/routers/modules/dynamicRouter.ts @@ -19,7 +19,10 @@ import { useUserStore, useAuthStore, useDictStore, useConfigStore } from "@/stor import router from "@/routers"; // 引入 views 文件夹下所有 vue 文件 -const modules = import.meta.glob("@/views/**/*.vue"); +const modules = import.meta.glob([ + "@/views/**/*.vue", + "!@/views/onboarding/**/*.vue" +]); /** * @description 初始化动态路由 diff --git a/src/routers/modules/staticRouter.ts b/src/routers/modules/staticRouter.ts index 138500b..15b8bff 100644 --- a/src/routers/modules/staticRouter.ts +++ b/src/routers/modules/staticRouter.ts @@ -31,12 +31,6 @@ export const staticRouter: RouteRecordRaw[] = [ title: "登录" } }, - { - path: "/onboarding/fill", - name: "onboardingFill", - component: () => import("@/views/onboarding/fill/index.vue"), - meta: { title: "入职服务", standaloneTitle: true } - }, { path: "/layout", name: "layout", diff --git a/src/routers/public.ts b/src/routers/public.ts new file mode 100644 index 0000000..97cd62a --- /dev/null +++ b/src/routers/public.ts @@ -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; diff --git a/src/typings/global.d.ts b/src/typings/global.d.ts index b24e955..d0d6e55 100644 --- a/src/typings/global.d.ts +++ b/src/typings/global.d.ts @@ -57,6 +57,9 @@ declare type Recordable = Record; declare interface ViteEnv { 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_PORT: number; VITE_OPEN: boolean; @@ -69,6 +72,7 @@ declare interface ViteEnv { VITE_DEVTOOLS: boolean; VITE_PUBLIC_PATH: string; VITE_API_URL: string; + VITE_PUBLIC_ONBOARDING_URL: string; VITE_PROXY: [string, string][]; VITE_CODEINSPECTOR: boolean; } diff --git a/src/views/biz/onboarding/task/index.vue b/src/views/biz/onboarding/task/index.vue index 0a3687b..a74915f 100644 --- a/src/views/biz/onboarding/task/index.vue +++ b/src/views/biz/onboarding/task/index.vue @@ -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() { if (!validateContractPreviewForm()) return; if (!form.remark.trim()) { @@ -2532,7 +2542,7 @@ async function createTask() { createVisible.value = false; 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 selectedDepartment = departments.value.find(d => d.id === form.departmentId)?.departmentName || ""; const selectedPosition = positions.value.find(p => p.id === form.positionId)?.positionName || ""; @@ -2571,7 +2581,7 @@ function copyInviteMessage() { async function copyOrShareTaskLink(row: OnboardingTask) { try { 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 = { taskNo: row.taskNo, diff --git a/src/views/onboarding/fill/index.vue b/src/views/onboarding/fill/index.vue index ab594c0..40e70ed 100644 --- a/src/views/onboarding/fill/index.vue +++ b/src/views/onboarding/fill/index.vue @@ -514,8 +514,8 @@
- + @@ -529,8 +529,9 @@
- @@ -547,8 +548,9 @@
- @@ -642,8 +644,8 @@
{{ getContractActionText(c.id) }} @@ -682,9 +684,9 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } import { useRoute } from "vue-router"; import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus"; import { - OnboardingTask, + OnboardingPublicTask, onboardingPublicApi -} from "@/api/modules/biz/onboarding"; +} from "@/api/modules/open/onboarding"; import { getIdCardBirthday, getIdCardGender, @@ -713,8 +715,11 @@ const uploadingMaterials = reactive>({}); type ContractPrepareStatus = "idle" | "preparing" | "ready" | "error"; const contractPrepareStatus = reactive>({}); const contractPrepareErrors = reactive>({}); +const contractDownloading = reactive>({}); +const materialPreviewUrls = reactive>({}); +let materialPreviewGeneration = 0; -const task = ref(null); +const task = ref(null); const contracts = ref([]); const materialRequirements = ref([]); const uploadedMaterials = ref([]); @@ -1405,6 +1410,7 @@ async function load(silent = false) { try { Object.assign(customVariables, JSON.parse(data.employeeInfo.customVariablesJson)); } catch { } } } + await refreshMaterialPreviews(); } catch (err: any) { error.value = err.message || "无法加载入职任务"; } finally { @@ -1492,23 +1498,60 @@ async function nextStep() { } } -function getMaterialUploadUrl(materialTypeId: number, taskMaterialId?: number) { - const query = new URLSearchParams({ - token: token.value, - materialTypeId: String(materialTypeId) - }); - if (taskMaterialId) { - query.set("taskMaterialId", String(taskMaterialId)); - } - return `/api/open/onboarding/upload-material?${query.toString()}`; +function materialPreviewKey(file: any) { + return `${file.id}-${file.fileId || 0}`; } function getMaterialSourceUrl(file: any) { - return onboardingPublicApi.materialSourceUrl({ - token: token.value, - taskMaterialId: file.id, - fileId: file.fileId - }); + return materialPreviewUrls[materialPreviewKey(file)] || ""; +} + +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) { @@ -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); - ElMessage.error(error.message || "材料上传失败,请稍后重试"); + if (!error?.reported) { + ElMessage.error(error?.message || "材料上传失败,请稍后重试"); + } } async function deleteFile(id: number) { @@ -1594,6 +1639,14 @@ function isContractPreparing(taskContractId: number) { 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) { return getContractPrepareStatus(taskContractId) === "ready"; } @@ -1603,6 +1656,9 @@ function isContractPrepareFailed(taskContractId: number) { } function getContractActionText(taskContractId: number) { + if (isContractDownloading(taskContractId)) { + return "正在下载签署材料"; + } const status = getContractPrepareStatus(taskContractId); if (status === "preparing") { return "正在准备签署材料"; @@ -1670,23 +1726,105 @@ async function handleContractAction(contract: any) { } if (isContractPrepared(contract.id)) { - previewAndDownloadContract(contract); + await previewAndDownloadContract(contract); } } -function previewAndDownloadContract(c: any) { - const url = onboardingPublicApi.contractSourceUrl({ - token: token.value, - taskContractId: c.id, - fileVersion: c.generatedDocxFileId - }); +function normalizeDownloadFileName(fileName: string | undefined, fallback: string) { + const normalized = String(fileName || fallback) + .replace(/[\\/:*?"<>|]/g, "_") + .trim(); + return normalized || fallback; +} - c.hasViewed = true; - c.isAgreed = false; - // 下载地址必须在用户点击事件中直接打开,微信及部分手机浏览器会拦截异步跳转空白页。 - const downloadWindow = window.open(url, "_blank"); - if (!downloadWindow) { - window.location.assign(url); +function triggerContractDownload(blob: Blob, fileName: string) { + if (!blob.size) { + throw new Error("服务器返回的合同文件为空"); + } + + 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(() => { + materialPreviewGeneration++; + revokeMaterialPreviewUrls(); if (linkExpiryTimer !== undefined) { window.clearInterval(linkExpiryTimer); } diff --git a/src/views/onboarding/public-status/index.vue b/src/views/onboarding/public-status/index.vue new file mode 100644 index 0000000..1ea0ab5 --- /dev/null +++ b/src/views/onboarding/public-status/index.vue @@ -0,0 +1,68 @@ + + + + +