支持客服主动发起申诉

This commit is contained in:
yml2213
2026-07-26 09:54:27 +08:00
parent 4bc25b87d8
commit 9ccc16e068
20 changed files with 522 additions and 25 deletions
+2
View File
@@ -10,6 +10,8 @@ type Dispute struct {
ID uint64 `gorm:"primaryKey" json:"id"`
OrderID uint64 `gorm:"not null;index" json:"order_id"`
InitiatorID uint64 `gorm:"not null" json:"initiator_id"`
InitiatorType string `gorm:"size:16;not null;default:'user';index" json:"initiator_type"`
InitiatorAdminID *uint64 `gorm:"index" json:"initiator_admin_id"`
TargetUserID uint64 `gorm:"not null" json:"target_user_id"`
Type string `gorm:"size:32;not null" json:"type"`
Status string `gorm:"size:32;not null;default:'open'" json:"status"`
+2 -2
View File
@@ -14,7 +14,7 @@ import (
func (r *Repository) CancelByOrder(ctx context.Context, userID uint64, orderID uint64) (*DisputeDTO, error) {
var row model.Dispute
if err := r.db.WithContext(ctx).
Where("order_id = ? AND initiator_id = ? AND status IN ?", orderID, userID, []string{"open", "processing"}).
Where("order_id = ? AND initiator_type = ? AND initiator_id = ? AND status IN ?", orderID, disputeInitiatorUser, userID, []string{"open", "processing"}).
Order("id DESC").
First(&row).Error; err != nil {
return nil, err
@@ -28,7 +28,7 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, id uint64) (*Dis
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
return err
}
if row.InitiatorID != userID {
if effectiveInitiatorType(row) != disputeInitiatorUser || row.InitiatorID != userID {
return ErrPermissionDenied
}
if row.Status != "open" && row.Status != "processing" {
+9
View File
@@ -22,6 +22,8 @@ type DisputeDTO struct {
OwnerPhone string `json:"owner_phone"`
RenterPhone string `json:"renter_phone"`
InitiatorID uint64 `json:"initiator_id"`
InitiatorType string `json:"initiator_type"`
InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"`
TargetUserID uint64 `json:"target_user_id"`
Type string `json:"type"`
Status string `json:"status"`
@@ -46,6 +48,13 @@ type CreateRequest struct {
EvidenceURLS []string `json:"evidence_urls"`
}
type AdminCreateRequest struct {
Type string `json:"type" binding:"required"`
Description string `json:"description" binding:"required"`
TargetRole string `json:"target_role" binding:"required"`
EvidenceURLS []string `json:"evidence_urls"`
}
type ArbitrateRequest struct {
Result string `json:"result" binding:"required"`
Remark string `json:"remark" binding:"required"`
@@ -129,6 +129,29 @@ func (h *Handler) AdminList(c *gin.Context) {
response.OK(c, result)
}
func (h *Handler) AdminCreateByOrder(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
orderID, ok := parseID(c)
if !ok {
return
}
var req AdminCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "申诉信息不完整")
return
}
item, err := h.service.AdminCreateByOrder(c.Request.Context(), adminID, orderID, req, auditMeta(c))
if err != nil {
writeDisputeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) AdminArbitrate(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
@@ -11,6 +11,14 @@ import (
"gorm.io/gorm/clause"
)
const (
disputeInitiatorUser = "user"
disputeInitiatorAdmin = "admin"
disputeTargetOwner = "owner"
disputeTargetRenter = "renter"
)
func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64, req CreateRequest) (*DisputeDTO, error) {
var createdID uint64
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
@@ -46,6 +54,7 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64,
row := model.Dispute{
OrderID: order.ID,
InitiatorID: userID,
InitiatorType: disputeInitiatorUser,
TargetUserID: targetID,
Type: disputeType(req.Type, isCheckoutDispute),
Status: "open",
@@ -154,9 +163,183 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64,
return r.FindForUser(ctx, userID, createdID)
}
func (r *Repository) AdminCreateByOrder(ctx context.Context, adminID uint64, orderID uint64, req AdminCreateRequest, meta AuditMeta) (*DisputeDTO, error) {
var createdID uint64
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
}
if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" {
return ErrInvalidDispute
}
var count int64
if err := tx.Model(&model.Dispute{}).
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return ErrDisputeExists
}
targetID, err := adminDisputeTargetUserID(order, req.TargetRole)
if err != nil {
return err
}
evidence, err := marshalEvidence(req.EvidenceURLS)
if err != nil {
return err
}
isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept"
adminIDCopy := adminID
row := model.Dispute{
OrderID: order.ID,
InitiatorID: 0,
InitiatorType: disputeInitiatorAdmin,
InitiatorAdminID: &adminIDCopy,
TargetUserID: targetID,
Type: disputeType(req.Type, isCheckoutDispute),
Status: "open",
Description: req.Description,
EvidenceURLS: evidence,
PreviousOrderStatus: order.Status,
PreviousHandoffStatus: order.HandoffStatus,
PreviousSettlementStatus: order.SettlementStatus,
}
var checkout model.OrderCheckout
if isCheckoutDispute {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id = ? AND status IN ?", order.ID, []string{"submitted", "countered"}).
Order("id DESC").
First(&checkout).Error; err != nil {
return err
}
row.CheckoutID = &checkout.ID
row.PreviousCheckoutStatus = checkout.Status
}
if err := tx.Create(&row).Error; err != nil {
return err
}
if isCheckoutDispute {
now := time.Now()
order.Status = "checkout_disputing"
order.HandoffStatus = "checkout_disputed"
order.SettlementStatus = "disputed"
if err := tx.Model(&model.OrderCheckout{}).
Where("id = ?", checkout.ID).
Updates(map[string]any{"status": "disputed", "updated_at": now}).Error; err != nil {
return err
}
} else {
order.Status = "disputing"
}
recordType := "admin_dispute_opened"
recordContent := "客服发起订单申诉:" + req.Description
if isCheckoutDispute {
recordType = "admin_checkout_dispute_opened"
recordContent = "客服发起结账争议:" + req.Description
}
record := model.HandoffRecord{
OrderID: order.ID,
FromUserID: adminID,
ToUserID: targetID,
Type: recordType,
Content: recordContent,
}
if err := tx.Create(&record).Error; err != nil {
return err
}
if err := tx.Save(&order).Error; err != nil {
return err
}
disputeID := row.ID
title := "客服已发起订单申诉"
content := "客服已将订单提交仲裁流程,请等待处理或补充沟通记录。"
if isCheckoutDispute {
title = "客服已发起结账争议"
content = "客服已将结账问题提交仲裁流程,请等待处理或补充结账证据。"
}
if err := appendAdminCreatedDisputeNotifications(tx, order, disputeID, title, content); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "dispute.admin_create", "dispute", row.ID, meta, map[string]any{
"dispute_id": row.ID,
"order_id": order.ID,
"order_no": order.OrderNo,
"type": row.Type,
"target_role": req.TargetRole,
"target_user_id": targetID,
"description": req.Description,
"previous_order_status": row.PreviousOrderStatus,
"previous_handoff_status": row.PreviousHandoffStatus,
"previous_settlement_status": row.PreviousSettlementStatus,
"after_order_status": order.Status,
"after_handoff_status": order.HandoffStatus,
"after_settlement_status": order.SettlementStatus,
}); err != nil {
return err
}
createdID = row.ID
return nil
})
if err != nil {
return nil, err
}
return r.FindAdminByID(ctx, createdID)
}
func disputeType(input string, isCheckoutDispute bool) string {
if isCheckoutDispute {
return "checkout_dispute"
}
return input
}
func effectiveInitiatorType(row model.Dispute) string {
if row.InitiatorType == disputeInitiatorAdmin {
return disputeInitiatorAdmin
}
return disputeInitiatorUser
}
func adminDisputeTargetUserID(order model.RentalOrder, role string) (uint64, error) {
switch role {
case disputeTargetOwner:
return order.OwnerID, nil
case disputeTargetRenter:
return order.RenterID, nil
default:
return 0, ErrInvalidDispute
}
}
func appendAdminCreatedDisputeNotifications(tx *gorm.DB, order model.RentalOrder, disputeID uint64, title string, content string) error {
if isPlatformManagedOrder(order) {
return notification.Append(tx, notification.Entry{
UserID: order.RenterID,
Type: "dispute",
Title: title,
Content: content,
BizType: "dispute",
BizID: &disputeID,
})
}
return notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "dispute",
Title: title,
Content: content,
BizType: "dispute",
BizID: &disputeID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "dispute",
Title: title,
Content: content,
BizType: "dispute",
BizID: &disputeID,
},
)
}
@@ -37,6 +37,8 @@ func (row disputeRow) toDTO() DisputeDTO {
OwnerPhone: row.OwnerPhone,
RenterPhone: row.RenterPhone,
InitiatorID: row.InitiatorID,
InitiatorType: effectiveInitiatorType(row.Dispute),
InitiatorAdminID: row.InitiatorAdminID,
TargetUserID: row.TargetUserID,
Type: row.Type,
Status: row.Status,
+28 -8
View File
@@ -5,22 +5,18 @@ import (
"strconv"
"strings"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
func (r *Repository) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) {
db := r.db.WithContext(ctx)
conditions := db.Model(&model.Dispute{}).Where("initiator_id = ? OR target_user_id = ?", userID, userID)
conditions := r.userVisibleQuery(ctx, userID)
var total int64
if err := conditions.Count(&total).Error; err != nil {
return nil, err
}
offset := (page - 1) * pageSize
var rows []disputeRow
err := r.baseQuery(ctx).
Where("d.initiator_id = ? OR d.target_user_id = ?", userID, userID).
err := applyUserVisibleFilter(r.baseQuery(ctx), userID).
Order("d.id DESC").
Offset(offset).Limit(pageSize).
Scan(&rows).Error
@@ -32,8 +28,7 @@ func (r *Repository) ListForUser(ctx context.Context, userID uint64, page, pageS
func (r *Repository) FindForUser(ctx context.Context, userID uint64, id uint64) (*DisputeDTO, error) {
var row disputeRow
if err := r.baseQuery(ctx).
Where("d.id = ? AND (d.initiator_id = ? OR d.target_user_id = ?)", id, userID, userID).
if err := applyUserVisibleFilter(r.baseQuery(ctx).Where("d.id = ?", id), userID).
First(&row).Error; err != nil {
return nil, err
}
@@ -68,6 +63,31 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Pagi
return &PaginatedResult{Items: toDTOs(rows), Total: total, Page: page, PageSize: pageSize}, nil
}
func (r *Repository) FindAdminByID(ctx context.Context, id uint64) (*DisputeDTO, error) {
var row disputeRow
if err := r.baseQuery(ctx).Where("d.id = ?", id).First(&row).Error; err != nil {
return nil, err
}
dto := row.toDTO()
return &dto, nil
}
func (r *Repository) userVisibleQuery(ctx context.Context, userID uint64) *gorm.DB {
return applyUserVisibleFilter(r.adminFilterQuery(ctx), userID)
}
func applyUserVisibleFilter(db *gorm.DB, userID uint64) *gorm.DB {
return db.Where(
`((d.initiator_type = ? AND d.initiator_id = ?) OR d.target_user_id = ? OR (d.initiator_type = ? AND (o.owner_id = ? OR o.renter_id = ?)))`,
disputeInitiatorUser,
userID,
userID,
disputeInitiatorAdmin,
userID,
userID,
)
}
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
return r.adminFilterQuery(ctx).
Select(`d.*, o.order_no, o.status AS order_status, o.handoff_status, o.settlement_status,
@@ -169,6 +169,58 @@ func TestRepositoryCancelRestoresCheckoutDisputeStatus(t *testing.T) {
}
}
func TestAdminCreateByOrderCreatesAdminInitiatedDispute(t *testing.T) {
db := setupDisputeTestDB(t)
repo := NewRepository(db)
adminID := uint64(88)
owner, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{
Status: "renting",
HandoffStatus: "received",
SettlementStatus: "unsettled",
})
created, err := repo.AdminCreateByOrder(t.Context(), adminID, order.ID, AdminCreateRequest{
Type: "cannot_login",
TargetRole: "owner",
Description: "客服核查后主动发起申诉",
}, AuditMeta{})
if err != nil {
t.Fatalf("后台发起申诉失败: %v", err)
}
if created.InitiatorType != disputeInitiatorAdmin || created.InitiatorAdminID == nil || *created.InitiatorAdminID != adminID {
t.Fatalf("发起方 = %s/%v, want admin/%d", created.InitiatorType, created.InitiatorAdminID, adminID)
}
if created.InitiatorID != 0 || created.TargetUserID != owner.ID {
t.Fatalf("initiator/target = %d/%d, want 0/%d", created.InitiatorID, created.TargetUserID, owner.ID)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if saved.Status != "disputing" || saved.HandoffStatus != "received" || saved.SettlementStatus != "unsettled" {
t.Fatalf("订单状态 = %s/%s/%s, want disputing/received/unsettled", saved.Status, saved.HandoffStatus, saved.SettlementStatus)
}
if _, err := repo.FindForUser(t.Context(), renter.ID, created.ID); err != nil {
t.Fatalf("租客应能看到客服发起的申诉: %v", err)
}
if _, err := repo.FindForUser(t.Context(), owner.ID, created.ID); err != nil {
t.Fatalf("号主应能看到客服发起的申诉: %v", err)
}
if _, err := repo.CancelByOrder(t.Context(), renter.ID, order.ID); err == nil {
t.Fatal("客服发起的申诉不应允许租客取消")
}
var record model.HandoffRecord
if err := db.Where("order_id = ? AND type = ?", order.ID, "admin_dispute_opened").First(&record).Error; err != nil {
t.Fatalf("读取客服发起申诉记录失败: %v", err)
}
if record.FromUserID != adminID || record.ToUserID != owner.ID {
t.Fatalf("record from/to = %d/%d, want %d/%d", record.FromUserID, record.ToUserID, adminID, owner.ID)
}
}
func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
db := setupDisputeTestDB(t)
repo := NewRepository(db)
@@ -32,6 +32,16 @@ func (s *Service) Create(ctx context.Context, userID uint64, orderID uint64, req
return s.repo.Create(ctx, userID, orderID, req)
}
func (s *Service) AdminCreateByOrder(ctx context.Context, adminID uint64, orderID uint64, req AdminCreateRequest, meta AuditMeta) (*DisputeDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if orderID == 0 || req.Type == "" || req.Description == "" || req.TargetRole == "" {
return nil, ErrInvalidDispute
}
return s.repo.AdminCreateByOrder(ctx, adminID, orderID, req, meta)
}
func (s *Service) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
+6 -4
View File
@@ -74,10 +74,12 @@ type AdminActionDTO struct {
}
type ActiveDisputeDTO struct {
ID uint64 `json:"id"`
InitiatorID uint64 `json:"initiator_id"`
Type string `json:"type"`
Status string `json:"status"`
ID uint64 `json:"id"`
InitiatorID uint64 `json:"initiator_id"`
InitiatorType string `json:"initiator_type"`
InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"`
Type string `json:"type"`
Status string `json:"status"`
}
type CreateRequest struct {
@@ -336,7 +336,9 @@ func (r *Repository) AdminPlatformCheckoutDispute(ctx context.Context, adminID u
beforeSettlementStatus := order.SettlementStatus
row := model.Dispute{
OrderID: order.ID,
InitiatorID: adminID,
InitiatorID: 0,
InitiatorType: "admin",
InitiatorAdminID: &adminID,
TargetUserID: order.RenterID,
Type: "checkout_dispute",
Status: "open",
+13 -4
View File
@@ -203,13 +203,22 @@ func (r *Repository) activeDisputeDTO(ctx context.Context, orderID uint64) *Acti
return nil
}
return &ActiveDisputeDTO{
ID: row.ID,
InitiatorID: row.InitiatorID,
Type: row.Type,
Status: row.Status,
ID: row.ID,
InitiatorID: row.InitiatorID,
InitiatorType: effectiveDisputeInitiatorType(row),
InitiatorAdminID: row.InitiatorAdminID,
Type: row.Type,
Status: row.Status,
}
}
func effectiveDisputeInitiatorType(row model.Dispute) string {
if row.InitiatorType == "admin" {
return "admin"
}
return "user"
}
func shouldAttachCheckout(status string) bool {
switch status {
case orderStatusPendingCheckoutConfirm, orderStatusPendingCheckoutAccept, orderStatusCheckoutDisputing, orderStatusCompleted:
@@ -1068,7 +1068,7 @@ func TestAdminPlatformCheckoutDisputeCreatesArbitrationCase(t *testing.T) {
if err := db.Where("order_id = ?", order.ID).First(&dispute).Error; err != nil {
t.Fatalf("load dispute failed: %v", err)
}
if dispute.InitiatorID != adminID || dispute.TargetUserID != renter.ID || dispute.Type != "checkout_dispute" {
if dispute.InitiatorID != 0 || dispute.InitiatorType != "admin" || dispute.InitiatorAdminID == nil || *dispute.InitiatorAdminID != adminID || dispute.TargetUserID != renter.ID || dispute.Type != "checkout_dispute" {
t.Fatalf("dispute = %#v, want admin initiated checkout dispute to renter", dispute)
}
if dispute.CheckoutID == nil || *dispute.CheckoutID != checkout.ID || dispute.PreviousCheckoutStatus != checkoutStatusSubmitted {
+1
View File
@@ -588,6 +588,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.POST("/orders/:id/deposit-hold", requirePerm("order:deposit_hold"), orderHandler.AdminHoldDeposit)
adminRoutes.POST("/orders/:id/deposit-release", requirePerm("order:deposit_hold"), orderHandler.AdminReleaseDeposit)
adminRoutes.GET("/orders/refund-pending", requirePerm("order:close"), orderHandler.ListPendingRefund)
adminRoutes.POST("/orders/:id/dispute", requirePerm("dispute:arbitrate"), disputeHandler.AdminCreateByOrder)
// 管理员线下提号(独立于正常订单流程)
adminRoutes.POST("/pickups", requirePerm("order:pickup"), pickupHandler.Create)
@@ -0,0 +1,19 @@
-- +goose Up
ALTER TABLE disputes
ADD COLUMN initiator_type VARCHAR(16) NOT NULL DEFAULT 'user' COMMENT '发起方类型: user用户/admin客服' AFTER initiator_id,
ADD COLUMN initiator_admin_id BIGINT UNSIGNED NULL COMMENT '客服发起时的管理员ID' AFTER initiator_type,
ADD KEY idx_disputes_initiator_type (initiator_type, initiator_id),
ADD KEY idx_disputes_initiator_admin (initiator_admin_id);
UPDATE disputes
SET initiator_type = 'user'
WHERE initiator_type = '';
-- +goose Down
ALTER TABLE disputes
DROP KEY idx_disputes_initiator_admin,
DROP KEY idx_disputes_initiator_type,
DROP COLUMN initiator_admin_id,
DROP COLUMN initiator_type;
@@ -103,6 +103,13 @@ function userPhone(row: Dispute, userID: number) {
return '-'
}
function initiatorLabel(row: Dispute) {
if (row.initiator_type === 'admin') {
return row.initiator_admin_id ? `客服 ID ${row.initiator_admin_id}` : '客服'
}
return `${roleLabel(row, row.initiator_id)} ${userPhone(row, row.initiator_id)}`
}
function evidenceCount(row: Dispute) {
return evidenceItems(row).length
}
@@ -388,10 +395,7 @@ async function handleArbitrate() {
<el-table-column label="双方信息" min-width="240">
<template #default="{ row }">
<div class="party-cell">
<span
>申诉方{{ roleLabel(row, row.initiator_id) }}
{{ userPhone(row, row.initiator_id) }}</span
>
<span>申诉方{{ initiatorLabel(row) }}</span>
<span
>被申诉方{{ roleLabel(row, row.target_user_id) }}
{{ userPhone(row, row.target_user_id) }}</span
@@ -24,6 +24,7 @@ import {
type Order,
type RefundStatus,
} from '@/features/orders'
import { adminCreateOrderDispute } from '@/features/disputes'
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
import {
getSnapshotHafCoinM,
@@ -76,6 +77,14 @@ const platformCheckoutCounterForm = ref({
const offlineSettlementVisible = ref(false)
const offlineSettlementRemark = ref('')
const platformSubmitting = ref(false)
const adminDisputeVisible = ref(false)
const adminDisputeSubmitting = ref(false)
const adminDisputeForm = ref({
type: 'cannot_login',
targetRole: 'renter',
description: '',
evidenceText: '',
})
type FundSplitRow = {
label: string
@@ -144,6 +153,7 @@ const canPlatformCheckoutDispute = computed(
() => platformCheckoutDisputeAction.value?.enabled === true
)
const canOfflineSettlement = computed(() => offlineSettlementAction.value?.enabled === true)
const canAdminCreateDispute = computed(() => canOperate.value && !order.value?.active_dispute)
const isPlatformManaged = computed(
() =>
order.value?.handoff_mode === 'platform' ||
@@ -199,6 +209,16 @@ const depositReleaseActionTip = computed(() =>
const refundActionTip =
'仅发起后台人工原路退款,不关闭订单或调整商品/账号状态;押金已暂扣时仅退非暂扣部分。'
const refundButtonDisabled = computed(() => refundStatus.value?.refund_status === 'refunded')
const disputeTypeOptions = [
{ label: '无法登录', value: 'cannot_login' },
{ label: '描述不符', value: 'false_description' },
{ label: '账号封禁', value: 'account_banned' },
{ label: '资产损失', value: 'asset_loss' },
{ label: '哈夫币争议', value: 'haf_coin_dispute' },
{ label: '交接超时', value: 'handoff_timeout' },
{ label: '归还超时', value: 'return_timeout' },
{ label: '结账金额争议', value: 'checkout_amount' },
]
const orderTotalCent = computed(
() => Number(order.value?.rent_amount_cent || 0) + Number(order.value?.deposit_amount_cent || 0)
)
@@ -493,6 +513,43 @@ async function submitPlatformCheckoutCounter() {
}
}
function openAdminDispute() {
adminDisputeForm.value = {
type: ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value?.status || '')
? 'checkout_amount'
: 'cannot_login',
targetRole: 'renter',
description: '',
evidenceText: '',
}
adminDisputeVisible.value = true
}
async function submitAdminDispute() {
if (!order.value) return
const form = adminDisputeForm.value
if (!form.description.trim()) {
ElMessage.warning('请填写申诉说明')
return
}
adminDisputeSubmitting.value = true
try {
await adminCreateOrderDispute(order.value.id, {
type: form.type,
target_role: form.targetRole,
description: form.description.trim(),
evidence_urls: linesToList(form.evidenceText),
})
ElMessage.success('申诉已发起,订单进入仲裁流程')
adminDisputeVisible.value = false
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '发起申诉失败'))
} finally {
adminDisputeSubmitting.value = false
}
}
function openOfflineSettlement() {
offlineSettlementRemark.value = ''
offlineSettlementVisible.value = true
@@ -706,10 +763,28 @@ function disputeTypeLabel(type: string) {
handoff: '交接申诉',
checkout: '结账争议',
order: '订单争议',
cannot_login: '无法登录',
false_description: '描述不符',
account_banned: '账号封禁',
asset_loss: '资产损失',
haf_coin_dispute: '哈夫币争议',
handoff_timeout: '交接超时',
return_timeout: '归还超时',
checkout_amount: '结账金额争议',
checkout_dispute: '结账争议',
}
return map[type] || type || '-'
}
function disputeInitiatorLabel() {
const active = order.value?.active_dispute
if (!active) return '-'
if (active.initiator_type === 'admin') {
return active.initiator_admin_id ? `客服 ID ${active.initiator_admin_id}` : '客服'
}
return `用户 ID ${active.initiator_id}`
}
function userDisplay(phone: string | undefined, id: number) {
return phone ? `${phone} / ID ${id}` : `ID ${id}`
}
@@ -796,6 +871,14 @@ function paymentPaidAt(record: AdminPayment) {
<el-button v-if="canOfflineSettlement" type="success" @click="openOfflineSettlement">
{{ offlineSettlementAction?.label || '确认线下结算' }}
</el-button>
<el-button
v-if="canAdminCreateDispute"
type="warning"
plain
@click="openAdminDispute"
>
发起申诉
</el-button>
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
>标记异常</el-button
>
@@ -1135,7 +1218,7 @@ function paymentPaidAt(record: AdminPayment) {
</div>
<div>
<dt>发起人</dt>
<dd>ID {{ order.active_dispute.initiator_id }}</dd>
<dd>{{ disputeInitiatorLabel() }}</dd>
</div>
<div>
<dt>当前状态</dt>
@@ -1297,6 +1380,61 @@ function paymentPaidAt(record: AdminPayment) {
</template>
</el-dialog>
<el-dialog
v-model="adminDisputeVisible"
title="发起订单申诉"
width="620px"
destroy-on-close
>
<div v-if="order" class="dialog-body">
<p>
<strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }}
</p>
<div class="form-grid">
<label>
<span>申诉类型</span>
<el-select v-model="adminDisputeForm.type" placeholder="选择申诉类型">
<el-option
v-for="item in disputeTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</label>
<label>
<span>被申诉方</span>
<el-select v-model="adminDisputeForm.targetRole" placeholder="选择被申诉方">
<el-option label="租客" value="renter" />
<el-option label="号主" value="owner" />
</el-select>
</label>
</div>
<el-input
v-model="adminDisputeForm.description"
type="textarea"
:rows="4"
placeholder="填写申诉说明,会通知订单双方并进入仲裁中心"
/>
<el-input
v-model="adminDisputeForm.evidenceText"
type="textarea"
:rows="3"
placeholder="证据链接,一行一个,可留空"
/>
</div>
<template #footer>
<el-button @click="adminDisputeVisible = false">取消</el-button>
<el-button
type="primary"
:loading="adminDisputeSubmitting"
@click="submitAdminDispute"
>
发起申诉
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="platformCheckoutCounterVisible"
title="客服修改结账方案"
@@ -17,6 +17,8 @@ export interface Dispute {
owner_phone: string
renter_phone: string
initiator_id: number
initiator_type?: 'user' | 'admin' | string
initiator_admin_id?: number
target_user_id: number
type: string
status: DisputeStatus
@@ -49,6 +51,22 @@ export async function createDispute(
return data.data
}
export async function adminCreateOrderDispute(
orderId: number,
payload: {
type: string
description: string
target_role: 'owner' | 'renter' | string
evidence_urls?: string[]
}
) {
const { data } = await apiClient.post<ApiResponse<Dispute>>(
`/admin/orders/${orderId}/dispute`,
payload
)
return data.data
}
export async function cancelOrderDispute(orderId: number) {
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/orders/${orderId}/dispute/cancel`)
return data.data
@@ -74,6 +74,8 @@ export interface AdminAction {
export interface ActiveDispute {
id: number
initiator_id: number
initiator_type?: 'user' | 'admin' | string
initiator_admin_id?: number
type: string
status: string
}
@@ -224,6 +224,7 @@ export function useOrderActions(options: UseOrderActionsOptions) {
!!order.value &&
(isOwner.value || isRenter.value) &&
['disputing', 'checkout_disputing'].includes(order.value.status) &&
order.value.active_dispute?.initiator_type !== 'admin' &&
order.value.active_dispute?.initiator_id === session.userId
)
})