fix(ticket):修复工单显示问题

This commit is contained in:
qian committed 2026-09-02 23:05:15 +08:00
1 parent 2fcbd265b3
commit 65de0783f2
4 files changed
+68 -45

No files matched your search

+7 -6
View File
@@ -54,12 +54,13 @@ type TenantTicketGetReq struct {
}
type TenantTicketReplyItem struct {
Id int64 `json:"id"`
TicketId int64 `json:"ticket_id"`
UserId int64 `json:"user_id"`
UserType string `json:"user_type"`
Content string `json:"content"`
CreatedAt *gtime.Time `json:"created_at"`
Id int64 `json:"id"`
TicketId int64 `json:"ticket_id"`
UserId int64 `json:"user_id"`
UserType string `json:"user_type"`
AuthorName string `json:"author_name"`
Content string `json:"content"`
CreatedAt *gtime.Time `json:"created_at"`
}
type TenantTicketAttachmentItem struct {
+25 -4
View File
@@ -104,15 +104,36 @@ func (s *sTenant) TicketGet(ctx context.Context, req *v1.TenantTicketGetReq) (*v
return nil, common.NewNotFoundError("工单")
}
var replyRows []*v1.TenantTicketReplyItem
// 回复按 user_type 分别关联管理员/租户用户表取作者名
type replyRow struct {
entity.SptReplies
AuthorName string `json:"author_name" orm:"author_name"`
}
var replyRows []*replyRow
err = dao.SptReplies.Ctx(ctx).
Where("ticket_id", req.Id).
OrderAsc("created_at").
LeftJoin("sys_admin_users sa", "spt_replies.user_type = 'admin' AND spt_replies.user_id = sa.id").
LeftJoin("tnt_users tu", "spt_replies.user_type = 'tenant' AND spt_replies.user_id = tu.id").
Fields("spt_replies.*, CASE WHEN spt_replies.user_type = 'admin' THEN COALESCE(sa.username, '') ELSE COALESCE(tu.display_name, '') END as author_name").
Where("spt_replies.ticket_id", req.Id).
OrderAsc("spt_replies.created_at").
Scan(&replyRows)
if err != nil {
return nil, err
}
replies := make([]*v1.TenantTicketReplyItem, 0, len(replyRows))
for _, r := range replyRows {
replies = append(replies, &v1.TenantTicketReplyItem{
Id: r.Id,
TicketId: r.TicketId,
UserId: r.UserId,
UserType: r.UserType,
AuthorName: r.AuthorName,
Content: r.Content,
CreatedAt: r.CreatedAt,
})
}
var attachRows []*entity.SptAttachments
err = dao.SptAttachments.Ctx(ctx).
Where("ticket_id", req.Id).
@@ -149,7 +170,7 @@ func (s *sTenant) TicketGet(ctx context.Context, req *v1.TenantTicketGetReq) (*v
AssignedAdminName: ticket.AssignedAdminName,
CreatedAt: ticket.CreatedAt,
UpdatedAt: ticket.UpdatedAt,
Replies: replyRows,
Replies: replies,
Attachments: attachments,
}, nil
}
+25 -24
View File
@@ -30,8 +30,7 @@ const categoryOptions = [
{ label: '全部类型', value: '' },
{ label: '技术支持', value: 'technical' },
{ label: '账单问题', value: 'billing' },
{ label: '账号问题', value: 'account' },
{ label: '功能建议', value: 'feature' },
{ label: '功能建议', value: 'feature_request' },
{ label: '其他', value: 'other' },
]
@@ -54,21 +53,20 @@ const statusLabel: Record<string, string> = {
const categoryLabel: Record<string, string> = {
technical: '技术支持',
billing: '账单问题',
account: '账号问题',
feature: '功能建议',
feature_request: '功能建议',
other: '其他',
}
const urgencyLabel: Record<string, string> = {
low: '低',
medium: '中',
normal: '普通',
high: '高',
urgent: '紧急',
}
const urgencyColor: Record<string, string> = {
low: undefined,
medium: 'arcoblue',
normal: 'arcoblue',
high: 'orangered',
urgent: 'red',
}
@@ -81,10 +79,19 @@ const changeStatusOptions = [
{ label: '已重开', value: 'reopened' },
]
// 状态机:与后端 validTransitions(internal/logic/admin/ticket.go)保持一致
const statusTransitions: Record<string, string[]> = {
pending: ['processing', 'closed'],
processing: ['replied', 'closed'],
replied: ['processing', 'closed'],
closed: ['reopened'],
reopened: ['processing', 'closed'],
}
const columns: TableColumnData[] = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '租户名称', dataIndex: 'tenant_name', width: 120, ellipsis: true },
{ title: '用户名称', dataIndex: 'user_name', width: 100, ellipsis: true },
{ title: '用户名称', dataIndex: 'user_display_name', width: 100, ellipsis: true },
{
title: '类型', dataIndex: 'category', width: 100,
render({ record }) { return h(Tag, { size: 'small' }, () => categoryLabel[record.category] || record.category) },
@@ -139,6 +146,11 @@ const detailLoading = ref(false)
const detailTicket = ref<any>(null)
const detailReplies = ref<any[]>([])
// 按当前工单状态过滤出允许变更的目标状态,避免选到被后端状态机拒绝的组合
const availableStatusOptions = computed(() =>
changeStatusOptions.filter(opt => (statusTransitions[detailTicket.value?.status || ''] || []).includes(opt.value))
)
async function openDetail(row: any) {
detailTicket.value = row
detailReplies.value = []
@@ -220,17 +232,6 @@ async function handleReply(done: () => void) {
}
}
// === Change Status ===
async function changeStatus(ticketId: number, status: string) {
try {
await request.put(`/admin/tickets/${ticketId}/status`, { status })
Message.success('状态已更新')
fetchTickets()
} catch {
// error auto-displayed by Axios interceptor
}
}
// === Detail Reply (from detail modal) ===
const detailReplyContent = ref('')
const detailReplyLoading = ref(false)
@@ -295,7 +296,7 @@ onMounted(fetchTickets)
:loading="loading"
row-key="id"
:scroll="{ x: 1300 }"
:card-fields="['category', 'user_name', 'assigned_admin_name', 'created_at']"
:card-fields="['category', 'user_display_name', 'assigned_admin_name', 'created_at']"
>
<template #card-header="{ row }">
<div class="flex items-start justify-between gap-3">
@@ -337,7 +338,7 @@ onMounted(fetchTickets)
<ADropdown trigger="hover">
<AButton size="mini">修改状态</AButton>
<template #content>
<ADoption v-for="opt in changeStatusOptions" :key="opt.value" @click="handleDetailChangeStatus(opt.value)">{{ opt.label }}</ADoption>
<ADoption v-for="opt in availableStatusOptions" :key="opt.value" @click="handleDetailChangeStatus(opt.value)">{{ opt.label }}</ADoption>
</template>
</ADropdown>
</ASpace>
@@ -347,7 +348,7 @@ onMounted(fetchTickets)
<div>类型: {{ categoryLabel[detailTicket.category] || detailTicket.category }}</div>
<div>紧急度: <ATag :color="urgencyColor[detailTicket.urgency]" size="small">{{ urgencyLabel[detailTicket.urgency] || detailTicket.urgency }}</ATag></div>
<div>租户: {{ detailTicket.tenant_name || '-' }}</div>
<div>用户: {{ detailTicket.user_name || '-' }}</div>
<div>用户: {{ detailTicket.user_display_name || '-' }}</div>
<div>处理人: {{ detailTicket.assigned_admin_name || '未分配' }}</div>
</div>
<div class="text-sm text-gray-700 mt-2 whitespace-pre-wrap">{{ detailTicket.description }}</div>
@@ -357,10 +358,10 @@ onMounted(fetchTickets)
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-700">回复记录 ({{ detailReplies.length }})</h4>
<div v-if="detailReplies.length === 0" class="text-sm text-gray-400 text-center py-4">暂无回复</div>
<div v-for="reply in detailReplies" :key="reply.id" class="p-3 border rounded-lg" :class="reply.is_admin ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-200'">
<div v-for="reply in detailReplies" :key="reply.id" class="p-3 border rounded-lg" :class="reply.user_type === 'admin' ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-200'">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium" :class="reply.is_admin ? 'text-blue-700' : 'text-gray-700'">
{{ reply.author_name || (reply.is_admin ? '管理员' : '用户') }}
<span class="text-xs font-medium" :class="reply.user_type === 'admin' ? 'text-blue-700' : 'text-gray-700'">
{{ reply.user_name || (reply.user_type === 'admin' ? '管理员' : '用户') }}
</span>
<span class="text-xs text-gray-400">{{ reply.created_at ? new Date(reply.created_at).toLocaleString() : '' }}</span>
</div>
+11 -11
View File
@@ -18,7 +18,7 @@ interface Ticket {
urgency: string
status: string
description: string
assigned_admin: string
assigned_admin_name: string
created_at: string
updated_at: string
}
@@ -26,7 +26,7 @@ interface Ticket {
interface Reply {
id: number
content: string
is_admin: boolean
user_type: string
author_name: string
created_at: string
}
@@ -228,7 +228,7 @@ const columns = computed<DataTableColumns<Ticket>>(() => [
},
{ title: '优先级', key: 'urgency', width: 100, render: (row) => renderBadge(row.urgency, urgencyLabel, urgencyBadgeClass) },
{ title: '状态', key: 'status', width: 110, render: (row) => renderBadge(row.status, statusLabel, statusBadgeClass) },
{ title: '处理人', key: 'assigned_admin', width: 140, render: (row) => h('span', { class: 'text-sm text-gray-500' }, row.assigned_admin || '暂未分配') },
{ title: '处理人', key: 'assigned_admin_name', width: 140, render: (row) => h('span', { class: 'text-sm text-gray-500' }, row.assigned_admin_name || '暂未分配') },
{
title: '创建时间',
key: 'created_at',
@@ -290,7 +290,7 @@ onMounted(() => {
card-title-key="title"
card-badge-key="status"
card-subtitle-key="created_at"
:card-fields="['id', 'category', 'urgency', 'assigned_admin']"
:card-fields="['id', 'category', 'urgency', 'assigned_admin_name']"
:row-click="openDetail"
@update:page="fetchTickets"
@update:page-size="handlePageSizeChange"
@@ -382,7 +382,7 @@ onMounted(() => {
{{ urgencyLabel[detailTicket.urgency] || detailTicket.urgency }}
</span>
</span>
<span>处理人: {{ detailTicket.assigned_admin || '暂未分配' }}</span>
<span>处理人: {{ detailTicket.assigned_admin_name || '暂未分配' }}</span>
</div>
<div class="text-xs text-gray-500">
创建: {{ detailTicket.created_at ? new Date(detailTicket.created_at).toLocaleString() : '-' }}
@@ -420,25 +420,25 @@ onMounted(() => {
v-for="reply in detailReplies"
:key="reply.id"
class="flex gap-3"
:class="reply.is_admin ? 'flex-row-reverse' : 'flex-row'"
:class="reply.user_type === 'admin' ? 'flex-row-reverse' : 'flex-row'"
>
<!-- Avatar -->
<div
class="h-8 w-8 rounded-full flex items-center justify-center text-white text-xs font-medium flex-shrink-0"
:class="reply.is_admin ? 'bg-gradient-to-r from-primary-500 to-primary-600' : 'bg-gradient-to-r from-gray-400 to-gray-500'"
:class="reply.user_type === 'admin' ? 'bg-gradient-to-r from-primary-500 to-primary-600' : 'bg-gradient-to-r from-gray-400 to-gray-500'"
>
{{ reply.is_admin ? (reply.author_name || '客').charAt(0) : '我' }}
{{ reply.user_type === 'admin' ? (reply.author_name || '客').charAt(0) : '我' }}
</div>
<!-- Bubble -->
<div
class="max-w-[75%] rounded-2xl px-4 py-2.5"
:class="reply.is_admin
:class="reply.user_type === 'admin'
? 'bg-primary-50 border border-primary-100 rounded-tl-md'
: 'bg-white border border-gray-200 rounded-tr-md'"
>
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium" :class="reply.is_admin ? 'text-primary-600' : 'text-gray-600'">
{{ reply.is_admin ? (reply.author_name || '客服') : '我' }}
<span class="text-xs font-medium" :class="reply.user_type === 'admin' ? 'text-primary-600' : 'text-gray-600'">
{{ reply.user_type === 'admin' ? (reply.author_name || '客服') : '我' }}
</span>
<span class="text-xs text-gray-400">{{ reply.created_at ? new Date(reply.created_at).toLocaleString() : '' }}</span>
</div>