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:
@@ -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>
|
||||
@@ -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)
|
||||
// 不在这里调 scheduleReconnect,onclose 会处理
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 公共方法
|
||||
// ==========================================================================
|
||||
|
||||
/** 建立 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,
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,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')
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
/** 员工ID(confirmed时有值) */
|
||||
employee_id?: string
|
||||
/** 员工姓名(confirmed时有值) */
|
||||
name?: string
|
||||
/** 认证Token(confirmed时有值) */
|
||||
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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 composable(meetingroomId 初始为 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>
|
||||
Reference in New Issue
Block a user