feat(portal): 扫码登录 + 角色自动分发 (Phase 1.3 task #16)
- 新建 frontend-portal/src/api/qrcode.ts — /api/auth_qrcode/* API 适配
- 新建 frontend-portal/src/composables/useQrcodeLogin.ts — 扫码核心逻辑
- 新建 frontend-portal/src/views/QrcodeLogin.vue — Portal 扫码登录 UI
- 扫码成功后按角色自动跳:
- 只有 admin → /itadmin/
- 只有 agent → /itagent/
- admin+agent → /itportal/select(多角色)
- 默认 user → /itdesk/
- 改 frontend-portal/src/router/index.ts — 默认 / → /qrcode-login
(原 PortalSelect.vue 保留作多角色 fallback)
- 新建 docs/NGINX-DOMAIN-ROUTING.md — 运维域名分发配置模板
build: ✅ frontend-portal vue-tsc + vite build 通过
QrcodeLogin chunk 4.82 kB
This commit is contained in:
@@ -0,0 +1,256 @@
|
|||||||
|
# Nginx 域名路由分发配置(Phase 1.3 task #16)
|
||||||
|
|
||||||
|
> 创建:2026-06-21
|
||||||
|
> 适用版本:v0.7.0+ (Phase 1.3 扫码登录上线后)
|
||||||
|
|
||||||
|
## 🎯 目标
|
||||||
|
|
||||||
|
不同入口域名/子路径 → 不同前端应用,但所有请求共用同一个后端 API。
|
||||||
|
|
||||||
|
| 入口 | URL | 前端应用 | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **坐席端** | `https://itsupport.servyou.com.cn/itagent/` | `frontend-agent/dist` | 坐席工作台 |
|
||||||
|
| **管理端** | `https://itsupport.servyou.com.cn/itadmin/` | `frontend-admin/dist` | 管理后台 |
|
||||||
|
| **Portal 统一入口** | `https://itsupport.servyou.com.cn/itportal/` | `frontend-portal/dist` | 扫码登录 + 多角色选择 |
|
||||||
|
| **H5 员工端** | `https://itsupport.servyou.com.cn/itdesk/` | `frontend-h5/dist` | 员工端(企微内) |
|
||||||
|
|
||||||
|
> **两种方案**:单域名多路径(本项目当前)+ 多子域名(可选升级)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🅰️ 方案 A:单域名 + 多子路径(推荐,运维简单)
|
||||||
|
|
||||||
|
### nginx server block
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name itsupport.servyou.com.cn;
|
||||||
|
|
||||||
|
# SSL 证书(由公司统一管理)
|
||||||
|
ssl_certificate /etc/nginx/certs/itsupport.servyou.com.cn.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/itsupport.servyou.com.cn.key;
|
||||||
|
|
||||||
|
# 通用安全头
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 1. Portal 统一入口(扫码登录)
|
||||||
|
# ========================================================================
|
||||||
|
location /itportal/ {
|
||||||
|
alias /opt/wecom-it-desk/frontend-portal/dist/;
|
||||||
|
try_files $uri $uri/ /itportal/index.html;
|
||||||
|
|
||||||
|
# 允许企业微信 OAuth 回调(测试期)
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 2. 坐席工作台
|
||||||
|
# ========================================================================
|
||||||
|
location /itagent/ {
|
||||||
|
alias /opt/wecom-it-desk/frontend-agent/dist/;
|
||||||
|
try_files $uri $uri/ /itagent/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 3. 管理后台
|
||||||
|
# ========================================================================
|
||||||
|
# IP 白名单(临时方案,v1.0 前收窄 — 见 ip-whitelist-trust-proxies-todo.md)
|
||||||
|
location /itadmin/ {
|
||||||
|
allow 0.0.0.0/0; # ⚠️ 临时全开
|
||||||
|
# allow 10.90.0.0/16; # TODO 收窄到内网
|
||||||
|
# allow 115.236.188.3; # 公网入口 IP
|
||||||
|
|
||||||
|
alias /opt/wecom-it-desk/frontend-admin/dist/;
|
||||||
|
try_files $uri $uri/ /itadmin/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 4. H5 员工端
|
||||||
|
# ========================================================================
|
||||||
|
location /itdesk/ {
|
||||||
|
alias /opt/wecom-it-desk/frontend-h5/dist/;
|
||||||
|
try_files $uri $uri/ /itdesk/index.html;
|
||||||
|
|
||||||
|
# 允许嵌入到企微 WebView
|
||||||
|
add_header X-Frame-Options "ALLOW-FROM https://work.weixin.qq.com" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 5. 后端 API(4 个端共用)
|
||||||
|
# ========================================================================
|
||||||
|
location /api/ {
|
||||||
|
# 管理端 API 严格白名单
|
||||||
|
location /api/admin/ {
|
||||||
|
allow 0.0.0.0/0; # ⚠️ 临时全开
|
||||||
|
# allow 10.90.0.0/16; # TODO 收窄
|
||||||
|
# allow 115.236.188.3;
|
||||||
|
|
||||||
|
proxy_pass http://wecom_it_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 其他 API 放行
|
||||||
|
proxy_pass http://wecom_it_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 6. WebSocket(坐席端 WS-01 鉴权)
|
||||||
|
# ========================================================================
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://wecom_it_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# WS 心跳
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 7. 静态资源(图片/上传文件)
|
||||||
|
# ========================================================================
|
||||||
|
location /api/media/ {
|
||||||
|
proxy_pass http://wecom_it_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
# 上传文件 30 天缓存
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 8. 根路径 → Portal 统一入口
|
||||||
|
# ========================================================================
|
||||||
|
location = / {
|
||||||
|
return 302 /itportal/;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# upstream 后端(内网容器)
|
||||||
|
upstream wecom_it_backend {
|
||||||
|
server 127.0.0.1:8000; # 容器映射到宿主机的端口
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 部署步骤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 上传 dist 文件(各前端 build 产物)
|
||||||
|
scp -r frontend-portal/dist root@10.90.5.110:/opt/wecom-it-desk/frontend-portal/
|
||||||
|
scp -r frontend-agent/dist root@10.90.5.110:/opt/wecom-it-desk/frontend-agent/
|
||||||
|
scp -r frontend-admin/dist root@10.90.5.110:/opt/wecom-it-desk/frontend-admin/
|
||||||
|
scp -r frontend-h5/dist root@10.90.5.110:/opt/wecom-it-desk/frontend-h5/
|
||||||
|
|
||||||
|
# 2. 上传 nginx 配置(本地 + 堡垒机 PuTTY)
|
||||||
|
# 参考:feedback-putty-not-openssh.md(用 PuTTY 操作)
|
||||||
|
|
||||||
|
# 3. 验证配置
|
||||||
|
sudo nginx -t
|
||||||
|
|
||||||
|
# 4. reload
|
||||||
|
sudo nginx -s reload
|
||||||
|
|
||||||
|
# 5. 验证(本地或企微)
|
||||||
|
curl -I https://itsupport.servyou.com.cn/itportal/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🅱️ 方案 B:多子域名(可选升级,需要 DNS 解析)
|
||||||
|
|
||||||
|
| 子域名 | 解析到 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| `portal.itsupport.servyou.com.cn` | nginx:443 | 统一入口 |
|
||||||
|
| `agent.itsupport.servyou.com.cn` | nginx:443 | 坐席工作台 |
|
||||||
|
| `admin.itsupport.servyou.com.cn` | nginx:443 | 管理后台(内网白名单) |
|
||||||
|
| `h5.itsupport.servyou.com.cn` | nginx:443 | H5 员工端 |
|
||||||
|
|
||||||
|
### 优点
|
||||||
|
- 跨域 cookie 隔离更清晰
|
||||||
|
- 每个子域可独立上 HTTPS 证书
|
||||||
|
- 内网白名单更容易配置(直接 deny all 到 admin.*)
|
||||||
|
|
||||||
|
### 缺点
|
||||||
|
- 需要运维额外加 4 个 A 记录
|
||||||
|
- 前端跨域 API 调用要 CORS 配全
|
||||||
|
- 坐席/管理员跨域切换要 CORS preflight
|
||||||
|
|
||||||
|
**当前 v0.7.0 推荐方案 A**,v1.0 再考虑方案 B。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 扫码登录流程(方案 A 下)
|
||||||
|
|
||||||
|
```
|
||||||
|
[1] 用户访问 https://itsupport.servyou.com.cn/itagent/
|
||||||
|
→ nginx 命中 location /itagent/ → 返回 frontend-agent/dist/index.html
|
||||||
|
→ 前端路由守卫检查 localStorage.agent_token,没有 → 跳 /itportal/
|
||||||
|
|
||||||
|
[2] 用户访问 https://itsupport.servyou.com.cn/itportal/
|
||||||
|
→ nginx 命中 location /itportal/ → 返回 frontend-portal/dist/index.html
|
||||||
|
→ QrcodeLogin.vue 显示二维码
|
||||||
|
|
||||||
|
[3] 员工用企微扫码
|
||||||
|
→ 企微 OAuth 回调到后端 → 后端写 Redis qrcode:scan:{ticket}
|
||||||
|
→ Portal 轮询 /api/auth_qrcode/poll/{ticket} → 拿到 status=scanned
|
||||||
|
→ UI 显示"请在手机上确认登录"
|
||||||
|
|
||||||
|
[4] 员工在手机上点"确认登录"
|
||||||
|
→ 后端 /api/auth_qrcode/confirm → 创建 token → 写 Redis qrcode:confirm:{ticket}
|
||||||
|
→ Portal 轮询拿到 status=confirmed + token + roles
|
||||||
|
|
||||||
|
[5] Portal 按角色分发(见 QrcodeLogin.vue dispatchToRole)
|
||||||
|
- 只有 agent → window.location.href = /itagent/?token=xxx
|
||||||
|
- 只有 admin → window.location.href = /itadmin/?token=xxx
|
||||||
|
- admin + agent → window.location.href = /itportal/select(让用户选)
|
||||||
|
- 默认 user → window.location.href = /itdesk/?token=xxx
|
||||||
|
|
||||||
|
[6] 目标端 Login.vue 读 ?token=xxx 写入 localStorage + 跳 /workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 已知问题 & TODO
|
||||||
|
|
||||||
|
| 问题 | 状态 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/itadmin/` IP 白名单临时全开 | 🟡 临时 | v1.0 前必须收窄(见 `ip-whitelist-trust-proxies-todo.md`) |
|
||||||
|
| `/api/admin/` IP 白名单临时全开 | 🟡 临时 | 同上 |
|
||||||
|
| H5 端需要企微内访问 | 🟢 保持 | 用户决策,H5 仍在企微内是主场景 |
|
||||||
|
| 跨子路径刷新 404 | 🟢 已处理 | `try_files $uri $uri/ /itagent/index.html` |
|
||||||
|
| 静态资源 cache | 🟡 待优化 | 可加 version hash 强制刷新 |
|
||||||
|
| admin Login.vue 仍用表单 | 🟡 待改 | 后续 task:重写 admin Login 为扫码 UI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 验证清单
|
||||||
|
|
||||||
|
部署完成后,在以下场景测试:
|
||||||
|
|
||||||
|
- [ ] 浏览器直接访问 `/itportal/` → 显示扫码二维码
|
||||||
|
- [ ] 用企微扫码 + 确认 → Portal 自动跳到对应端
|
||||||
|
- [ ] 坐席(只有 agent 角色)扫码 → 自动跳 `/itagent/?token=xxx` → 自动登录进 /workspace
|
||||||
|
- [ ] 管理员(只有 admin 角色)扫码 → 自动跳 `/itadmin/?token=xxx` → 进 admin dashboard
|
||||||
|
- [ ] 多角色用户(admin + agent)扫码 → 跳 `/itportal/select` → 看到选择页
|
||||||
|
- [ ] H5(企微内) → 仍走企微 OAuth,扫码二维码区域正常
|
||||||
|
- [ ] 浏览器直接访问 `/itagent/workspace`(没 token)→ 跳 `/itportal/`
|
||||||
|
- [ ] 扫码登录 120s 过期 → UI 显示"已过期,点击刷新"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [project-knowledge-base.md](../memory/project-knowledge-base.md) — 项目知识库
|
||||||
|
- [feedback-wecom-only-external-urls.md](../memory/feedback-wecom-only-external-urls.md) — 企微入口约束(部分解除)
|
||||||
|
- [phase1-progress.md](../memory/phase1-progress.md) — Phase 1+2 进度
|
||||||
|
- [deployment.md](../memory/deployment.md) — 部署经验
|
||||||
|
- [nginx-container-name-wecom-it-nginx.md](../memory/nginx-container-name-wecom-it-nginx.md) — 容器名坑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**变更历史**:
|
||||||
|
- 2026-06-21 创建(Phase 1.3 task #16)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// IT智能服务台 — Portal 扫码登录 API 适配层 (Phase 1.3, task #16)
|
||||||
|
// =============================================================================
|
||||||
|
// 说明:复用 backend/app/api/auth_qrcode.py 接口(Phase 1.1)
|
||||||
|
// Portal 是统一入口,扫码成功后根据用户角色自动跳到对应端:
|
||||||
|
// - 只有 user 角色 → /itdesk/(H5)
|
||||||
|
// - 只有 agent 角色 → /itagent/(坐席工作台)
|
||||||
|
// - 只有 admin 角色 → /itadmin/(管理后台)
|
||||||
|
// - 多角色 → /itportal/select(角色选择页)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import apiClient from './index'
|
||||||
|
import type { AxiosResponse } from 'axios'
|
||||||
|
|
||||||
|
export interface QrcodeCreateData {
|
||||||
|
ticket: string
|
||||||
|
qrcode_url: string
|
||||||
|
expires_in: number
|
||||||
|
expires_at: string
|
||||||
|
qrcode_png_base64?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QrcodePollStatus = 'waiting' | 'scanned' | 'confirmed' | 'expired'
|
||||||
|
|
||||||
|
export interface QrcodePollData {
|
||||||
|
status: QrcodePollStatus
|
||||||
|
employee_id?: string
|
||||||
|
name?: string
|
||||||
|
token?: string
|
||||||
|
roles?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成登录二维码
|
||||||
|
*/
|
||||||
|
export async function createQrcode(): Promise<QrcodeCreateData> {
|
||||||
|
const response: AxiosResponse = await apiClient.post('/auth_qrcode/create')
|
||||||
|
return response.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询扫码状态
|
||||||
|
*/
|
||||||
|
export async function pollQrcode(ticket: string): Promise<QrcodePollData> {
|
||||||
|
const response: AxiosResponse = await apiClient.get(`/auth_qrcode/poll/${ticket}`)
|
||||||
|
return response.data.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// IT智能服务台 — Portal 扫码登录 Composable (Phase 1.3, task #16)
|
||||||
|
// =============================================================================
|
||||||
|
// 说明:跟 frontend-agent/src/composables/useQrcodeLogin.ts 同款逻辑
|
||||||
|
// Portal 端的 onSuccess 由调用方提供,通常实现"按角色跳对应端"
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { ref, onUnmounted, type Ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { createQrcode, pollQrcode } from '@/api/qrcode'
|
||||||
|
import type { QrcodePollStatus } from '@/api/qrcode'
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 2000
|
||||||
|
const COUNTDOWN_TICK_MS = 1000
|
||||||
|
|
||||||
|
export interface UseQrcodeLoginOptions {
|
||||||
|
/** 登录成功回调(token, employeeId, roles)— Portal 一般这里按角色跳对应端 */
|
||||||
|
onSuccess: (token: string, employeeId: string, roles: string[]) => void
|
||||||
|
onError?: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseQrcodeLoginReturn {
|
||||||
|
qrcodePngBase64: Ref<string | null>
|
||||||
|
qrcodeUrl: Ref<string | null>
|
||||||
|
countdown: Ref<number>
|
||||||
|
status: Ref<QrcodePollStatus>
|
||||||
|
scannedBy: Ref<string | null>
|
||||||
|
loading: Ref<boolean>
|
||||||
|
errorMessage: Ref<string | null>
|
||||||
|
startLogin: () => Promise<void>
|
||||||
|
refreshQrcode: () => Promise<void>
|
||||||
|
stopPolling: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQrcodeLogin(options: UseQrcodeLoginOptions): UseQrcodeLoginReturn {
|
||||||
|
const qrcodePngBase64 = ref<string | null>(null)
|
||||||
|
const qrcodeUrl = ref<string | null>(null)
|
||||||
|
const countdown = ref<number>(0)
|
||||||
|
const status = ref<QrcodePollStatus>('waiting')
|
||||||
|
const scannedBy = ref<string | null>(null)
|
||||||
|
const loading = ref<boolean>(false)
|
||||||
|
const errorMessage = ref<string | null>(null)
|
||||||
|
|
||||||
|
let ticket: string | null = null
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let expiresAt: number | null = null
|
||||||
|
|
||||||
|
function clearTimers(): void {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
if (countdownTimer) {
|
||||||
|
clearInterval(countdownTimer)
|
||||||
|
countdownTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown(): void {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
if (!expiresAt) {
|
||||||
|
countdown.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000))
|
||||||
|
countdown.value = remaining
|
||||||
|
if (remaining === 0 && status.value === 'waiting') {
|
||||||
|
status.value = 'expired'
|
||||||
|
clearTimers()
|
||||||
|
}
|
||||||
|
}, COUNTDOWN_TICK_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling(): void {
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
if (!ticket) return
|
||||||
|
try {
|
||||||
|
const data = await pollQrcode(ticket)
|
||||||
|
status.value = data.status
|
||||||
|
scannedBy.value = data.name || null
|
||||||
|
|
||||||
|
if (data.status === 'confirmed' && data.token && data.employee_id) {
|
||||||
|
clearTimers()
|
||||||
|
const roles = data.roles || ['user']
|
||||||
|
options.onSuccess(data.token, data.employee_id, roles)
|
||||||
|
} else if (data.status === 'expired') {
|
||||||
|
clearTimers()
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn('[useQrcodeLogin] poll error:', err)
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling(): void {
|
||||||
|
clearTimers()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startLogin(): Promise<void> {
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = null
|
||||||
|
clearTimers()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await createQrcode()
|
||||||
|
ticket = data.ticket
|
||||||
|
qrcodeUrl.value = data.qrcode_url
|
||||||
|
qrcodePngBase64.value = data.qrcode_png_base64 || null
|
||||||
|
countdown.value = data.expires_in
|
||||||
|
expiresAt = Date.now() + data.expires_in * 1000
|
||||||
|
status.value = 'waiting'
|
||||||
|
scannedBy.value = null
|
||||||
|
|
||||||
|
startCountdown()
|
||||||
|
startPolling()
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.message || '生成二维码失败'
|
||||||
|
errorMessage.value = msg
|
||||||
|
if (options.onError) {
|
||||||
|
options.onError(msg)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(msg)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshQrcode(): Promise<void> {
|
||||||
|
await startLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
qrcodePngBase64,
|
||||||
|
qrcodeUrl,
|
||||||
|
countdown,
|
||||||
|
status,
|
||||||
|
scannedBy,
|
||||||
|
loading,
|
||||||
|
errorMessage,
|
||||||
|
startLogin,
|
||||||
|
refreshQrcode,
|
||||||
|
stopPolling,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,22 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
// 路由配置
|
// 路由配置
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
// 根路径重定向到角色选择页
|
// 根路径重定向到扫码登录页(Phase 1.3 task #16)
|
||||||
|
// 原 PortalSelect.vue 保留作为多角色用户的 fallback
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/select',
|
redirect: '/qrcode-login',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 角色选择页
|
// 扫码登录页(主入口,Phase 1.3 新增)
|
||||||
|
path: '/qrcode-login',
|
||||||
|
name: 'QrcodeLogin',
|
||||||
|
component: () => import('@/views/QrcodeLogin.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '扫码登录',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 角色选择页(多角色用户扫码成功后的 fallback,保留)
|
||||||
path: '/select',
|
path: '/select',
|
||||||
name: 'PortalSelect',
|
name: 'PortalSelect',
|
||||||
component: () => import('@/views/PortalSelect.vue'),
|
component: () => import('@/views/PortalSelect.vue'),
|
||||||
@@ -35,7 +45,7 @@ const routes = [
|
|||||||
// 404 页面
|
// 404 页面
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: 'NotFound',
|
name: 'NotFound',
|
||||||
redirect: '/select',
|
redirect: '/qrcode-login',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<!-- =============================================================================
|
||||||
|
// IT智能服务台 — Portal 扫码登录页 (Phase 1.3, task #16)
|
||||||
|
// =============================================================================
|
||||||
|
// 说明:替代原 PortalSelect.vue 的"企微 OAuth + 角色选择"流程
|
||||||
|
// 新流程:Portal 显示二维码 → 员工扫码 → 后端 confirm → 按角色自动跳到对应端
|
||||||
|
//
|
||||||
|
// 角色分发规则(扫码成功后):
|
||||||
|
// 只有 admin → /itadmin/(管理后台)
|
||||||
|
// 只有 agent → /itagent/(坐席工作台)
|
||||||
|
// admin + agent → /itportal/select(让用户选)
|
||||||
|
// 默认 user → /itdesk/(H5 员工端)
|
||||||
|
//
|
||||||
|
// nginx 域名分发建议配置见 docs/NGINX-DOMAIN-ROUTING.md
|
||||||
|
// ============================================================================= -->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="qrcode-login-page">
|
||||||
|
<div class="qrcode-login-card">
|
||||||
|
<!-- 头部 -->
|
||||||
|
<div class="header">
|
||||||
|
<h1 class="title">🛠️ IT智能服务台</h1>
|
||||||
|
<p class="subtitle">扫码登录</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 二维码区 -->
|
||||||
|
<div class="qrcode-section">
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-if="loading && !qrcodePngBase64" class="qrcode-placeholder">
|
||||||
|
<el-icon class="is-loading" :size="48"><Loading /></el-icon>
|
||||||
|
<p>正在生成二维码…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 二维码图片 -->
|
||||||
|
<img
|
||||||
|
v-else-if="qrcodePngBase64"
|
||||||
|
:src="`data:image/png;base64,${qrcodePngBase64}`"
|
||||||
|
alt="登录二维码"
|
||||||
|
class="qrcode-image"
|
||||||
|
:class="{ 'qrcode-expired': status === 'expired' }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<div v-else-if="errorMessage" class="qrcode-error">
|
||||||
|
<el-icon :size="48" color="#ef4444"><CircleCloseFilled /></el-icon>
|
||||||
|
<p>{{ errorMessage }}</p>
|
||||||
|
<el-button type="primary" @click="refreshQrcode">重试</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 状态文字 -->
|
||||||
|
<div class="status-section">
|
||||||
|
<div v-if="status === 'waiting' && countdown > 0" class="status-waiting">
|
||||||
|
<p class="status-main">
|
||||||
|
<el-icon><Iphone /></el-icon>
|
||||||
|
请用<span class="highlight">企业微信</span>扫描二维码
|
||||||
|
</p>
|
||||||
|
<p class="status-sub">
|
||||||
|
二维码 <span class="countdown">{{ countdown }}</span> 秒后过期
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="status === 'scanned'" class="status-scanned">
|
||||||
|
<p class="status-main">
|
||||||
|
<el-icon color="#67c23a"><Check /></el-icon>
|
||||||
|
扫码成功
|
||||||
|
</p>
|
||||||
|
<p class="status-sub">
|
||||||
|
{{ scannedBy || '员工' }},请在手机上点<span class="highlight">"确认登录"</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="status === 'confirmed'" class="status-confirmed">
|
||||||
|
<el-icon :size="32" color="#67c23a"><Loading /></el-icon>
|
||||||
|
<p>登录成功,正在跳转…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="status === 'expired'" class="status-expired">
|
||||||
|
<p class="status-main">
|
||||||
|
<el-icon color="#e6a23c"><Warning /></el-icon>
|
||||||
|
二维码已过期
|
||||||
|
</p>
|
||||||
|
<el-button type="primary" @click="refreshQrcode">刷新二维码</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部 -->
|
||||||
|
<div class="footer">
|
||||||
|
<p class="footer-text">
|
||||||
|
扫码后系统会根据您的角色自动跳转到对应工作台
|
||||||
|
</p>
|
||||||
|
<p class="footer-sub">
|
||||||
|
坐席/管理员/H5 多端入口统一管理
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ============================================================================
|
||||||
|
// 导入
|
||||||
|
// ============================================================================
|
||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useQrcodeLogin } from '@/composables/useQrcodeLogin'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 角色 URL 映射(跟 backend/app/api/portal.py _get_role_url 保持一致)
|
||||||
|
// ============================================================================
|
||||||
|
const ROLE_URLS = {
|
||||||
|
user: '/itdesk/',
|
||||||
|
agent: '/itagent/',
|
||||||
|
admin: '/itadmin/',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 角色分发逻辑
|
||||||
|
// ============================================================================
|
||||||
|
/**
|
||||||
|
* 按角色决定跳到哪个端
|
||||||
|
* 规则:
|
||||||
|
* - 只有 admin → /itadmin/
|
||||||
|
* - 只有 agent → /itagent/
|
||||||
|
* - admin + agent → /itportal/select(用户多角色,给选择页)
|
||||||
|
* - 默认 user → /itdesk/
|
||||||
|
*/
|
||||||
|
function dispatchToRole(roles: string[]): string {
|
||||||
|
const hasAgent = roles.includes('agent')
|
||||||
|
const hasAdmin = roles.includes('admin')
|
||||||
|
|
||||||
|
// 多角色:让用户在 PortalSelect 选择
|
||||||
|
if (hasAdmin && hasAgent) {
|
||||||
|
return '/itportal/select'
|
||||||
|
}
|
||||||
|
if (hasAdmin) {
|
||||||
|
return ROLE_URLS.admin
|
||||||
|
}
|
||||||
|
if (hasAgent) {
|
||||||
|
return ROLE_URLS.agent
|
||||||
|
}
|
||||||
|
// 默认 user
|
||||||
|
return ROLE_URLS.user
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录成功回调
|
||||||
|
* 1. 存 token 到 localStorage(双 key: portal_token + 各端 token,跨端共享)
|
||||||
|
* 2. 按角色自动跳到对应端(整页跳,因为跨基础路径)
|
||||||
|
*/
|
||||||
|
function handleLoginSuccess(token: string, _employeeId: string, roles: string[]): void {
|
||||||
|
// 存 token 到所有可能的 key(避免各端读不到)
|
||||||
|
localStorage.setItem('portal_token', token)
|
||||||
|
localStorage.setItem('agent_token', token)
|
||||||
|
localStorage.setItem('admin_token', token)
|
||||||
|
localStorage.setItem('agent_user_id', _employeeId)
|
||||||
|
|
||||||
|
// 按角色跳
|
||||||
|
const targetUrl = dispatchToRole(roles)
|
||||||
|
const separator = targetUrl.includes('?') ? '&' : '?'
|
||||||
|
const finalUrl = `${targetUrl}${separator}token=${encodeURIComponent(token)}`
|
||||||
|
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
// 短暂延迟让用户看到"登录成功"提示
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = finalUrl
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
qrcodePngBase64,
|
||||||
|
countdown,
|
||||||
|
status,
|
||||||
|
scannedBy,
|
||||||
|
loading,
|
||||||
|
errorMessage,
|
||||||
|
startLogin,
|
||||||
|
refreshQrcode,
|
||||||
|
stopPolling,
|
||||||
|
} = useQrcodeLogin({
|
||||||
|
onSuccess: handleLoginSuccess,
|
||||||
|
onError: (msg) => ElMessage.error(msg),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 生命周期
|
||||||
|
// ============================================================================
|
||||||
|
onMounted(() => {
|
||||||
|
startLogin()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPolling()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.qrcode-login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
background: rgba(30, 41, 59, 0.85);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px 32px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(71, 85, 105, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 220px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-image {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
padding: 8px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-expired {
|
||||||
|
opacity: 0.3;
|
||||||
|
filter: grayscale(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-placeholder,
|
||||||
|
.qrcode-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-section {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-main {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-confirmed {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown {
|
||||||
|
color: #60a5fa;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
color: #60a5fa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
border-top: 1px solid rgba(71, 85, 105, 0.5);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-sub {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user