chore: initial baseline with P0-safety .gitignore
This commit is contained in:
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>IT智能服务台 - 选择工作台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1980
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "wecom-it-desk-portal",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.0",
|
||||
"axios": "^1.7.0",
|
||||
"element-plus": "^2.7.0",
|
||||
"@element-plus/icons-vue": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.3.0",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<!-- 路由出口 -->
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// App 根组件,仅作为路由出口
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 全局样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 深色科技风背景 */
|
||||
body {
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
// =============================================================================
|
||||
// IT智能服务台 — Portal API 层
|
||||
// =============================================================================
|
||||
// 说明:封装所有与后端 API 的交互
|
||||
// =============================================================================
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
// 创建 axios 实例
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器:添加 Token
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// 从 localStorage 获取 Token
|
||||
const token = localStorage.getItem('portal_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器:处理错误
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
// 401 错误:Token 过期或无效
|
||||
if (error.response?.status === 401) {
|
||||
// 清除本地存储的 Token
|
||||
localStorage.removeItem('portal_token')
|
||||
localStorage.removeItem('portal_user')
|
||||
// 跳转到选择页
|
||||
window.location.href = '/itportal/select'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
@@ -0,0 +1,67 @@
|
||||
// =============================================================================
|
||||
// IT智能服务台 — Portal API 接口
|
||||
// =============================================================================
|
||||
// 说明:Portal 统一入口相关的 API 接口
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from './index'
|
||||
|
||||
// 角色信息接口
|
||||
export interface Role {
|
||||
id: string
|
||||
name: string
|
||||
display_name: string
|
||||
description: string | null
|
||||
permissions: string[]
|
||||
is_default: boolean
|
||||
user_count: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 用户信息接口
|
||||
export interface UserInfo {
|
||||
employee_id: string
|
||||
name: string
|
||||
department: string | null
|
||||
avatar: string | null
|
||||
roles: Role[]
|
||||
current_role: string
|
||||
}
|
||||
|
||||
// 角色切换响应接口
|
||||
export interface SwitchRoleResponse {
|
||||
current_role: string
|
||||
redirect_url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户角色信息
|
||||
* @returns 用户信息(包含角色列表)
|
||||
*/
|
||||
export async function getUserRoles(): Promise<UserInfo> {
|
||||
const response = await apiClient.get('/portal/roles')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换当前角色
|
||||
* @param newRole 目标角色标识
|
||||
* @returns 切换结果(包含重定向URL)
|
||||
*/
|
||||
export async function switchRole(newRole: string): Promise<SwitchRoleResponse> {
|
||||
const response = await apiClient.post('/portal/switch-role', {
|
||||
new_role: newRole,
|
||||
})
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色对应的入口 URL
|
||||
* @param roleName 角色标识
|
||||
* @returns 入口 URL 信息
|
||||
*/
|
||||
export async function getRoleEntry(roleName: string): Promise<{ role: string; url: string; display_name: string }> {
|
||||
const response = await apiClient.get(`/portal/entry/${roleName}`)
|
||||
return response.data.data
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// =============================================================================
|
||||
// IT智能服务台 — Portal 统一入口前端应用
|
||||
// =============================================================================
|
||||
// 说明:统一入口前端应用,负责:
|
||||
// 1. OAuth2 认证后获取用户角色
|
||||
// 2. 展示角色选择页面(卡片选择)
|
||||
// 3. 根据用户选择跳转到对应端
|
||||
// =============================================================================
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// 导入 Element Plus
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
// 创建 Vue 应用实例
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册 Element Plus
|
||||
app.use(ElementPlus)
|
||||
|
||||
// 注册所有 Element Plus 图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
// 注册 Pinia 状态管理
|
||||
app.use(createPinia())
|
||||
|
||||
// 注册路由
|
||||
app.use(router)
|
||||
|
||||
// 挂载应用
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,59 @@
|
||||
// =============================================================================
|
||||
// IT智能服务台 — Portal 路由配置
|
||||
// =============================================================================
|
||||
// 说明:Portal 统一入口的路由配置
|
||||
// =============================================================================
|
||||
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
// 路由配置
|
||||
const routes = [
|
||||
{
|
||||
// 根路径重定向到角色选择页
|
||||
path: '/',
|
||||
redirect: '/select',
|
||||
},
|
||||
{
|
||||
// 角色选择页
|
||||
path: '/select',
|
||||
name: 'PortalSelect',
|
||||
component: () => import('@/views/PortalSelect.vue'),
|
||||
meta: {
|
||||
title: '选择工作台',
|
||||
},
|
||||
},
|
||||
{
|
||||
// 加载中页面
|
||||
path: '/loading',
|
||||
name: 'PortalLoading',
|
||||
component: () => import('@/views/PortalLoading.vue'),
|
||||
meta: {
|
||||
title: '正在加载...',
|
||||
},
|
||||
},
|
||||
{
|
||||
// 404 页面
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
redirect: '/select',
|
||||
},
|
||||
]
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
// 使用 history 模式
|
||||
history: createWebHistory('/itportal/'),
|
||||
routes,
|
||||
})
|
||||
|
||||
// 路由守卫:设置页面标题
|
||||
router.beforeEach((to, _from, next) => {
|
||||
// 设置页面标题
|
||||
const title = to.meta.title as string
|
||||
if (title) {
|
||||
document.title = `${title} - IT智能服务台`
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,174 @@
|
||||
// =============================================================================
|
||||
// IT智能服务台 — Portal 状态管理
|
||||
// =============================================================================
|
||||
// 说明:Portal 统一入口的状态管理,负责:
|
||||
// 1. 用户信息和角色管理
|
||||
// 2. Token 管理
|
||||
// 3. 角色切换
|
||||
// =============================================================================
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { getUserRoles, switchRole as apiSwitchRole, type UserInfo, type Role } from '@/api/portal'
|
||||
|
||||
/**
|
||||
* Portal 状态管理 Store
|
||||
*/
|
||||
export const usePortalStore = defineStore('portal', () => {
|
||||
// ==================== 状态 ====================
|
||||
|
||||
// 用户信息
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 错误信息
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
// Token
|
||||
const token = computed(() => localStorage.getItem('portal_token'))
|
||||
|
||||
// 是否已认证
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
// 用户角色列表
|
||||
const roles = computed(() => userInfo.value?.roles || [])
|
||||
|
||||
// 当前选择的角色
|
||||
const currentRole = computed(() => userInfo.value?.current_role || 'user')
|
||||
|
||||
// 是否有坐席角色
|
||||
const hasAgentRole = computed(() => roles.value.some((r: Role) => r.name === 'agent'))
|
||||
|
||||
// 是否有管理员角色
|
||||
const hasAdminRole = computed(() => roles.value.some((r: Role) => r.name === 'admin'))
|
||||
|
||||
// 角色数量(用于决定是否显示选择页)
|
||||
const roleCount = computed(() => roles.value.length)
|
||||
|
||||
// ==================== 方法 ====================
|
||||
|
||||
/**
|
||||
* 设置 Token
|
||||
* @param newToken Token 字符串
|
||||
*/
|
||||
function setToken(newToken: string) {
|
||||
localStorage.setItem('portal_token', newToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除认证信息
|
||||
*/
|
||||
function clearAuth() {
|
||||
localStorage.removeItem('portal_token')
|
||||
localStorage.removeItem('portal_user')
|
||||
userInfo.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户角色信息
|
||||
* 从后端 API 获取当前用户的角色列表
|
||||
*/
|
||||
async function fetchUserInfo() {
|
||||
if (!token.value) {
|
||||
error.value = '未登录'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await getUserRoles()
|
||||
userInfo.value = data
|
||||
|
||||
// 缓存到 localStorage
|
||||
localStorage.setItem('portal_user', JSON.stringify(data))
|
||||
} catch (err: any) {
|
||||
console.error('获取用户信息失败:', err)
|
||||
error.value = err.response?.data?.detail || '获取用户信息失败'
|
||||
|
||||
// 如果是 401 错误,清除认证信息
|
||||
if (err.response?.status === 401) {
|
||||
clearAuth()
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换角色
|
||||
* @param newRole 目标角色标识
|
||||
*/
|
||||
async function switchToRole(newRole: string) {
|
||||
if (!token.value) {
|
||||
error.value = '未登录'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const result = await apiSwitchRole(newRole)
|
||||
|
||||
// 更新本地状态
|
||||
if (userInfo.value) {
|
||||
userInfo.value.current_role = result.current_role
|
||||
}
|
||||
|
||||
// 跳转到目标页面
|
||||
window.location.href = result.redirect_url
|
||||
} catch (err: any) {
|
||||
console.error('角色切换失败:', err)
|
||||
error.value = err.response?.data?.detail || '角色切换失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存恢复用户信息
|
||||
* 用于页面刷新时恢复状态
|
||||
*/
|
||||
function restoreFromCache() {
|
||||
const cachedUser = localStorage.getItem('portal_user')
|
||||
if (cachedUser) {
|
||||
try {
|
||||
userInfo.value = JSON.parse(cachedUser)
|
||||
} catch (e) {
|
||||
console.error('解析缓存用户信息失败:', e)
|
||||
localStorage.removeItem('portal_user')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 返回 ====================
|
||||
|
||||
return {
|
||||
// 状态
|
||||
userInfo,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// 计算属性
|
||||
token,
|
||||
isAuthenticated,
|
||||
roles,
|
||||
currentRole,
|
||||
hasAgentRole,
|
||||
hasAdminRole,
|
||||
roleCount,
|
||||
|
||||
// 方法
|
||||
setToken,
|
||||
clearAuth,
|
||||
fetchUserInfo,
|
||||
switchToRole,
|
||||
restoreFromCache,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<!-- 加载中页面 -->
|
||||
<div class="portal-loading">
|
||||
<div class="loading-content">
|
||||
<!-- 加载动画 -->
|
||||
<div class="loading-spinner">
|
||||
<div class="spinner-ring"></div>
|
||||
<div class="spinner-ring"></div>
|
||||
<div class="spinner-ring"></div>
|
||||
</div>
|
||||
|
||||
<!-- 加载文本 -->
|
||||
<h2 class="loading-title">正在加载...</h2>
|
||||
<p class="loading-subtitle">请稍候,正在准备您的工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 加载中页面,无特殊逻辑
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 页面容器 */
|
||||
.portal-loading {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 加载内容 */
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.spinner-ring {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 3px solid transparent;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1.5s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-ring:nth-child(2) {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
border-top-color: #f59e0b;
|
||||
animation-duration: 1.2s;
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
.spinner-ring:nth-child(3) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
border-top-color: #10b981;
|
||||
animation-duration: 0.9s;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载文本 */
|
||||
.loading-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.loading-subtitle {
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,475 @@
|
||||
<template>
|
||||
<!-- 角色选择页面 -->
|
||||
<div class="portal-select">
|
||||
<!-- 加载中状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<el-icon class="loading-icon" :size="48">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<p class="loading-text">正在加载用户信息...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="error-container">
|
||||
<el-icon class="error-icon" :size="48" color="#ef4444">
|
||||
<CircleCloseFilled />
|
||||
</el-icon>
|
||||
<p class="error-text">{{ error }}</p>
|
||||
<el-button type="primary" @click="handleRetry">重试</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 角色选择页面 -->
|
||||
<div v-else class="select-container">
|
||||
<!-- 标题区域 -->
|
||||
<div class="header">
|
||||
<h1 class="title">IT智能服务台</h1>
|
||||
<p class="subtitle">选择您要进入的工作台</p>
|
||||
</div>
|
||||
|
||||
<!-- 用户信息卡片 -->
|
||||
<div v-if="userInfo" class="user-info">
|
||||
<el-avatar :size="48" :src="userInfo.avatar || undefined">
|
||||
{{ userInfo.name?.charAt(0) || '?' }}
|
||||
</el-avatar>
|
||||
<div class="user-details">
|
||||
<span class="user-name">{{ userInfo.name }}</span>
|
||||
<span v-if="userInfo.department" class="user-department">{{ userInfo.department }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 角色卡片列表 -->
|
||||
<div class="role-cards">
|
||||
<!-- 用户端卡片 -->
|
||||
<div
|
||||
class="role-card"
|
||||
:class="{ 'role-card--active': selectedRole === 'user' }"
|
||||
@click="selectRole('user')"
|
||||
>
|
||||
<div class="role-card__icon">
|
||||
<el-icon :size="48" color="#3b82f6">
|
||||
<User />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="role-card__content">
|
||||
<h3 class="role-card__title">用户端</h3>
|
||||
<p class="role-card__desc">提交工单、查看进度、浏览知识库</p>
|
||||
</div>
|
||||
<div class="role-card__action">
|
||||
<el-button type="primary" size="large" @click.stop="enterRole('user')">
|
||||
进入
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 坐席端卡片 -->
|
||||
<div
|
||||
v-if="hasAgentRole"
|
||||
class="role-card"
|
||||
:class="{ 'role-card--active': selectedRole === 'agent' }"
|
||||
@click="selectRole('agent')"
|
||||
>
|
||||
<div class="role-card__icon">
|
||||
<el-icon :size="48" color="#f59e0b">
|
||||
<Headset />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="role-card__content">
|
||||
<h3 class="role-card__title">坐席端</h3>
|
||||
<p class="role-card__desc">处理会话、AI辅助、管理工单</p>
|
||||
</div>
|
||||
<div class="role-card__action">
|
||||
<el-button type="warning" size="large" @click.stop="enterRole('agent')">
|
||||
进入
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理端卡片 -->
|
||||
<div
|
||||
v-if="hasAdminRole"
|
||||
class="role-card"
|
||||
:class="{ 'role-card--active': selectedRole === 'admin' }"
|
||||
@click="selectRole('admin')"
|
||||
>
|
||||
<div class="role-card__icon">
|
||||
<el-icon :size="48" color="#ef4444">
|
||||
<Setting />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="role-card__content">
|
||||
<h3 class="role-card__title">管理端</h3>
|
||||
<p class="role-card__desc">系统配置、数据分析、权限管理</p>
|
||||
</div>
|
||||
<div class="role-card__action">
|
||||
<el-button type="danger" size="large" @click.stop="enterRole('admin')">
|
||||
进入
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<div class="footer">
|
||||
<p class="footer-text">
|
||||
当前账号:{{ userInfo?.name }} ({{ userInfo?.employee_id }})
|
||||
</p>
|
||||
<p class="footer-hint">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
每次进入需要重新选择工作台
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { usePortalStore } from '@/stores/portal'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { Loading, CircleCloseFilled, User, Headset, Setting, InfoFilled } from '@element-plus/icons-vue'
|
||||
import apiClient from '@/api/index'
|
||||
|
||||
// 获取 Portal Store
|
||||
const portalStore = usePortalStore()
|
||||
|
||||
// 解构状态
|
||||
const { userInfo, loading, error, hasAgentRole, hasAdminRole } = storeToRefs(portalStore)
|
||||
|
||||
// 选中的角色
|
||||
const selectedRole = ref<string | null>(null)
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
onMounted(async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const token = urlParams.get('token')
|
||||
const code = urlParams.get('code')
|
||||
|
||||
// 1. 企微 OAuth2 回调:URL 中有 code 参数
|
||||
if (code && !token) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiClient.post('/h5/oauth/callback', { code })
|
||||
const data = response.data.data
|
||||
if (data?.token) {
|
||||
portalStore.setToken(data.token)
|
||||
// 清除 URL 中的 code/state 参数
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
} else {
|
||||
error.value = 'OAuth2授权失败:未获取到Token'
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('OAuth2 回调失败:', err)
|
||||
error.value = err.response?.data?.detail || 'OAuth2授权失败'
|
||||
return
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 从 Portal 跳转回来:URL 中有 token 参数
|
||||
if (token) {
|
||||
portalStore.setToken(token)
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
}
|
||||
|
||||
// 3. 检查是否已登录(localStorage 缓存)
|
||||
if (!portalStore.isAuthenticated) {
|
||||
portalStore.restoreFromCache()
|
||||
if (!portalStore.isAuthenticated) {
|
||||
// 未登录,尝试触发 OAuth2 流程
|
||||
const corpId = import.meta.env.VITE_WECOM_CORP_ID || ''
|
||||
if (corpId) {
|
||||
// 生产环境:调用后端获取 OAuth2 授权 URL
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await apiClient.get('/h5/oauth/authorize')
|
||||
const authorizeUrl = response.data.data?.authorize_url
|
||||
if (authorizeUrl) {
|
||||
window.location.href = authorizeUrl
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取 OAuth2 URL 失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
// 无 corpId 或获取失败:显示错误(开发环境可 Mock 登录)
|
||||
error.value = '未登录,请通过企业微信工作台访问'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 加载用户信息
|
||||
await portalStore.fetchUserInfo()
|
||||
|
||||
// 5. 如果用户只有一个有效角色,直接跳转(避免多角色用户被自动跳走)
|
||||
// 注意:user角色是默认的,但如果有agent或admin角色,应该让用户选择
|
||||
const validRoles = portalStore.roles.filter(
|
||||
(r: any) => r.name === 'agent' || r.name === 'admin'
|
||||
)
|
||||
if (validRoles.length === 0 && portalStore.roleCount === 1) {
|
||||
// 只有默认的user角色,直接跳转
|
||||
const singleRole = portalStore.roles[0]
|
||||
if (singleRole) {
|
||||
enterRole(singleRole.name)
|
||||
}
|
||||
}
|
||||
// 否则:显示角色选择页面(让用户选择)
|
||||
})
|
||||
|
||||
// ==================== 方法 ====================
|
||||
|
||||
/**
|
||||
* 选择角色
|
||||
*/
|
||||
function selectRole(role: string) {
|
||||
selectedRole.value = role
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入角色对应的工作台
|
||||
*/
|
||||
function enterRole(role: string) {
|
||||
// 保存当前选择的角色到 localStorage(用于记住选择)
|
||||
localStorage.setItem('portal_selected_role', role)
|
||||
|
||||
// 跳转到对应的工作台
|
||||
const roleUrls: Record<string, string> = {
|
||||
user: '/itdesk/',
|
||||
agent: '/itagent/',
|
||||
admin: '/itadmin/',
|
||||
}
|
||||
|
||||
const url = roleUrls[role]
|
||||
if (url) {
|
||||
// 将 Token 传递给目标页面
|
||||
const token = portalStore.token
|
||||
if (token) {
|
||||
window.location.href = `${url}?token=${encodeURIComponent(token)}`
|
||||
} else {
|
||||
window.location.href = url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试加载
|
||||
*/
|
||||
function handleRetry() {
|
||||
portalStore.fetchUserInfo()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 页面容器 */
|
||||
.portal-select {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 加载中状态 */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
animation: spin 1s linear infinite;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #94a3b8;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef4444;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 选择页面 */
|
||||
.select-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 标题区域 */
|
||||
.header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 16px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* 用户信息卡片 */
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(71, 85, 105, 0.5);
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.user-department {
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* 角色卡片列表 */
|
||||
.role-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 角色卡片 */
|
||||
.role-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border-radius: 16px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.role-card:hover {
|
||||
background: rgba(30, 41, 59, 0.95);
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.role-card--active {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.role-card__icon {
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.role-card__content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.role-card__title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.role-card__desc {
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.role-card__action {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 底部提示 */
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.footer-hint {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
@media (max-width: 640px) {
|
||||
.role-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.role-card__icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.role-card__action {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.role-card__action .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Alias */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
// 基础路径 -- 部署在 /itportal/ 子路径
|
||||
base: '/itportal/',
|
||||
|
||||
plugins: [vue()],
|
||||
|
||||
// 开发服务器配置
|
||||
server: {
|
||||
// 开发端口:5176,避免与 agent(5173)、h5(5174)、admin(5175) 冲突
|
||||
port: 5176,
|
||||
// 启动后自动打开浏览器
|
||||
open: true,
|
||||
// 代理配置:开发环境将 /api 请求代理到后端
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
// 剥离 /api 前缀(生产环境由 Nginx 负责剥离)
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 构建配置
|
||||
build: {
|
||||
// 输出目录
|
||||
outDir: 'dist',
|
||||
// 小于 4KB 的资源内联为 base64
|
||||
assetsInlineLimit: 4096,
|
||||
},
|
||||
|
||||
// 路径别名
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user