chore: 整理项目结构,清理归档文件,更新部署配置

This commit is contained in:
Simon
2026-07-04 21:01:39 +08:00
parent 8bd4ab0366
commit 64ff1bf7d5
508 changed files with 43575 additions and 14129 deletions
+8 -7
View File
@@ -37,6 +37,8 @@ export interface Agent {
export interface LoginData extends Agent {
/** 认证 token(存入 localStorage,后续请求自动携带) */
token: string
/** 是否需要 OTP 验证 */
require_otp?: boolean
}
/** 坐席列表响应 */
@@ -51,19 +53,18 @@ export interface AgentListData {
/**
* 坐席登录
* 第一步使用简单的用户名登录(无密码验证)
* admin 角色需要 OTP 二次验证
* 使用账号密码+OTP认证
* 登录成功后 token 存入 localStorage,后续请求自动携带
*
* @param userId - 企微用户ID
* @param name - 坐席姓名
* @param otpCode - OTP 动态码(admin 角色必填)
* @param userId - 账号/用户名
* @param password - 密码
* @param otpCode - OTP 动态码(可选,首次登录不需要,需要时必填)
* @returns 坐席信息和 token
*/
export async function login(userId: string, name: string, otpCode?: string): Promise<LoginData> {
export async function login(userId: string, password: string, otpCode?: string): Promise<LoginData> {
const response: AxiosResponse = await apiClient.post('/agents/login', {
user_id: userId,
name: name,
password: password,
otp_code: otpCode || undefined,
})
return response.data.data
+4 -2
View File
@@ -205,7 +205,8 @@ export async function uploadImage(file: File): Promise<{
const response: AxiosResponse = await apiClient.post(
'/messages/image',
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
// ISS-B4 修复:不显式设置 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
{ headers: { 'Content-Type': undefined } }
)
return response.data.data
}
@@ -226,7 +227,8 @@ export async function uploadMessageFile(file: File): Promise<{
const response: AxiosResponse = await apiClient.post(
'/messages/file',
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
// ISS-B4 修复:不显式设置 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
{ headers: { 'Content-Type': undefined } }
)
return response.data.data
}
+38 -10
View File
@@ -56,14 +56,42 @@ export async function uploadFile(file: File | Blob): Promise<UploadResponse> {
formData.append('file', file, fileName)
}
const response: AxiosResponse = await apiClient.post('/upload', formData, {
// 必须显式删除 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
// 原因:apiClient 实例默认设置了 'Content-Type': 'application/json'
// 如果不覆盖,Axios 会保留 application/json,后端无法解析 FormData 中的 file 字段
headers: {
'Content-Type': undefined,
},
timeout: 60000,
})
return response.data.data
// ISS-B3 修复:上传带自动重试(3次,指数退避 1s→2s→4s)
return await uploadWithRetry(formData)
}
/**
* 上传重试包装函数
*
* 做什么:对文件上传请求进行自动重试,最多 3 次
* 为什么:网络波动时避免用户手动重试,提升上传成功率
* 重试策略:指数退避(1秒 → 2秒 → 4秒),最多 3 次重试(共 4 次尝试)
*
* @param formData - 已构建好的 FormData 对象
* @param maxRetries - 最大重试次数(默认 3)
* @returns 上传响应数据
*/
async function uploadWithRetry(formData: FormData, maxRetries: number = 3): Promise<UploadResponse> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response: AxiosResponse = await apiClient.post('/upload', formData, {
// 必须显式删除 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
// 原因:apiClient 实例默认设置了 'Content-Type': 'application/json'
// 如果不覆盖,Axios 会保留 application/json,后端无法解析 FormData 中的 file 字段
headers: {
'Content-Type': undefined,
},
timeout: 60000,
})
return response.data.data
} catch (err) {
if (attempt === maxRetries) throw err
// 指数退避:1s, 2s, 4s
const delay = 1000 * Math.pow(2, attempt)
console.warn(`[Upload] 上传失败,${delay / 1000}秒后重试(第 ${attempt + 1}/${maxRetries} 次)`)
await new Promise(r => setTimeout(r, delay))
}
}
// 不应到达此处,但 TypeScript 需要返回值
throw new Error('上传失败:已达最大重试次数')
}
@@ -44,6 +44,8 @@
</div>
<div v-if="showEmojiPicker" class="emoji-picker-overlay" @click="showEmojiPicker = false"></div>
</div>
<!-- 图片/截图功能已移除 -->
<!--
<button class="tb-btn" title="图片" @click="handleToolbarClick('image')">
🖼
<span class="tb-tip">图片</span>
@@ -52,6 +54,7 @@
<span class="tb-tip">截图</span>
</button>
-->
<button class="tb-btn" title="文件" @click="handleToolbarClick('file')">
📎
<span class="tb-tip">文件</span>
+3 -2
View File
@@ -99,8 +99,9 @@ router.beforeEach((to, _from, next) => {
}
if (requiresAuth && !token) {
// 需要认证但没有 token,跳转到 Portal 统一入口
window.location.href = '/itportal/'
// 需要认证但没有 token,跳转到当前端登录页(不再跳转到 Portal
// v1.1: 坐席/管理端直接登录,无需经过 Portal
next({ path: '/login' })
} else if (to.path === '/login' && token) {
// 已登录用户访问登录页,跳转到工作台
next({ path: '/workspace' })
+13 -7
View File
@@ -69,24 +69,24 @@ export const useAgentStore = defineStore('agent', () => {
/**
* 坐席登录
* 调用后端登录 API,获取坐席信息和 token
* admin 角色需要 OTP 二次验
* 支持账号密码+OTP认
* 登录成功后自动跳转到工作台页面
*
* @param inputUserId - 企微用户ID
* @param inputName - 坐席姓名
* @param otpCode - OTP 动态码(可选)
* @param inputUserId - 账号/用户名
* @param password - 密码
* @param otpCode - OTP 动态码(可选,首次登录不需要
* @returns 登录数据(包含 require_otp 标记)
*/
async function login(inputUserId: string, inputName: string, otpCode?: string): Promise<any> {
async function login(inputUserId: string, password: string, otpCode?: string): Promise<any> {
try {
logging.value = true
const data = await apiLogin(inputUserId, inputName, otpCode)
const data = await apiLogin(inputUserId, password, otpCode)
// 检查是否需要 OTP 验证
if ('require_otp' in data && data.require_otp) {
// 返回 data,让 Login.vue 处理 require_otp
logging.value = false
return data
throw new Error('require_otp')
}
// 保存登录信息
@@ -106,6 +106,12 @@ export const useAgentStore = defineStore('agent', () => {
router.push('/workspace')
} catch (error) {
console.error('登录失败:', error)
// 如果是 require_otp 错误,直接抛出让 UI 处理
if (error instanceof Error && error.message === 'require_otp') {
throw error
}
// 使用 mock 数据作为 fallback(开发/演示用)
if (import.meta.env.DEV) {
console.warn('[Mock] 使用模拟登录数据')
+142 -245
View File
@@ -1,18 +1,14 @@
<!-- =============================================================================
// 企微IT智能服务台 — 坐席扫码登录页 (Phase 1.2, task #15)
// IT智能服务台 — 坐席登录页 (v1.1, 2026-07-04)
// =============================================================================
// 说明:重写自原"用户名 + 姓名表单"登录,改为"企微扫码登录"
// 说明: 从扫码登录改为账号密码+OTP表单登录
//
// 流程:
// 1. 进入页面 → useQrcodeLogin.startLogin() 调后端 /auth_qrcode/create
// 2. 展示二维码 + 倒计时(120s)
// 3. 员工用企微扫 → 后端状态 → scanned → UI 切换"已扫码,请在手机上确认"
// 4. 员工在手机上点确认 → 后端 confirm → 拿到 token → 写 localStorage → 跳 /workspace
// 5. 倒计时归 0 → 自动停止轮询,UI 显示"已过期",用户点"刷新二维码"
//
// 管理员场景(扫码确认后 require_otp=true)→ 当前版本暂未集成 OTP 输入框
// OTP 二次认证由 task #17 (Phase 2.1 后端 MFA) + task #20 (Phase 2.4 前端 MFA UI) 负责
// 短期方案:管理员走 /itportal/ 入口(那边有 OTP UI)
// 1. 用户输入账号 + 密码
// 2. 点击登录 → 调用后端 /api/agents/login
// 3. 若 require_otp=true,显示 OTP 输入框
// 4. 用户输入 OTP 后再次提交
// 5. 登录成功 → 存 token → 跳 /workspace
// ============================================================================= -->
<template>
@@ -21,101 +17,79 @@
<!-- 标题区 -->
<div class="login-title">
<h1>🛠 IT智能服务台</h1>
<p>坐席工作台 · 扫码登录</p>
<p>坐席工作台 · 账号登录</p>
</div>
<!-- 二维码区 -->
<div class="qrcode-section">
<!-- 加载中 -->
<div v-if="loading && !qrcodePngBase64" class="qrcode-placeholder">
<el-icon class="is-loading"><Loading /></el-icon>
<p>正在生成二维码</p>
</div>
<!-- 二维码图片(base64 PNG) -->
<img
v-else-if="qrcodePngBase64"
:src="`data:image/png;base64,${qrcodePngBase64}`"
alt="登录二维码"
class="qrcode-image"
:class="{ 'qrcode-expired': status === 'expired' }"
/>
<!-- 降级:后端没返回 base64,显示 qrcode_url 提示用户手动复制 -->
<div v-else-if="qrcodeUrl" class="qrcode-fallback">
<p class="qrcode-fallback-hint">请复制以下链接到企业微信打开:</p>
<!-- 登录表单 -->
<el-form
ref="formRef"
:model="loginForm"
:rules="rules"
label-position="top"
@submit.prevent="handleLogin"
>
<el-form-item label="账号" prop="userId">
<el-input
:model-value="qrcodeUrl"
readonly
type="textarea"
:rows="3"
class="qrcode-fallback-url"
v-model="loginForm.userId"
placeholder="请输入账号(如 sxn"
size="large"
:prefix-icon="User"
@keydown.enter="handleLogin"
/>
<p class="qrcode-fallback-tip">
(前端暂未集成二维码渲染库,后端应返回 base64 PNG)
</p>
</div>
</el-form-item>
<!-- 错误状态 -->
<div v-else-if="errorMessage" class="qrcode-error">
<el-icon><CircleClose /></el-icon>
<p>{{ errorMessage }}</p>
</div>
</div>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
size="large"
:prefix-icon="Lock"
show-password
@keydown.enter="handleLogin"
/>
</el-form-item>
<!-- 状态文字 -->
<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>
<!-- OTP 输入区: 仅在 require_otp 时显示 -->
<el-form-item v-if="requireOtp" label="OTP 验证码" prop="otpCode">
<el-input
v-model="loginForm.otpCode"
placeholder="请输入 Google Authenticator 验证码"
size="large"
:prefix-icon="Key"
maxlength="6"
@keydown.enter="handleLogin"
/>
</el-form-item>
<!-- 已扫码,等待员工在手机上确认 -->
<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" v-if="scannedBy">
{{ scannedBy }},请在手机上点<span class="highlight">"确认登录"</span>
</p>
<p class="status-sub" v-else>
请在手机上点<span class="highlight">"确认登录"</span>
</p>
<el-form-item>
<el-button
v-if="otpRequired"
type="primary"
class="otp-button"
@click="handleOtpConfirm"
size="large"
:loading="logging"
:disabled="logging"
@click="handleLogin"
class="login-btn"
>
输入 OTP 动态码
{{ logging ? '登录中...' : (requireOtp ? '验证 OTP' : '登录') }}
</el-button>
</div>
</el-form-item>
</el-form>
<!-- 已过期 -->
<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" class="refresh-button" @click="refreshQrcode">
刷新二维码
</el-button>
</div>
</div>
<!-- 错误提示 -->
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="true"
@close="errorMsg = ''"
style="margin-top: 16px"
/>
<!-- 底部提示 -->
<!-- 提示信息 -->
<div class="login-hint">
<p>登录即表示同意IT智能服务台使用规范</p>
<p class="login-hint-sub">
首次使用请确保已在企业微信中完成认证
</p>
</div>
</div>
</div>
@@ -125,65 +99,93 @@
// ============================================================================
// 导入
// ============================================================================
import { onMounted, onUnmounted } from 'vue'
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useQrcodeLogin } from '@/composables/useQrcodeLogin'
import type { FormInstance, FormRules } from 'element-plus'
import { User, Lock, Key } from '@element-plus/icons-vue'
import { useAgentStore } from '@/stores/agent'
// ============================================================================
// 状态
// ============================================================================
const router = useRouter()
const agentStore = useAgentStore()
/**
* 扫码登录成功回调
* 1. 存 token 到 localStorage(双 key: agent_token + portal_token,跨端共享)
* 2. 跳转到 /workspace
*/
function handleLoginSuccess(token: string, _employeeId: string, _roles: string[]): void {
localStorage.setItem('agent_token', token)
localStorage.setItem('portal_token', token)
ElMessage.success('登录成功')
router.push('/workspace')
}
/** 表单引用 */
const formRef = ref<FormInstance>()
const {
qrcodePngBase64,
qrcodeUrl,
countdown,
status,
otpRequired,
scannedBy,
loading,
errorMessage,
startLogin,
refreshQrcode,
stopPolling,
} = useQrcodeLogin({
onSuccess: handleLoginSuccess,
onError: (msg) => ElMessage.error(msg),
/** 登录表单数据 */
const loginForm = reactive({
userId: '',
password: '',
otpCode: '',
})
/**
* 管理员 OTP 输入按钮(暂未实现完整流程,提示用户去 /itportal/)
* Phase 2.4 完成后这里跳到 OTP 输入弹窗
*/
function handleOtpConfirm(): void {
ElMessage.info('管理员 OTP 二次认证:请前往 /itportal/ 完成(Phase 2.4 即将上线)')
/** 是否需要 OTP 验证 */
const requireOtp = ref(false)
/** 登录中状态 */
const logging = ref(false)
/** 错误信息 */
const errorMsg = ref<string>('')
/** 表单校验规则 */
const rules: FormRules = {
userId: [
{ required: true, message: '请输入账号', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
],
otpCode: [
{ required: true, message: '请输入 OTP 验证码', trigger: 'blur' },
{ len: 6, message: '验证码为 6 位数字', trigger: 'blur' },
],
}
// ============================================================================
// 生命周期
// 方法
// ============================================================================
onMounted(() => {
// 进入页面立即生成二维码
startLogin()
})
onUnmounted(() => {
// 离开页面停止轮询(防止内存泄漏)
stopPolling()
})
/**
* 处理登录
*/
async function handleLogin(): Promise<void> {
// 表单校验
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
logging.value = true
errorMsg.value = ''
try {
const result = await agentStore.login(
loginForm.userId.trim(),
loginForm.password,
loginForm.otpCode.trim() || undefined
)
// 检查是否需要 OTP 验证
if (result && result.require_otp) {
requireOtp.value = true
loginForm.otpCode = ''
ElMessage.info('请输入 OTP 验证码')
logging.value = false
return
}
// 登录成功
ElMessage.success('登录成功')
router.push('/workspace')
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : '登录失败,请重试'
errorMsg.value = errMsg
} finally {
logging.value = false
}
}
</script>
<style scoped>
@@ -200,7 +202,7 @@ onUnmounted(() => {
/* 登录卡片 */
.login-card {
width: 100%;
max-width: 440px;
max-width: 400px;
background: var(--bg-secondary, #ffffff);
border-radius: 16px;
padding: 40px 32px;
@@ -226,111 +228,10 @@ onUnmounted(() => {
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;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
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: var(--text-tertiary, #909399);
}
.qrcode-placeholder .el-icon,
.qrcode-error .el-icon {
font-size: 48px;
}
.qrcode-fallback {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.qrcode-fallback-hint {
font-size: 13px;
color: var(--text-regular, #606266);
margin: 0;
}
.qrcode-fallback-url {
font-size: 11px;
font-family: monospace;
}
.qrcode-fallback-tip {
font-size: 11px;
color: var(--text-placeholder, #c0c4cc);
margin: 0;
text-align: center;
}
/* 状态区 */
.status-section {
text-align: center;
margin-bottom: 24px;
min-height: 80px;
}
.status-main {
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #303133);
margin: 0 0 8px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.status-sub {
font-size: 13px;
color: var(--text-tertiary, #909399);
margin: 0;
line-height: 1.5;
}
.status-waiting .status-main .el-icon {
font-size: 20px;
}
.countdown {
color: #409eff;
font-weight: 600;
font-family: monospace;
}
.highlight {
color: #409eff;
font-weight: 600;
}
.refresh-button,
.otp-button {
margin-top: 16px;
/* 登录按钮 */
.login-btn {
width: 100%;
margin-top: 8px;
}
/* 底部提示 */
@@ -341,14 +242,10 @@ onUnmounted(() => {
line-height: 1.6;
border-top: 1px solid var(--border-color-lighter, #ebeef5);
padding-top: 16px;
margin-top: 24px;
}
.login-hint p {
margin: 4px 0;
}
.login-hint-sub {
font-size: 11px;
color: var(--text-placeholder, #c0c4cc);
}
</style>
</style>