Files
jczxw-pc/app/pages/developer/tickets.vue
2026-04-23 16:30:57 +08:00

866 lines
30 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="dev-page">
<!-- 页面头部 -->
<div class="page-header">
<div>
<h2 class="page-title">🎫 工单处理</h2>
<p class="page-desc">查看和处理用户提交的技术支持工单及时响应用户反馈</p>
</div>
<a-space>
<a-input
v-model:value="keywords"
allow-clear
placeholder="搜索工单标题/编号"
style="width: 200px"
@press-enter="doSearch"
/>
<a-button :loading="loading" @click="loadTickets">
<ReloadOutlined /> 刷新
</a-button>
</a-space>
</div>
<div class="page-body">
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-5">
<a-col :xs="12" :sm="6" v-for="s in statsCards" :key="s.label">
<div class="stat-card" :class="s.colorClass">
<div class="stat-icon">{{ s.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ s.value }}</div>
<div class="stat-label">{{ s.label }}</div>
</div>
</div>
</a-col>
</a-row>
<!-- 主内容面板 -->
<div class="panel">
<!-- 筛选栏 -->
<div class="filter-bar">
<a-segmented
v-model:value="statusFilter"
:options="statusOptions"
@change="doSearch"
/>
<a-space class="filter-right" wrap>
<a-select
v-model:value="appFilter"
allow-clear
placeholder="按应用筛选"
style="min-width: 160px"
@change="doSearch"
>
<a-select-option v-for="app in appList" :key="app.productId" :value="app.productId">
{{ app.siteName || app.productName }}
</a-select-option>
</a-select>
<a-select
v-model:value="assigneeFilter"
allow-clear
placeholder="按处理人筛选"
style="min-width: 140px"
@change="doSearch"
>
<a-select-option value="mine">仅我负责</a-select-option>
<a-select-option value="unassigned">未分配</a-select-option>
</a-select>
<a-select
v-model:value="priorityFilter"
allow-clear
placeholder="优先级"
style="min-width: 110px"
@change="doSearch"
>
<a-select-option value="urgent">🔥 紧急</a-select-option>
<a-select-option value="high"> </a-select-option>
<a-select-option value="normal"> 普通</a-select-option>
<a-select-option value="low"> </a-select-option>
</a-select>
</a-space>
</div>
<a-alert v-if="error" class="mx-4 mb-3" show-icon type="error" :message="error" />
<!-- 工单表格 -->
<a-spin :spinning="loading">
<a-table
:data-source="tickets"
:columns="columns"
:pagination="false"
row-key="ticketId"
size="middle"
:scroll="{ x: 900 }"
class="tickets-table"
@row-click="openDetail"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="ticket-title-cell">
<span class="ticket-title-text">{{ record.title }}</span>
<a-badge v-if="record.hasUnread" color="#4f46e5" />
</div>
<div class="ticket-no-text">{{ record.ticketNo }}</div>
</template>
<template v-else-if="column.key === 'app'">
<span class="app-name">📦 {{ record.productName || `#${record.productId}` }}</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'priority'">
<a-tag :color="priorityColor(record.priority)">{{ priorityLabel(record.priority) }}</a-tag>
</template>
<template v-else-if="column.key === 'category'">
{{ categoryLabel(record.category) }}
</template>
<template v-else-if="column.key === 'assignee'">
<div v-if="record.assigneeName" class="assignee-cell">
<a-avatar :size="22" :src="record.assigneeAvatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<span>{{ record.assigneeName }}</span>
</div>
<a-tag v-else color="orange" @click.stop="openAssign(record)">
<PlusOutlined /> 分配
</a-tag>
</template>
<template v-else-if="column.key === 'submitter'">
<div class="assignee-cell">
<a-avatar :size="22" :src="record.submitUserAvatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<span>{{ record.submitUserName }}</span>
</div>
</template>
<template v-else-if="column.key === 'replyCount'">
<span class="reply-count-cell">
<MessageOutlined /> {{ record.replyCount }}
</span>
</template>
<template v-else-if="column.key === 'createTime'">
{{ formatTime(record.createTime) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space size="small" @click.stop>
<a-button size="small" type="link" @click="openDetail(record)">查看</a-button>
<a-button
v-if="record.status === 'pending' || record.status === 'assigned'"
size="small"
type="link"
@click="handlePickUp(record)"
>接单</a-button>
<a-button
v-if="record.status === 'processing'"
size="small"
type="link"
style="color: #16a34a"
@click="handleResolve(record)"
>标记解决</a-button>
</a-space>
</template>
</template>
<!-- 空状态 -->
<template #emptyText>
<div class="empty-state">
<div class="empty-icon">🎫</div>
<div class="empty-title">暂无工单</div>
<div class="empty-desc">当前筛选条件下没有工单记录</div>
</div>
</template>
</a-table>
</a-spin>
<!-- 分页 -->
<div v-if="total > pageSize" class="pagination-wrap">
<a-pagination
v-model:current="currentPage"
:total="total"
:page-size="pageSize"
show-quick-jumper
:show-total="(t: number) => `${t}`"
@change="loadTickets"
/>
</div>
</div>
</div>
<!-- ========= 工单详情抽屉 ========= -->
<a-drawer
v-model:open="showDetail"
:title="`工单详情 — ${currentTicket?.ticketNo}`"
width="660"
placement="right"
>
<template v-if="currentTicket">
<!-- 顶部操作栏 -->
<div class="detail-action-bar">
<a-space wrap>
<a-tag :color="statusColor(currentTicket.status)">{{ statusLabel(currentTicket.status) }}</a-tag>
<a-tag :color="priorityColor(currentTicket.priority)">{{ priorityLabel(currentTicket.priority) }}</a-tag>
<a-tag>{{ categoryLabel(currentTicket.category) }}</a-tag>
</a-space>
<a-space>
<a-button v-if="!currentTicket.assigneeName" size="small" @click="openAssign(currentTicket)">
分配处理人
</a-button>
<a-button
v-if="currentTicket.status === 'pending' || currentTicket.status === 'assigned'"
size="small"
type="primary"
@click="handlePickUp(currentTicket)"
>接单处理</a-button>
<a-button
v-if="currentTicket.status === 'processing'"
size="small"
style="background:#16a34a;border-color:#16a34a;color:#fff"
@click="handleResolve(currentTicket)"
>标记已解决</a-button>
</a-space>
</div>
<!-- 工单信息 -->
<a-descriptions :column="2" size="small" class="detail-desc mt-4">
<a-descriptions-item label="工单编号">{{ currentTicket.ticketNo }}</a-descriptions-item>
<a-descriptions-item label="关联应用">
📦 {{ currentTicket.productName || `#${currentTicket.productId}` }}
</a-descriptions-item>
<a-descriptions-item label="提交用户">{{ currentTicket.submitUserName }}</a-descriptions-item>
<a-descriptions-item label="处理人">
<span v-if="currentTicket.assigneeName">{{ currentTicket.assigneeName }}</span>
<a-tag v-else color="orange">未分配</a-tag>
</a-descriptions-item>
<a-descriptions-item label="提交时间">{{ formatTime(currentTicket.createTime) }}</a-descriptions-item>
<a-descriptions-item label="最后更新">{{ formatTime(currentTicket.updateTime) }}</a-descriptions-item>
</a-descriptions>
<a-divider />
<!-- 问题描述 -->
<div class="section-title">问题描述</div>
<div class="detail-content">{{ currentTicket.content }}</div>
<div v-if="currentTicket.attachments?.length" class="detail-attachments">
<span class="detail-attachments-label">📎 附件:</span>
<a
v-for="url in currentTicket.attachments"
:key="url"
:href="getAttachmentUrl(url)"
target="_blank"
class="detail-attachment-link"
>{{ url.split('/').slice(-1)[0] }}</a>
</div>
<a-divider />
<!-- 沟通记录 -->
<div class="section-title">
沟通记录
<a-spin v-if="repliesLoading" size="small" style="margin-left: 8px" />
</div>
<div class="reply-list">
<div
v-for="reply in replies"
:key="reply.replyId"
class="reply-item"
:class="{ 'reply-staff': reply.isStaff }"
>
<a-avatar :size="32" :src="reply.userAvatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<div class="reply-body">
<div class="reply-header">
<span class="reply-name">{{ reply.userName }}</span>
<a-tag v-if="reply.isStaff" color="blue" class="staff-tag">技术人员</a-tag>
<span class="reply-time">{{ formatTime(reply.createTime) }}</span>
</div>
<div class="reply-content">{{ reply.content }}</div>
<div v-if="reply.attachments?.length" class="reply-attachments">
<a v-for="url in reply.attachments" :key="url" :href="getAttachmentUrl(url)" target="_blank" class="reply-attachment-link">
📎 {{ url.split('/').slice(-1)[0] }}
</a>
</div>
</div>
</div>
<a-empty v-if="!repliesLoading && replies.length === 0" description="暂无回复" class="mt-4" />
</div>
<!-- 回复输入 -->
<div
v-if="!['resolved','closed','rejected'].includes(currentTicket.status)"
class="reply-input-wrap mt-4"
>
<a-textarea
v-model:value="replyContent"
placeholder="回复用户将通知到客户"
:rows="3"
:maxlength="1000"
show-count
/>
<a-upload
v-model:file-list="replyFileList"
:custom-request="handleReplyUpload"
:before-upload="beforeUpload"
:on-remove="handleReplyRemove"
multiple
:max-count="5"
class="mt-2"
>
<a-button size="small"><PaperClipOutlined /> 添加附件最多5个</a-button>
</a-upload>
<div class="reply-footer">
<span class="reply-hint">💡 回复后客户将收到通知</span>
<a-button type="primary" :loading="replyLoading" :disabled="!replyContent.trim()" @click="handleReply">
发送回复
</a-button>
</div>
</div>
</template>
</a-drawer>
<!-- ========= 分配处理人弹窗 ========= -->
<a-modal
v-model:open="showAssign"
title="分配处理人"
:confirm-loading="assignLoading"
ok-text="确认分配"
@ok="handleAssign"
@cancel="showAssign = false"
>
<a-form layout="vertical" class="mt-2">
<a-form-item label="选择技术人员">
<a-select v-model:value="assigneeId" placeholder="选择处理人" style="width: 100%">
<a-select-option v-for="staff in staffList" :key="staff.userId" :value="staff.userId">
<div style="display:flex;align-items:center;gap:8px">
<a-avatar :size="20" :src="staff.avatar">
<template #icon><UserOutlined /></template>
</a-avatar>
{{ staff.nickname }}
</div>
</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import {
MessageOutlined,
PaperClipOutlined,
PlusOutlined,
ReloadOutlined,
UserOutlined,
} from '@ant-design/icons-vue'
import {
getAllTickets,
getTicketReplies,
replyTicket,
updateTicketStatus,
assignTicket,
getTicketStats,
getTechStaffList,
} from '@/api/ticket'
import type { Ticket, TicketReply } from '@/api/ticket/model'
import { getMyAccessibleApps } from '@/api/app/appProduct'
import { uploadFile } from '@/api/system/file'
import type { UploadFile } from 'ant-design-vue'
definePageMeta({ layout: 'developer' })
useHead({ title: '工单处理 - 开发者中心' })
// ─── 表格列定义 ──────────────────────────────────────────────────
const columns = [
{ title: '工单标题', key: 'title', ellipsis: true, width: 220 },
{ title: '应用', key: 'app', width: 140 },
{ title: '状态', key: 'status', width: 90 },
{ title: '优先级', key: 'priority', width: 80 },
{ title: '分类', key: 'category', width: 90 },
{ title: '提交人', key: 'submitter', width: 110 },
{ title: '处理人', key: 'assignee', width: 120 },
{ title: '回复', key: 'replyCount', width: 70, align: 'center' },
{ title: '提交时间', key: 'createTime', width: 110 },
{ title: '操作', key: 'action', width: 140, fixed: 'right' },
]
// ─── 状态 & 筛选 ─────────────────────────────────────────────────
const loading = ref(false)
const error = ref('')
const tickets = ref<Ticket[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = 20
const keywords = ref('')
const statusFilter = ref('all')
const appFilter = ref<number | undefined>(undefined)
const assigneeFilter = ref<string | undefined>(undefined)
const priorityFilter = ref<string | undefined>(undefined)
const appList = ref<{ productId: number; siteName?: string; productName?: string }[]>([])
const staffList = ref<{ userId: number; nickname: string; avatar: string }[]>([])
// ─── 统计 ─────────────────────────────────────────────────────────
const stats = ref({ total: 0, pending: 0, processing: 0, resolved: 0, closed: 0 })
const statsCards = computed(() => [
{ label: '全部工单', value: stats.value.total, icon: '🎫', colorClass: 'blue' },
{ label: '待处理', value: stats.value.pending, icon: '⏳', colorClass: 'orange' },
{ label: '处理中', value: stats.value.processing, icon: '⚙️', colorClass: 'purple' },
{ label: '已解决', value: stats.value.resolved, icon: '✅', colorClass: 'green' },
])
const statusOptions = [
{ label: '全部', value: 'all' },
{ label: '待处理', value: 'pending' },
{ label: '已分配', value: 'assigned' },
{ label: '处理中', value: 'processing' },
{ label: '已解决', value: 'resolved' },
{ label: '已关闭', value: 'closed' },
]
function statusColor(s: string) {
return { pending: 'orange', assigned: 'blue', processing: 'geekblue', resolved: 'green', closed: 'default', rejected: 'red' }[s] || 'default'
}
function statusLabel(s: string) {
return { pending: '待处理', assigned: '已分配', processing: '处理中', resolved: '已解决', closed: '已关闭', rejected: '已拒绝' }[s] || s
}
function priorityColor(p: string) {
return { low: 'default', normal: 'blue', high: 'orange', urgent: 'red' }[p] || 'default'
}
function priorityLabel(p: string) {
return { low: '低', normal: '普通', high: '高', urgent: '紧急' }[p] || p
}
function categoryLabel(c: string) {
return { bug: '🐛 Bug', feature: '✨ 需求', consultation: '❓ 咨询', complaint: '📢 投诉', other: '📋 其他' }[c] || c
}
function formatTime(t: string) {
if (!t) return ''
const d = new Date(t)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`
return d.toLocaleDateString('zh-CN')
}
// ─── 加载数据 ──────────────────────────────────────────────────────
async function loadTickets() {
loading.value = true
error.value = ''
try {
const params: any = {
keywords: keywords.value || undefined,
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
productId: appFilter.value,
priority: priorityFilter.value,
page: currentPage.value,
limit: pageSize,
}
if (assigneeFilter.value === 'mine') {
const userId = localStorage.getItem('UserId')
if (userId) params.assigneeId = Number(userId)
} else if (assigneeFilter.value === 'unassigned') {
params.assigneeId = 0
}
const res = await getAllTickets(params)
tickets.value = (res?.data as any)?.data?.list || []
total.value = (res?.data as any)?.data?.count || 0
} catch (e: any) {
error.value = e?.message || '加载失败'
} finally {
loading.value = false
}
}
async function loadStats() {
try {
const res = await getTicketStats()
Object.assign(stats.value, (res?.data as any)?.data || {})
} catch {}
}
async function loadStaff() {
try {
const res = await getTechStaffList()
staffList.value = res?.data || []
} catch {}
}
async function loadApps() {
try {
const userId = Number(localStorage.getItem('UserId') || 0)
if (!userId) return
const res = await getMyAccessibleApps()
appList.value = (res?.data || []).map((app: any) => ({
productId: app.productId,
siteName: app.siteName,
productName: app.productName,
}))
} catch {}
}
function doSearch() {
currentPage.value = 1
loadTickets()
}
// ─── 工单操作 ──────────────────────────────────────────────────────
async function handlePickUp(ticket: Ticket) {
const userId = Number(localStorage.getItem('UserId'))
try {
await updateTicketStatus({ ticketId: ticket.ticketId, status: 'processing' })
message.success('已接单,开始处理')
await loadTickets()
await loadStats()
if (currentTicket.value?.ticketId === ticket.ticketId) {
currentTicket.value.status = 'processing'
currentTicket.value.assigneeId = userId
}
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
async function handleResolve(ticket: Ticket) {
try {
await updateTicketStatus({ ticketId: ticket.ticketId, status: 'resolved' })
message.success('已标记为已解决')
await loadTickets()
await loadStats()
if (currentTicket.value?.ticketId === ticket.ticketId) {
currentTicket.value.status = 'resolved'
}
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
// ─── 详情 & 回复 ─────────────────────────────────────────────────
const showDetail = ref(false)
const currentTicket = ref<Ticket | null>(null)
const replies = ref<TicketReply[]>([])
const repliesLoading = ref(false)
const replyContent = ref('')
const replyLoading = ref(false)
const replyAttachments = ref<string[]>([])
const replyFileList = ref<UploadFile[]>([])
type UploadRequestOption = { file?: File; onSuccess?: (body: unknown, file: File) => void; onError?: (err: unknown) => void }
/** 只有图片类型才附加 OSS 图片处理参数 */
function getAttachmentUrl(url: string) {
const imageExts = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i
if (imageExts.test(url)) {
const base = url.split('?')[0]
return `${base}?x-oss-process=image/resize,w_750/quality,Q_90`
}
return url
}
function beforeUpload(file: File) {
if (file.size > 10 * 1024 * 1024) {
message.error('文件大小不能超过 10MB')
return false
}
return true
}
async function handleReplyUpload(option: UploadRequestOption) {
const rawFile = option.file
if (!rawFile) return
try {
const record = await uploadFile(rawFile)
const url = (record?.url || record?.downloadUrl || '').trim()
if (!url) throw new Error('上传成功但未返回文件地址')
replyAttachments.value = [...replyAttachments.value, url]
replyFileList.value = [
...replyFileList.value.filter(f => f.status !== 'uploading'),
{ uid: url, name: rawFile.name, status: 'done', url } as UploadFile,
]
option.onSuccess?.(record, rawFile)
} catch (e) {
option.onError?.(e)
message.error(e instanceof Error ? e.message : '上传失败')
}
}
function handleReplyRemove(file: UploadFile) {
replyAttachments.value = replyAttachments.value.filter(u => u !== file.url)
replyFileList.value = replyFileList.value.filter(f => f.uid !== file.uid)
}
async function openDetail(ticket: Ticket) {
currentTicket.value = { ...ticket }
showDetail.value = true
repliesLoading.value = true
replyContent.value = ''
replyAttachments.value = []
replyFileList.value = []
try {
const res = await getTicketReplies(ticket.ticketId)
replies.value = (res?.data as any)?.data || res?.data || []
} catch {} finally {
repliesLoading.value = false
}
}
async function handleReply() {
if (!replyContent.value.trim()) return
replyLoading.value = true
try {
await replyTicket({
ticketId: currentTicket.value!.ticketId,
content: replyContent.value.trim(),
attachments: replyAttachments.value.length ? replyAttachments.value : undefined,
})
message.success('回复成功,已通知客户')
replyContent.value = ''
replyAttachments.value = []
replyFileList.value = []
const res = await getTicketReplies(currentTicket.value!.ticketId)
replies.value = (res?.data as any)?.data || res?.data || []
const t = tickets.value.find(t => t.ticketId === currentTicket.value!.ticketId)
if (t) t.replyCount++
} catch (e: any) {
message.error(e?.message || '回复失败')
} finally {
replyLoading.value = false
}
}
// ─── 分配处理人 ──────────────────────────────────────────────────
const showAssign = ref(false)
const assignLoading = ref(false)
const assigneeId = ref<number | undefined>(undefined)
let assignTarget: Ticket | null = null
function openAssign(ticket: Ticket) {
assignTarget = ticket
assigneeId.value = ticket.assigneeId
showAssign.value = true
}
async function handleAssign() {
if (!assigneeId.value) { message.warning('请选择处理人'); return }
assignLoading.value = true
try {
await assignTicket({ ticketId: assignTarget!.ticketId, assigneeId: assigneeId.value })
message.success('分配成功')
showAssign.value = false
await loadTickets()
} catch (e: any) {
message.error(e?.message || '分配失败')
} finally {
assignLoading.value = false
}
}
onMounted(() => {
loadApps()
loadStaff()
loadTickets()
loadStats()
})
</script>
<style scoped>
.dev-page { min-height: 100%; }
/* ── 页面头部 ─────────────────────────────── */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 28px 16px;
border-bottom: 1px solid #f0f0f0;
gap: 16px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: rgba(0, 0, 0, 0.88);
margin: 0 0 4px;
}
.page-desc {
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
margin: 0;
}
.page-body {
padding: 20px 24px 28px;
}
/* ── 统计卡片 ──────────────────────────────── */
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 1px solid transparent;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-1px); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.purple { background: #f5f3ff; border-color: #e9d5ff; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0, 0, 0, 0.85); line-height: 1.2; }
.stat-label { font-size: 12px; color: rgba(0, 0, 0, 0.45); margin-top: 2px; }
/* ── 主面板 ──────────────────────────────────── */
.panel {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
overflow: hidden;
}
/* ── 筛选栏 ──────────────────────────────────── */
.filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
}
/* ── 表格 ────────────────────────────────────── */
.tickets-table {
padding: 0;
}
:deep(.tickets-table .ant-table-thead > tr > th) {
background: #fafafa;
font-size: 13px;
}
:deep(.tickets-table .ant-table-tbody > tr) {
cursor: pointer;
transition: background 0.15s;
}
:deep(.tickets-table .ant-table-tbody > tr:hover > td) {
background: #f5f7ff !important;
}
.ticket-title-cell { display: flex; align-items: center; gap: 6px; }
.ticket-title-text { font-weight: 500; color: rgba(0, 0, 0, 0.85); }
.ticket-no-text { font-size: 11px; color: #8c8c8c; font-family: monospace; }
.app-name { font-size: 13px; }
.assignee-cell { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.reply-count-cell { display: flex; align-items: center; gap: 4px; color: #8c8c8c; font-size: 12px; }
/* ── 分页 ────────────────────────────────────── */
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding: 16px 18px;
border-top: 1px solid #f5f5f5;
}
/* ── 空状态 ──────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 24px;
text-align: center;
}
.empty-icon { font-size: 40px; margin-bottom: 10px; }
.empty-title { font-size: 15px; font-weight: 600; color: rgba(0, 0, 0, 0.7); }
.empty-desc { font-size: 13px; color: rgba(0, 0, 0, 0.4); margin-top: 4px; }
/* ── 详情抽屉 ────────────────────────────────── */
.detail-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: rgba(0, 0, 0, 0.6);
margin-bottom: 10px;
display: flex;
align-items: center;
}
.detail-content {
font-size: 14px;
color: #262626;
line-height: 1.7;
white-space: pre-wrap;
background: #fafafa;
padding: 12px 14px;
border-radius: 8px;
}
.detail-attachments {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.detail-attachments-label { font-size: 12px; color: #8c8c8c; }
.detail-attachment-link {
font-size: 12px;
color: #4f46e5;
text-decoration: none;
background: #f0f0ff;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid #e0e7ff;
}
.detail-attachment-link:hover { text-decoration: underline; }
/* ── 回复 ────────────────────────────────────── */
.reply-list {
display: flex;
flex-direction: column;
gap: 14px;
max-height: 280px;
overflow-y: auto;
}
.reply-item { display: flex; gap: 10px; align-items: flex-start; }
.reply-staff .reply-body { background: #e6f4ff; }
.reply-body { flex: 1; background: #f5f5f5; border-radius: 8px; padding: 10px 12px; }
.reply-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.reply-name { font-weight: 500; font-size: 13px; }
.staff-tag { font-size: 11px !important; padding: 0 5px !important; }
.reply-time { font-size: 11px; color: #8c8c8c; margin-left: auto; }
.reply-content { font-size: 13px; color: #262626; line-height: 1.6; white-space: pre-wrap; }
.reply-attachments { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; }
.reply-attachment-link { font-size: 12px; color: #4f46e5; text-decoration: none; }
.reply-attachment-link:hover { text-decoration: underline; }
.reply-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 8px; }
.reply-hint { font-size: 12px; color: #8c8c8c; }
</style>