chore: initial baseline with P0-safety .gitignore

This commit is contained in:
Simon
2026-06-14 16:49:18 +08:00
commit 63262292d7
510 changed files with 146008 additions and 0 deletions
+295
View File
@@ -0,0 +1,295 @@
<!-- =============================================================================
// 企微IT智能服务台 — H5用户端聊天主页面
// =============================================================================
// 说明:响应式双栏/单栏布局
// - 桌面端(≥500px):左栏对话区 + 拖拽手柄 + 右栏三段式面板
// - 手机端(<500px):全宽单栏对话区,无右侧面板
// - 拖拽手柄:左右栏宽度可手动调节(桌面端)
// ============================================================================= -->
<template>
<div class="chat-view">
<!-- 左栏对话区始终显示 -->
<div
class="chat-view__left"
:class="{ 'chat-view__left--full': !showRightPanel }"
:style="!isMobile && showRightPanel ? { flex: `0 0 ${leftWidth}%`, maxWidth: `${leftWidth}%` } : {}"
>
<ChatPanel />
</div>
<!-- 拖拽手柄左右栏分隔条仅桌面端显示 -->
<div
v-if="showRightPanel && !isMobile"
class="chat-view__resize-handle"
@mousedown="handleResizeStart"
>
<div class="chat-view__resize-grip"></div>
</div>
<!-- 右栏三段式面板仅桌面端显示 -->
<div
v-if="showRightPanel"
class="chat-view__right"
>
<RightPanel />
</div>
</div>
</template>
<script setup lang="ts">
/**
* ChatView 聊天主页面
* 响应式布局:
* - 桌面端(≥500px):双栏 — 左栏对话区 + 右栏三段式面板
* - 手机端(<500px):单栏 — 全宽对话区,无右侧面板
* 桌面端支持拖拽手柄手动调节左右栏宽度
* 右侧面板三段式:AI推送区 / 常用资源标签页 / 趣味问答
*/
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' // 新增:useRoute 用于读取 URL 参数
import { useConversationStore } from '@/stores/conversation'
import { useEmployeeStore } from '@/stores/employee' // 新增:检查登录状态
import { useH5WebSocket } from '@/composables/useH5WebSocket' // H5 WebSocket
import ChatPanel from '@/components/chat/ChatPanel.vue'
import RightPanel from '@/components/assistant/RightPanel.vue'
import { joinConversation as joinConversationApi } from '@/api/conversation' // 邀请功能
// 新增:路由实例(用于跳转登录页)
const router = useRouter()
const route = useRoute() // 用于读取 URL 参数(邀请链接等)
// 新增:员工状态管理(用于检查登录状态)
const employeeStore = useEmployeeStore()
const store = useConversationStore()
// H5 WebSocket 连接(实时推送参与者变更、新消息等事件)
const h5ws = useH5WebSocket()
/** 当前窗口宽度 */
const windowWidth = ref<number>(window.innerWidth)
/** 左栏宽度百分比(默认60%) */
const leftWidth = ref<number>(60)
/** 是否正在拖拽调节宽度 */
const isResizing = ref<boolean>(false)
/** 是否为移动端窄屏(宽度 < 500px,与原型图对齐) */
const isMobile = computed(() => windowWidth.value < 500)
/** 是否显示右栏面板(桌面端始终显示,手机端隐藏) */
const showRightPanel = computed(() => {
// 桌面端:始终显示右栏
if (!isMobile.value) return true
// 手机端:不显示右栏
return false
})
/**
* ── 拖拽调节左右栏宽度 ──
* 方案:只固定左侧宽度,右侧 flex:1 自动填满,不留空白
* 左栏最小宽度 40%,右栏最小宽度 220px
*/
/** 拖拽开始:记录初始 X 坐标和初始左栏宽度 */
function handleResizeStart(event: MouseEvent): void {
isResizing.value = true
const startX = event.clientX
const startLeftWidth = leftWidth.value
const containerWidth = windowWidth.value
/**
* 拖拽中:根据鼠标移动距离换算为百分比,更新左栏宽度
*/
function handleMouseMove(e: MouseEvent): void {
if (!isResizing.value) return
const deltaX = e.clientX - startX
const deltaPercent = (deltaX / containerWidth) * 100
let newLeftWidth = startLeftWidth + deltaPercent
// 限制最小/最大宽度:左栏 40%~80%
newLeftWidth = Math.max(40, Math.min(80, newLeftWidth))
leftWidth.value = newLeftWidth
}
/**
* 拖拽结束:移除事件监听
*/
function handleMouseUp(): void {
isResizing.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
/**
* 监听窗口大小变化
* 更新 windowWidth 以响应式调整布局
*/
function handleResize(): void {
windowWidth.value = window.innerWidth
}
// 生命周期:挂载时添加 resize 监听 + 检查登录状态 + 初始化应用
onMounted(() => {
window.addEventListener('resize', handleResize)
// 🔒 登录状态检查:未登录则跳转登录页
if (!employeeStore.isAuthenticated) {
console.warn('[ChatView] 未登录,跳转登录页')
router.push('/login')
return
}
// 初始化应用(获取用户信息、会话、审批链接等)
store.initialize()
// 🔌 建立 WebSocket 连接(实时推送参与者变更、新消息等)
// 做什么:登录后建立 WS 连接,替代纯轮询
// 为什么:WS 推送比3秒轮询更实时,参与者变更可立即可见
h5ws.connect()
})
// 📋 邀请链接处理:URL 中的 invite 和 eid 参数
// 做什么:被邀请人点击企微卡片后,自动加入会话并切换视图
// 为什么:实现邀请-加入闭环(P0-10)
// 修复(2026-06-12):原实现使用 setTimeout(1000) 等待 store 初始化,
// 存在竞态风险(初始化超过1秒时会失败)。现改为 watch 监听 initialized 变化,
// 确保 store 完全初始化后再执行邀请加入逻辑。
const inviteId = route.query.invite as string
const eid = route.query.eid as string
if (inviteId && eid) {
console.log(`[ChatView] 检测到邀请链接: conv=${inviteId}, eid=${eid}`)
// 监听 store 初始化完成,然后执行邀请加入
const stopWatch = watch(
() => store.initialized,
async (isInitialized) => {
if (!isInitialized) return
// 停止监听(只执行一次)
stopWatch()
try {
// H5 专用端点通过 Token 认证获取 employee_id,无需传递 eid
await joinConversationApi(inviteId)
console.log('[ChatView] 已成功加入邀请的会话')
// 切换到邀请的会话(刷新会话信息 + 加载消息)
await store.switchToConversation(inviteId)
} catch (err: any) {
console.error('[ChatView] 加入会话失败:', err)
// 如果是拒绝性错误(未被邀请/会话已结束),不切换
const code = err?.response?.data?.code
if (code !== 3034 && code !== 3033) {
// 其他错误(如重复加入),仍尝试切换到该会话
try {
await store.switchToConversation(inviteId)
} catch (switchErr) {
console.error('[ChatView] 切换会话也失败:', switchErr)
}
}
} finally {
// 清理 URL 参数(避免刷新后重复加入)
router.replace({ path: route.path, query: {} })
}
},
{ immediate: true } // 如果已经初始化完成,立即执行
)
}
// 生命周期:卸载时移除 resize 监听 + 停止轮询 + 断开WS + 清理
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
h5ws.disconnect()
store.cleanup()
})
</script>
<style scoped>
/* 聊天主页面容器:双栏 Flex 布局 */
.chat-view {
display: flex;
width: 100%;
height: 100dvh; /* 修复:用 dvh 单位,移动端友好 */
background-color: var(--bg-primary);
position: relative;
}
/* 左栏:对话区 */
.chat-view__left {
flex: 0 0 60%;
max-width: 60%;
height: 100%;
overflow: hidden;
}
/* 左栏全宽(移动端右栏收起时) */
.chat-view__left--full {
flex: 0 0 100%;
max-width: 100%;
}
/* 拖拽手柄:左右栏分隔条 */
.chat-view__resize-handle {
flex: 0 0 6px;
width: 6px;
cursor: col-resize;
background-color: var(--border-color);
position: relative;
z-index: 10;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
/* 拖拽手柄悬停和拖拽中高亮 */
.chat-view__resize-handle:hover,
.chat-view__resize-handle:active {
background-color: var(--accent);
}
/* 拖拽手柄上的抓握指示点 */
.chat-view__resize-grip {
width: 2px;
height: 32px;
border-radius: 1px;
background-color: var(--text-placeholder);
transition: background-color 0.2s;
}
.chat-view__resize-handle:hover .chat-view__resize-grip {
background-color: var(--bg-secondary);
}
/* 右栏:三段式面板 */
.chat-view__right {
flex: 1;
height: 100%;
overflow: hidden;
}
/* 移动端适配(<500px */
@media (max-width: 499px) {
/* 移动端左栏全宽 */
.chat-view__left {
flex: 0 0 100%;
max-width: 100%;
}
}
</style>
+182
View File
@@ -0,0 +1,182 @@
<!-- =============================================================================
// 企微IT智能服务台 — H5用户端降级登录页
// =============================================================================
// 说明:测试阶段的降级登录页面
// - 未认证企微无法进行 OAuth2 授权(可信域名备案限制)
// - 通过后端 Mock 登录接口获取真实 Bearer Token
// - 仅当后端 MOCK_LOGIN_ENABLED=true 时可用
// ============================================================================= -->
<template>
<div class="login-page">
<div class="login-card">
<!-- 标题区 -->
<div class="login-header">
<div class="login-icon">🛠</div>
<h1 class="login-title">IT智能服务台</h1>
<p class="login-subtitle">测试模式登录</p>
</div>
<!-- 表单区 -->
<div class="login-form">
<van-field
v-model="employeeId"
label="员工ID"
placeholder="请输入企微员工 UserID"
clearable
:rules="[{ required: true, message: '请输入员工ID' }]"
@keyup.enter="handleLogin"
/>
<van-field
v-model="employeeName"
label="姓名"
placeholder="请输入姓名(可选)"
clearable
@keyup.enter="handleLogin"
/>
<van-button
type="primary"
block
class="login-btn"
:loading="loading"
:disabled="!employeeId.trim()"
@click="handleLogin"
>
</van-button>
<p class="login-hint">
此页面仅用于测试阶段<br />
通过后端 Mock 登录接口获取真实 Token<br />
正式上线后将使用企微 OAuth2 静默授权
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* Login 测试登录页
* 测试阶段绕过企微 OAuth2,通过后端 mock-login 获取真实 Bearer Token
*/
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useEmployeeStore } from '@/stores/employee'
import { showToast } from 'vant'
const router = useRouter()
const employeeStore = useEmployeeStore()
/** 员工ID输入值 */
const employeeId = ref<string>('')
/** 员工姓名输入值 */
const employeeName = ref<string>('')
/** 是否正在登录 */
const loading = ref<boolean>(false)
/**
* 处理登录
* 调用后端 mock-login 接口获取真实 Bearer Token
*/
async function handleLogin(): Promise<void> {
const id = employeeId.value.trim()
if (!id) {
showToast('请输入员工ID')
return
}
loading.value = true
try {
await employeeStore.mockLogin(id, employeeName.value.trim() || undefined)
showToast('登录成功')
router.push({ name: 'ChatView' })
} catch (error: any) {
console.error('[Login] 登录失败:', error)
const msg = error?.response?.data?.message || error?.message || '登录失败,请重试'
showToast(msg)
} finally {
loading.value = false
}
}
</script>
<style scoped>
/* 登录页面容器:全屏居中 */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
min-height: 100dvh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 24px;
}
/* 登录卡片 */
.login-card {
width: 100%;
max-width: 400px;
background: var(--bg-secondary);
border-radius: 16px;
padding: 40px 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
}
/* 标题区 */
.login-header {
text-align: center;
margin-bottom: 32px;
}
/* 图标 */
.login-icon {
font-size: 48px;
margin-bottom: 12px;
}
/* 标题 */
.login-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 8px 0;
}
/* 副标题 */
.login-subtitle {
font-size: 14px;
color: var(--text-tertiary);
margin: 0;
}
/* 表单区 */
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 登录按钮 */
.login-btn {
margin-top: 8px;
height: 44px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
}
/* 提示文字 */
.login-hint {
font-size: 12px;
color: var(--text-placeholder);
text-align: center;
line-height: 1.6;
margin: 8px 0 0 0;
}
</style>
+119
View File
@@ -0,0 +1,119 @@
<!-- =============================================================================
# 企微专属拦截页 非企微环境展示引导用户在企微中打开
#============================================================================= -->
<template>
<div class="wework-only">
<div class="wework-only__card">
<!-- 企微图标 -->
<div class="wework-only__icon">
<svg viewBox="0 0 1024 1024" width="80" height="80">
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm-32 664c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm64-160c-17.7 0-32-14.3-32-32V416c0-17.7 14.3-32 32-32s32 14.3 32 32v120c0 17.7-14.3 32-32 32z"
fill="#07C160"
/>
<path
d="M685.6 354.4c-9.6-9.6-25.2-9.6-34.8 0L512 493.2 373.2 354.4c-9.6-9.6-25.2-9.6-34.8 0s-9.6 25.2 0 34.8L486.8 537.6c4.8 4.8 11.1 7.2 17.6 7.2s12.8-2.4 17.6-7.2l138.8-148.4c9.5-9.6 9.5-25.2-.2-34.8z"
fill="#07C160"
opacity="0.6"
/>
</svg>
</div>
<!-- 提示文案 -->
<h2 class="wework-only__title">请在企业微信中打开</h2>
<p class="wework-only__desc">
IT智能服务台仅支持在企业微信内使用<br />
请通过企业微信工作台进入
</p>
<!-- 操作指引 -->
<div class="wework-only__steps">
<div class="wework-only__step">
<span class="wework-only__step-num">1</span>
<span>打开企业微信</span>
</div>
<div class="wework-only__step">
<span class="wework-only__step-num">2</span>
<span>进入工作台</span>
</div>
<div class="wework-only__step">
<span class="wework-only__step-num">3</span>
<span>找到IT支持服务</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.wework-only {
/* 全屏居中,适配移动端 */
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
/* 企微设计语言:浅色背景 */
background: #f5f6f7;
padding: 24px;
box-sizing: border-box;
}
.wework-only__card {
text-align: center;
background: #fff;
border-radius: 16px;
padding: 48px 32px;
max-width: 360px;
width: 100%;
/* 轻微阴影 */
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.wework-only__icon {
margin-bottom: 24px;
}
.wework-only__title {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 12px;
}
.wework-only__desc {
font-size: 14px;
color: #666;
line-height: 1.6;
margin: 0 0 32px;
}
.wework-only__steps {
display: flex;
flex-direction: column;
gap: 16px;
text-align: left;
padding: 0 16px;
}
.wework-only__step {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: #333;
}
.wework-only__step-num {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: #07C160;
color: #fff;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
</style>