feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复

== 已部署上线 (9项) ==
- 代办事项真实数据源集成 (企微审批API 8bug修复链)
- H5/坐席端 Logo样式统一+绿色背景
- 视频引导页修复 (localStorage key v2)
- 坐席端 v9 Vue版本修复 (ElMessage._context)
- 截图按钮 v10 修复 (getDisplayMedia user gesture)
- 扫码样式恢复+H5扫码登录跳转修复
- H5截图快捷键提示

== 代码完成待部署 (3项) ==
- 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查)
- 会议室预定-小鱼易联终端 (40文件, 40/40测试通过)
- IT资产升级审批推送 (asset_service.py)

== 需求文档 (2项) ==
- 坐席端AI辅助消息框-PRD (4项新功能确认)
- 坐席端布局优化建议 v2.0 (7天计划)

== 新增文档 ==
- 日报-2026-07-11.md
- 知识迭代Bug修复报告-20260711.md
- 会议室预定-部署指南.md
- CHANGELOG.md 更新

== 测试 ==
- test_todo_integration.py: 40/40
- test_meetingroom.py: 40/40
- test_bugfix_ki_suggestions.py: 21/21
This commit is contained in:
Simon
2026-07-11 23:13:10 +08:00
parent 3d152fc8eb
commit bea288e414
928 changed files with 85169 additions and 54205 deletions
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/terminal/favicon.ico" />
<meta name="viewport" content="width=1920, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="format-detection" content="telephone=no" />
<title>会议室预定 · IT智能服务台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "frontend-terminal",
"version": "1.0.0",
"description": "小鱼易联终端会议室预定页面 — 企微IT智能服务台",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0",
"axios": "^1.7.0"
},
"devDependencies": {
"vite": "^5.3.0",
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.5.0",
"vue-tsc": "^2.0.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0",
"@types/node": "^20.0.0"
}
}
+10
View File
@@ -0,0 +1,10 @@
// =============================================================================
// 企微IT智能服务台 — 终端前端 PostCSS 配置
// =============================================================================
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+22
View File
@@ -0,0 +1,22 @@
<!-- =============================================================================
// 企微IT智能服务台 — 终端前端根组件
// ============================================================================= -->
<template>
<div class="app-root">
<router-view />
</div>
</template>
<script setup lang="ts">
// 根组件:仅提供 router-view 出口
</script>
<style scoped>
.app-root {
width: 100vw;
height: 100vh;
overflow: hidden;
background-color: #1a1a2e;
}
</style>
+137
View File
@@ -0,0 +1,137 @@
// =============================================================================
// 企微IT智能服务台 — 终端前端 Axios 实例与API封装
// =============================================================================
// 说明:创建 Axios 实例,封装会议室预定相关的 API 调用
// - 基础URL: ''Vite proxy 处理 /itportal 和 /api 前缀)
// - 请求拦截器:添加 Bearer Token(从 localStorage 读取)
// - 响应拦截器:统一处理 code !== 0 的业务错误
// =============================================================================
import axios from 'axios'
import type { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios'
import type {
ApiResponse,
TerminalBinding,
RoomStatusResponse,
Booking,
BookingDetail,
BookRequest,
BookResponse,
Meetingroom,
QrcodeResponse,
ScanStatusResponse,
} from '@/types/meetingroom'
// 创建 Axios 实例
const apiClient: AxiosInstance = axios.create({
baseURL: '',
timeout: 15000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器:添加 Bearer Token
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('terminal_token')
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error),
)
// 响应拦截器:统一处理业务错误
apiClient.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const res = response.data
if (res.code !== 0) {
console.error('[API] 业务错误:', res.code, res.message)
return Promise.reject({ code: res.code, message: res.message || '请求失败' })
}
return res.data as any
},
(error) => {
console.error('[API] 网络错误:', error)
return Promise.reject({ code: -1, message: '网络异常,请稍后重试' })
},
)
// =============================================================================
// 会议室预定 API
// =============================================================================
/** 获取终端绑定关系 */
export async function getTerminalBinding(sn: string): Promise<TerminalBinding | null> {
return await apiClient.get(`/itportal/meetingroom/terminal/${sn}/binding`)
}
/** 获取会议室列表 */
export async function getMeetingroomList(
city?: string,
building?: string,
floor?: string,
): Promise<{ rooms: Meetingroom[] }> {
const params: Record<string, string> = {}
if (city) params.city = city
if (building) params.building = building
if (floor) params.floor = floor
return await apiClient.get('/itportal/meetingroom/list', { params })
}
/** 获取预定状态(指定日期) */
export async function getBookingInfo(
meetingroomId: number,
date?: string,
): Promise<{ bookings: Booking[] }> {
const params: Record<string, string> = {}
if (date) params.date = date
return await apiClient.get(`/itportal/meetingroom/${meetingroomId}/booking`, { params })
}
/** 获取当前实时状态 */
export async function getRoomStatus(meetingroomId: number): Promise<RoomStatusResponse> {
return await apiClient.get(`/itportal/meetingroom/${meetingroomId}/status`)
}
/** 预定会议室 */
export async function bookMeetingroom(data: BookRequest): Promise<BookResponse> {
return await apiClient.post('/itportal/meetingroom/book', data)
}
/** 取消预定 */
export async function cancelBooking(bookingId: string, meetingroomId: number): Promise<void> {
await apiClient.delete(`/itportal/meetingroom/booking/${bookingId}`, {
params: { meetingroom_id: meetingroomId },
})
}
/** 获取预定详情 */
export async function getBookingDetail(
bookingId: string,
meetingroomId: number,
): Promise<BookingDetail> {
return await apiClient.get(`/itportal/meetingroom/booking/${bookingId}/detail`, {
params: { meetingroom_id: meetingroomId },
})
}
// =============================================================================
// 扫码登录 API
// =============================================================================
/** 创建扫码登录二维码 */
export async function createQrcode(): Promise<QrcodeResponse> {
return await apiClient.get('/api/auth/qrcode')
}
/** 轮询扫码状态 */
export async function getScanStatus(ticket: string): Promise<ScanStatusResponse> {
return await apiClient.get('/api/auth/scan/status', {
params: { ticket },
})
}
export default apiClient
@@ -0,0 +1,211 @@
<!--
=============================================================================
企微IT智能服务台 扫码登录组件
=============================================================================
说明终端大屏扫码登录弹窗
- 显示企微二维码
- 轮询扫码状态
- 登录成功后自动关闭弹窗
- 二维码过期自动刷新
=============================================================================
-->
<script setup lang="ts">
import { watch, onMounted } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { useTerminalStore } from '@/stores/terminal'
const props = defineProps<{
/** 是否显示 */
visible: boolean
}>()
const emit = defineEmits<{
/** 登录成功 */
(e: 'success', payload: { userid: string; name: string }): void
/** 关闭弹窗 */
(e: 'close'): void
}>()
const store = useTerminalStore()
const {
qrcode,
scanStatus,
loading,
error,
isLoggedIn,
fetchQrcode,
} = useAuth()
// ==========================================================================
// 生命周期 & 监听
// ==========================================================================
// 弹窗打开时获取二维码
watch(
() => props.visible,
(val) => {
if (val && !isLoggedIn.value) {
fetchQrcode()
}
},
)
// 监听登录成功
watch(isLoggedIn, (val) => {
if (val) {
emit('success', {
userid: store.loginUser?.userid || '',
name: store.loginUser?.name || '',
})
emit('close')
}
})
onMounted(() => {
if (props.visible && !isLoggedIn.value) {
fetchQrcode()
}
})
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
@click.self="emit('close')"
>
<div class="bg-bg-card rounded-2xl p-8 shadow-2xl w-[480px] animate-slide-up">
<!-- 标题 -->
<div class="text-center mb-6">
<h2 class="text-2xl font-bold text-text-primary">
企微扫码登录
</h2>
<p class="text-sm text-text-secondary mt-2">
使用企业微信扫码登录后可进行预定操作
</p>
</div>
<!-- 二维码区域 -->
<div class="flex flex-col items-center">
<!-- 加载中 -->
<div
v-if="loading"
class="w-[240px] h-[240px] flex items-center justify-center bg-bg-input rounded-xl"
>
<span class="text-text-secondary text-lg">加载中...</span>
</div>
<!-- 错误 -->
<div
v-else-if="error"
class="w-[240px] h-[240px] flex flex-col items-center justify-center bg-bg-input rounded-xl"
>
<span class="text-status-busy text-lg mb-3">{{ error }}</span>
<button
class="px-6 py-2 bg-status-free text-white rounded-lg text-button hover:opacity-80"
@click="fetchQrcode"
>
重新获取
</button>
</div>
<!-- 二维码图片 -->
<div
v-else-if="qrcode"
class="relative w-[240px] h-[240px] bg-white rounded-xl p-3"
>
<!-- 优先使用 base64 图片 -->
<img
v-if="qrcode.qrcode_png_base64"
:src="`data:image/png;base64,${qrcode.qrcode_png_base64}`"
alt="二维码"
class="w-full h-full"
/>
<!-- 退化使用 URL 生成二维码简化处理实际应使用 qrcode -->
<div
v-else
class="w-full h-full flex items-center justify-center"
>
<img
:src="`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrcode.qrcode_url || qrcode.ticket)}`"
alt="二维码"
class="w-full h-full"
/>
</div>
<!-- 扫码成功遮罩 -->
<div
v-if="scanStatus === 'scanned' || scanStatus === 'confirmed'"
class="absolute inset-0 bg-white/90 rounded-xl flex flex-col items-center justify-center"
>
<div class="w-16 h-16 rounded-full bg-status-free flex items-center justify-center mb-3">
<svg class="w-10 h-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<span class="text-lg font-bold text-status-free">
{{ scanStatus === 'confirmed' ? '登录成功' : '扫码成功' }}
</span>
<span v-if="scanStatus === 'scanned'" class="text-sm text-text-secondary mt-1">
请在手机上确认登录
</span>
</div>
<!-- 过期遮罩 -->
<div
v-if="scanStatus === 'expired'"
class="absolute inset-0 bg-black/60 rounded-xl flex flex-col items-center justify-center"
>
<span class="text-white text-lg mb-3">二维码已过期</span>
<button
class="px-6 py-2 bg-status-free text-white rounded-lg text-button hover:opacity-80"
@click="fetchQrcode"
>
刷新二维码
</button>
</div>
</div>
<!-- 提示文字 -->
<div class="mt-4 text-center">
<p v-if="scanStatus === 'waiting'" class="text-sm text-text-secondary">
请使用企业微信扫描二维码
</p>
<p v-else-if="scanStatus === 'scanned'" class="text-sm text-status-free">
扫码成功请在手机上确认
</p>
<p v-else-if="scanStatus === 'confirmed'" class="text-sm text-status-free">
登录成功正在跳转...
</p>
</div>
</div>
<!-- 关闭按钮 -->
<div class="mt-6 text-center">
<button
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
@click="emit('close')"
>
取消
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,87 @@
<!--
=============================================================================
企微IT智能服务台 状态徽章组件
=============================================================================
说明根据会议室状态显示对应颜色的徽章
- free: 绿色"空闲中"
- busy: 红色"使用中"
- starting_soon: 橙色"即将开始"
=============================================================================
-->
<script setup lang="ts">
import { computed } from 'vue'
import type { RoomStatus } from '@/types/meetingroom'
const props = withDefaults(defineProps<{
/** 会议室状态 */
status: RoomStatus
/** 尺寸 */
size?: 'sm' | 'md' | 'lg'
}>(), {
size: 'md',
})
/** 状态文字映射 */
const statusText = computed(() => {
switch (props.status) {
case 'free':
return '空闲中'
case 'busy':
return '使用中'
case 'starting_soon':
return '即将开始'
default:
return '未知'
}
})
/** 状态颜色映射 */
const statusColor = computed(() => {
switch (props.status) {
case 'free':
return 'status-free'
case 'busy':
return 'status-busy'
case 'starting_soon':
return 'status-soon'
default:
return 'text-muted'
}
})
/** 尺寸样式 */
const sizeClass = computed(() => {
switch (props.size) {
case 'sm':
return 'px-3 py-1 text-sm rounded'
case 'lg':
return 'px-8 py-3 text-xl rounded-lg'
default:
return 'px-5 py-2 text-base rounded-md'
}
})
</script>
<template>
<span
:class="[
'inline-flex items-center font-bold border-2',
sizeClass,
statusColor,
]"
:style="{
borderColor: `var(--color-status-${status === 'free' ? 'free' : status === 'busy' ? 'busy' : 'soon'})`,
color: `var(--color-status-${status === 'free' ? 'free' : status === 'busy' ? 'busy' : 'soon'})`,
}"
>
<span
class="inline-block rounded-full mr-2 animate-pulse-slow"
:style="{
width: size === 'lg' ? '12px' : size === 'sm' ? '6px' : '8px',
height: size === 'lg' ? '12px' : size === 'sm' ? '6px' : '8px',
backgroundColor: `var(--color-status-${status === 'free' ? 'free' : status === 'busy' ? 'busy' : 'soon'})`,
}"
/>
{{ statusText }}
</span>
</template>
@@ -0,0 +1,185 @@
<!--
=============================================================================
企微IT智能服务台 今日时间轴组件
=============================================================================
说明以时间轴方式展示当日会议室预定情况
- 横向时间轴 08:00 22:00
- 已预定时段显示为色块使用中=红色即将开始=橙色空闲=绿色
- 当前时间指示线
- 鼠标悬停显示会议详情
=============================================================================
-->
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Booking } from '@/types/meetingroom'
const props = withDefaults(defineProps<{
/** 当日预定列表 */
bookings: Booking[]
/** 当前状态 */
currentStatus?: string
}>(), {
currentStatus: 'free',
})
/** 时间轴范围 */
const START_HOUR = 8
const END_HOUR = 22
const TOTAL_HOURS = END_HOUR - START_HOUR
/** 当前时间 */
const now = ref(new Date())
// 每分钟更新一次当前时间
setInterval(() => {
now.value = new Date()
}, 60000)
/** 将 ISO 时间字符串转为小时小数 */
function timeToHours(isoTime: string): number {
const d = new Date(isoTime)
return d.getHours() + d.getMinutes() / 60
}
/** 计算预定色块的样式 */
function getBookingStyle(booking: Booking) {
const start = Math.max(timeToHours(booking.start_time), START_HOUR)
const end = Math.min(timeToHours(booking.end_time), END_HOUR)
const left = ((start - START_HOUR) / TOTAL_HOURS) * 100
const width = ((end - start) / TOTAL_HOURS) * 100
// 判断色块颜色
const isCurrent =
props.currentStatus === 'busy' &&
timeToHours(booking.start_time) <= timeToHours(now.value.toISOString()) &&
timeToHours(booking.end_time) > timeToHours(now.value.toISOString())
const isUpcoming = props.currentStatus === 'starting_soon'
let bgColor = 'var(--color-status-free)'
if (isCurrent) {
bgColor = 'var(--color-status-busy)'
} else if (isUpcoming) {
bgColor = 'var(--color-status-soon)'
} else {
bgColor = 'rgba(160, 160, 176, 0.3)'
}
return {
left: `${left}%`,
width: `${width}%`,
backgroundColor: bgColor,
}
}
/** 当前时间指示线位置 */
const nowPosition = computed(() => {
const currentHours = now.value.getHours() + now.value.getMinutes() / 60
if (currentHours < START_HOUR || currentHours > END_HOUR) return null
return ((currentHours - START_HOUR) / TOTAL_HOURS) * 100
})
/** 时间刻度标签 */
const hourLabels = computed(() => {
const labels: { hour: string; position: number }[] = []
for (let h = START_HOUR; h <= END_HOUR; h += 2) {
labels.push({
hour: `${h.toString().padStart(2, '0')}:00`,
position: ((h - START_HOUR) / TOTAL_HOURS) * 100,
})
}
return labels
})
/** 格式化时间显示 */
function formatTime(isoTime: string): string {
const d = new Date(isoTime)
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
}
/** 悬停的预定索引 */
const hoveredIndex = ref<number | null>(null)
</script>
<template>
<div class="w-full">
<!-- 标题 -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-timeline text-text-secondary font-medium">
今日预定时间轴
</h3>
<span class="text-sm text-text-muted">
{{ bookings.filter(b => b.status === 0).length }} 场会议
</span>
</div>
<!-- 时间轴主体 -->
<div class="relative h-16 bg-bg-input rounded-lg overflow-hidden">
<!-- 背景刻度线 -->
<div
v-for="label in hourLabels"
:key="label.hour"
class="absolute top-0 bottom-0 border-l border-border-subtle"
:style="{ left: `${label.position}%` }"
/>
<!-- 预定色块 -->
<div
v-for="(booking, index) in bookings.filter(b => b.status === 0)"
:key="booking.booking_id"
class="absolute top-1 bottom-1 rounded cursor-pointer transition-all hover:opacity-80"
:style="getBookingStyle(booking)"
@mouseenter="hoveredIndex = index"
@mouseleave="hoveredIndex = null"
/>
<!-- 当前时间指示线 -->
<div
v-if="nowPosition !== null"
class="absolute top-0 bottom-0 w-0.5 bg-white shadow-lg z-10"
:style="{ left: `${nowPosition}%` }"
>
<div class="absolute -top-1 left-1/2 -translate-x-1/2 w-3 h-3 bg-white rounded-full" />
</div>
</div>
<!-- 时间刻度标签 -->
<div class="relative h-6 mt-1">
<span
v-for="label in hourLabels"
:key="label.hour"
class="absolute text-sm text-text-muted -translate-x-1/2"
:style="{ left: `${label.position}%` }"
>
{{ label.hour }}
</span>
</div>
<!-- 悬停详情 -->
<div
v-if="hoveredIndex !== null && bookings[hoveredIndex]"
class="mt-3 p-4 bg-bg-card rounded-lg border border-border-subtle animate-fade-in"
>
<div class="flex items-center justify-between">
<span class="text-timeline text-text-primary font-medium">
{{ bookings[hoveredIndex].subject }}
</span>
<span class="text-sm text-text-secondary">
{{ formatTime(bookings[hoveredIndex].start_time) }} -
{{ formatTime(bookings[hoveredIndex].end_time) }}
</span>
</div>
<div v-if="bookings[hoveredIndex].booker_name" class="mt-1 text-sm text-text-secondary">
预定人: {{ bookings[hoveredIndex].booker_name }}
</div>
</div>
<!-- 无预定时显示 -->
<div
v-if="bookings.filter(b => b.status === 0).length === 0"
class="mt-4 text-center text-text-muted text-timeline"
>
今日暂无预定
</div>
</div>
</template>
@@ -0,0 +1,162 @@
// =============================================================================
// 企微IT智能服务台 — 终端扫码登录 Composable
// =============================================================================
// 说明:管理企微扫码登录的完整流程
// - 获取二维码(/api/auth/qrcode
// - 轮询扫码状态(/api/auth/scan/status
// - 登录成功后保存 token 到 Store + localStorage
// - 二维码过期自动刷新
// =============================================================================
import { ref, onUnmounted } from 'vue'
import type { ScanStatus, QrcodeResponse } from '@/types/meetingroom'
import { useTerminalStore } from '@/stores/terminal'
import * as api from '@/api/meetingroom'
/** 轮询间隔 */
const POLL_INTERVAL = 2000 // 2s
/** 最大轮询时长(5分钟) */
const MAX_POLL_DURATION = 5 * 60 * 1000
/**
* 扫码登录 Composable
*
* 提供完整的二维码登录流程管理:
* 1. 调用 /api/auth/qrcode 获取二维码和 ticket
* 2. 每 2s 轮询 /api/auth/scan/status?ticket=xxx
* 3. 状态变为 confirmed 时保存 token 并触发回调
* 4. 状态变为 expired 时自动刷新二维码
*/
export function useAuth() {
const store = useTerminalStore()
/** 二维码信息 */
const qrcode = ref<QrcodeResponse | null>(null)
/** 扫码状态 */
const scanStatus = ref<ScanStatus>('waiting')
/** 加载中 */
const loading = ref(false)
/** 错误信息 */
const error = ref('')
/** 是否已登录 */
const isLoggedIn = ref(store.isLoggedIn)
/** 轮询定时器 */
let pollTimer: ReturnType<typeof setInterval> | null = null
/** 轮询开始时间 */
let pollStartTime = 0
// ==========================================================================
// 内部方法
// ==========================================================================
/** 停止轮询 */
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
/** 轮询扫码状态 */
async function pollScanStatus(ticket: string) {
// 检查是否超时
if (Date.now() - pollStartTime > MAX_POLL_DURATION) {
console.warn('[Auth] 轮询超时,刷新二维码')
stopPolling()
await fetchQrcode()
return
}
try {
const result = await api.getScanStatus(ticket)
scanStatus.value = result.status
switch (result.status) {
case 'waiting':
// 继续等待
break
case 'scanned':
// 已扫码,等待确认
break
case 'confirmed':
// 登录成功
stopPolling()
if (result.token && result.employee_id && result.name) {
store.setLogin(result.token, result.employee_id, result.name)
isLoggedIn.value = true
}
break
case 'expired':
// 二维码过期,自动刷新
stopPolling()
await fetchQrcode()
break
}
} catch (e: any) {
console.error('[Auth] 轮询扫码状态失败:', e)
// 不停止轮询,继续重试
}
}
// ==========================================================================
// 公共方法
// ==========================================================================
/** 获取二维码 */
async function fetchQrcode() {
loading.value = true
error.value = ''
scanStatus.value = 'waiting'
try {
const result = await api.createQrcode()
qrcode.value = result
pollStartTime = Date.now()
// 启动轮询
stopPolling()
pollTimer = setInterval(() => {
if (result.ticket) {
pollScanStatus(result.ticket)
}
}, POLL_INTERVAL)
} catch (e: any) {
error.value = `获取二维码失败: ${e.message || e}`
console.error('[Auth] fetchQrcode error:', e)
} finally {
loading.value = false
}
}
/** 退出登录 */
function logout() {
stopPolling()
store.logout()
isLoggedIn.value = false
qrcode.value = null
scanStatus.value = 'waiting'
}
// ==========================================================================
// 生命周期
// ==========================================================================
onUnmounted(() => {
stopPolling()
})
return {
qrcode,
scanStatus,
loading,
error,
isLoggedIn,
fetchQrcode,
logout,
}
}
@@ -0,0 +1,321 @@
// =============================================================================
// 企微IT智能服务台 — 终端 WebSocket Composable
// =============================================================================
// 说明:管理终端 WebSocket 连接的生命周期
// - 自动连接 / 断线重连(指数退避,最大 30s)
// - 心跳保活(每 30s 发送 ping)
// - 接收 room_status_update 推送,回调更新 Store
// - WS 不可用时自动降级为 30s 轮询
// =============================================================================
import { ref, type Ref, onUnmounted } from 'vue'
import type { WSMessage, RoomStatusUpdateData } from '@/types/meetingroom'
import { useTerminalStore } from '@/stores/terminal'
import * as api from '@/api/meetingroom'
/** 重连配置 */
const RECONNECT_BASE_DELAY = 1000 // 初始重连延迟 1s
const RECONNECT_MAX_DELAY = 30000 // 最大重连延迟 30s
const RECONNECT_MAX_ATTEMPTS = 10 // 最大重连次数(0 = 无限)
const HEARTBEAT_INTERVAL = 30000 // 心跳间隔 30s
const POLLING_INTERVAL = 30000 // 降级轮询间隔 30s
/**
* 终端 WebSocket 连接管理 Composable
*
* @param terminalSn 终端序列号(Ref,响应式)
* @param meetingroomId 会议室 ID(Ref,用于轮询降级和状态刷新,可为 null)
*/
export function useWebSocket(
terminalSn: Ref<string>,
meetingroomId: Ref<number | null>,
) {
const store = useTerminalStore()
/** WebSocket 实例 */
let ws: WebSocket | null = null
/** 重连次数 */
let reconnectAttempts = 0
/** 心跳定时器 */
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
/** 重连定时器 */
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
/** 轮询定时器 */
let pollingTimer: ReturnType<typeof setInterval> | null = null
/** 是否手动关闭(手动关闭不触发重连) */
let manualClose = false
/** 连接状态 */
const connected = ref(false)
/** 是否已降级为轮询模式 */
const isPollingMode = ref(false)
/** 重连中 */
const isReconnecting = ref(false)
// ==========================================================================
// 内部方法
// ==========================================================================
/**
* 构造 WebSocket URL
* 终端WS端点:/ws/terminal/{terminal_sn}
* token 通过 subprotocol 传递(后端从 sec-websocket-protocol 读取)
*/
function buildWsUrl(): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
return `${protocol}//${host}/ws/terminal/${terminalSn.value}`
}
/**
* 构造 subprotocol(携带 token
* 格式:bearer.{token}
* 无 token 时返回空数组(终端WS token 可选)
*/
function buildSubprotocols(): string[] {
const token = localStorage.getItem('terminal_token')
if (token) {
return [`bearer.${token}`]
}
return []
}
/** 启动心跳定时器 */
function startHeartbeat() {
stopHeartbeat()
heartbeatTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }))
}
}, HEARTBEAT_INTERVAL)
}
/** 停止心跳定时器 */
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
}
/** 计算重连延迟(指数退避) */
function getReconnectDelay(): number {
const delay = Math.min(
RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts),
RECONNECT_MAX_DELAY,
)
return delay
}
/** 尝试重连 */
function scheduleReconnect() {
if (manualClose) return
if (
RECONNECT_MAX_ATTEMPTS > 0 &&
reconnectAttempts >= RECONNECT_MAX_ATTEMPTS
) {
console.warn('[WS] 达到最大重连次数,降级为轮询模式')
startPolling()
return
}
isReconnecting.value = true
const delay = getReconnectDelay()
console.log(`[WS] ${delay}ms 后重连 (attempt ${reconnectAttempts + 1})`)
reconnectTimer = setTimeout(() => {
reconnectAttempts++
connect()
}, delay)
}
/** 启动轮询降级模式 */
function startPolling() {
isPollingMode.value = true
connected.value = false
store.setWsConnected(false)
// 立即执行一次
pollStatus()
pollingTimer = setInterval(() => {
pollStatus()
}, POLLING_INTERVAL)
}
/** 停止轮询 */
function stopPolling() {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
isPollingMode.value = false
}
/** 轮询获取会议室状态 */
async function pollStatus() {
const roomId = meetingroomId.value
if (!roomId) return
try {
const data = await api.getRoomStatus(roomId)
store.updateStatus(data)
} catch (e) {
console.error('[WS Polling] 获取状态失败:', e)
}
}
// ==========================================================================
// WebSocket 事件处理
// ==========================================================================
/** onopen 回调 */
function onOpen() {
console.log('[WS] 连接已建立')
connected.value = true
store.setWsConnected(true)
isReconnecting.value = false
reconnectAttempts = 0
// 如果之前在轮询模式,停止轮询
if (isPollingMode.value) {
stopPolling()
}
// 启动心跳
startHeartbeat()
}
/** onmessage 回调 */
function onMessage(event: MessageEvent) {
try {
const msg: WSMessage = JSON.parse(event.data)
switch (msg.type) {
case 'pong':
// 心跳响应,无需处理
break
case 'room_status_update': {
// 会议室状态更新推送
const data = msg.data as RoomStatusUpdateData | undefined
if (data) {
// 通过 API 获取完整状态(推送只包含摘要,需要完整 bookings)
const roomId = meetingroomId.value
if (roomId) {
pollStatus()
}
}
break
}
default:
console.debug('[WS] 收到未知消息类型:', msg.type)
}
} catch (e) {
console.error('[WS] 解析消息失败:', e)
}
}
/** onclose 回调 */
function onClose(event: CloseEvent) {
console.log(`[WS] 连接关闭 (code=${event.code}, reason=${event.reason})`)
connected.value = false
store.setWsConnected(false)
stopHeartbeat()
if (!manualClose) {
// 4001 = 认证失败,不重连(但终端 token 可选,不应该出现)
if (event.code === 4001) {
console.warn('[WS] 认证失败,降级为轮询模式')
startPolling()
} else {
scheduleReconnect()
}
}
}
/** onerror 回调 */
function onError(event: Event) {
console.error('[WS] 连接错误:', event)
// 不在这里调 scheduleReconnectonclose 会处理
}
// ==========================================================================
// 公共方法
// ==========================================================================
/** 建立 WebSocket 连接 */
function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return
}
manualClose = false
const url = buildWsUrl()
const subprotocols = buildSubprotocols()
console.log(`[WS] 正在连接: ${url}`)
ws = new WebSocket(url, subprotocols.length > 0 ? subprotocols : undefined)
ws.onopen = onOpen
ws.onmessage = onMessage
ws.onclose = onClose
ws.onerror = onError
}
/** 主动断开连接 */
function disconnect() {
manualClose = true
stopHeartbeat()
stopPolling()
clearTimeout(reconnectTimer as unknown as number)
if (ws) {
ws.onopen = null
ws.onmessage = null
ws.onclose = null
ws.onerror = null
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close(1000, 'Normal closure')
}
ws = null
}
connected.value = false
store.setWsConnected(false)
isReconnecting.value = false
}
/** 主动请求状态刷新(通过 WS 发送 request_status */
function requestStatus(roomId: number) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: 'request_status',
data: { meetingroom_id: roomId },
}),
)
} else {
// WS 未连接时直接 API 轮询
pollStatus()
}
}
// ==========================================================================
// 生命周期
// ==========================================================================
onUnmounted(() => {
disconnect()
})
return {
connected,
isPollingMode,
isReconnecting,
connect,
disconnect,
requestStatus,
}
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+16
View File
@@ -0,0 +1,16 @@
// =============================================================================
// 企微IT智能服务台 — 终端前端应用入口
// =============================================================================
// 说明:挂载 Vue 应用,注册 Pinia 和 Vue Router
// =============================================================================
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './styles/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+39
View File
@@ -0,0 +1,39 @@
// =============================================================================
// 企微IT智能服务台 — 终端前端路由配置
// =============================================================================
// 说明:定义终端页面路由
// /terminal/:sn → StatusView(会议室状态展示主页)
// /terminal/:sn/book → BookingView(快速预定弹窗,可选路由)
// =============================================================================
import { createRouter, createWebHistory } from 'vue-router'
import StatusView from '@/views/StatusView.vue'
const routes = [
{
path: '/:sn',
name: 'StatusView',
component: StatusView,
meta: { title: '会议室状态' },
},
{
path: '/:sn/book',
name: 'BookingView',
component: () => import('@/views/BookingView.vue'),
meta: { title: '快速预定' },
},
// 默认重定向(无SN时提示联系管理员)
{
path: '/',
name: 'NoTerminal',
component: () => import('@/views/StatusView.vue'),
meta: { title: '会议室状态' },
},
]
const router = createRouter({
history: createWebHistory('/terminal/'),
routes,
})
export default router
+196
View File
@@ -0,0 +1,196 @@
// =============================================================================
// 企微IT智能服务台 — 终端前端 Pinia Store
// =============================================================================
// 说明:管理终端会议室状态、登录状态、WebSocket连接状态
// =============================================================================
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
TerminalBinding,
RoomStatusResponse,
RoomStatus,
Booking,
BookRequest,
} from '@/types/meetingroom'
import * as api from '@/api/meetingroom'
export const useTerminalStore = defineStore('terminal', () => {
// ==========================================================================
// State
// ==========================================================================
/** 终端序列号 */
const terminalSn = ref<string>('')
/** 终端绑定信息 */
const meetingroomInfo = ref<TerminalBinding | null>(null)
/** 当前会议室状态 */
const currentStatus = ref<RoomStatusResponse | null>(null)
/** 当日预定列表 */
const bookings = ref<Booking[]>([])
/** 登录Token */
const loginToken = ref<string>(localStorage.getItem('terminal_token') || '')
/** 登录用户信息 */
const loginUser = ref<{ userid: string; name: string } | null>(
(() => {
const saved = localStorage.getItem('terminal_user')
return saved ? JSON.parse(saved) : null
})(),
)
/** WebSocket连接状态 */
const wsConnected = ref<boolean>(false)
/** 加载状态 */
const loading = ref<boolean>(false)
/** 错误信息 */
const error = ref<string>('')
// ==========================================================================
// Getters
// ==========================================================================
/** 是否已登录 */
const isLoggedIn = computed(() => !!loginToken.value)
/** 会议室名称 */
const meetingroomName = computed(() => meetingroomInfo.value?.meetingroom_name || '未绑定')
/** 位置 */
const location = computed(() => meetingroomInfo.value?.location || '')
/** 当前状态文字 */
const statusText = computed(() => {
const status = currentStatus.value?.status
if (status === 'free') return '空闲中'
if (status === 'busy') return '使用中'
if (status === 'starting_soon') return '即将开始'
return '加载中...'
})
/** 距下一个会议的分钟数 */
const minutesToNext = computed(() => currentStatus.value?.minutes_to_next ?? null)
// ==========================================================================
// Actions
// ==========================================================================
/** 设置终端SN */
function setTerminalSn(sn: string) {
terminalSn.value = sn
}
/** 加载终端绑定关系 */
async function loadBinding(sn: string) {
try {
const data = await api.getTerminalBinding(sn)
meetingroomInfo.value = data
return data
} catch (e: any) {
error.value = `加载终端绑定失败: ${e.message || e}`
console.error('[Store] loadBinding error:', e)
return null
}
}
/** 加载会议室实时状态 */
async function loadStatus(meetingroomId: number) {
loading.value = true
try {
const data = await api.getRoomStatus(meetingroomId)
currentStatus.value = data
bookings.value = data.bookings || []
return data
} catch (e: any) {
error.value = `加载会议室状态失败: ${e.message || e}`
console.error('[Store] loadStatus error:', e)
return null
} finally {
loading.value = false
}
}
/** 预定会议室 */
async function bookRoom(params: {
meetingroomId: number
subject: string
startTime: string
endTime: string
attendees?: string[]
}): Promise<string> {
if (!isLoggedIn.value) {
throw new Error('请先扫码登录')
}
const request: BookRequest = {
meetingroom_id: params.meetingroomId,
subject: params.subject,
start_time: params.startTime,
end_time: params.endTime,
booker: loginUser.value?.userid || '',
attendees: params.attendees,
}
const result = await api.bookMeetingroom(request)
// 预定成功后刷新状态
await loadStatus(params.meetingroomId)
return result.booking_id
}
/** 取消预定 */
async function cancelBooking(bookingId: string, meetingroomId: number) {
await api.cancelBooking(bookingId, meetingroomId)
// 取消成功后刷新状态
await loadStatus(meetingroomId)
}
/** 设置登录状态 */
function setLogin(token: string, userid: string, name: string) {
loginToken.value = token
loginUser.value = { userid, name }
localStorage.setItem('terminal_token', token)
localStorage.setItem('terminal_user', JSON.stringify({ userid, name }))
}
/** 退出登录 */
function logout() {
loginToken.value = ''
loginUser.value = null
localStorage.removeItem('terminal_token')
localStorage.removeItem('terminal_user')
}
/** 设置WS连接状态 */
function setWsConnected(connected: boolean) {
wsConnected.value = connected
}
/** 更新状态(WS推送后调用) */
function updateStatus(data: RoomStatusResponse) {
currentStatus.value = data
if (data.bookings) {
bookings.value = data.bookings
}
}
return {
// State
terminalSn,
meetingroomInfo,
currentStatus,
bookings,
loginToken,
loginUser,
wsConnected,
loading,
error,
// Getters
isLoggedIn,
meetingroomName,
location,
statusText,
minutesToNext,
// Actions
setTerminalSn,
loadBinding,
loadStatus,
bookRoom,
cancelBooking,
setLogin,
logout,
setWsConnected,
updateStatus,
}
})
+87
View File
@@ -0,0 +1,87 @@
/* =============================================================================
* 企微IT智能服务台 — 终端前端全局样式
* =============================================================================
* 说明:Tailwind 指令 + 深色主题 CSS 变量 + 大屏基础样式
* ============================================================================= */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 深色主题 CSS 变量(参考架构文档8.5节) */
:root {
--color-bg-primary: #1a1a2e;
--color-bg-card: #16213e;
--color-bg-card-hover: #1a2744;
--color-status-free: #07C160;
--color-status-busy: #FF6B6B;
--color-status-soon: #FFA502;
--color-text-primary: #FFFFFF;
--color-text-secondary: #A0A0B0;
}
/* 全局重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
#app {
width: 100%;
height: 100%;
}
/* 自定义滚动条(深色主题适配) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0f1729;
}
::-webkit-scrollbar-thumb {
background: #2A2A3E;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #3A3A4E;
}
/* 大屏字号工具类 */
.text-status-main {
font-size: 96px;
line-height: 1;
}
.text-room-name {
font-size: 36px;
line-height: 1.2;
}
.text-timeline {
font-size: 20px;
line-height: 1.4;
}
.text-button {
font-size: 24px;
line-height: 1.2;
}
+182
View File
@@ -0,0 +1,182 @@
// =============================================================================
// 企微IT智能服务台 — 终端前端 TypeScript 类型定义
// =============================================================================
// 说明:定义会议室预定相关的所有 TypeScript 类型
// - RoomStatus: 会议室实时状态
// - Meetingroom: 会议室信息
// - Booking: 预定记录
// - TerminalBinding: 终端绑定关系
// - WSMessage: WebSocket 消息格式
// =============================================================================
/** 会议室实时状态 */
export type RoomStatus = 'free' | 'busy' | 'starting_soon'
/** 会议室信息(企微API返回) */
export interface Meetingroom {
/** 企微会议室ID */
meetingroom_id: number
/** 会议室名称 */
name: string
/** 容纳人数 */
capacity: number
/** 位置描述 */
location: string
/** 设备列表(1=电视, 2=电话, 3=投影, 4=白板, 5=视频) */
devices: number[]
/** 是否需要审批(0=不需要, 1=需要) */
need_approval: number
}
/** 预定记录 */
export interface Booking {
/** 企微预定ID */
booking_id: string
/** 会议主题 */
subject: string
/** 预定人userid */
booker: string
/** 预定人姓名(后端补充) */
booker_name?: string
/** 开始时间(ISO 8601 格式) */
start_time: string
/** 结束时间(ISO 8601 格式) */
end_time: string
/** 预定状态(0=已预定, 1=已取消) */
status: number
}
/** 预定详情 */
export interface BookingDetail {
/** 企微预定ID */
booking_id: string
/** 会议主题 */
subject: string
/** 预定人userid */
booker: string
/** 预定人姓名 */
booker_name?: string
/** 参与人列表 */
attendees?: string[]
/** 开始时间 */
start_time: string
/** 结束时间 */
end_time: string
}
/** 终端绑定关系 */
export interface TerminalBinding {
/** 绑定记录ID */
id: number
/** 终端序列号 */
terminal_sn: string
/** 终端名称 */
terminal_name: string
/** 企微会议室ID */
meetingroom_id: number
/** 会议室名称 */
meetingroom_name: string
/** 位置描述 */
location: string
/** 是否启用 */
is_active: boolean
/** 创建时间 */
created_at: string
/** 更新时间 */
updated_at: string
}
/** 会议室实时状态响应 */
export interface RoomStatusResponse {
/** 当前状态 */
status: RoomStatus
/** 当前进行中的会议(status=busy时有值) */
current_meeting: Booking | null
/** 下一个会议(status=free时有值) */
next_meeting: Booking | null
/** 距下一个会议开始的分钟数(status=free时有值) */
minutes_to_next: number | null
/** 当日预定列表 */
bookings: Booking[]
}
/** 预定请求参数 */
export interface BookRequest {
/** 会议室ID */
meetingroom_id: number
/** 会议主题 */
subject: string
/** 开始时间(ISO 8601 */
start_time: string
/** 结束时间(ISO 8601 */
end_time: string
/** 预定人userid */
booker: string
/** 参与人列表(可选) */
attendees?: string[]
}
/** 预定响应 */
export interface BookResponse {
/** 企微预定ID */
booking_id: string
}
/** 扫码登录状态 */
export type ScanStatus = 'waiting' | 'scanned' | 'confirmed' | 'expired'
/** 扫码登录响应 */
export interface ScanStatusResponse {
/** 扫码状态 */
status: ScanStatus
/** 员工IDconfirmed时有值) */
employee_id?: string
/** 员工姓名(confirmed时有值) */
name?: string
/** 认证Tokenconfirmed时有值) */
token?: string
}
/** 二维码创建响应 */
export interface QrcodeResponse {
/** 扫码票据 */
ticket: string
/** 二维码URL */
qrcode_url: string
/** 二维码PNG base64(可选) */
qrcode_png_base64?: string
/** 过期秒数 */
expires_in: number
/** 过期时间 */
expires_at: string
}
/** WebSocket 消息格式 */
export interface WSMessage<T = unknown> {
/** 消息类型 */
type: string
/** 消息数据 */
data?: T
}
/** 会议室状态更新推送数据 */
export interface RoomStatusUpdateData {
/** 会议室ID */
meetingroom_id: number
/** 当前状态 */
status: RoomStatus
/** 当前进行中的会议 */
current_meeting: Booking | null
/** 下一个会议 */
next_meeting: Booking | null
}
/** 统一API响应格式 */
export interface ApiResponse<T = unknown> {
/** 业务码(0=成功) */
code: number
/** 业务数据 */
data: T
/** 消息 */
message: string
}
+444
View File
@@ -0,0 +1,444 @@
<!--
=============================================================================
企微IT智能服务台 快速预定页面
=============================================================================
说明终端大屏快速预定弹窗页面
- 预定人信息当前登录用户
- 会议主题输入
- 时长选择30分钟/60分钟/90分钟/120分钟
- 起始时间选择下一个可用时段
- 确认预定 / 取消
- 预定成功后返回状态页
=============================================================================
-->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTerminalStore } from '@/stores/terminal'
import type { Booking } from '@/types/meetingroom'
// ==========================================================================
// 路由 & Store
// ==========================================================================
const route = useRoute()
const router = useRouter()
const store = useTerminalStore()
// ==========================================================================
// 表单状态
// ==========================================================================
/** 会议主题 */
const subject = ref('')
/** 预定时长(分钟) */
const duration = ref(30)
/** 自定义开始时间 */
const customStartTime = ref('')
/** 是否使用自定义开始时间 */
const useCustomStart = ref(false)
/** 提交中 */
const submitting = ref(false)
/** 错误信息 */
const errorMsg = ref('')
// ==========================================================================
// 计算属性
// ==========================================================================
/** 会议室ID */
const meetingroomId = computed(() => store.meetingroomInfo?.meetingroom_id ?? null)
/** 可选时长列表 */
const durationOptions = [
{ label: '30 分钟', value: 30 },
{ label: '60 分钟', value: 60 },
{ label: '90 分钟', value: 90 },
{ label: '120 分钟', value: 120 },
]
/** 计算最近可用的开始时间 */
const earliestStartTime = computed(() => {
const now = new Date()
// 向上取整到下一个 30 分钟
const minutes = now.getMinutes()
const roundedMinutes = minutes <= 0 ? 0 : minutes <= 30 ? 30 : 60
now.setMinutes(roundedMinutes, 0, 0)
// 如果当前在使用中,找下一个空闲时段
const bookings = store.bookings.filter(b => b.status === 0)
let startTime = now
for (const booking of bookings) {
const bookingStart = new Date(booking.start_time)
const bookingEnd = new Date(booking.end_time)
if (startTime >= bookingEnd) continue
if (startTime < bookingStart) {
// 当前时间在预定开始之前,检查是否有足够时间
const availableMinutes = (bookingStart.getTime() - startTime.getTime()) / 60000
if (availableMinutes >= duration.value) {
break // 有足够时间
}
}
// 冲突,移到这个预定结束之后
startTime = new Date(bookingEnd)
}
return startTime
})
/** 计算的起始时间 */
const startTime = computed(() => {
if (useCustomStart.value && customStartTime.value) {
const [h, m] = customStartTime.value.split(':').map(Number)
const d = new Date()
d.setHours(h, m, 0, 0)
return d
}
return earliestStartTime.value
})
/** 计算的结束时间 */
const endTime = computed(() => {
return new Date(startTime.value.getTime() + duration.value * 60000)
})
/** 格式化时间 */
function formatTime(d: Date): string {
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
}
/** 格式化为 ISO 8601 */
function toISOString(d: Date): string {
return d.toISOString()
}
/** 检查时间冲突 */
const hasConflict = computed(() => {
const start = startTime.value
const end = endTime.value
const bookings = store.bookings.filter(b => b.status === 0)
for (const booking of bookings) {
const bookingStart = new Date(booking.start_time)
const bookingEnd = new Date(booking.end_time)
// 时间重叠判断
if (start < bookingEnd && end > bookingStart) {
return true
}
}
return false
})
/** 冲突的会议 */
const conflictingMeeting = computed<Booking | null>(() => {
if (!hasConflict.value) return null
const start = startTime.value
const end = endTime.value
const bookings = store.bookings.filter(b => b.status === 0)
for (const booking of bookings) {
const bookingStart = new Date(booking.start_time)
const bookingEnd = new Date(booking.end_time)
if (start < bookingEnd && end > bookingStart) {
return booking
}
}
return null
})
/** 可选的起始时间列表(每30分钟一个) */
const availableStartTimes = computed(() => {
const times: { label: string; value: string; disabled: boolean }[] = []
const now = new Date()
for (let h = 8; h <= 21; h++) {
for (let m = 0; m < 60; m += 30) {
const d = new Date()
d.setHours(h, m, 0, 0)
// 过滤已过去的时间
if (d < now) continue
times.push({
label: `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`,
value: `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`,
disabled: false,
})
}
}
return times
})
// ==========================================================================
// 方法
// ==========================================================================
/** 确认预定 */
async function confirmBooking() {
if (!meetingroomId.value) {
errorMsg.value = '会议室信息缺失'
return
}
if (!subject.value.trim()) {
errorMsg.value = '请输入会议主题'
return
}
if (hasConflict.value) {
errorMsg.value = '所选时段与已有预定冲突'
return
}
if (!store.isLoggedIn) {
errorMsg.value = '请先扫码登录'
return
}
submitting.value = true
errorMsg.value = ''
try {
await store.bookRoom({
meetingroomId: meetingroomId.value,
subject: subject.value.trim(),
startTime: toISOString(startTime.value),
endTime: toISOString(endTime.value),
})
// 预定成功,返回状态页
const sn = route.params.sn as string
router.replace(`/${sn}`)
} catch (e: any) {
errorMsg.value = e.message || '预定失败,请稍后重试'
} finally {
submitting.value = false
}
}
/** 取消,返回状态页 */
function cancel() {
const sn = route.params.sn as string
router.replace(`/${sn}`)
}
/** 快速填充主题 */
function quickSubject(text: string) {
subject.value = text
}
// ==========================================================================
// 生命周期
// ==========================================================================
onMounted(() => {
// 检查登录状态
if (!store.isLoggedIn) {
// 未登录则返回状态页
const sn = route.params.sn as string
router.replace(`/${sn}`)
return
}
// 如果还没有状态数据,先加载
if (meetingroomId.value && !store.currentStatus) {
store.loadStatus(meetingroomId.value)
}
// 设置默认主题
if (!subject.value) {
subject.value = `${store.loginUser?.name || '我'}的会议`
}
})
onUnmounted(() => {
// 离开时刷新状态
if (meetingroomId.value) {
store.loadStatus(meetingroomId.value)
}
})
</script>
<template>
<div class="w-full h-screen flex items-center justify-center bg-bg-primary p-8">
<div class="bg-bg-card rounded-2xl border border-border-subtle shadow-2xl w-[800px] max-h-[90vh] overflow-y-auto animate-slide-up">
<!-- 标题栏 -->
<div class="flex items-center justify-between p-6 border-b border-border-subtle">
<div>
<h2 class="text-2xl font-bold text-text-primary">快速预定</h2>
<p class="text-sm text-text-secondary mt-1">
{{ store.meetingroomName }}
<span v-if="store.location"> · {{ store.location }}</span>
</p>
</div>
<button
class="text-text-secondary hover:text-text-primary text-2xl"
@click="cancel"
>
</button>
</div>
<!-- 表单内容 -->
<div class="p-6 space-y-6">
<!-- 预定人 -->
<div class="flex items-center gap-4">
<span class="text-lg text-text-secondary min-w-[100px]">预定人</span>
<div class="px-4 py-2 bg-bg-input rounded-lg text-lg text-text-primary">
{{ store.loginUser?.name || store.loginUser?.userid || '未知' }}
</div>
</div>
<!-- 会议主题 -->
<div>
<div class="flex items-center gap-4 mb-2">
<span class="text-lg text-text-secondary min-w-[100px]">会议主题</span>
<input
v-model="subject"
type="text"
placeholder="请输入会议主题"
maxlength="50"
class="flex-1 px-4 py-3 bg-bg-input rounded-lg text-lg text-text-primary border border-border-subtle focus:border-status-free focus:outline-none"
/>
</div>
<!-- 快速填充 -->
<div class="flex gap-2 ml-[116px]">
<button
v-for="preset in ['临时会议', '项目讨论', '客户沟通', '团队周会']"
:key="preset"
class="px-3 py-1 text-sm bg-bg-input text-text-secondary rounded border border-border-subtle hover:text-text-primary hover:border-status-free transition-colors"
@click="quickSubject(preset)"
>
{{ preset }}
</button>
</div>
</div>
<!-- 预定时长 -->
<div class="flex items-center gap-4">
<span class="text-lg text-text-secondary min-w-[100px]">预定时长</span>
<div class="flex gap-3">
<button
v-for="opt in durationOptions"
:key="opt.value"
class="px-6 py-3 rounded-lg text-button font-medium transition-all"
:class="duration === opt.value
? 'bg-status-free text-white'
: 'bg-bg-input text-text-secondary border border-border-subtle hover:text-text-primary'"
@click="duration = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<!-- 开始时间 -->
<div>
<div class="flex items-center gap-4 mb-2">
<span class="text-lg text-text-secondary min-w-[100px]">开始时间</span>
<div class="flex gap-3">
<button
class="px-6 py-3 rounded-lg text-button font-medium transition-all"
:class="!useCustomStart
? 'bg-status-free text-white'
: 'bg-bg-input text-text-secondary border border-border-subtle hover:text-text-primary'"
@click="useCustomStart = false"
>
最近空闲 ({{ formatTime(startTime) }})
</button>
<button
class="px-6 py-3 rounded-lg text-button font-medium transition-all"
:class="useCustomStart
? 'bg-status-free text-white'
: 'bg-bg-input text-text-secondary border border-border-subtle hover:text-text-primary'"
@click="useCustomStart = true"
>
自定义时间
</button>
</div>
</div>
<!-- 自定义时间选择器 -->
<div v-if="useCustomStart" class="ml-[116px] flex flex-wrap gap-2 max-h-32 overflow-y-auto p-3 bg-bg-input rounded-lg">
<button
v-for="t in availableStartTimes"
:key="t.value"
class="px-4 py-2 rounded text-base transition-colors"
:class="customStartTime === t.value
? 'bg-status-free text-white'
: 'bg-bg-card text-text-secondary hover:text-text-primary'"
@click="customStartTime = t.value"
>
{{ t.label }}
</button>
</div>
</div>
<!-- 时间预览 -->
<div class="p-4 bg-bg-input rounded-xl">
<div class="flex items-center justify-between">
<div>
<div class="text-sm text-text-secondary mb-1">预定时段</div>
<div class="text-2xl font-bold text-text-primary">
{{ formatTime(startTime) }} - {{ formatTime(endTime) }}
</div>
</div>
<div class="text-right">
<div class="text-sm text-text-secondary mb-1">时长</div>
<div class="text-2xl font-bold text-status-free">
{{ duration }} 分钟
</div>
</div>
</div>
</div>
<!-- 冲突提示 -->
<div
v-if="hasConflict && conflictingMeeting"
class="p-4 bg-status-busy/10 border border-status-busy/30 rounded-xl"
>
<div class="flex items-center gap-2 text-status-busy font-bold text-lg">
时间冲突
</div>
<div class="mt-2 text-text-secondary">
"{{ conflictingMeeting.subject }}" 冲突
({{ formatTime(new Date(conflictingMeeting.start_time)) }} -
{{ formatTime(new Date(conflictingMeeting.end_time)) }})
</div>
</div>
<!-- 错误提示 -->
<div
v-if="errorMsg"
class="p-4 bg-status-busy/10 border border-status-busy/30 rounded-xl text-status-busy text-lg"
>
{{ errorMsg }}
</div>
</div>
<!-- 底部操作栏 -->
<div class="flex items-center justify-end gap-4 p-6 border-t border-border-subtle">
<button
class="px-8 py-3 rounded-xl text-button font-medium bg-bg-input text-text-secondary border border-border-subtle hover:text-text-primary transition-colors"
@click="cancel"
>
取消
</button>
<button
class="px-10 py-3 rounded-xl text-button font-bold transition-all"
:class="[
submitting || hasConflict || !subject.trim()
? 'bg-text-muted text-text-secondary cursor-not-allowed'
: 'bg-status-free text-white hover:scale-105',
]"
:disabled="submitting || hasConflict || !subject.trim()"
@click="confirmBooking"
>
{{ submitting ? '预定中...' : '确认预定' }}
</button>
</div>
</div>
</div>
</template>
+494
View File
@@ -0,0 +1,494 @@
<!--
=============================================================================
企微IT智能服务台 终端状态主页面
=============================================================================
说明小鱼易联终端大屏主页面
- 1920×1080 横屏深色主题适配
- 96px 大字显示当前状态空闲/使用中/即将开始
- 会议室名称 + 位置信息
- 当前进行中的会议详情
- 今日时间轴
- 扫码登录 + 快速预定入口
- WS 实时推送 + 30s 轮询降级
=============================================================================
-->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTerminalStore } from '@/stores/terminal'
import { useWebSocket } from '@/composables/useWebSocket'
import StatusBadge from '@/components/StatusBadge.vue'
import Timeline from '@/components/Timeline.vue'
import QrLogin from '@/components/QrLogin.vue'
import type { RoomStatus } from '@/types/meetingroom'
// ==========================================================================
// 路由 & Store
// ==========================================================================
const route = useRoute()
const router = useRouter()
const store = useTerminalStore()
// ==========================================================================
// 本地状态
// ==========================================================================
/** 当前时间(每秒更新) */
const currentTime = ref(new Date())
/** 是否显示扫码登录弹窗 */
const showQrLogin = ref(false)
/** 初始化完成 */
const initialized = ref(false)
/** 初始化错误 */
const initError = ref('')
// 每秒更新时间
let clockTimer: ReturnType<typeof setInterval> | null = null
// WebSocket composablemeetingroomId 初始为 null,绑定加载后更新)
// 传入 Ref 以确保异步加载绑定后 WS 轮询降级能获取到 meetingroomId
const meetingroomId = computed(() => store.meetingroomInfo?.meetingroom_id ?? null)
const terminalSnRef = computed(() => route.params.sn as string || '')
const {
connected: wsConnected,
isPollingMode,
connect: wsConnect,
disconnect: wsDisconnect,
requestStatus: wsRequestStatus,
} = useWebSocket(terminalSnRef, meetingroomId)
// ==========================================================================
// 计算属性
// ==========================================================================
/** 当前状态 */
const currentStatus = computed<RoomStatus>(() => {
return store.currentStatus?.status ?? 'free'
})
/** 格式化的当前时间 */
const formattedTime = computed(() => {
const d = currentTime.value
const h = d.getHours().toString().padStart(2, '0')
const m = d.getMinutes().toString().padStart(2, '0')
return `${h}:${m}`
})
/** 格式化的日期 */
const formattedDate = computed(() => {
const d = currentTime.value
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}日 星期${weekdays[d.getDay()]}`
})
/** 当前会议信息 */
const currentMeeting = computed(() => store.currentStatus?.current_meeting ?? null)
/** 下一个会议信息 */
const nextMeeting = computed(() => store.currentStatus?.next_meeting ?? null)
/** 距下一个会议的分钟数 */
const minutesToNext = computed(() => store.currentStatus?.minutes_to_next ?? null)
/** 状态描述文字 */
const statusDescription = computed(() => {
switch (currentStatus.value) {
case 'free':
if (nextMeeting.value) {
if (minutesToNext.value !== null && minutesToNext.value > 0) {
return `距下一场会议还有 ${minutesToNext.value} 分钟`
}
return '当前空闲'
}
return '今日剩余时段空闲'
case 'busy':
if (currentMeeting.value) {
return `当前会议: ${currentMeeting.value.subject}`
}
return '使用中'
case 'starting_soon':
if (nextMeeting.value) {
return `即将开始: ${nextMeeting.value.subject}`
}
return '即将有会议开始'
default:
return ''
}
})
/** 状态主色 */
const statusColor = computed(() => {
switch (currentStatus.value) {
case 'free':
return 'var(--color-status-free)'
case 'busy':
return 'var(--color-status-busy)'
case 'starting_soon':
return 'var(--color-status-soon)'
default:
return 'var(--color-text-secondary)'
}
})
/** 是否未绑定会议室 */
const isUnbound = computed(() => initialized.value && !store.meetingroomInfo)
/** 格式化时间 */
function formatTime(isoTime: string): string {
const d = new Date(isoTime)
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
}
// ==========================================================================
// 方法
// ==========================================================================
/** 初始化:加载终端绑定 + 会议室状态 */
async function init() {
const sn = route.params.sn as string
if (!sn) {
initError.value = '缺少终端序列号,请联系管理员配置终端SN'
initialized.value = true
return
}
store.setTerminalSn(sn)
try {
// 1. 加载终端绑定关系
const binding = await store.loadBinding(sn)
if (binding && binding.meetingroom_id) {
// 2. 加载会议室实时状态
await store.loadStatus(binding.meetingroom_id)
// 3. 建立 WebSocket 连接
wsConnect()
}
initialized.value = true
} catch (e: any) {
initError.value = `初始化失败: ${e.message || e}`
initialized.value = true
}
}
/** 跳转到预定页面 */
function goBooking() {
if (!store.isLoggedIn) {
showQrLogin.value = true
return
}
const sn = route.params.sn as string
router.push(`/${sn}/book`)
}
/** 登录成功回调 */
function onLoginSuccess() {
showQrLogin.value = false
// 登录成功后可以跳转预定页面或直接刷新
}
/** 手动刷新状态 */
function refreshStatus() {
if (meetingroomId.value) {
wsRequestStatus(meetingroomId.value)
store.loadStatus(meetingroomId.value)
}
}
// ==========================================================================
// 监听
// ==========================================================================
// 当 meetingroomId 变化时重新连接 WS
watch(meetingroomId, (newId) => {
if (newId && initialized.value) {
wsDisconnect()
// 重新连接需要传新的 meetingroomId,但 composable 已经初始化
// 这里直接刷新状态即可
store.loadStatus(newId)
}
})
// ==========================================================================
// 生命周期
// ==========================================================================
onMounted(async () => {
// 启动时钟
clockTimer = setInterval(() => {
currentTime.value = new Date()
}, 1000)
await init()
})
onUnmounted(() => {
if (clockTimer) {
clearInterval(clockTimer)
}
wsDisconnect()
})
</script>
<template>
<div class="w-full h-screen flex flex-col bg-bg-primary overflow-hidden">
<!-- ================================================================ -->
<!-- 初始化加载中 -->
<!-- ================================================================ -->
<div v-if="!initialized" class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="w-16 h-16 border-4 border-border-subtle border-t-status-free rounded-full animate-spin mx-auto mb-6" />
<p class="text-2xl text-text-secondary">正在加载...</p>
</div>
</div>
<!-- ================================================================ -->
<!-- 初始化错误 / 未绑定 -->
<!-- ================================================================ -->
<div
v-else-if="initError || isUnbound"
class="flex-1 flex items-center justify-center"
>
<div class="text-center max-w-2xl">
<div class="text-6xl mb-6">📺</div>
<h1 class="text-4xl font-bold text-text-primary mb-4">
{{ initError ? '初始化失败' : '终端未绑定' }}
</h1>
<p class="text-xl text-text-secondary mb-2">
{{ initError || '此终端尚未绑定会议室' }}
</p>
<p class="text-lg text-text-muted">
请联系IT管理员在后台配置终端序列号 (SN) 与会议室的绑定关系
</p>
<div class="mt-6 text-sm text-text-muted">
终端SN: {{ route.params.sn || '未知' }}
</div>
</div>
</div>
<!-- ================================================================ -->
<!-- 正常显示 -->
<!-- ================================================================ -->
<div v-else class="flex-1 flex flex-col p-8">
<!-- ============================================================ -->
<!-- 顶部栏会议室名称 + 位置 + 时间 + 连接状态 -->
<!-- ============================================================ -->
<header class="flex items-start justify-between mb-6">
<!-- 左侧会议室信息 -->
<div>
<h1 class="text-room-name font-bold text-text-primary">
{{ store.meetingroomName }}
</h1>
<p v-if="store.location" class="text-lg text-text-secondary mt-1">
📍 {{ store.location }}
</p>
</div>
<!-- 右侧时间 + 连接状态 -->
<div class="text-right">
<div class="text-5xl font-bold text-text-primary font-mono">
{{ formattedTime }}
</div>
<div class="text-lg text-text-secondary mt-1">
{{ formattedDate }}
</div>
<div class="mt-2 flex items-center justify-end gap-2">
<span
class="inline-flex items-center text-sm"
:class="wsConnected ? 'text-status-free' : isPollingMode ? 'text-status-soon' : 'text-status-busy'"
>
<span
class="inline-block w-2 h-2 rounded-full mr-1"
:class="wsConnected ? 'bg-status-free' : isPollingMode ? 'bg-status-soon' : 'bg-status-busy'"
/>
{{ wsConnected ? '实时连接' : isPollingMode ? '轮询模式' : '离线' }}
</span>
</div>
</div>
</header>
<!-- ============================================================ -->
<!-- 主体内容区 -->
<!-- ============================================================ -->
<main class="flex-1 flex gap-6">
<!-- ======================================================== -->
<!-- 左侧状态展示区 60% -->
<!-- ======================================================== -->
<div class="flex-[3] flex flex-col">
<!-- 大状态展示 -->
<div
class="flex-1 rounded-2xl border-2 flex flex-col items-center justify-center transition-all duration-500"
:style="{
borderColor: statusColor,
backgroundColor: `${statusColor}10`,
}"
>
<!-- 状态图标 -->
<div class="mb-4">
<div
class="w-24 h-24 rounded-full flex items-center justify-center"
:style="{ backgroundColor: statusColor }"
>
<span v-if="currentStatus === 'free'" class="text-6xl"></span>
<span v-else-if="currentStatus === 'busy'" class="text-6xl"></span>
<span v-else class="text-6xl"></span>
</div>
</div>
<!-- 状态文字 -->
<div
class="text-status-main font-bold"
:style="{ color: statusColor }"
>
{{ store.statusText }}
</div>
<!-- 状态描述 -->
<div class="mt-4 text-xl text-text-secondary text-center max-w-2xl">
{{ statusDescription }}
</div>
<!-- 当前会议详情 -->
<div
v-if="currentMeeting"
class="mt-6 px-8 py-4 bg-bg-card rounded-xl border border-border-subtle"
>
<div class="text-lg text-text-primary font-medium">
{{ currentMeeting.subject }}
</div>
<div class="text-base text-text-secondary mt-1">
{{ formatTime(currentMeeting.start_time) }} -
{{ formatTime(currentMeeting.end_time) }}
<span v-if="currentMeeting.booker_name" class="ml-4">
预定人: {{ currentMeeting.booker_name }}
</span>
</div>
</div>
<!-- 下一个会议提示 -->
<div
v-if="nextMeeting && currentStatus !== 'busy'"
class="mt-3 text-lg text-text-secondary"
>
下一场: {{ nextMeeting.subject }}
({{ formatTime(nextMeeting.start_time) }} -
{{ formatTime(nextMeeting.end_time) }})
</div>
</div>
<!-- 操作按钮区 -->
<div class="mt-6 flex items-center justify-center gap-4">
<button
class="px-10 py-4 rounded-xl text-button font-bold transition-all hover:scale-105"
:class="store.isLoggedIn
? 'bg-status-free text-white hover:bg-status-free/80'
: 'bg-bg-card text-text-secondary border-2 border-border-subtle'"
@click="goBooking"
>
{{ store.isLoggedIn ? '立即预定' : '扫码登录预定' }}
</button>
<button
v-if="store.isLoggedIn"
class="px-8 py-4 rounded-xl text-button font-medium bg-bg-card text-text-secondary border-2 border-border-subtle transition-all hover:scale-105 hover:text-text-primary"
@click="showQrLogin = false; store.logout()"
>
退出登录
</button>
<button
class="px-6 py-4 rounded-xl text-button font-medium bg-bg-card text-text-secondary border-2 border-border-subtle transition-all hover:scale-105"
@click="refreshStatus"
>
刷新
</button>
</div>
<!-- 已登录用户提示 -->
<div v-if="store.isLoggedIn" class="mt-3 text-center text-sm text-text-muted">
已登录: {{ store.loginUser?.name }}
</div>
</div>
<!-- ======================================================== -->
<!-- 右侧时间轴 + 预定列表 40% -->
<!-- ======================================================== -->
<div class="flex-[2] flex flex-col gap-6">
<!-- 时间轴卡片 -->
<div class="bg-bg-card rounded-2xl p-6 border border-border-subtle">
<Timeline
:bookings="store.bookings"
:current-status="currentStatus"
/>
</div>
<!-- 今日预定列表 -->
<div class="bg-bg-card rounded-2xl p-6 border border-border-subtle flex-1 overflow-hidden flex flex-col">
<h3 class="text-timeline text-text-secondary font-medium mb-4">
今日预定列表
</h3>
<div class="flex-1 overflow-y-auto space-y-3">
<div
v-for="booking in store.bookings.filter(b => b.status === 0)"
:key="booking.booking_id"
class="flex items-center gap-4 p-4 rounded-xl bg-bg-input hover:bg-bg-card-hover transition-colors"
>
<!-- 时间 -->
<div class="text-center min-w-[80px]">
<div class="text-lg font-bold text-text-primary">
{{ formatTime(booking.start_time) }}
</div>
<div class="text-sm text-text-muted">
{{ formatTime(booking.end_time) }}
</div>
</div>
<!-- 分割线 -->
<div class="w-px h-12 bg-border-subtle" />
<!-- 会议信息 -->
<div class="flex-1 min-w-0">
<div class="text-lg text-text-primary truncate">
{{ booking.subject }}
</div>
<div class="text-sm text-text-secondary mt-1">
<span v-if="booking.booker_name">{{ booking.booker_name }}</span>
<span v-else>{{ booking.booker }}</span>
</div>
</div>
<!-- 状态徽章 -->
<StatusBadge
:status="currentMeeting?.booking_id === booking.booking_id ? 'busy' : 'free'"
size="sm"
/>
</div>
<!-- 无预定时 -->
<div
v-if="store.bookings.filter(b => b.status === 0).length === 0"
class="flex flex-col items-center justify-center h-full text-text-muted"
>
<div class="text-4xl mb-3">📅</div>
<p class="text-lg">今日暂无预定</p>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- ================================================================ -->
<!-- 扫码登录弹窗 -->
<!-- ================================================================ -->
<QrLogin
:visible="showQrLogin"
@success="onLoginSuccess"
@close="showQrLogin = false"
/>
</div>
</template>
+57
View File
@@ -0,0 +1,57 @@
// =============================================================================
// 企微IT智能服务台 — 终端前端 Tailwind CSS 配置
// =============================================================================
// 说明:深色主题色板,大屏适配
// - 自定义颜色对应架构文档8.5节定义
// - 大字号断点(终端大屏专用)
// =============================================================================
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
// 深色主题色板(参考架构文档8.5节)
'bg-primary': '#1a1a2e', // 主背景
'bg-card': '#16213e', // 卡片背景
'bg-card-hover': '#1a2744', // 卡片悬停
'bg-input': '#0f1729', // 输入框背景
'status-free': '#07C160', // 空闲(企微绿)
'status-busy': '#FF6B6B', // 使用中(红色)
'status-soon': '#FFA502', // 即将开始(橙色)
'text-primary': '#FFFFFF', // 主文本
'text-secondary': '#A0A0B0', // 次要文本
'text-muted': '#5A5A6A', // 弱化文本
'border-subtle': '#2A2A3E', // 微妙边框
},
fontSize: {
// 大屏字号规范
'status-main': ['96px', '1'], // 当前状态主字号
'room-name': ['36px', '1.2'], // 会议室名称
'timeline': ['20px', '1.4'], // 时间轴
'button-text': ['24px', '1.2'], // 操作按钮
},
screens: {
// 终端大屏分辨率
'terminal': '1920px',
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'fade-in': 'fadeIn 0.3s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+51
View File
@@ -0,0 +1,51 @@
// =============================================================================
// 企微IT智能服务台 — 终端前端 Vite 配置
// =============================================================================
// 说明:小鱼易联终端大屏页面构建配置
// - base: /terminal/ (生产环境 nginx 路由前缀)
// - port: 5176(本地开发端口)
// - proxy: /itportal, /api, /ws → 后端 http://localhost:8000
// =============================================================================
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
base: '/terminal/',
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 5176,
host: '0.0.0.0',
proxy: {
// 会议室预定API(终端+H5共用)
'/itportal': {
target: 'http://localhost:8000',
changeOrigin: true,
},
// 统一认证API
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
// WebSocket
'/ws': {
target: 'ws://localhost:8000',
ws: true,
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
// 终端大屏设备,不生成 sourcemap(生产优化)
sourcemap: false,
// chunk 大小警告阈值(终端页面资源较少,设为 500KB)
chunkSizeWarningLimit: 500,
},
})