Files
tiantian-system/app/pages/console/tickets.vue
2026-04-08 17:10:58 +08:00

825 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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="space-y-4">
<a-page-header title="工单管理" sub-title="提交需求与问题反馈跟踪处理进度">
<template #extra>
<a-space>
<a-input
v-model:value="keywords"
allow-clear
placeholder="搜索工单标题"
class="w-52"
@press-enter="doSearch"
/>
<a-button type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template>
提交工单
</a-button>
</a-space>
</template>
</a-page-header>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]">
<a-col :xs="12" :sm="6" v-for="s in statsCards" :key="s.label">
<div class="stat-card" :class="s.colorClass">
<div class="stat-value">{{ s.value }}</div>
<div class="stat-label">{{ s.label }}</div>
</div>
</a-col>
</a-row>
<!-- 主内容 -->
<a-card :bordered="false" class="card">
<!-- 筛选栏 -->
<div class="filter-bar">
<a-segmented
v-model:value="statusFilter"
:options="statusOptions"
@change="doSearch"
/>
<a-space class="filter-right">
<a-select
v-model:value="appFilter"
allow-clear
placeholder="全部应用"
style="min-width: 160px"
@change="doSearch"
>
<a-select-option v-for="app in myApps" :key="app.productId" :value="app.productId">
{{ app.siteName || app.productName }}
</a-select-option>
</a-select>
<a-select
v-model:value="categoryFilter"
allow-clear
placeholder="全部分类"
style="min-width: 120px"
@change="doSearch"
>
<a-select-option v-for="c in categoryOptions" :key="c.value" :value="c.value">
{{ c.label }}
</a-select-option>
</a-select>
</a-space>
</div>
<a-alert v-if="error" class="mb-4" show-icon type="error" :message="error" />
<!-- 工单列表 -->
<a-spin :spinning="loading">
<div v-if="!loading && tickets.length === 0" class="empty-wrap">
<a-empty description="暂无工单">
<a-button type="primary" @click="openCreate">立即提交工单</a-button>
</a-empty>
</div>
<div v-else class="ticket-list">
<div
v-for="ticket in tickets"
:key="ticket.ticketId"
class="ticket-item"
:class="{ 'has-unread': ticket.hasUnread }"
@click="openDetail(ticket)"
>
<div class="ticket-item-left">
<div class="ticket-badge-row">
<a-tag :color="statusColor(ticket.status)" class="status-tag">
{{ statusLabel(ticket.status) }}
</a-tag>
<a-tag :color="priorityColor(ticket.priority)" class="priority-tag">
{{ priorityLabel(ticket.priority) }}
</a-tag>
<a-tag class="category-tag">{{ categoryLabel(ticket.category) }}</a-tag>
<span v-if="ticket.hasUnread" class="unread-dot" />
</div>
<div class="ticket-title">{{ ticket.title }}</div>
<div class="ticket-meta">
<span class="meta-app">📦 {{ ticket.productName || `应用 #${ticket.productId}` }}</span>
<span class="meta-no">{{ ticket.ticketNo }}</span>
<span v-if="ticket.attachments?.length" class="meta-attach">📎 {{ ticket.attachments.length }}个附件</span>
<span class="meta-time">{{ formatTime(ticket.createTime) }}</span>
</div>
</div>
<div class="ticket-item-right">
<div v-if="ticket.assigneeName" class="assignee">
<a-avatar :size="24" :src="ticket.assigneeAvatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<span class="assignee-name">{{ ticket.assigneeName }}</span>
</div>
<div v-else class="assignee-pending">待分配</div>
<div class="reply-count">
<MessageOutlined />
<span>{{ ticket.replyCount }}</span>
</div>
</div>
</div>
</div>
</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>
</a-card>
<!-- ========= 提交工单弹窗 ========= -->
<a-modal
v-model:open="showCreate"
title="提交工单"
width="640px"
:confirm-loading="submitLoading"
ok-text="提交"
@ok="handleSubmit"
@cancel="showCreate = false"
>
<a-form :model="form" :rules="rules" ref="formRef" layout="vertical" class="mt-2">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="工单标题" name="title">
<a-input v-model:value="form.title" placeholder="简短描述您的问题或需求" allow-clear />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="关联应用" name="productId">
<a-select v-model:value="form.productId" placeholder="选择关联应用" style="width: 100%">
<a-select-option v-for="app in myApps" :key="app.productId" :value="app.productId">
{{ app.siteName || app.productName }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="问题分类" name="category">
<a-select v-model:value="form.category" style="width: 100%">
<a-select-option v-for="c in categoryOptions" :key="c.value" :value="c.value">
{{ c.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="优先级" name="priority">
<a-select v-model:value="form.priority" style="width: 100%">
<a-select-option v-for="p in priorityOptions" :key="p.value" :value="p.value">
{{ p.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="问题描述" name="content">
<a-textarea
v-model:value="form.content"
:rows="6"
placeholder="详细描述您遇到的问题包括操作步骤期望效果实际效果等"
show-count
:maxlength="2000"
/>
</a-form-item>
<a-form-item label="附件可选">
<a-upload
v-model:file-list="createFileList"
:custom-request="handleCreateUpload"
:before-upload="beforeUpload"
:on-remove="handleCreateRemove"
multiple
:max-count="5"
list-type="text"
>
<a-button>
<template #icon><PlusOutlined /></template>
上传附件最多5个单个≤10MB
</a-button>
</a-upload>
</a-form-item>
<div class="form-tip">
<InfoCircleOutlined />
提交后将自动分配给对应应用的技术负责人,通常在 1-2 个工作日内响应
</div>
</a-form>
</a-modal>
<!-- ========= 工单详情抽屉 ========= -->
<a-drawer
v-model:open="showDetail"
:title="`工单详情 — ${currentTicket?.ticketNo}`"
width="600"
placement="right"
>
<template v-if="currentTicket">
<!-- 状态栏 -->
<div class="detail-status-bar">
<a-space size="small" wrap>
<a-tag :color="statusColor(currentTicket.status)" class="text-sm">
{{ 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>
<div class="detail-actions">
<a-button
v-if="['pending','assigned','processing'].includes(currentTicket.status)"
size="small"
danger
@click="handleClose"
>
关闭工单
</a-button>
</div>
</div>
<!-- 工单信息 -->
<a-descriptions :column="2" size="small" class="detail-desc">
<a-descriptions-item label="工单编号">{{ currentTicket.ticketNo }}</a-descriptions-item>
<a-descriptions-item label="关联应用">
📦 {{ currentTicket.productName || `#${currentTicket.productId}` }}
</a-descriptions-item>
<a-descriptions-item label="提交时间">{{ formatTime(currentTicket.createTime) }}</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>
<a-divider />
<!-- 工单正文 -->
<div class="detail-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="detail-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="currentTicket && !['resolved','closed'].includes(currentTicket.status)"
class="reply-input-wrap"
>
<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"
list-type="text"
class="mt-2"
>
<a-button size="small">
<template #icon><PlusOutlined /></template>
添加附件
</a-button>
</a-upload>
<a-button
type="primary"
class="mt-2"
:loading="replyLoading"
:disabled="!replyContent.trim()"
@click="handleReply"
>
发送回复
</a-button>
</div>
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import {
InfoCircleOutlined,
MessageOutlined,
PlusOutlined,
UserOutlined,
} from '@ant-design/icons-vue'
import {
getMyTickets,
submitTicket,
closeTicket,
getTicketReplies,
replyTicket,
getTicketStats,
} from '@/api/ticket'
import type { Ticket, TicketReply, TicketSubmitForm } from '@/api/ticket/model'
import { pageAppProductAll } from '@/api/app/appProduct'
import { uploadFile } from '@/api/system/file'
import type { UploadFile } from 'ant-design-vue'
definePageMeta({ layout: 'console' })
useHead({ title: '工单管理 - 控制台' })
// ─── 状态 & 筛选 ───────────────────────────────────────────────
const loading = ref(false)
const error = ref('')
const tickets = ref<Ticket[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = 15
const keywords = ref('')
const statusFilter = ref('all')
const appFilter = ref<number | undefined>(undefined)
const categoryFilter = ref<string | undefined>(undefined)
// ─── 应用列表(从 localStorage / API 获取) ───────────────────
const myApps = ref<{ productId: number; siteName?: string; productName?: string }[]>([])
// ─── 统计 ─────────────────────────────────────────────────────
const stats = ref({ total: 0, pending: 0, processing: 0, resolved: 0, closed: 0 })
const statsCards = computed(() => [
{ label: '全部工单', value: stats.value.total, colorClass: 'stat-default' },
{ label: '待处理', value: stats.value.pending, colorClass: 'stat-warning' },
{ label: '处理中', value: stats.value.processing, colorClass: 'stat-processing' },
{ label: '已解决', value: stats.value.resolved, colorClass: 'stat-success' },
])
// ─── 字典 ─────────────────────────────────────────────────────
const statusOptions = [
{ label: '全部', value: 'all' },
{ label: '待处理', value: 'pending' },
{ label: '已分配', value: 'assigned' },
{ label: '处理中', value: 'processing' },
{ label: '已解决', value: 'resolved' },
{ label: '已关闭', value: 'closed' },
]
const categoryOptions = [
{ label: '🐛 Bug 反馈', value: 'bug' },
{ label: '✨ 功能需求', value: 'feature' },
{ label: '❓ 咨询', value: 'consultation' },
{ label: '📢 投诉', value: 'complaint' },
{ label: '📋 其他', value: 'other' },
]
const priorityOptions = [
{ label: '⬇️ 低', value: 'low' },
{ label: '➡️ 普通', value: 'normal' },
{ label: '⬆️ 高', value: 'high' },
{ label: '🔥 紧急', value: 'urgent' },
]
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 res = await getMyTickets({
keywords: keywords.value || undefined,
status: statusFilter.value === 'all' ? undefined : (statusFilter.value as any),
productId: appFilter.value,
category: categoryFilter.value as any,
page: currentPage.value,
limit: pageSize,
})
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 loadMyApps() {
try {
// 取当前登录用户 ID
const userId = Number(localStorage.getItem('UserId') || 0)
if (!userId) return
const res = await pageAppProductAll({
current: 1,
size: 200,
userId: userId,
})
myApps.value = res?.list ?? []
} catch {}
}
function doSearch() {
currentPage.value = 1
loadTickets()
}
// ─── 提交工单 ─────────────────────────────────────────────────
const showCreate = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const form = reactive<TicketSubmitForm>({
title: '',
content: '',
productId: undefined as any,
category: 'bug',
priority: 'normal',
attachments: [],
})
const rules = {
title: [{ required: true, message: '请输入工单标题' }],
productId: [{ required: true, message: '请选择关联应用' }],
content: [{ required: true, message: '请描述您的问题' }, { min: 10, message: '描述不少于10字' }],
}
// 附件上传
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
}
const createFileList = ref<UploadFile[]>([])
const replyFileList = ref<UploadFile[]>([])
function beforeUpload(file: File) {
const maxMb = 10
if (file.size > maxMb * 1024 * 1024) {
message.error(`文件大小不能超过 ${maxMb}MB`)
return false
}
return true
}
async function handleCreateUpload(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('上传成功但未返回文件地址')
form.attachments = [...(form.attachments || []), url]
createFileList.value = [
...createFileList.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 handleCreateRemove(file: UploadFile) {
form.attachments = (form.attachments || []).filter(u => u !== file.url)
createFileList.value = createFileList.value.filter(f => f.uid !== file.uid)
}
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)
}
function openCreate() {
showCreate.value = true
createFileList.value = []
form.attachments = []
}
async function handleSubmit() {
try {
await formRef.value?.validate()
} catch {
return
}
submitLoading.value = true
try {
// 将附件数组转为 JSON 字符串提交
const submitData = {
...form,
attachments: form.attachments?.length ? JSON.stringify(form.attachments) : null,
}
await submitTicket(submitData)
message.success('工单已提交,将自动分配给技术人员')
showCreate.value = false
Object.assign(form, { title: '', content: '', productId: undefined, category: 'bug', priority: 'normal', attachments: [] })
createFileList.value = []
await loadTickets()
await loadStats()
} catch (e: any) {
message.error(e?.message || '提交失败')
} finally {
submitLoading.value = false
}
}
// ─── 工单详情 ─────────────────────────────────────────────────
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[]>([])
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 ? JSON.stringify(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
}
}
async function handleClose() {
try {
await closeTicket(currentTicket.value!.ticketId)
message.success('工单已关闭')
showDetail.value = false
await loadTickets()
await loadStats()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
// ─── 初始化 ───────────────────────────────────────────────────
onMounted(() => {
loadMyApps()
loadTickets()
loadStats()
})
</script>
<style scoped>
/* ─── 统计卡片 ─────────────────────────────────────── */
.stat-card {
border-radius: 10px;
padding: 16px 20px;
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.stat-label {
font-size: 12px;
margin-top: 6px;
opacity: 0.7;
}
.stat-default { background: #f5f5f5; color: #333; }
.stat-warning { background: #fff7e6; color: #d46b08; }
.stat-processing { background: #e6f4ff; color: #0958d9; }
.stat-success { background: #f6ffed; color: #389e0d; }
/* ─── 筛选栏 ─────────────────────────────────────── */
.filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.filter-right { flex-shrink: 0; }
/* ─── 工单列表 ─────────────────────────────────────── */
.ticket-list { display: flex; flex-direction: column; gap: 10px; }
.ticket-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 10px;
border: 1px solid #f0f0f0;
background: #fff;
cursor: pointer;
transition: all 0.2s;
}
.ticket-item:hover { border-color: #4f46e5; box-shadow: 0 2px 8px rgba(79,70,229,0.08); }
.ticket-item.has-unread { border-left: 3px solid #4f46e5; }
.ticket-item-left { flex: 1; min-width: 0; }
.ticket-badge-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; }
.status-tag, .priority-tag, .category-tag { font-size: 11px !important; padding: 0 6px !important; }
.unread-dot {
width: 7px; height: 7px;
background: #4f46e5; border-radius: 50%; display: inline-block;
}
.ticket-title {
font-size: 14px; font-weight: 500; color: #1a1a2e;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: 4px;
}
.ticket-meta { display: flex; gap: 12px; font-size: 12px; color: #8c8c8c; flex-wrap: wrap; }
.meta-no { font-family: monospace; }
.ticket-item-right {
display: flex; flex-direction: column;
align-items: flex-end; gap: 8px; flex-shrink: 0; margin-left: 16px;
}
.assignee { display: flex; align-items: center; gap: 6px; }
.assignee-name { font-size: 12px; color: #595959; }
.assignee-pending { font-size: 12px; color: #faad14; }
.reply-count { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #8c8c8c; }
/* ─── 分页 ─────────────────────────────────────────── */
.pagination-wrap { display: flex; justify-content: flex-end; margin-top: 20px; }
.empty-wrap { padding: 40px 0; }
/* ─── 表单提示 ─────────────────────────────────────── */
.form-tip {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: #8c8c8c;
background: #f5f5f5; padding: 8px 12px; border-radius: 6px;
}
/* ─── 详情抽屉 ─────────────────────────────────────── */
.detail-status-bar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px; flex-wrap: wrap; gap: 8px;
}
.detail-desc { margin-bottom: 0; }
.detail-section-title {
font-size: 13px; font-weight: 600; color: #595959;
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: #1677ff; text-decoration: none;
background: #f0f5ff; padding: 2px 8px; border-radius: 4px; border: 1px solid #d6e4ff;
}
.detail-attachment-link:hover { text-decoration: underline; background: #e6f0ff; }
/* ─── 回复 ─────────────────────────────────────────── */
.reply-list { display: flex; flex-direction: column; gap: 14px; max-height: 320px; 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: #1677ff; text-decoration: none; }
.reply-attachment-link:hover { text-decoration: underline; }
.reply-input-wrap { margin-top: 16px; }
/* ─── card ─────────────────────────────────────────── */
.card { border-radius: 12px; }
</style>