优化后台高频查询性能
This commit is contained in:
@@ -313,7 +313,15 @@ func applyMigrations(t *testing.T, db *sql.DB) {
|
||||
t.Fatal("无法定位当前测试文件")
|
||||
}
|
||||
migrationDir := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations")
|
||||
for _, name := range []string{"000001_init.sql", "000002_dispute_cancel_snapshot.sql"} {
|
||||
entries, err := os.ReadDir(migrationDir)
|
||||
if err != nil {
|
||||
t.Fatalf("读取迁移目录失败: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
migrationPath := filepath.Join(migrationDir, name)
|
||||
raw, err := os.ReadFile(migrationPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -21,7 +21,7 @@ func setupRefundRetryTestDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试数据库失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}, &model.ChatConversation{}); err != nil {
|
||||
t.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
return db
|
||||
@@ -165,19 +165,19 @@ func TestResetRetryClearsRetryFields(t *testing.T) {
|
||||
func createArbitrationOrphanFixture(t *testing.T, db *gorm.DB, suffix string, settlementStatus string) model.RentalOrder {
|
||||
t.Helper()
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD" + suffix,
|
||||
ListingID: 1,
|
||||
AccountID: 1,
|
||||
OwnerID: 1,
|
||||
RenterID: 2,
|
||||
RentAmountCent: 1000,
|
||||
DepositAmountCent: 500,
|
||||
Status: "closed",
|
||||
HandoffStatus: "arbitrated",
|
||||
SettlementStatus: settlementStatus,
|
||||
RefundStatus: "pending",
|
||||
RefundAmountCent: 800,
|
||||
UpdatedAt: time.Now().Add(-20 * time.Minute), // 超过 10min 静默窗口
|
||||
OrderNo: "ORD" + suffix,
|
||||
ListingID: 1,
|
||||
AccountID: 1,
|
||||
OwnerID: 1,
|
||||
RenterID: 2,
|
||||
RentAmountCent: 1000,
|
||||
DepositAmountCent: 500,
|
||||
Status: "closed",
|
||||
HandoffStatus: "arbitrated",
|
||||
SettlementStatus: settlementStatus,
|
||||
RefundStatus: "pending",
|
||||
RefundAmountCent: 800,
|
||||
UpdatedAt: time.Now().Add(-20 * time.Minute), // 超过 10min 静默窗口
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("create order failed: %v", err)
|
||||
|
||||
@@ -4,21 +4,32 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ChatConversation struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"`
|
||||
ListingID *uint64 `gorm:"index" json:"listing_id"`
|
||||
Type string `gorm:"size:32;not null;default:'order_group'" json:"type"`
|
||||
SupportScene string `gorm:"column:support_scene;size:32;not null;default:''" json:"support_scene"`
|
||||
Title string `gorm:"size:128;not null" json:"title"`
|
||||
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
||||
LastMessageID *uint64 `json:"last_message_id"`
|
||||
LastMessagePreview string `gorm:"size:255;not null;default:''" json:"last_message_preview"`
|
||||
LastMessageAt *time.Time `json:"last_message_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"`
|
||||
ListingID *uint64 `gorm:"index" json:"listing_id"`
|
||||
// 发布群没有固定订单时,以下字段保存该商品最新订单的轻量快照,供客服列表筛选使用。
|
||||
// 这样读取会话列表不必每次扫描全部订单再按商品分组。
|
||||
LatestOrderID *uint64 `gorm:"index" json:"-"`
|
||||
LatestOrderNo string `gorm:"size:64;not null;default:''" json:"-"`
|
||||
LatestOrderStatus string `gorm:"size:32;not null;default:'';index" json:"-"`
|
||||
LatestOrderHandoffStatus string `gorm:"size:32;not null;default:''" json:"-"`
|
||||
LatestOrderRefundStatus string `gorm:"size:32;not null;default:''" json:"-"`
|
||||
// 待回复判断只需比较两个游标,避免每个会话再扫描聊天消息表。
|
||||
LastAttentionMessageID uint64 `gorm:"not null;default:0" json:"-"`
|
||||
LastAdminMessageID uint64 `gorm:"not null;default:0" json:"-"`
|
||||
Type string `gorm:"size:32;not null;default:'order_group'" json:"type"`
|
||||
SupportScene string `gorm:"column:support_scene;size:32;not null;default:''" json:"support_scene"`
|
||||
Title string `gorm:"size:128;not null" json:"title"`
|
||||
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
||||
LastMessageID *uint64 `json:"last_message_id"`
|
||||
LastMessagePreview string `gorm:"size:255;not null;default:''" json:"last_message_preview"`
|
||||
LastMessageAt *time.Time `json:"last_message_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (ChatConversation) TableName() string {
|
||||
@@ -76,6 +87,24 @@ func (ChatMessage) TableName() string {
|
||||
return "chat_messages"
|
||||
}
|
||||
|
||||
// AfterCreate 维护客服列表所需的两个消息游标;列表页只比较游标,不再对每个会话聚合消息表。
|
||||
func (m *ChatMessage) AfterCreate(tx *gorm.DB) error {
|
||||
if m.ID == 0 || m.ConversationID == 0 {
|
||||
return nil
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if m.AdminAttentionType != "" || m.SenderType == "user" {
|
||||
updates["last_attention_message_id"] = m.ID
|
||||
}
|
||||
if m.SenderType == "admin" {
|
||||
updates["last_admin_message_id"] = m.ID
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&ChatConversation{}).Where("id = ?", m.ConversationID).Updates(updates).Error
|
||||
}
|
||||
|
||||
type ChatQrCode struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
ImageURL string `gorm:"size:512;not null" json:"image_url"`
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RentalOrder struct {
|
||||
@@ -73,6 +74,24 @@ func (RentalOrder) TableName() string {
|
||||
return "rental_orders"
|
||||
}
|
||||
|
||||
// AfterSave 将商品关联发布群的最新订单快照一并更新。
|
||||
// 客服列表会高频按订单状态筛选,直接读这个快照可避免每次聚合全量 rental_orders。
|
||||
func (o *RentalOrder) AfterSave(tx *gorm.DB) error {
|
||||
if o.ID == 0 || o.ListingID == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&ChatConversation{}).
|
||||
Where("listing_id = ? AND order_id IS NULL", o.ListingID).
|
||||
Where("latest_order_id IS NULL OR latest_order_id <= ?", o.ID).
|
||||
Updates(map[string]any{
|
||||
"latest_order_id": o.ID,
|
||||
"latest_order_no": o.OrderNo,
|
||||
"latest_order_status": o.Status,
|
||||
"latest_order_handoff_status": o.HandoffStatus,
|
||||
"latest_order_refund_status": o.RefundStatus,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// 押金暂扣状态:客服可对进行中订单的押金退款进行暂扣,订单照常结算,
|
||||
// 但本应原路退给租客的押金部分挂起不退,后续由客服手动归还。
|
||||
const (
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
)
|
||||
|
||||
func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) {
|
||||
if cached := r.loadDashboardCache(ctx, query); cached != nil {
|
||||
return cached, nil
|
||||
}
|
||||
dailyItems, err := r.dailyItems(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -31,7 +34,7 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DashboardDTO{
|
||||
result := &DashboardDTO{
|
||||
Summary: *summary,
|
||||
DailyItems: dailyItems,
|
||||
PickupSummary: *pickup,
|
||||
@@ -39,7 +42,9 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash
|
||||
DisbursementSummary: *disbursement,
|
||||
OperatingExpenseSummary: *operatingExpense,
|
||||
GeneratedAt: timeutil.ShanghaiNow(),
|
||||
}, nil
|
||||
}
|
||||
r.storeDashboardCache(ctx, query, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) operatingExpenseSummary(ctx context.Context, query DashboardQuery) (*OperatingExpenseSummaryDTO, error) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package adminfinance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dashboardCacheTTL = time.Minute
|
||||
|
||||
func (r *Repository) dashboardCacheKey(query DashboardQuery) string {
|
||||
return fmt.Sprintf("admin-finance:dashboard:v1:%d:%d", query.StartDate.Unix(), query.EndDate.Unix())
|
||||
}
|
||||
|
||||
func (r *Repository) loadDashboardCache(ctx context.Context, query DashboardQuery) *DashboardDTO {
|
||||
if r.redis == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := r.redis.Get(ctx, r.dashboardCacheKey(query)).Bytes()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var value DashboardDTO
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func (r *Repository) storeDashboardCache(ctx context.Context, query DashboardQuery, value *DashboardDTO) {
|
||||
if r.redis == nil || value == nil {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 缓存不可用时静默降级到实时统计,不能影响财务页面可用性。
|
||||
_ = r.redis.Set(ctx, r.dashboardCacheKey(query), raw, dashboardCacheTTL).Err()
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
package adminfinance
|
||||
|
||||
import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
func NewRepository(db *gorm.DB, redisClient ...*redis.Client) *Repository {
|
||||
repo := &Repository{db: db}
|
||||
if len(redisClient) > 0 {
|
||||
repo.redis = redisClient[0]
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const adminChatCountsCacheTTL = 5 * time.Second
|
||||
|
||||
func (r *Repository) adminChatCountsCacheKey(principal Principal, filter, stage, keyword string) string {
|
||||
sum := sha256.Sum256([]byte(keyword))
|
||||
return fmt.Sprintf("admin-chat:counts:v2:%s:%d:%s:%s:%s", principal.Type, principal.ID, filter, stage, hex.EncodeToString(sum[:8]))
|
||||
}
|
||||
|
||||
func (r *Repository) loadAdminChatCountsCache(ctx context.Context, principal Principal, filter, stage, keyword string) *AdminConversationCountsDTO {
|
||||
if r.redis == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := r.redis.Get(ctx, r.adminChatCountsCacheKey(principal, filter, stage, keyword)).Bytes()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var value AdminConversationCountsDTO
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func (r *Repository) storeAdminChatCountsCache(ctx context.Context, principal Principal, filter, stage, keyword string, value *AdminConversationCountsDTO) {
|
||||
if r.redis == nil || value == nil {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = r.redis.Set(ctx, r.adminChatCountsCacheKey(principal, filter, stage, keyword), raw, adminChatCountsCacheTTL).Err()
|
||||
}
|
||||
@@ -139,3 +139,61 @@ func TestListAdminConversationsUsesPersonalStateAndBatchData(t *testing.T) {
|
||||
t.Fatalf("客服回复后待处理数量 = %d, want 0", result.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAdminConversationsUsesListingOrderSnapshot(t *testing.T) {
|
||||
db := setupAdminListTestDB(t)
|
||||
repo := NewRepository(db, nil, nil)
|
||||
|
||||
admin := model.AdminUser{Username: "cs-snapshot", Nickname: "客服快照", Status: "active"}
|
||||
owner := model.User{Phone: "13800000011", Nickname: "号主"}
|
||||
renter := model.User{Phone: "13800000012", Nickname: "租客"}
|
||||
if err := db.Create(&admin).Error; err != nil {
|
||||
t.Fatalf("创建客服失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&owner).Error; err != nil {
|
||||
t.Fatalf("创建号主失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&renter).Error; err != nil {
|
||||
t.Fatalf("创建租客失败: %v", err)
|
||||
}
|
||||
listing := model.RentalListing{ListingNo: "L202608250002", OwnerID: owner.ID, AccountID: 2, Status: "active"}
|
||||
if err := db.Create(&listing).Error; err != nil {
|
||||
t.Fatalf("创建商品失败: %v", err)
|
||||
}
|
||||
conversation := model.ChatConversation{ListingID: &listing.ID, Type: ConversationTypeListingGroup, Title: "发布群", Status: "active"}
|
||||
if err := db.Create(&conversation).Error; err != nil {
|
||||
t.Fatalf("创建发布群失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ChatParticipant{ConversationID: conversation.ID, ParticipantType: "admin", ParticipantID: admin.ID, Role: "support", JoinedAt: time.Now()}).Error; err != nil {
|
||||
t.Fatalf("创建客服成员失败: %v", err)
|
||||
}
|
||||
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "RO-SNAPSHOT-1", ListingID: listing.ID, AccountID: listing.AccountID,
|
||||
OwnerID: owner.ID, RenterID: renter.ID, Status: "pending_handoff", HandoffStatus: "pending_owner", RefundStatus: "none",
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("创建订单失败: %v", err)
|
||||
}
|
||||
|
||||
result, err := repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStageHandoff, "")
|
||||
if err != nil {
|
||||
t.Fatalf("按交接阶段查询发布群失败: %v", err)
|
||||
}
|
||||
items := result.Items.([]ConversationDTO)
|
||||
if result.Total != 1 || len(items) != 1 {
|
||||
t.Fatalf("发布群数量 = total %d/items %d, want 1/1", result.Total, len(items))
|
||||
}
|
||||
if items[0].LatestOrderID == nil || *items[0].LatestOrderID != order.ID || items[0].LatestOrderStatus != "pending_handoff" {
|
||||
t.Fatalf("最新订单快照未生效: %+v", items[0])
|
||||
}
|
||||
|
||||
order.Status = "renting"
|
||||
if err := db.Save(&order).Error; err != nil {
|
||||
t.Fatalf("更新订单状态失败: %v", err)
|
||||
}
|
||||
result, err = repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStageRenting, "")
|
||||
if err != nil || result.Total != 1 {
|
||||
t.Fatalf("更新后使用中筛选失败: total=%d err=%v", result.Total, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,12 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
|
||||
conversation.LastMessageID = &message.ID
|
||||
conversation.LastMessagePreview = messagePreview(message.Content, req.AttachmentURLS)
|
||||
conversation.LastMessageAt = &message.CreatedAt
|
||||
if message.AdminAttentionType != "" || message.SenderType == "user" {
|
||||
conversation.LastAttentionMessageID = message.ID
|
||||
}
|
||||
if message.SenderType == "admin" {
|
||||
conversation.LastAdminMessageID = message.ID
|
||||
}
|
||||
if err := tx.Save(&conversation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -233,11 +233,11 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
|
||||
c.last_message_preview, c.last_message_at, c.created_at, c.updated_at,
|
||||
COALESCE(cp_me.role, 'admin') AS role,
|
||||
COALESCE(cas.remark, '') AS admin_remark,
|
||||
COALESCE(explicit_lo.id, listing_lo.id) AS latest_order_id,
|
||||
COALESCE(explicit_lo.order_no, listing_lo.order_no) AS latest_order_no,
|
||||
COALESCE(explicit_lo.status, listing_lo.status) AS latest_order_status,
|
||||
COALESCE(explicit_lo.handoff_status, listing_lo.handoff_status) AS latest_handoff_status,
|
||||
COALESCE(explicit_lo.refund_status, listing_lo.refund_status) AS latest_refund_status,
|
||||
COALESCE(explicit_lo.id, c.latest_order_id) AS latest_order_id,
|
||||
COALESCE(explicit_lo.order_no, c.latest_order_no) AS latest_order_no,
|
||||
COALESCE(explicit_lo.status, c.latest_order_status) AS latest_order_status,
|
||||
COALESCE(explicit_lo.handoff_status, c.latest_order_handoff_status) AS latest_handoff_status,
|
||||
COALESCE(explicit_lo.refund_status, c.latest_order_refund_status) AS latest_refund_status,
|
||||
lm.sender_type AS last_sender_type, lm.sender_id AS last_sender_id, lm.sender_role AS last_sender_role,
|
||||
CASE WHEN c.type = 'general_support' AND (` + adminNeedsReplyExpression() + `) THEN 1 ELSE 0 END AS needs_reply,
|
||||
(
|
||||
@@ -273,10 +273,17 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
|
||||
}
|
||||
|
||||
func (r *Repository) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
|
||||
result, err := r.adminConversationCounts(ctx, principal, normalizeAdminChatFilter(filter), normalizeAdminChatStage(stage), strings.TrimSpace(keyword))
|
||||
filter = normalizeAdminChatFilter(filter)
|
||||
stage = normalizeAdminChatStage(stage)
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if cached := r.loadAdminChatCountsCache(ctx, principal, filter, stage, keyword); cached != nil {
|
||||
return cached, nil
|
||||
}
|
||||
result, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.storeAdminChatCountsCache(ctx, principal, filter, stage, keyword, result.DTO)
|
||||
return result.DTO, nil
|
||||
}
|
||||
|
||||
@@ -297,13 +304,12 @@ func (r *Repository) adminConversationBase(ctx context.Context, principal Princi
|
||||
Joins("LEFT JOIN chat_admin_conversation_states AS cas ON cas.conversation_id = c.id AND cas.admin_user_id = ?", principal.ID).
|
||||
Joins("LEFT JOIN chat_messages AS lm ON lm.id = c.last_message_id").
|
||||
Joins("LEFT JOIN rental_orders AS explicit_lo ON explicit_lo.id = c.order_id").
|
||||
Joins("LEFT JOIN (SELECT ro.* FROM rental_orders AS ro JOIN (SELECT listing_id, MAX(id) AS max_id FROM rental_orders GROUP BY listing_id) AS latest ON latest.max_id = ro.id) AS listing_lo ON listing_lo.listing_id = c.listing_id AND c.order_id IS NULL").
|
||||
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, listing_lo.listing_id, c.listing_id)")
|
||||
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, c.listing_id)")
|
||||
if keyword != "" {
|
||||
db = db.Joins("LEFT JOIN users AS renter ON renter.id = COALESCE(explicit_lo.renter_id, listing_lo.renter_id)").
|
||||
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(explicit_lo.owner_id, listing_lo.owner_id, l.owner_id)")
|
||||
db = db.Joins("LEFT JOIN users AS renter ON renter.id = explicit_lo.renter_id").
|
||||
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(explicit_lo.owner_id, l.owner_id)")
|
||||
like := "%" + keyword + "%"
|
||||
db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR COALESCE(explicit_lo.order_no, listing_lo.order_no) LIKE ? OR l.listing_no LIKE ?
|
||||
db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR COALESCE(explicit_lo.order_no, c.latest_order_no) LIKE ? OR l.listing_no LIKE ?
|
||||
OR renter.phone LIKE ? OR owner.phone LIKE ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM chat_participants AS cp_kw
|
||||
@@ -410,9 +416,9 @@ func adminChatOwnershipCondition(filter string, principal Principal) (string, []
|
||||
}
|
||||
|
||||
func adminChatStageCondition(stage string, principal Principal) (string, []interface{}) {
|
||||
latestID := "COALESCE(explicit_lo.id, listing_lo.id)"
|
||||
latestStatus := "COALESCE(explicit_lo.status, listing_lo.status)"
|
||||
latestRefundStatus := "COALESCE(explicit_lo.refund_status, listing_lo.refund_status)"
|
||||
latestID := "COALESCE(explicit_lo.id, c.latest_order_id)"
|
||||
latestStatus := "COALESCE(explicit_lo.status, c.latest_order_status)"
|
||||
latestRefundStatus := "COALESCE(explicit_lo.refund_status, c.latest_order_refund_status)"
|
||||
switch stage {
|
||||
case adminChatStagePending:
|
||||
return "c.type = 'general_support' AND (" + adminNeedsReplyExpression() + ")", nil
|
||||
@@ -434,17 +440,7 @@ func adminChatStageCondition(stage string, principal Principal) (string, []inter
|
||||
}
|
||||
|
||||
func adminNeedsReplyExpression() string {
|
||||
return `COALESCE((
|
||||
SELECT MAX(cm_attention.id)
|
||||
FROM chat_messages AS cm_attention
|
||||
WHERE cm_attention.conversation_id = c.id
|
||||
AND (cm_attention.admin_attention_type <> '' OR cm_attention.sender_type = 'user')
|
||||
), 0) > COALESCE((
|
||||
SELECT MAX(cm_admin.id)
|
||||
FROM chat_messages AS cm_admin
|
||||
WHERE cm_admin.conversation_id = c.id
|
||||
AND cm_admin.sender_type = 'admin'
|
||||
), 0)`
|
||||
return "c.last_attention_message_id > c.last_admin_message_id"
|
||||
}
|
||||
|
||||
// ArchiveListingConversation 将商品关联的发布群标记为已归档(解散),并写入系统提示。
|
||||
|
||||
@@ -98,9 +98,9 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g
|
||||
like := "%" + keyword + "%"
|
||||
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
|
||||
db = db.Where(
|
||||
`(l.listing_no LIKE ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no LIKE ?) OR l.id = ? OR l.account_id = ? OR l.owner_id = ?)`,
|
||||
like,
|
||||
like,
|
||||
`(l.listing_no = ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no = ?) OR l.id = ? OR l.account_id = ? OR l.owner_id = ?)`,
|
||||
keyword,
|
||||
keyword,
|
||||
id,
|
||||
id,
|
||||
id,
|
||||
@@ -204,22 +204,21 @@ func (r *Repository) findDTO(ctx context.Context, where string, args ...any) (*L
|
||||
}
|
||||
|
||||
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
||||
uploadSource := r.db.WithContext(ctx).Table("listing_uploads").
|
||||
Select("listing_id, MAX(id) AS upload_id, MAX(NULLIF(source_channel, '')) AS source_channel").
|
||||
Where("listing_id IS NOT NULL").
|
||||
Group("listing_id")
|
||||
return r.db.WithContext(ctx).Table("rental_listings AS l").
|
||||
Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level,
|
||||
a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname,
|
||||
CASE WHEN lu.upload_id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
|
||||
CASE WHEN lu.id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
|
||||
CASE
|
||||
WHEN lu.upload_id IS NULL THEN ?
|
||||
WHEN lu.id IS NULL THEN ?
|
||||
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
|
||||
ELSE lu.source_channel
|
||||
END AS source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown).
|
||||
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
||||
Joins("LEFT JOIN users AS u ON u.id = l.owner_id").
|
||||
Joins("LEFT JOIN (?) AS lu ON lu.listing_id = l.id", uploadSource)
|
||||
// 逐商品按 listing_id 索引定位最后一次导入,避免每次列表查询都对全量导入记录分组。
|
||||
Joins(`LEFT JOIN listing_uploads AS lu ON lu.id = (
|
||||
SELECT MAX(lu_latest.id) FROM listing_uploads AS lu_latest WHERE lu_latest.listing_id = l.id
|
||||
)`)
|
||||
}
|
||||
|
||||
func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) {
|
||||
|
||||
@@ -100,12 +100,11 @@ func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB {
|
||||
like := "%" + keyword + "%"
|
||||
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
|
||||
db = db.Where(
|
||||
`(o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ? OR o.id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?)`,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
`(o.order_no = ? OR l.listing_no = ? OR owner.phone = ? OR renter.phone = ? OR o.id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?)`,
|
||||
keyword,
|
||||
keyword,
|
||||
keyword,
|
||||
keyword,
|
||||
id,
|
||||
id,
|
||||
id,
|
||||
|
||||
@@ -134,7 +134,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminDashboardHandler := admindashboard.NewHandler(adminDashboardService)
|
||||
var adminFinanceRepo *adminfinance.Repository
|
||||
if deps.DB != nil {
|
||||
adminFinanceRepo = adminfinance.NewRepository(deps.DB)
|
||||
adminFinanceRepo = adminfinance.NewRepository(deps.DB, deps.Redis)
|
||||
}
|
||||
adminFinanceService := adminfinance.NewService(adminFinanceRepo)
|
||||
adminFinanceHandler := adminfinance.NewHandler(adminFinanceService)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
-- +goose Up
|
||||
-- 高频客服列表不再实时扫描全部订单与聊天消息;以下字段是可由业务记录重建的读取快照。
|
||||
|
||||
ALTER TABLE chat_conversations
|
||||
ADD COLUMN latest_order_id BIGINT UNSIGNED NULL COMMENT '发布群最新订单ID快照' AFTER listing_id,
|
||||
ADD COLUMN latest_order_no VARCHAR(64) NOT NULL DEFAULT '' COMMENT '发布群最新订单号快照' AFTER latest_order_id,
|
||||
ADD COLUMN latest_order_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '发布群最新订单状态快照' AFTER latest_order_no,
|
||||
ADD COLUMN latest_order_handoff_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '发布群最新交接状态快照' AFTER latest_order_status,
|
||||
ADD COLUMN latest_order_refund_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '发布群最新退款状态快照' AFTER latest_order_handoff_status,
|
||||
ADD COLUMN last_attention_message_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后一条需客服关注消息ID' AFTER latest_order_refund_status,
|
||||
ADD COLUMN last_admin_message_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后一条客服消息ID' AFTER last_attention_message_id,
|
||||
ADD KEY idx_chat_conversations_latest_order_status (latest_order_status, last_message_at, id);
|
||||
|
||||
UPDATE chat_conversations AS c
|
||||
JOIN (
|
||||
SELECT ro.id, ro.listing_id, ro.order_no, ro.status, ro.handoff_status, ro.refund_status
|
||||
FROM rental_orders AS ro
|
||||
JOIN (
|
||||
SELECT listing_id, MAX(id) AS id
|
||||
FROM rental_orders
|
||||
GROUP BY listing_id
|
||||
) AS latest ON latest.id = ro.id
|
||||
) AS o ON o.listing_id = c.listing_id
|
||||
SET c.latest_order_id = o.id,
|
||||
c.latest_order_no = o.order_no,
|
||||
c.latest_order_status = o.status,
|
||||
c.latest_order_handoff_status = o.handoff_status,
|
||||
c.latest_order_refund_status = o.refund_status
|
||||
WHERE c.order_id IS NULL;
|
||||
|
||||
UPDATE chat_conversations AS c
|
||||
LEFT JOIN (
|
||||
SELECT conversation_id,
|
||||
COALESCE(MAX(CASE WHEN admin_attention_type <> '' OR sender_type = 'user' THEN id END), 0) AS attention_id,
|
||||
COALESCE(MAX(CASE WHEN sender_type = 'admin' THEN id END), 0) AS admin_id
|
||||
FROM chat_messages
|
||||
GROUP BY conversation_id
|
||||
) AS m ON m.conversation_id = c.id
|
||||
SET c.last_attention_message_id = COALESCE(m.attention_id, 0),
|
||||
c.last_admin_message_id = COALESCE(m.admin_id, 0);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE chat_conversations
|
||||
DROP KEY idx_chat_conversations_latest_order_status,
|
||||
DROP COLUMN last_admin_message_id,
|
||||
DROP COLUMN last_attention_message_id,
|
||||
DROP COLUMN latest_order_refund_status,
|
||||
DROP COLUMN latest_order_handoff_status,
|
||||
DROP COLUMN latest_order_status,
|
||||
DROP COLUMN latest_order_no,
|
||||
DROP COLUMN latest_order_id;
|
||||
@@ -24,61 +24,126 @@ export interface ProcessTimelineEvent {
|
||||
const props = defineProps<{ events: ProcessTimelineEvent[]; emptyText?: string }>()
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
owner_handoff_submitted: '号主提交交接', platform_handoff_submitted: '客服代交接',
|
||||
admin_force_handoff: '客服确认交接', renter_received_confirmed: '租客确认收号',
|
||||
checkout_submitted: '租客发起结账', checkout_countered: '修改结账方案',
|
||||
checkout_confirmed: '号主确认结账', checkout_accepted: '租客接受结账方案',
|
||||
platform_checkout_countered: '客服修改结账方案', platform_checkout_confirmed: '客服确认结账',
|
||||
platform_checkout_dispute_opened: '客服发起结账争议', offline_settlement_confirmed: '确认线下结算',
|
||||
deposit_held: '暂扣押金', deposit_released: '归还暂扣押金', pickup_created: '创建提号',
|
||||
pickup_completed: '完成提号', pickup_profit_updated: '修改提号利润',
|
||||
pickup_financial_adjusted: '创建财务调整', pickup_adjustment_settled: '确认财务调整',
|
||||
pickup_offline_settlement_confirmed: '确认提号线下结算', pickup_cancelled: '取消提号',
|
||||
owner_handoff_submitted: '号主提交交接',
|
||||
platform_handoff_submitted: '客服代交接',
|
||||
admin_force_handoff: '客服确认交接',
|
||||
renter_received_confirmed: '租客确认收号',
|
||||
checkout_submitted: '租客发起结账',
|
||||
checkout_countered: '修改结账方案',
|
||||
checkout_confirmed: '号主确认结账',
|
||||
checkout_accepted: '租客接受结账方案',
|
||||
platform_checkout_countered: '客服修改结账方案',
|
||||
platform_checkout_confirmed: '客服确认结账',
|
||||
platform_checkout_dispute_opened: '客服发起结账争议',
|
||||
offline_settlement_confirmed: '确认线下结算',
|
||||
deposit_held: '暂扣押金',
|
||||
deposit_released: '归还暂扣押金',
|
||||
pickup_created: '创建提号',
|
||||
pickup_completed: '完成提号',
|
||||
pickup_profit_updated: '修改提号利润',
|
||||
pickup_financial_adjusted: '创建财务调整',
|
||||
pickup_adjustment_settled: '确认财务调整',
|
||||
pickup_offline_settlement_confirmed: '确认提号线下结算',
|
||||
pickup_cancelled: '取消提号',
|
||||
}
|
||||
|
||||
const stageLabels: Record<string, string> = {
|
||||
handoff: '交接', checkout: '结账', settlement: '结算', dispute: '争议', pickup: '提号',
|
||||
handoff: '交接',
|
||||
checkout: '结账',
|
||||
settlement: '结算',
|
||||
dispute: '争议',
|
||||
pickup: '提号',
|
||||
}
|
||||
|
||||
const payloadLabels: Record<string, string> = {
|
||||
checkout_id: '结账单', round: '协商轮次', turn: '等待确认方', proposed_by: '方案提出人',
|
||||
rent_amount_cent: '实际结算租金', owner_rent_amount_cent: '号主租金', platform_fee_cent: '平台费用',
|
||||
deposit_amount_cent: '押金', consumable_amount_cent: '消耗品金额', coin_consumed_m: '哈夫币消耗',
|
||||
deposit_deduct_amount_cent: '押金赔付扣除', renter_refund_amount_cent: '退还租客',
|
||||
owner_income_amount_cent: '号主最终收入', shortfall_cent: '押金不足差额', overshoot_amount_cent: '打超金额',
|
||||
offline_settlement_amount_cent: '线下结算金额', deposit_hold_amount_cent: '暂扣金额',
|
||||
profit_amount_cent: '利润金额', settle_amount_cent: '结算给号主', profit_delta_cent: '利润调整',
|
||||
settle_delta_cent: '结算调整', settlement_mode: '结算方式', account_source: '账号来源', source_channel: '来源渠道',
|
||||
checkout_id: '结账单',
|
||||
round: '协商轮次',
|
||||
turn: '等待确认方',
|
||||
proposed_by: '方案提出人',
|
||||
rent_amount_cent: '实际结算租金',
|
||||
owner_rent_amount_cent: '号主租金',
|
||||
platform_fee_cent: '平台费用',
|
||||
deposit_amount_cent: '押金',
|
||||
consumable_amount_cent: '消耗品金额',
|
||||
coin_consumed_m: '哈夫币消耗',
|
||||
deposit_deduct_amount_cent: '押金赔付扣除',
|
||||
renter_refund_amount_cent: '退还租客',
|
||||
owner_income_amount_cent: '号主最终收入',
|
||||
shortfall_cent: '押金不足差额',
|
||||
overshoot_amount_cent: '打超金额',
|
||||
offline_settlement_amount_cent: '线下结算金额',
|
||||
deposit_hold_amount_cent: '暂扣金额',
|
||||
profit_amount_cent: '利润金额',
|
||||
settle_amount_cent: '结算给号主',
|
||||
profit_delta_cent: '利润调整',
|
||||
settle_delta_cent: '结算调整',
|
||||
settlement_mode: '结算方式',
|
||||
account_source: '账号来源',
|
||||
source_channel: '来源渠道',
|
||||
}
|
||||
|
||||
const primaryPayloadKeys = new Set([
|
||||
'round', 'turn', 'rent_amount_cent', 'renter_refund_amount_cent', 'owner_income_amount_cent',
|
||||
'offline_settlement_amount_cent', 'profit_amount_cent', 'settle_amount_cent',
|
||||
'round',
|
||||
'turn',
|
||||
'rent_amount_cent',
|
||||
'renter_refund_amount_cent',
|
||||
'owner_income_amount_cent',
|
||||
'offline_settlement_amount_cent',
|
||||
'profit_amount_cent',
|
||||
'settle_amount_cent',
|
||||
])
|
||||
|
||||
const stateLabels: Record<string, Record<string, string>> = {
|
||||
order_status: {
|
||||
pending_payment: '待支付', pending_handoff: '待交接', renting: '租用中', overdue: '已逾期',
|
||||
pending_checkout_confirm: '待号主确认结账', pending_checkout_accept: '待租客确认结账',
|
||||
completed: '已完成', cancelled: '已取消', closed: '已关闭', abnormal: '异常',
|
||||
pending_payment: '待支付',
|
||||
pending_handoff: '待交接',
|
||||
renting: '租用中',
|
||||
overdue: '已逾期',
|
||||
pending_checkout_confirm: '待号主确认结账',
|
||||
pending_checkout_accept: '待租客确认结账',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
closed: '已关闭',
|
||||
abnormal: '异常',
|
||||
},
|
||||
handoff_status: {
|
||||
none: '未开始', pending_owner: '待号主交接', pending_renter_confirm: '待租客确认收号',
|
||||
received: '租客已收号', pending_owner_checkout: '待号主确认结账',
|
||||
pending_renter_checkout: '待租客确认结账', returned: '已归还',
|
||||
none: '未开始',
|
||||
pending_owner: '待号主交接',
|
||||
pending_renter_confirm: '待租客确认收号',
|
||||
received: '租客已收号',
|
||||
pending_owner_checkout: '待号主确认结账',
|
||||
pending_renter_checkout: '待租客确认结账',
|
||||
returned: '已归还',
|
||||
},
|
||||
settlement_status: {
|
||||
unsettled: '未结算',
|
||||
pending: '结算待确认',
|
||||
settled: '已结算',
|
||||
arbitrated: '仲裁结算',
|
||||
},
|
||||
settlement_status: { unsettled: '未结算', pending: '结算待确认', settled: '已结算', arbitrated: '仲裁结算' },
|
||||
offline_settlement_status: { none: '无需线下结算', pending: '待线下结算', settled: '已线下结算' },
|
||||
pickup_status: { pending: '待处理', processing: '处理中', completed: '已完成', cancelled: '已取消' },
|
||||
pickup_status: {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
},
|
||||
}
|
||||
|
||||
const stateFieldLabels: Record<string, string> = {
|
||||
order_status: '订单', handoff_status: '交接', settlement_status: '结算',
|
||||
offline_settlement_status: '线下结算', pickup_status: '提号',
|
||||
order_status: '订单',
|
||||
handoff_status: '交接',
|
||||
settlement_status: '结算',
|
||||
offline_settlement_status: '线下结算',
|
||||
pickup_status: '提号',
|
||||
}
|
||||
|
||||
function actionLabel(action: string) { return actionLabels[action] || action }
|
||||
function stageLabel(stage: string) { return stageLabels[stage] || '流程' }
|
||||
function actionLabel(action: string) {
|
||||
return actionLabels[action] || action
|
||||
}
|
||||
function stageLabel(stage: string) {
|
||||
return stageLabels[stage] || '流程'
|
||||
}
|
||||
function actorLabel(item: ProcessTimelineEvent) {
|
||||
if (item.actor_name) return item.actor_name
|
||||
if (item.actor_type === 'system') return '系统'
|
||||
@@ -91,7 +156,9 @@ function actorRole(item: ProcessTimelineEvent) {
|
||||
}
|
||||
function payloadRows(payload?: Record<string, unknown>) {
|
||||
if (!payload || typeof payload !== 'object') return []
|
||||
return Object.entries(payload).filter(([, value]) => value !== null && value !== undefined && value !== '')
|
||||
return Object.entries(payload).filter(
|
||||
([, value]) => value !== null && value !== undefined && value !== ''
|
||||
)
|
||||
}
|
||||
function primaryPayloadRows(item: ProcessTimelineEvent) {
|
||||
return payloadRows(item.payload).filter(([key]) => primaryPayloadKeys.has(key))
|
||||
@@ -99,12 +166,16 @@ function primaryPayloadRows(item: ProcessTimelineEvent) {
|
||||
function detailPayloadRows(item: ProcessTimelineEvent) {
|
||||
return payloadRows(item.payload).filter(([key]) => !primaryPayloadKeys.has(key))
|
||||
}
|
||||
function payloadLabel(key: string) { return payloadLabels[key] || key }
|
||||
function payloadLabel(key: string) {
|
||||
return payloadLabels[key] || key
|
||||
}
|
||||
function payloadValue(key: string, value: unknown) {
|
||||
if (key.endsWith('_cent')) return formatCentWithSymbol(Number(value || 0))
|
||||
if (key === 'turn') return value === 'owner' ? '号主' : value === 'renter' ? '租客' : String(value)
|
||||
if (key === 'turn')
|
||||
return value === 'owner' ? '号主' : value === 'renter' ? '租客' : String(value)
|
||||
if (key === 'account_source') return value === 'external' ? '外部上传' : '站内上传'
|
||||
if (key === 'settlement_mode') return value === 'platform_managed' ? '平台线下结算' : '号主钱包结算'
|
||||
if (key === 'settlement_mode')
|
||||
return value === 'platform_managed' ? '平台线下结算' : '号主钱包结算'
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
@@ -117,7 +188,11 @@ function stateChanges(item: ProcessTimelineEvent) {
|
||||
const after = item.state_after || {}
|
||||
return Object.keys(stateFieldLabels)
|
||||
.filter(key => before[key] !== undefined && before[key] !== after[key])
|
||||
.map(key => ({ label: stateFieldLabels[key], before: stateValue(key, before[key]), after: stateValue(key, after[key]) }))
|
||||
.map(key => ({
|
||||
label: stateFieldLabels[key],
|
||||
before: stateValue(key, before[key]),
|
||||
after: stateValue(key, after[key]),
|
||||
}))
|
||||
}
|
||||
function hasMore(item: ProcessTimelineEvent) {
|
||||
return detailPayloadRows(item).length > 0 || stateChanges(item).length > 0
|
||||
@@ -138,7 +213,10 @@ function hasMore(item: ProcessTimelineEvent) {
|
||||
</header>
|
||||
|
||||
<div class="process-people">
|
||||
<span><b>{{ actorRole(item) }}</b>{{ actorLabel(item) }}</span>
|
||||
<span
|
||||
><b>{{ actorRole(item) }}</b
|
||||
>{{ actorLabel(item) }}</span
|
||||
>
|
||||
<span v-if="item.target_name" class="target-text">通知/关联:{{ item.target_name }}</span>
|
||||
</div>
|
||||
<p v-if="item.content" class="process-content">{{ item.content }}</p>
|
||||
@@ -182,44 +260,214 @@ function hasMore(item: ProcessTimelineEvent) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.process-timeline { display: grid; gap: 12px; padding-left: 4px; }
|
||||
.process-item { position: relative; padding: 14px 16px 14px 22px; border: 1px solid #e5eaf2; border-radius: 10px; background: #fff; box-shadow: 0 1px 2px rgb(15 23 42 / 2%); }
|
||||
.process-item::before { content: ''; position: absolute; top: -13px; bottom: calc(100% - 1px); left: -1px; width: 1px; background: #dce5f0; }
|
||||
.process-item:first-child::before { display: none; }
|
||||
.process-dot { position: absolute; left: -5px; top: 21px; width: 9px; height: 9px; border: 2px solid #fff; border-radius: 50%; background: #ff6a00; box-shadow: 0 0 0 1px #f5a561; }
|
||||
.process-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.heading-main { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.heading-main strong { color: #182232; font-size: 15px; }
|
||||
.stage-badge { flex: none; padding: 2px 7px; border-radius: 4px; background: #fff3e8; color: #d85d00; font-size: 12px; font-weight: 600; }
|
||||
.process-heading time { flex: none; color: #94a3b8; font-size: 12px; white-space: nowrap; }
|
||||
.process-people { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 8px; color: #64748b; font-size: 12px; }
|
||||
.process-people b { margin-right: 6px; color: #475569; font-weight: 600; }
|
||||
.target-text { color: #718096; }
|
||||
.process-content, .process-reason { margin: 10px 0 0; color: #334155; line-height: 1.65; white-space: pre-wrap; }
|
||||
.process-reason { padding: 8px 10px; border-radius: 6px; background: #fff9ed; color: #9a5b13; }
|
||||
.process-reason b { margin-right: 8px; }
|
||||
.process-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 11px; }
|
||||
.process-summary div { display: flex; align-items: baseline; gap: 7px; padding: 7px 10px; border: 1px solid #e6edf5; border-radius: 6px; background: #f8fafc; }
|
||||
.process-summary span { color: #64748b; font-size: 12px; }
|
||||
.process-summary strong { color: #1e293b; font-size: 13px; }
|
||||
.process-details { margin-top: 10px; }
|
||||
.process-details summary { width: fit-content; color: #477fc1; font-size: 12px; cursor: pointer; user-select: none; }
|
||||
.process-details[open] summary { margin-bottom: 9px; }
|
||||
.process-payload { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 7px 12px; padding: 10px; border-radius: 7px; background: #f4f7fb; }
|
||||
.process-payload div { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; }
|
||||
.process-payload span { color: #64748b; }
|
||||
.process-payload strong { color: #1e293b; text-align: right; word-break: break-word; }
|
||||
.process-state { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 9px; color: #64748b; font-size: 12px; }
|
||||
.process-state b { color: #475569; }
|
||||
.process-state span { padding-left: 10px; border-left: 1px solid #dbe4ee; }
|
||||
.process-state i { color: #94a3b8; font-style: normal; }
|
||||
.process-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.process-attachments :deep(.el-image) { width: 76px; height: 76px; border: 1px solid #e2e8f0; border-radius: 6px; }
|
||||
.process-timeline {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.process-item {
|
||||
position: relative;
|
||||
padding: 14px 16px 14px 22px;
|
||||
border: 1px solid #e5eaf2;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 2%);
|
||||
}
|
||||
.process-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -13px;
|
||||
bottom: calc(100% - 1px);
|
||||
left: -1px;
|
||||
width: 1px;
|
||||
background: #dce5f0;
|
||||
}
|
||||
.process-item:first-child::before {
|
||||
display: none;
|
||||
}
|
||||
.process-dot {
|
||||
position: absolute;
|
||||
left: -5px;
|
||||
top: 21px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
background: #ff6a00;
|
||||
box-shadow: 0 0 0 1px #f5a561;
|
||||
}
|
||||
.process-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.heading-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.heading-main strong {
|
||||
color: #182232;
|
||||
font-size: 15px;
|
||||
}
|
||||
.stage-badge {
|
||||
flex: none;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: #fff3e8;
|
||||
color: #d85d00;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.process-heading time {
|
||||
flex: none;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.process-people {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 18px;
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
.process-people b {
|
||||
margin-right: 6px;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
.target-text {
|
||||
color: #718096;
|
||||
}
|
||||
.process-content,
|
||||
.process-reason {
|
||||
margin: 10px 0 0;
|
||||
color: #334155;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.process-reason {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: #fff9ed;
|
||||
color: #9a5b13;
|
||||
}
|
||||
.process-reason b {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.process-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 11px;
|
||||
}
|
||||
.process-summary div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.process-summary span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
.process-summary strong {
|
||||
color: #1e293b;
|
||||
font-size: 13px;
|
||||
}
|
||||
.process-details {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.process-details summary {
|
||||
width: fit-content;
|
||||
color: #477fc1;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.process-details[open] summary {
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
.process-payload {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 7px 12px;
|
||||
padding: 10px;
|
||||
border-radius: 7px;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
.process-payload div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.process-payload span {
|
||||
color: #64748b;
|
||||
}
|
||||
.process-payload strong {
|
||||
color: #1e293b;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
.process-state {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
margin-top: 9px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
.process-state b {
|
||||
color: #475569;
|
||||
}
|
||||
.process-state span {
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid #dbe4ee;
|
||||
}
|
||||
.process-state i {
|
||||
color: #94a3b8;
|
||||
font-style: normal;
|
||||
}
|
||||
.process-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.process-attachments :deep(.el-image) {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.process-item { padding: 13px 12px 13px 18px; }
|
||||
.process-heading { align-items: flex-start; flex-direction: column; gap: 5px; }
|
||||
.process-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.process-summary div { min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.process-payload { grid-template-columns: 1fr; }
|
||||
.process-item {
|
||||
padding: 13px 12px 13px 18px;
|
||||
}
|
||||
.process-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.process-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.process-summary div {
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.process-payload {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,8 +46,8 @@ export interface Order {
|
||||
growth_points_awarded?: number
|
||||
growth_points_awarded_at?: string
|
||||
account_snapshot?: Record<string, unknown>
|
||||
account_source?: 'internal' | 'external' | string
|
||||
source_channel?: string
|
||||
account_source?: 'internal' | 'external' | string
|
||||
source_channel?: string
|
||||
listing_snapshot?: string
|
||||
checkout_info?: string
|
||||
counter_info?: string
|
||||
@@ -152,24 +152,24 @@ export interface HandoffRecord {
|
||||
}
|
||||
|
||||
export interface ProcessEvent {
|
||||
id: number
|
||||
business_type: string
|
||||
business_id: number
|
||||
stage: string
|
||||
action: string
|
||||
actor_type: 'user' | 'admin' | 'system' | string
|
||||
actor_id: number
|
||||
actor_name: string
|
||||
target_type: string
|
||||
target_id?: number
|
||||
target_name: string
|
||||
content: string
|
||||
reason: string
|
||||
payload: Record<string, unknown>
|
||||
attachment_urls: string[]
|
||||
state_before: Record<string, unknown>
|
||||
state_after: Record<string, unknown>
|
||||
created_at: string
|
||||
id: number
|
||||
business_type: string
|
||||
business_id: number
|
||||
stage: string
|
||||
action: string
|
||||
actor_type: 'user' | 'admin' | 'system' | string
|
||||
actor_id: number
|
||||
actor_name: string
|
||||
target_type: string
|
||||
target_id?: number
|
||||
target_name: string
|
||||
content: string
|
||||
reason: string
|
||||
payload: Record<string, unknown>
|
||||
attachment_urls: string[]
|
||||
state_before: Record<string, unknown>
|
||||
state_after: Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PaymentOrder {
|
||||
@@ -312,10 +312,10 @@ export async function fetchHandoffRecords(id: string | number) {
|
||||
}
|
||||
|
||||
export async function fetchAdminOrderProcessEvents(id: string | number) {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: ProcessEvent[] }>>(
|
||||
`/admin/orders/${id}/process-events`
|
||||
)
|
||||
return Array.isArray(data.data?.items) ? data.data.items : []
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: ProcessEvent[] }>>(
|
||||
`/admin/orders/${id}/process-events`
|
||||
)
|
||||
return Array.isArray(data.data?.items) ? data.data.items : []
|
||||
}
|
||||
|
||||
export async function confirmReceive(id: number) {
|
||||
|
||||
Reference in New Issue
Block a user