优化客服群聊订单信息展示
This commit is contained in:
@@ -54,6 +54,7 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal,
|
|||||||
dto := ConversationDTO{
|
dto := ConversationDTO{
|
||||||
ID: conversation.ID,
|
ID: conversation.ID,
|
||||||
OrderID: conversation.OrderID,
|
OrderID: conversation.OrderID,
|
||||||
|
ListingID: conversation.ListingID,
|
||||||
Type: conversation.Type,
|
Type: conversation.Type,
|
||||||
Title: conversation.Title,
|
Title: conversation.Title,
|
||||||
Status: conversation.Status,
|
Status: conversation.Status,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Principal struct {
|
|||||||
type ConversationDTO struct {
|
type ConversationDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
OrderID *uint64 `json:"order_id"`
|
OrderID *uint64 `json:"order_id"`
|
||||||
|
ListingID *uint64 `json:"listing_id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
type conversationRow struct {
|
type conversationRow struct {
|
||||||
ID uint64
|
ID uint64
|
||||||
OrderID *uint64
|
OrderID *uint64
|
||||||
|
ListingID *uint64
|
||||||
Type string
|
Type string
|
||||||
Title string
|
Title string
|
||||||
Status string
|
Status string
|
||||||
@@ -28,7 +29,7 @@ type conversationRow struct {
|
|||||||
|
|
||||||
func (r *Repository) conversationQuery(ctx context.Context, principal Principal) *gorm.DB {
|
func (r *Repository) conversationQuery(ctx context.Context, principal Principal) *gorm.DB {
|
||||||
return r.db.WithContext(ctx).Table("chat_conversations AS c").
|
return r.db.WithContext(ctx).Table("chat_conversations AS c").
|
||||||
Select(`c.id, c.order_id, c.type, c.title, c.status, c.last_message_id,
|
Select(`c.id, c.order_id, c.listing_id, c.type, c.title, c.status, c.last_message_id,
|
||||||
c.last_message_preview, c.last_message_at, c.created_at, c.updated_at, cp.role,
|
c.last_message_preview, c.last_message_at, c.created_at, c.updated_at, cp.role,
|
||||||
(
|
(
|
||||||
SELECT COUNT(1)
|
SELECT COUNT(1)
|
||||||
@@ -282,6 +283,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO
|
|||||||
return ConversationDTO{
|
return ConversationDTO{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
OrderID: row.OrderID,
|
OrderID: row.OrderID,
|
||||||
|
ListingID: row.ListingID,
|
||||||
Type: row.Type,
|
Type: row.Type,
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal
|
|||||||
items = append(items, ConversationDTO{
|
items = append(items, ConversationDTO{
|
||||||
ID: conv.ID,
|
ID: conv.ID,
|
||||||
OrderID: conv.OrderID,
|
OrderID: conv.OrderID,
|
||||||
|
ListingID: conv.ListingID,
|
||||||
Type: conv.Type,
|
Type: conv.Type,
|
||||||
Title: conv.Title,
|
Title: conv.Title,
|
||||||
Status: conv.Status,
|
Status: conv.Status,
|
||||||
|
|||||||
@@ -32,6 +32,19 @@ func (h *Handler) AdminDetail(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
response.OK(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminLatestByListing(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.FindLatestAdminByListing(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeOrderError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) AdminHandoffRecords(c *gin.Context) {
|
func (h *Handler) AdminHandoffRecords(c *gin.Context) {
|
||||||
id, ok := parseID(c)
|
id, ok := parseID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -80,6 +80,22 @@ func (r *Repository) FindAdmin(ctx context.Context, orderID uint64) (*OrderDTO,
|
|||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) FindLatestAdminByListing(ctx context.Context, listingID uint64) (*OrderDTO, error) {
|
||||||
|
var row orderRow
|
||||||
|
db := r.db.WithContext(ctx)
|
||||||
|
if err := r.adminQuery(ctx).
|
||||||
|
Where("o.listing_id = ?", listingID).
|
||||||
|
Order("o.id DESC").
|
||||||
|
First(&row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dto := row.toAdminDTO()
|
||||||
|
applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(db))
|
||||||
|
dto.ActiveDispute = r.activeDisputeDTO(ctx, row.ID)
|
||||||
|
dto.Checkout = r.latestCheckoutAdminDTO(ctx, row.ID)
|
||||||
|
return &dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) FindForUser(ctx context.Context, userID uint64, orderID uint64) (*OrderDTO, error) {
|
func (r *Repository) FindForUser(ctx context.Context, userID uint64, orderID uint64) (*OrderDTO, error) {
|
||||||
var row orderRow
|
var row orderRow
|
||||||
db := r.db.WithContext(ctx)
|
db := r.db.WithContext(ctx)
|
||||||
|
|||||||
@@ -159,6 +159,16 @@ func (s *Service) FindAdmin(ctx context.Context, orderID uint64) (*OrderDTO, err
|
|||||||
return s.repo.FindAdmin(ctx, orderID)
|
return s.repo.FindAdmin(ctx, orderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) FindLatestAdminByListing(ctx context.Context, listingID uint64) (*OrderDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if listingID == 0 {
|
||||||
|
return nil, ErrListingUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.FindLatestAdminByListing(ctx, listingID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) HandoffRecordsAdmin(ctx context.Context, orderID uint64) ([]HandoffRecordDTO, error) {
|
func (s *Service) HandoffRecordsAdmin(ctx context.Context, orderID uint64) ([]HandoffRecordDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -489,6 +489,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.POST("/users/:id/deposit-free-quota", requirePerm("user:deposit_free"), adminUserHandler.SetDepositFreeQuota)
|
adminRoutes.POST("/users/:id/deposit-free-quota", requirePerm("user:deposit_free"), adminUserHandler.SetDepositFreeQuota)
|
||||||
adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname)
|
adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname)
|
||||||
adminRoutes.GET("/orders", requirePerm("order:view"), orderHandler.AdminList)
|
adminRoutes.GET("/orders", requirePerm("order:view"), orderHandler.AdminList)
|
||||||
|
adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing)
|
||||||
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
|
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
|
||||||
adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords)
|
adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords)
|
||||||
adminRoutes.POST("/orders/:id/close", requirePerm("order:close"), orderHandler.AdminClose)
|
adminRoutes.POST("/orders/:id/close", requirePerm("order:close"), orderHandler.AdminClose)
|
||||||
|
|||||||
@@ -36,15 +36,18 @@ export interface AdminPaymentQuery {
|
|||||||
provider?: string
|
provider?: string
|
||||||
page?: number
|
page?: number
|
||||||
page_size?: number
|
page_size?: number
|
||||||
|
silent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminPayments(query: AdminPaymentQuery = {}) {
|
export async function fetchAdminPayments(query: AdminPaymentQuery = {}) {
|
||||||
const params = Object.fromEntries(
|
const params = Object.fromEntries(
|
||||||
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
|
Object.entries(query).filter(
|
||||||
|
([key, value]) => key !== 'silent' && value !== '' && value !== undefined
|
||||||
|
)
|
||||||
)
|
)
|
||||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminPayment>>>(
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminPayment>>>(
|
||||||
'/admin/payments',
|
'/admin/payments',
|
||||||
{ params }
|
{ params, silent: query.silent }
|
||||||
)
|
)
|
||||||
const result = data.data
|
const result = data.data
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Close, Picture } from '@element-plus/icons-vue'
|
import { Close, Picture } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
@@ -14,21 +15,37 @@ import {
|
|||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
type QuickReply,
|
type QuickReply,
|
||||||
} from '@/features/chats'
|
} from '@/features/chats'
|
||||||
|
import {
|
||||||
|
fetchAdminLatestOrderByListing,
|
||||||
|
fetchAdminHandoffRecords,
|
||||||
|
fetchAdminOrder,
|
||||||
|
type HandoffRecord,
|
||||||
|
type Order,
|
||||||
|
} from '@/features/orders'
|
||||||
|
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
|
||||||
import { uploadAdminFile } from '@/shared/api/files'
|
import { uploadAdminFile } from '@/shared/api/files'
|
||||||
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
|
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
|
||||||
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
||||||
import { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
|
import { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
|
||||||
import NotificationSettings from '@/features/chats/components/NotificationSettings.vue'
|
import NotificationSettings from '@/features/chats/components/NotificationSettings.vue'
|
||||||
import { adminPath } from '@/shared/utils/adminPath'
|
import { adminPath } from '@/shared/utils/adminPath'
|
||||||
import { formatDateMinute } from '@/shared/utils/time'
|
import { centToYuan, formatCentWithSymbol, formatMoney } from '@/shared/utils/money'
|
||||||
|
import { formatDateMinute, formatDateTime } from '@/shared/utils/time'
|
||||||
|
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
||||||
|
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
import TransferDialog from '../components/TransferDialog.vue'
|
import TransferDialog from '../components/TransferDialog.vue'
|
||||||
import QuickReplyDialog from '../components/QuickReplyDialog.vue'
|
import QuickReplyDialog from '../components/QuickReplyDialog.vue'
|
||||||
|
|
||||||
const currentAdminId = Number(localStorage.getItem('admin_id') || 0)
|
const currentAdminId = Number(localStorage.getItem('admin_id') || 0)
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const conversations = ref<ChatConversation[]>([])
|
const conversations = ref<ChatConversation[]>([])
|
||||||
const active = ref<ChatConversation | null>(null)
|
const active = ref<ChatConversation | null>(null)
|
||||||
const messages = ref<ChatMessage[]>([])
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
const orderLoading = ref(false)
|
||||||
|
const activeOrder = ref<Order | null>(null)
|
||||||
|
const activeHandoffRecords = ref<HandoffRecord[]>([])
|
||||||
|
const activePaymentRecords = ref<AdminPayment[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const messageLoading = ref(false)
|
const messageLoading = ref(false)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
@@ -55,6 +72,27 @@ const activeMembers = computed(() => {
|
|||||||
.join(' / ')
|
.join(' / ')
|
||||||
})
|
})
|
||||||
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
|
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
|
||||||
|
const activeListingCode = computed(() =>
|
||||||
|
activeOrder.value
|
||||||
|
? formatListingNo(activeOrder.value.listing_no, activeOrder.value.listing_id)
|
||||||
|
: '-'
|
||||||
|
)
|
||||||
|
const latestHandoffRecord = computed(() => activeHandoffRecords.value[0])
|
||||||
|
const latestPaymentRecord = computed(() => activePaymentRecords.value[0])
|
||||||
|
const hasOrderContext = computed(() => Boolean(active.value?.order_id || active.value?.listing_id))
|
||||||
|
const orderDetailLink = computed(() =>
|
||||||
|
activeOrder.value
|
||||||
|
? {
|
||||||
|
path: adminPath(`orders/${activeOrder.value.id}`),
|
||||||
|
query: {
|
||||||
|
from: 'chat',
|
||||||
|
chat_id: String(active.value?.id || ''),
|
||||||
|
chat_filter: filter.value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
let orderLoadToken = 0
|
||||||
|
|
||||||
// 桌面通知
|
// 桌面通知
|
||||||
const desktopNotification = useDesktopNotification('admin')
|
const desktopNotification = useDesktopNotification('admin')
|
||||||
@@ -125,6 +163,10 @@ onEvent(handleSSEEvent)
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 静默请求通知权限(用户交互后才会弹窗)
|
// 静默请求通知权限(用户交互后才会弹窗)
|
||||||
desktopNotification.requestPermissionSilently()
|
desktopNotification.requestPermissionSilently()
|
||||||
|
const routeFilter = firstQueryValue(route.query.chat_filter)
|
||||||
|
if (['all', 'mine', 'unassigned'].includes(routeFilter)) {
|
||||||
|
filter.value = routeFilter as typeof filter.value
|
||||||
|
}
|
||||||
await Promise.all([loadConversations(), loadQuickReplies()])
|
await Promise.all([loadConversations(), loadQuickReplies()])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -134,8 +176,13 @@ async function loadConversations(showLoading = true) {
|
|||||||
const res = await fetchAdminChats(1, 100, filter.value)
|
const res = await fetchAdminChats(1, 100, filter.value)
|
||||||
conversations.value = res.items
|
conversations.value = res.items
|
||||||
const first = conversations.value[0]
|
const first = conversations.value[0]
|
||||||
if (!active.value && first) {
|
if (!active.value) {
|
||||||
await openConversation(first)
|
const routeChatID = Number(firstQueryValue(route.query.chat_id) || 0)
|
||||||
|
if (routeChatID) {
|
||||||
|
await openConversationById(routeChatID, false)
|
||||||
|
} else if (first) {
|
||||||
|
await openConversationById(first.id, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('会话加载失败')
|
ElMessage.error('会话加载失败')
|
||||||
@@ -153,12 +200,18 @@ async function loadQuickReplies() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openConversation(item: ChatConversation) {
|
async function openConversation(item: ChatConversation) {
|
||||||
|
await openConversationById(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConversationById(id: number, refreshList = true) {
|
||||||
messageLoading.value = true
|
messageLoading.value = true
|
||||||
try {
|
try {
|
||||||
active.value = await fetchAdminChat(item.id)
|
const chat = await fetchAdminChat(id)
|
||||||
await loadMessages(item.id)
|
active.value = chat
|
||||||
await markAdminChatRead(item.id)
|
void loadOrderContext(chat)
|
||||||
await loadConversations(false)
|
await loadMessages(id)
|
||||||
|
await markAdminChatRead(id)
|
||||||
|
if (refreshList) await loadConversations(false)
|
||||||
remarkEditing.value = false
|
remarkEditing.value = false
|
||||||
remarkValue.value = ''
|
remarkValue.value = ''
|
||||||
attachments.value = []
|
attachments.value = []
|
||||||
@@ -169,6 +222,43 @@ async function openConversation(item: ChatConversation) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadOrderContext(chat: ChatConversation) {
|
||||||
|
const token = ++orderLoadToken
|
||||||
|
activeOrder.value = null
|
||||||
|
activeHandoffRecords.value = []
|
||||||
|
activePaymentRecords.value = []
|
||||||
|
if (!chat.order_id && !chat.listing_id) {
|
||||||
|
orderLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderLoading.value = true
|
||||||
|
try {
|
||||||
|
const resolvedOrder = chat.order_id
|
||||||
|
? await fetchAdminOrder(chat.order_id)
|
||||||
|
: await fetchAdminLatestOrderByListing(chat.listing_id as number)
|
||||||
|
if (token !== orderLoadToken) return
|
||||||
|
const handoffRecords = await fetchAdminHandoffRecords(resolvedOrder.id)
|
||||||
|
if (token !== orderLoadToken) return
|
||||||
|
activeOrder.value = resolvedOrder
|
||||||
|
activeHandoffRecords.value = handoffRecords
|
||||||
|
fetchAdminPayments({ order_id: String(resolvedOrder.id), page_size: 5, silent: true })
|
||||||
|
.then(paymentRecords => {
|
||||||
|
if (token === orderLoadToken) activePaymentRecords.value = paymentRecords.items
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (token === orderLoadToken) activePaymentRecords.value = []
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
if (token === orderLoadToken) {
|
||||||
|
activeOrder.value = null
|
||||||
|
activeHandoffRecords.value = []
|
||||||
|
activePaymentRecords.value = []
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (token === orderLoadToken) orderLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMessages(id: number, scroll = true) {
|
async function loadMessages(id: number, scroll = true) {
|
||||||
const res = await fetchAdminChatMessages(id, 1, 100)
|
const res = await fetchAdminChatMessages(id, 1, 100)
|
||||||
messages.value = res.items
|
messages.value = res.items
|
||||||
@@ -342,6 +432,61 @@ function getSupportName(item: ChatConversation) {
|
|||||||
const support = item.participants?.find(p => p.role === 'support')
|
const support = item.participants?.find(p => p.role === 'support')
|
||||||
return support?.display_name || '未分配'
|
return support?.display_name || '未分配'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function amountYuan(cent: unknown) {
|
||||||
|
if (cent !== undefined && cent !== null) return centToYuan(Number(cent || 0))
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value: unknown) {
|
||||||
|
return formatMoney(Number(value || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
function moneyCent(value: number) {
|
||||||
|
return formatCentWithSymbol(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderRentedAt() {
|
||||||
|
return activeOrder.value?.rented_at
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderEstimatedEndAt() {
|
||||||
|
if (!activeOrder.value) return undefined
|
||||||
|
if (activeOrder.value.estimated_end_at) return activeOrder.value.estimated_end_at
|
||||||
|
const rentedAt = orderRentedAt()
|
||||||
|
const durationHours = Number(activeOrder.value.estimated_duration_hours || 0)
|
||||||
|
if (!rentedAt || durationHours <= 0) return undefined
|
||||||
|
return new Date(new Date(rentedAt).getTime() + durationHours * 60 * 60 * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paymentBizTypeLabel(type: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
order_pay: '订单支付',
|
||||||
|
checkout_refund: '结账退款',
|
||||||
|
arbitration_refund: '仲裁退款',
|
||||||
|
admin_refund: '人工退款',
|
||||||
|
cancel_refund: '取消退款',
|
||||||
|
admin_close_refund: '客服关闭退款',
|
||||||
|
}
|
||||||
|
return map[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHandoffRecordType(type: string) {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
owner_handoff: '卖家交接',
|
||||||
|
renter_checkout: '买家结账',
|
||||||
|
owner_counter_checkout: '卖家反驳结账',
|
||||||
|
renter_confirm_checkout: '买家确认结账',
|
||||||
|
owner_accept_checkout: '卖家接受结账',
|
||||||
|
admin_arbitration: '客服仲裁',
|
||||||
|
}
|
||||||
|
return typeMap[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstQueryValue(value: unknown) {
|
||||||
|
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : ''
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -418,44 +563,114 @@ function getSupportName(item: ChatConversation) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<el-button size="small" @click="transferVisible = true">转接</el-button>
|
<el-button size="small" @click="transferVisible = true">转接</el-button>
|
||||||
<RouterLink v-if="active.order_id" :to="adminPath(`orders/${active.order_id}`)">
|
<RouterLink v-if="activeOrder" :to="orderDetailLink">
|
||||||
<el-button size="small">查看订单</el-button>
|
<el-button size="small">查看订单</el-button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div ref="listRef" class="message-list" v-loading="messageLoading">
|
<div class="message-body" :class="{ 'has-order': hasOrderContext }">
|
||||||
<div
|
<div ref="listRef" class="message-list" v-loading="messageLoading">
|
||||||
v-for="item in messages"
|
<div
|
||||||
:key="item.id"
|
v-for="item in messages"
|
||||||
class="message-row"
|
:key="item.id"
|
||||||
:class="{
|
class="message-row"
|
||||||
self: item.is_self,
|
:class="{
|
||||||
system: item.sender_type === 'system',
|
self: item.is_self,
|
||||||
admin: item.sender_role === 'admin',
|
system: item.sender_type === 'system',
|
||||||
}"
|
admin: item.sender_role === 'admin',
|
||||||
>
|
}"
|
||||||
<template v-if="item.sender_type === 'system'">
|
>
|
||||||
<span>{{ item.content }}</span>
|
<template v-if="item.sender_type === 'system'">
|
||||||
</template>
|
<span>{{ item.content }}</span>
|
||||||
<template v-else>
|
</template>
|
||||||
<small :class="{ 'admin-label': item.sender_role === 'admin' }">
|
<template v-else>
|
||||||
{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}
|
<small :class="{ 'admin-label': item.sender_role === 'admin' }">
|
||||||
</small>
|
{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}
|
||||||
<p v-if="item.content">{{ item.content }}</p>
|
</small>
|
||||||
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
|
<p v-if="item.content">{{ item.content }}</p>
|
||||||
<ChatAttachmentImage
|
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
|
||||||
v-for="url in item.attachment_urls"
|
<ChatAttachmentImage
|
||||||
:key="url"
|
v-for="url in item.attachment_urls"
|
||||||
:source="url"
|
:key="url"
|
||||||
admin
|
:source="url"
|
||||||
/>
|
admin
|
||||||
</div>
|
/>
|
||||||
<small v-if="item.is_self" class="read-status" :class="{ read: item.is_read }">
|
</div>
|
||||||
{{ item.is_read ? '已读' : '未读' }}
|
<small v-if="item.is_self" class="read-status" :class="{ read: item.is_read }">
|
||||||
</small>
|
{{ item.is_read ? '已读' : '未读' }}
|
||||||
</template>
|
</small>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<aside v-if="hasOrderContext" class="order-side-panel" v-loading="orderLoading">
|
||||||
|
<template v-if="activeOrder">
|
||||||
|
<div class="order-side-head">
|
||||||
|
<span>{{ activeOrder.order_no }}</span>
|
||||||
|
<strong>{{ activeListingCode }}</strong>
|
||||||
|
<p>
|
||||||
|
{{ activeOrder.title }} · {{ activeOrder.server_region }} /
|
||||||
|
{{ activeOrder.login_platform }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-side-metrics">
|
||||||
|
<div>
|
||||||
|
<span>订单状态</span>
|
||||||
|
<strong>{{ orderStatusLabel(activeOrder.status) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>交接状态</span>
|
||||||
|
<strong>{{ handoffStatusLabel(activeOrder.handoff_status) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>订单金额</span>
|
||||||
|
<strong>¥{{ money(amountYuan(activeOrder.rent_amount_cent)) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>押金</span>
|
||||||
|
<strong>¥{{ money(amountYuan(activeOrder.deposit_amount_cent)) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="order-side-section">
|
||||||
|
<h3>用户信息</h3>
|
||||||
|
<p>商品编号:{{ activeListingCode }}</p>
|
||||||
|
<p>租客:{{ activeOrder.renter_phone || activeOrder.renter_id }}</p>
|
||||||
|
<p>号主:{{ activeOrder.owner_phone || activeOrder.owner_id }}</p>
|
||||||
|
<p>开始:{{ formatDateTime(orderRentedAt(), '未开始') }}</p>
|
||||||
|
<p>预计截止:{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="order-side-section">
|
||||||
|
<h3>支付与退款</h3>
|
||||||
|
<template v-if="latestPaymentRecord">
|
||||||
|
<p>
|
||||||
|
{{ paymentBizTypeLabel(latestPaymentRecord.biz_type) }} ·
|
||||||
|
{{ moneyCent(latestPaymentRecord.amount_cent) }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ latestPaymentRecord.provider || '-' }} ·
|
||||||
|
{{ latestPaymentRecord.third_order_id }}
|
||||||
|
</p>
|
||||||
|
<p>{{ formatDateTime(latestPaymentRecord.created_at) }}</p>
|
||||||
|
</template>
|
||||||
|
<p v-else>暂无支付流水</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="order-side-section">
|
||||||
|
<h3>交接记录</h3>
|
||||||
|
<template v-if="latestHandoffRecord">
|
||||||
|
<strong>{{ formatHandoffRecordType(latestHandoffRecord.type) }}</strong>
|
||||||
|
<p>{{ latestHandoffRecord.content }}</p>
|
||||||
|
<p>{{ formatDateTime(latestHandoffRecord.created_at) }}</p>
|
||||||
|
</template>
|
||||||
|
<p v-else>暂无交接记录</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<el-empty v-else description="暂无订单信息" />
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="composer">
|
<footer class="composer">
|
||||||
@@ -714,6 +929,17 @@ function getSupportName(item: ChatConversation) {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: #f3f6fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body.has-order {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(330px, 38%);
|
||||||
|
}
|
||||||
|
|
||||||
.message-list {
|
.message-list {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -721,6 +947,99 @@ function getSupportName(item: ChatConversation) {
|
|||||||
background: #f3f6fa;
|
background: #f3f6fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-side-panel {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
border-left: 1px solid #e5e7eb;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-side-head,
|
||||||
|
.order-side-section {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #edf2f7;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-side-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-side-head span {
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-side-head strong {
|
||||||
|
color: #071947;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-side-head p,
|
||||||
|
.order-side-section p {
|
||||||
|
margin: 0;
|
||||||
|
color: #7c8aaa;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-side-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-side-metrics div {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #edf2f7;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-side-metrics span {
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-side-metrics strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #071947;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-side-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-side-section + .order-side-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-side-section h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #071947;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-side-section strong {
|
||||||
|
color: #071947;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.message-row {
|
.message-row {
|
||||||
max-width: 70%;
|
max-width: 70%;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
|||||||
@@ -35,6 +35,26 @@ const refundStatus = ref<RefundStatus | null>(null)
|
|||||||
const listingCode = computed(() =>
|
const listingCode = computed(() =>
|
||||||
order.value ? formatListingNo(order.value.listing_no, order.value.listing_id) : '-'
|
order.value ? formatListingNo(order.value.listing_no, order.value.listing_id) : '-'
|
||||||
)
|
)
|
||||||
|
const returnTarget = computed(() => {
|
||||||
|
const from = firstQueryValue(route.query.from)
|
||||||
|
const chatID = firstQueryValue(route.query.chat_id)
|
||||||
|
const chatFilter = firstQueryValue(route.query.chat_filter)
|
||||||
|
if (from === 'chat' && chatID) {
|
||||||
|
return {
|
||||||
|
path: adminPath('chats'),
|
||||||
|
query: {
|
||||||
|
chat_id: chatID,
|
||||||
|
...(chatFilter ? { chat_filter: chatFilter } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return adminPath('orders')
|
||||||
|
})
|
||||||
|
const returnLabel = computed(() =>
|
||||||
|
firstQueryValue(route.query.from) === 'chat' && firstQueryValue(route.query.chat_id)
|
||||||
|
? '返回会话'
|
||||||
|
: '返回列表'
|
||||||
|
)
|
||||||
const refunding = ref(false)
|
const refunding = ref(false)
|
||||||
|
|
||||||
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
|
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
|
||||||
@@ -184,6 +204,11 @@ function moneyCent(value: number) {
|
|||||||
return formatCentWithSymbol(value)
|
return formatCentWithSymbol(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstQueryValue(value: unknown) {
|
||||||
|
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : ''
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
function formatHandoffRecordType(type: string) {
|
function formatHandoffRecordType(type: string) {
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
owner_handoff: '卖家交接',
|
owner_handoff: '卖家交接',
|
||||||
@@ -209,8 +234,8 @@ function formatHandoffRecordType(type: string) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<RouterLink :to="adminPath('orders')">
|
<RouterLink :to="returnTarget">
|
||||||
<el-button>返回列表</el-button>
|
<el-button>{{ returnLabel }}</el-button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<el-button v-if="canResetHandoff" type="success" @click="openAction('reset')"
|
<el-button v-if="canResetHandoff" type="success" @click="openAction('reset')"
|
||||||
>重置交接</el-button
|
>重置交接</el-button
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface ChatParticipant {
|
|||||||
export interface ChatConversation {
|
export interface ChatConversation {
|
||||||
id: number
|
id: number
|
||||||
order_id: number | null
|
order_id: number | null
|
||||||
|
listing_id?: number | null
|
||||||
type: string
|
type: string
|
||||||
title: string
|
title: string
|
||||||
status: string
|
status: string
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
formatListingCode,
|
formatListingCode,
|
||||||
getListingChips,
|
getListingChips,
|
||||||
getListingResources,
|
getListingResources,
|
||||||
|
getListingSkinGroups,
|
||||||
getListingTitle,
|
getListingTitle,
|
||||||
getLoginMethod,
|
getLoginMethod,
|
||||||
getServerRegion,
|
getServerRegion,
|
||||||
@@ -29,13 +30,13 @@ const props = defineProps<Props>()
|
|||||||
const coverURL = computed(() => props.listing.cover_url || props.listing.screenshot_urls?.[0] || '')
|
const coverURL = computed(() => props.listing.cover_url || props.listing.screenshot_urls?.[0] || '')
|
||||||
const dailyLoss = computed(() => getDailyLoss(props.listing))
|
const dailyLoss = computed(() => getDailyLoss(props.listing))
|
||||||
const skinNames = computed(() => getSkinNames(props.listing))
|
const skinNames = computed(() => getSkinNames(props.listing))
|
||||||
|
const skinGroups = computed(() => getListingSkinGroups(props.listing))
|
||||||
|
const skinSummaryText = computed(() =>
|
||||||
|
skinGroups.value.map(group => `${group.title}: ${group.options.join(' / ')}`).join(' | ')
|
||||||
|
)
|
||||||
const chipItems = computed(() => getListingChips(props.listing))
|
const chipItems = computed(() => getListingChips(props.listing))
|
||||||
const resourceItems = computed(() => getListingResources(props.listing))
|
const resourceItems = computed(() => getListingResources(props.listing))
|
||||||
const rentalDuration = computed(() => formatEstimatedRentalDuration(props.listing))
|
const rentalDuration = computed(() => formatEstimatedRentalDuration(props.listing))
|
||||||
const visibleSkinNames = computed(() => skinNames.value.slice(0, 4))
|
|
||||||
const skinOverflowCount = computed(() =>
|
|
||||||
Math.max(0, skinNames.value.length - visibleSkinNames.value.length)
|
|
||||||
)
|
|
||||||
const accessTags = computed(() =>
|
const accessTags = computed(() =>
|
||||||
[getServerRegion(props.listing), getLoginMethod(props.listing)]
|
[getServerRegion(props.listing), getLoginMethod(props.listing)]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -113,10 +114,10 @@ function formatStatNumber(value: number) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="skinNames.length" class="skin-line" :title="skinNames.join('、')">
|
<div v-if="skinGroups.length" class="skin-line" :title="skinNames.join('、')">
|
||||||
<span class="line-title">皮肤</span>
|
<span class="line-title">皮肤</span>
|
||||||
<span class="skin-list">{{ visibleSkinNames.join(' / ') }}</span>
|
<span class="skin-list">{{ skinSummaryText }}</span>
|
||||||
<span v-if="skinOverflowCount" class="skin-count">+{{ skinOverflowCount }}</span>
|
<span class="skin-count">{{ skinNames.length }}款</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="resourceItems.length" class="resource-line">
|
<div v-if="resourceItems.length" class="resource-line">
|
||||||
|
|||||||
@@ -303,6 +303,13 @@ export async function fetchAdminOrder(id: string | number) {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminLatestOrderByListing(listingId: string | number) {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<Order>>(
|
||||||
|
`/admin/listing-orders/${listingId}/latest`
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAdminHandoffRecords(id: string | number) {
|
export async function fetchAdminHandoffRecords(id: string | number) {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: HandoffRecord[] }>>(
|
const { data } = await apiClient.get<ApiResponse<{ items: HandoffRecord[] }>>(
|
||||||
`/admin/orders/${id}/handoff-records`
|
`/admin/orders/${id}/handoff-records`
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export interface ListingDisplayResource {
|
|||||||
amount: number
|
amount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListingDisplaySkinGroup {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
options: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export function getCoinWan(item: Listing) {
|
export function getCoinWan(item: Listing) {
|
||||||
return Math.round(Number(item.haf_coin_amount || 0) / 10000)
|
return Math.round(Number(item.haf_coin_amount || 0) / 10000)
|
||||||
}
|
}
|
||||||
@@ -204,12 +210,29 @@ export function getSkinGroup(item: Listing, groupKey: string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSkinNames(item: Listing) {
|
export function getListingSkinGroups(item: Listing): ListingDisplaySkinGroup[] {
|
||||||
const skinGroups = item.asset_summary?.skin_groups
|
const skinGroups = item.asset_summary?.skin_groups
|
||||||
if (typeof skinGroups !== 'object' || skinGroups === null) return []
|
if (typeof skinGroups !== 'object' || skinGroups === null) return []
|
||||||
return Object.values(skinGroups as Record<string, unknown>)
|
const titles: Record<string, string> = {
|
||||||
.flatMap(group => (Array.isArray(group) ? group : []))
|
melee: '近战',
|
||||||
.filter((skin): skin is string => typeof skin === 'string')
|
operator: '干员',
|
||||||
|
operatorGold: '金皮',
|
||||||
|
operatorRed: '红皮',
|
||||||
|
weapon: '武器',
|
||||||
|
}
|
||||||
|
return Object.entries(skinGroups as Record<string, unknown>)
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
title: titles[key] || key,
|
||||||
|
options: Array.isArray(value)
|
||||||
|
? value.filter((skin): skin is string => typeof skin === 'string' && Boolean(skin.trim()))
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
.filter(group => group.options.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSkinNames(item: Listing) {
|
||||||
|
return getListingSkinGroups(item).flatMap(group => group.options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assetRegions(item: Listing) {
|
export function assetRegions(item: Listing) {
|
||||||
|
|||||||
Reference in New Issue
Block a user