chore: initial baseline with P0-safety .gitignore
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user