diff --git a/api/tenant/v1/ticket_tenant.go b/api/tenant/v1/ticket_tenant.go index 24fc1eb..3af3e6c 100644 --- a/api/tenant/v1/ticket_tenant.go +++ b/api/tenant/v1/ticket_tenant.go @@ -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 { diff --git a/internal/logic/tenant/ticket.go b/internal/logic/tenant/ticket.go index 1268d45..0d3036d 100644 --- a/internal/logic/tenant/ticket.go +++ b/internal/logic/tenant/ticket.go @@ -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 } diff --git a/web/admin/src/views/operation/TicketsPage.vue b/web/admin/src/views/operation/TicketsPage.vue index 460cbec..1eaa9f3 100644 --- a/web/admin/src/views/operation/TicketsPage.vue +++ b/web/admin/src/views/operation/TicketsPage.vue @@ -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 = { const categoryLabel: Record = { technical: '技术支持', billing: '账单问题', - account: '账号问题', - feature: '功能建议', + feature_request: '功能建议', other: '其他', } const urgencyLabel: Record = { low: '低', - medium: '中', + normal: '普通', high: '高', urgent: '紧急', } const urgencyColor: Record = { 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 = { + 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(null) const detailReplies = ref([]) +// 按当前工单状态过滤出允许变更的目标状态,避免选到被后端状态机拒绝的组合 +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']" >