fix(ticket):修复工单显示问题
This commit is contained in:
1 parent
2fcbd265b3
commit
65de0783f2
4 files changed
+68
-45
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user