新增订单群聊

This commit is contained in:
yml
2026-05-25 14:57:19 +08:00
parent 1a3886f8d1
commit 7e7e6dc166
18 changed files with 2152 additions and 225 deletions
+4 -1
View File
@@ -27,7 +27,9 @@
```bash ```bash
docker compose -f deploy/docker-compose.dev.yml up -d docker compose -f deploy/docker-compose.dev.yml up -d
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < backend/migrations/000001_init.sql for file in backend/migrations/*.sql; do
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "$file"
done
cd backend cd backend
cp .env.example .env cp .env.example .env
@@ -53,6 +55,7 @@ npm run dev
- 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance``GET /api/wallet/ledger` 查看。 - 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance``GET /api/wallet/ledger` 查看。
- 后台资金流水已接入,页面为 `http://localhost:5173/admin/wallet-ledger`,支持按用户、订单和业务类型查询。 - 后台资金流水已接入,页面为 `http://localhost:5173/admin/wallet-ledger`,支持按用户、订单和业务类型查询。
- 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。 - 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。
- 订单群聊已支持支付成功后自动创建,租客、号主和客服可在移动端消息页进入会话。
- 申诉仲裁已支持订单双方发起申诉、开发态后台落账处理,后台页面为 `http://localhost:5173/admin/disputes` - 申诉仲裁已支持订单双方发起申诉、开发态后台落账处理,后台页面为 `http://localhost:5173/admin/disputes`
- 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。 - 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。
- 后台已使用独立登录、图形验证码和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456` - 后台已使用独立登录、图形验证码和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`
+56
View File
@@ -0,0 +1,56 @@
package model
import (
"time"
"gorm.io/datatypes"
)
type ChatConversation struct {
ID uint64 `gorm:"primaryKey" json:"id"`
OrderID uint64 `gorm:"not null;uniqueIndex" json:"order_id"`
Type string `gorm:"size:32;not null;default:'order_group'" json:"type"`
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 {
return "chat_conversations"
}
type ChatParticipant struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ConversationID uint64 `gorm:"not null;index" json:"conversation_id"`
ParticipantType string `gorm:"size:16;not null;index" json:"participant_type"`
ParticipantID uint64 `gorm:"not null;index" json:"participant_id"`
Role string `gorm:"size:32;not null" json:"role"`
LastReadAt *time.Time `json:"last_read_at"`
JoinedAt time.Time `json:"joined_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ChatParticipant) TableName() string {
return "chat_participants"
}
type ChatMessage struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ConversationID uint64 `gorm:"not null;index" json:"conversation_id"`
SenderType string `gorm:"size:16;not null" json:"sender_type"`
SenderID uint64 `gorm:"not null;default:0" json:"sender_id"`
SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"`
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
Content string `json:"content"`
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"`
CreatedAt time.Time `json:"created_at"`
}
func (ChatMessage) TableName() string {
return "chat_messages"
}
+62
View File
@@ -0,0 +1,62 @@
package chat
import "time"
type Principal struct {
Type string
ID uint64
}
type ConversationDTO struct {
ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"`
Type string `json:"type"`
Title string `json:"title"`
Status string `json:"status"`
Role string `json:"role"`
Participants []ParticipantDTO `json:"participants,omitempty"`
LastMessageID *uint64 `json:"last_message_id"`
LastMessagePreview string `json:"last_message_preview"`
LastMessageAt *time.Time `json:"last_message_at"`
UnreadCount int64 `json:"unread_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ParticipantDTO struct {
ID uint64 `json:"id"`
ConversationID uint64 `json:"conversation_id"`
ParticipantType string `json:"participant_type"`
ParticipantID uint64 `json:"participant_id"`
Role string `json:"role"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
LastReadAt *time.Time `json:"last_read_at"`
JoinedAt time.Time `json:"joined_at"`
}
type MessageDTO struct {
ID uint64 `json:"id"`
ConversationID uint64 `json:"conversation_id"`
SenderType string `json:"sender_type"`
SenderID uint64 `json:"sender_id"`
SenderRole string `json:"sender_role"`
SenderName string `json:"sender_name"`
SenderAvatar string `json:"sender_avatar"`
IsSelf bool `json:"is_self"`
ContentType string `json:"content_type"`
Content string `json:"content"`
AttachmentURLS []string `json:"attachment_urls"`
CreatedAt time.Time `json:"created_at"`
}
type SendMessageRequest struct {
Content string `json:"content" binding:"required"`
}
type PaginatedResult struct {
Items interface{} `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
+243
View File
@@ -0,0 +1,243 @@
package chat
import (
"errors"
"net/http"
"strconv"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) List(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
h.list(c, Principal{Type: "user", ID: userID})
}
func (h *Handler) AdminList(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
h.list(c, Principal{Type: "admin", ID: adminID})
}
func (h *Handler) Detail(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
h.detail(c, Principal{Type: "user", ID: userID})
}
func (h *Handler) AdminDetail(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
h.detail(c, Principal{Type: "admin", ID: adminID})
}
func (h *Handler) OrderConversation(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
orderID, ok := parseID(c, "id")
if !ok {
return
}
item, err := h.service.FindOrderConversation(userID, orderID)
if err != nil {
writeChatError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) Messages(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
h.messages(c, Principal{Type: "user", ID: userID})
}
func (h *Handler) AdminMessages(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
h.messages(c, Principal{Type: "admin", ID: adminID})
}
func (h *Handler) Send(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
h.send(c, Principal{Type: "user", ID: userID})
}
func (h *Handler) AdminSend(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
h.send(c, Principal{Type: "admin", ID: adminID})
}
func (h *Handler) MarkRead(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
h.markRead(c, Principal{Type: "user", ID: userID})
}
func (h *Handler) AdminMarkRead(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
h.markRead(c, Principal{Type: "admin", ID: adminID})
}
func (h *Handler) list(c *gin.Context, principal Principal) {
page, pageSize := parsePagination(c)
result, err := h.service.ListConversations(principal, page, pageSize)
if err != nil {
writeChatError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) detail(c *gin.Context, principal Principal) {
id, ok := parseID(c, "id")
if !ok {
return
}
item, err := h.service.FindConversation(principal, id)
if err != nil {
writeChatError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) messages(c *gin.Context, principal Principal) {
id, ok := parseID(c, "id")
if !ok {
return
}
page, pageSize := parsePagination(c)
result, err := h.service.Messages(principal, id, page, pageSize)
if err != nil {
writeChatError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) send(c *gin.Context, principal Principal) {
id, ok := parseID(c, "id")
if !ok {
return
}
var req SendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "消息内容不能为空")
return
}
message, err := h.service.SendMessage(principal, id, req)
if err != nil {
writeChatError(c, err)
return
}
response.Created(c, message)
}
func (h *Handler) markRead(c *gin.Context, principal Principal) {
id, ok := parseID(c, "id")
if !ok {
return
}
if err := h.service.MarkRead(principal, id); err != nil {
writeChatError(c, err)
return
}
response.OK(c, gin.H{"read": true})
}
func parsePagination(c *gin.Context) (int, int) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
return normalizePagination(page, pageSize)
}
func parseID(c *gin.Context, key string) (uint64, bool) {
id, err := strconv.ParseUint(c.Param(key), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "ID 不正确")
return 0, false
}
return id, true
}
func currentUserID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextUserID)
if !ok {
return 0, false
}
userID, ok := value.(uint64)
return userID, ok
}
func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
return 0, false
}
adminID, ok := value.(uint64)
return adminID, ok
}
func writeChatError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "聊天服务暂时不可用")
case errors.Is(err, ErrConversationNotFound):
response.Error(c, http.StatusNotFound, "chat_not_found", "会话不存在")
case errors.Is(err, ErrPermissionDenied):
response.Error(c, http.StatusForbidden, "permission_denied", "无权访问该会话")
case errors.Is(err, ErrInvalidMessage):
response.BadRequest(c, "消息内容不符合规则")
default:
response.Error(c, http.StatusInternalServerError, "internal_error", "聊天服务暂时不可用")
}
}
+581
View File
@@ -0,0 +1,581 @@
package chat
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"hfb_sys/backend/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Repository struct {
db *gorm.DB
}
const (
defaultSupportUsername = "admin"
defaultSupportPassword = "admin123456"
defaultSupportNickname = "超级管理员"
)
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatConversation, error) {
var existing model.ChatConversation
err := tx.Where("order_id = ?", order.ID).First(&existing).Error
if err == nil {
return &existing, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
now := time.Now()
conversation := model.ChatConversation{
OrderID: order.ID,
Type: "order_group",
Title: orderConversationTitle(order),
Status: "active",
}
if err := tx.Create(&conversation).Error; err != nil {
return nil, err
}
participants := []model.ChatParticipant{
{
ConversationID: conversation.ID,
ParticipantType: "user",
ParticipantID: order.RenterID,
Role: "renter",
JoinedAt: now,
},
{
ConversationID: conversation.ID,
ParticipantType: "user",
ParticipantID: order.OwnerID,
Role: "owner",
JoinedAt: now,
},
}
if supportID := defaultSupportAdminID(tx); supportID > 0 {
participants = append(participants, model.ChatParticipant{
ConversationID: conversation.ID,
ParticipantType: "admin",
ParticipantID: supportID,
Role: "support",
JoinedAt: now,
})
}
for _, participant := range participants {
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil {
return nil, err
}
}
message := model.ChatMessage{
ConversationID: conversation.ID,
SenderType: "system",
SenderRole: "system",
ContentType: "system",
Content: "订单已支付,群聊已创建。租客、号主和客服可在这里沟通交接与结账问题。",
AttachmentURLS: emptyJSONList(),
}
if err := tx.Create(&message).Error; err != nil {
return nil, err
}
conversation.LastMessageID = &message.ID
conversation.LastMessagePreview = truncatePreview(message.Content)
conversation.LastMessageAt = &message.CreatedAt
if err := tx.Save(&conversation).Error; err != nil {
return nil, err
}
return &conversation, nil
}
func (r *Repository) ListConversations(principal Principal, page, pageSize int) (*PaginatedResult, error) {
page, pageSize = normalizePagination(page, pageSize)
var total int64
countDB := r.db.Table("chat_conversations AS c").
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id").
Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
if err := countDB.Count(&total).Error; err != nil {
return nil, err
}
var rows []conversationRow
offset := (page - 1) * pageSize
err := r.conversationQuery(principal).
Order("COALESCE(c.last_message_at, c.created_at) DESC, c.id DESC").
Offset(offset).
Limit(pageSize).
Scan(&rows).Error
if err != nil {
return nil, err
}
items := make([]ConversationDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO(nil))
}
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
func (r *Repository) FindConversation(principal Principal, id uint64) (*ConversationDTO, error) {
var row conversationRow
err := r.conversationQuery(principal).Where("c.id = ?", id).First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrConversationNotFound
}
return nil, err
}
participants, err := r.participants(row.ID)
if err != nil {
return nil, err
}
dto := row.toDTO(participants)
return &dto, nil
}
func (r *Repository) FindOrderConversation(userID uint64, orderID uint64) (*ConversationDTO, error) {
var row conversationRow
principal := Principal{Type: "user", ID: userID}
err := r.conversationQuery(principal).Where("c.order_id = ?", orderID).First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrConversationNotFound
}
return nil, err
}
participants, err := r.participants(row.ID)
if err != nil {
return nil, err
}
dto := row.toDTO(participants)
return &dto, nil
}
func (r *Repository) Messages(principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) {
page, pageSize = normalizePagination(page, pageSize)
if _, err := r.findParticipant(r.db, principal, conversationID, false); err != nil {
return nil, err
}
var total int64
if err := r.db.Model(&model.ChatMessage{}).Where("conversation_id = ?", conversationID).Count(&total).Error; err != nil {
return nil, err
}
offset := (page - 1) * pageSize
var rows []model.ChatMessage
if err := r.db.Where("conversation_id = ?", conversationID).
Order("id ASC").
Offset(offset).
Limit(pageSize).
Find(&rows).Error; err != nil {
return nil, err
}
items, err := r.toMessageDTOs(principal, rows)
if err != nil {
return nil, err
}
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
func (r *Repository) SendMessage(principal Principal, conversationID uint64, req SendMessageRequest) (*MessageDTO, error) {
var messageID uint64
err := r.db.Transaction(func(tx *gorm.DB) error {
participant, err := r.findParticipant(tx, principal, conversationID, true)
if err != nil {
return err
}
var conversation model.ChatConversation
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&conversation, conversationID).Error; err != nil {
return err
}
if conversation.Status != "active" {
return ErrPermissionDenied
}
message := model.ChatMessage{
ConversationID: conversation.ID,
SenderType: principal.Type,
SenderID: principal.ID,
SenderRole: participant.Role,
ContentType: "text",
Content: req.Content,
AttachmentURLS: emptyJSONList(),
}
if err := tx.Create(&message).Error; err != nil {
return err
}
conversation.LastMessageID = &message.ID
conversation.LastMessagePreview = truncatePreview(message.Content)
conversation.LastMessageAt = &message.CreatedAt
if err := tx.Save(&conversation).Error; err != nil {
return err
}
now := time.Now()
if err := tx.Model(participant).Update("last_read_at", now).Error; err != nil {
return err
}
messageID = message.ID
return nil
})
if err != nil {
return nil, err
}
var message model.ChatMessage
if err := r.db.First(&message, messageID).Error; err != nil {
return nil, err
}
items, err := r.toMessageDTOs(principal, []model.ChatMessage{message})
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, ErrConversationNotFound
}
return &items[0], nil
}
func (r *Repository) MarkRead(principal Principal, conversationID uint64) error {
return r.db.Transaction(func(tx *gorm.DB) error {
participant, err := r.findParticipant(tx, principal, conversationID, true)
if err != nil {
return err
}
now := time.Now()
return tx.Model(participant).Update("last_read_at", now).Error
})
}
func (r *Repository) conversationQuery(principal Principal) *gorm.DB {
return r.db.Table("chat_conversations AS c").
Select(`c.id, c.order_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,
(
SELECT COUNT(1)
FROM chat_messages AS cm
WHERE cm.conversation_id = c.id
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)
) AS unread_count`, principal.Type, principal.ID).
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id").
Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
}
func (r *Repository) findParticipant(tx *gorm.DB, principal Principal, conversationID uint64, lock bool) (*model.ChatParticipant, error) {
var participant model.ChatParticipant
db := tx
if lock {
db = db.Clauses(clause.Locking{Strength: "UPDATE"})
}
err := db.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID).
First(&participant).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrPermissionDenied
}
return nil, err
}
return &participant, nil
}
func (r *Repository) participants(conversationID uint64) ([]ParticipantDTO, error) {
var rows []model.ChatParticipant
if err := r.db.Where("conversation_id = ?", conversationID).Order("id ASC").Find(&rows).Error; err != nil {
return nil, err
}
userNames, userAvatars, adminNames, err := r.participantNames(rows)
if err != nil {
return nil, err
}
items := make([]ParticipantDTO, 0, len(rows))
for _, row := range rows {
name := "系统"
avatar := ""
if row.ParticipantType == "user" {
name = userNames[row.ParticipantID]
avatar = userAvatars[row.ParticipantID]
}
if row.ParticipantType == "admin" {
name = adminNames[row.ParticipantID]
}
items = append(items, ParticipantDTO{
ID: row.ID,
ConversationID: row.ConversationID,
ParticipantType: row.ParticipantType,
ParticipantID: row.ParticipantID,
Role: row.Role,
DisplayName: fallbackName(row.ParticipantType, row.ParticipantID, name),
AvatarURL: avatar,
LastReadAt: row.LastReadAt,
JoinedAt: row.JoinedAt,
})
}
return items, nil
}
func (r *Repository) toMessageDTOs(principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) {
userIDs := make([]uint64, 0)
adminIDs := make([]uint64, 0)
for _, row := range rows {
if row.SenderType == "user" && row.SenderID > 0 {
userIDs = append(userIDs, row.SenderID)
}
if row.SenderType == "admin" && row.SenderID > 0 {
adminIDs = append(adminIDs, row.SenderID)
}
}
userNames, userAvatars, err := r.userNames(userIDs)
if err != nil {
return nil, err
}
adminNames, err := r.adminNames(adminIDs)
if err != nil {
return nil, err
}
items := make([]MessageDTO, 0, len(rows))
for _, row := range rows {
name := "系统"
avatar := ""
if row.SenderType == "user" {
name = userNames[row.SenderID]
avatar = userAvatars[row.SenderID]
}
if row.SenderType == "admin" {
name = adminNames[row.SenderID]
}
items = append(items, MessageDTO{
ID: row.ID,
ConversationID: row.ConversationID,
SenderType: row.SenderType,
SenderID: row.SenderID,
SenderRole: row.SenderRole,
SenderName: fallbackName(row.SenderType, row.SenderID, name),
SenderAvatar: avatar,
IsSelf: row.SenderType == principal.Type && row.SenderID == principal.ID,
ContentType: row.ContentType,
Content: row.Content,
AttachmentURLS: decodeStringList(row.AttachmentURLS),
CreatedAt: row.CreatedAt,
})
}
return items, nil
}
func (r *Repository) participantNames(rows []model.ChatParticipant) (map[uint64]string, map[uint64]string, map[uint64]string, error) {
userIDs := make([]uint64, 0)
adminIDs := make([]uint64, 0)
for _, row := range rows {
if row.ParticipantType == "user" {
userIDs = append(userIDs, row.ParticipantID)
}
if row.ParticipantType == "admin" {
adminIDs = append(adminIDs, row.ParticipantID)
}
}
userNames, userAvatars, err := r.userNames(userIDs)
if err != nil {
return nil, nil, nil, err
}
adminNames, err := r.adminNames(adminIDs)
if err != nil {
return nil, nil, nil, err
}
return userNames, userAvatars, adminNames, nil
}
func (r *Repository) userNames(ids []uint64) (map[uint64]string, map[uint64]string, error) {
names := map[uint64]string{}
avatars := map[uint64]string{}
if len(ids) == 0 {
return names, avatars, nil
}
var users []model.User
if err := r.db.Where("id IN ?", uniqueIDs(ids)).Find(&users).Error; err != nil {
return nil, nil, err
}
for _, user := range users {
name := user.Nickname
if name == "" {
name = user.Phone
}
names[user.ID] = name
avatars[user.ID] = user.AvatarURL
}
return names, avatars, nil
}
func (r *Repository) adminNames(ids []uint64) (map[uint64]string, error) {
names := map[uint64]string{}
if len(ids) == 0 {
return names, nil
}
var admins []model.AdminUser
if err := r.db.Where("id IN ?", uniqueIDs(ids)).Find(&admins).Error; err != nil {
return nil, err
}
for _, admin := range admins {
name := admin.Nickname
if name == "" {
name = admin.Username
}
names[admin.ID] = name
}
return names, nil
}
type conversationRow struct {
ID uint64
OrderID uint64
Type string
Title string
Status string
Role string
LastMessageID *uint64
LastMessagePreview string
LastMessageAt *time.Time
UnreadCount int64
CreatedAt time.Time
UpdatedAt time.Time
}
func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO {
return ConversationDTO{
ID: row.ID,
OrderID: row.OrderID,
Type: row.Type,
Title: row.Title,
Status: row.Status,
Role: row.Role,
Participants: participants,
LastMessageID: row.LastMessageID,
LastMessagePreview: row.LastMessagePreview,
LastMessageAt: row.LastMessageAt,
UnreadCount: row.UnreadCount,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
func defaultSupportAdminID(tx *gorm.DB) uint64 {
var cfg model.SystemConfig
if err := tx.Where("`key` = ?", "chat.default_support_admin_id").First(&cfg).Error; err == nil {
id, parseErr := strconv.ParseUint(cfg.Value, 10, 64)
if parseErr == nil && id > 0 && adminActive(tx, id) {
return id
}
}
var admin model.AdminUser
if err := tx.Where("status = ?", "active").Order("id ASC").First(&admin).Error; err != nil {
return ensureDefaultSupportAdmin(tx)
}
return admin.ID
}
func ensureDefaultSupportAdmin(tx *gorm.DB) uint64 {
var count int64
if err := tx.Model(&model.AdminUser{}).Count(&count).Error; err != nil || count > 0 {
return 0
}
hash, err := bcrypt.GenerateFromPassword([]byte(defaultSupportPassword), bcrypt.DefaultCost)
if err != nil {
return 0
}
admin := model.AdminUser{
Username: defaultSupportUsername,
PasswordHash: string(hash),
Nickname: defaultSupportNickname,
Status: "active",
}
if err := tx.Create(&admin).Error; err != nil {
return 0
}
return admin.ID
}
func adminActive(tx *gorm.DB, id uint64) bool {
var count int64
if err := tx.Model(&model.AdminUser{}).Where("id = ? AND status = ?", id, "active").Count(&count).Error; err != nil {
return false
}
return count > 0
}
func orderConversationTitle(order model.RentalOrder) string {
if order.OrderNo == "" {
return fmt.Sprintf("订单群聊 #%d", order.ID)
}
return "订单群聊 " + order.OrderNo
}
func normalizePagination(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}
func truncatePreview(content string) string {
runes := []rune(content)
if len(runes) <= 80 {
return content
}
return string(runes[:80])
}
func fallbackName(participantType string, id uint64, name string) string {
if name != "" {
return name
}
switch participantType {
case "admin":
return "客服"
case "system":
return "系统"
default:
return fmt.Sprintf("用户%d", id)
}
}
func uniqueIDs(ids []uint64) []uint64 {
seen := map[uint64]bool{}
result := make([]uint64, 0, len(ids))
for _, id := range ids {
if id == 0 || seen[id] {
continue
}
seen[id] = true
result = append(result, id)
}
return result
}
func emptyJSONList() datatypes.JSON {
raw, _ := json.Marshal([]string{})
return datatypes.JSON(raw)
}
func decodeStringList(raw datatypes.JSON) []string {
if len(raw) == 0 {
return []string{}
}
var items []string
if err := json.Unmarshal(raw, &items); err != nil {
return []string{}
}
return items
}
+70
View File
@@ -0,0 +1,70 @@
package chat
import (
"errors"
"strings"
)
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrConversationNotFound = errors.New("conversation not found")
ErrPermissionDenied = errors.New("permission denied")
ErrInvalidMessage = errors.New("invalid message")
)
type Service struct {
repo *Repository
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) ListConversations(principal Principal, page, pageSize int) (*PaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListConversations(principal, page, pageSize)
}
func (s *Service) FindConversation(principal Principal, id uint64) (*ConversationDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.FindConversation(principal, id)
}
func (s *Service) FindOrderConversation(userID uint64, orderID uint64) (*ConversationDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.FindOrderConversation(userID, orderID)
}
func (s *Service) Messages(principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.Messages(principal, conversationID, page, pageSize)
}
func (s *Service) SendMessage(principal Principal, conversationID uint64, req SendMessageRequest) (*MessageDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
req.Content = strings.TrimSpace(req.Content)
if conversationID == 0 || req.Content == "" {
return nil, ErrInvalidMessage
}
if len([]rune(req.Content)) > 1000 {
return nil, ErrInvalidMessage
}
return s.repo.SendMessage(principal, conversationID, req)
}
func (s *Service) MarkRead(principal Principal, conversationID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.MarkRead(principal, conversationID)
}
@@ -10,6 +10,7 @@ import (
"time" "time"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chat"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/modules/wallet" "hfb_sys/backend/internal/modules/wallet"
@@ -237,6 +238,9 @@ func (r *Repository) Pay(userID uint64, orderID uint64) error {
order.HandoffStatus = "pending_owner" order.HandoffStatus = "pending_owner"
listing.Status = "rented" listing.Status = "rented"
account.Status = "rented" account.Status = "rented"
if _, err := chat.EnsureOrderConversation(tx, order); err != nil {
return err
}
orderID := order.ID orderID := order.ID
if err := notification.Append(tx, if err := notification.Append(tx,
notification.Entry{ notification.Entry{
+22
View File
@@ -9,6 +9,7 @@ import (
"hfb_sys/backend/internal/modules/admindashboard" "hfb_sys/backend/internal/modules/admindashboard"
"hfb_sys/backend/internal/modules/adminuser" "hfb_sys/backend/internal/modules/adminuser"
"hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/internal/modules/auth"
"hfb_sys/backend/internal/modules/chat"
"hfb_sys/backend/internal/modules/dispute" "hfb_sys/backend/internal/modules/dispute"
filemodule "hfb_sys/backend/internal/modules/file" filemodule "hfb_sys/backend/internal/modules/file"
"hfb_sys/backend/internal/modules/listing" "hfb_sys/backend/internal/modules/listing"
@@ -95,6 +96,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
} }
notificationService := notification.NewService(notificationRepo) notificationService := notification.NewService(notificationRepo)
notificationHandler := notification.NewHandler(notificationService) notificationHandler := notification.NewHandler(notificationService)
var chatRepo *chat.Repository
if deps.DB != nil {
chatRepo = chat.NewRepository(deps.DB)
}
chatService := chat.NewService(chatRepo)
chatHandler := chat.NewHandler(chatService)
var disputeRepo *dispute.Repository var disputeRepo *dispute.Repository
if deps.DB != nil { if deps.DB != nil {
disputeRepo = dispute.NewRepository(deps.DB) disputeRepo = dispute.NewRepository(deps.DB)
@@ -166,6 +173,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
orderRoutes.POST("", orderHandler.Create) orderRoutes.POST("", orderHandler.Create)
orderRoutes.GET("", orderHandler.List) orderRoutes.GET("", orderHandler.List)
orderRoutes.GET("/:id", orderHandler.Detail) orderRoutes.GET("/:id", orderHandler.Detail)
orderRoutes.GET("/:id/chat", chatHandler.OrderConversation)
orderRoutes.POST("/:id/pay", orderHandler.Pay) orderRoutes.POST("/:id/pay", orderHandler.Pay)
orderRoutes.POST("/:id/cancel", orderHandler.Cancel) orderRoutes.POST("/:id/cancel", orderHandler.Cancel)
orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff) orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff)
@@ -205,6 +213,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
notificationRoutes.POST("/:id/read", notificationHandler.MarkRead) notificationRoutes.POST("/:id/read", notificationHandler.MarkRead)
} }
chatRoutes := api.Group("/chats", requireAuth)
{
chatRoutes.GET("", chatHandler.List)
chatRoutes.GET("/:id", chatHandler.Detail)
chatRoutes.GET("/:id/messages", chatHandler.Messages)
chatRoutes.POST("/:id/messages", chatHandler.Send)
chatRoutes.POST("/:id/read", chatHandler.MarkRead)
}
realnameRoutes := api.Group("/realname", requireAuth) realnameRoutes := api.Group("/realname", requireAuth)
{ {
realnameRoutes.POST("/start", realnameHandler.Start) realnameRoutes.POST("/start", realnameHandler.Start)
@@ -246,6 +263,11 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.GET("/system-configs", systemConfigHandler.List) adminRoutes.GET("/system-configs", systemConfigHandler.List)
adminRoutes.PUT("/system-configs/:key", systemConfigHandler.Update) adminRoutes.PUT("/system-configs/:key", systemConfigHandler.Update)
adminRoutes.GET("/audit-logs", adminAuditHandler.List) adminRoutes.GET("/audit-logs", adminAuditHandler.List)
adminRoutes.GET("/chats", chatHandler.AdminList)
adminRoutes.GET("/chats/:id", chatHandler.AdminDetail)
adminRoutes.GET("/chats/:id/messages", chatHandler.AdminMessages)
adminRoutes.POST("/chats/:id/messages", chatHandler.AdminSend)
adminRoutes.POST("/chats/:id/read", chatHandler.AdminMarkRead)
} }
} }
+47
View File
@@ -0,0 +1,47 @@
CREATE TABLE IF NOT EXISTS chat_conversations (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
type VARCHAR(32) NOT NULL DEFAULT 'order_group',
title VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
last_message_id BIGINT UNSIGNED NULL,
last_message_preview VARCHAR(255) NOT NULL DEFAULT '',
last_message_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_chat_conversations_order_id (order_id),
KEY idx_chat_conversations_last_message_at (last_message_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS chat_participants (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
conversation_id BIGINT UNSIGNED NOT NULL,
participant_type VARCHAR(16) NOT NULL,
participant_id BIGINT UNSIGNED NOT NULL,
role VARCHAR(32) NOT NULL,
last_read_at DATETIME NULL,
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_chat_participants_member (conversation_id, participant_type, participant_id),
KEY idx_chat_participants_participant (participant_type, participant_id),
KEY idx_chat_participants_conversation (conversation_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS chat_messages (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
conversation_id BIGINT UNSIGNED NOT NULL,
sender_type VARCHAR(16) NOT NULL,
sender_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
sender_role VARCHAR(32) NOT NULL DEFAULT '',
content_type VARCHAR(32) NOT NULL DEFAULT 'text',
content TEXT NULL,
attachment_urls JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_chat_messages_conversation_id (conversation_id, id),
KEY idx_chat_messages_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO system_configs (`key`, `value`, description)
VALUES ('chat.default_support_admin_id', '1', '订单群聊默认接入的客服管理员 ID')
ON DUPLICATE KEY UPDATE description = VALUES(description);
+108
View File
@@ -0,0 +1,108 @@
import { apiClient } from './client'
import type { ApiResponse, PaginatedResult } from './types'
export interface ChatParticipant {
id: number
conversation_id: number
participant_type: 'user' | 'admin'
participant_id: number
role: 'renter' | 'owner' | 'support'
display_name: string
avatar_url: string
last_read_at?: string
joined_at: string
}
export interface ChatConversation {
id: number
order_id: number
type: string
title: string
status: string
role: 'renter' | 'owner' | 'support'
participants?: ChatParticipant[]
last_message_id?: number
last_message_preview: string
last_message_at?: string
unread_count: number
created_at: string
updated_at: string
}
export interface ChatMessage {
id: number
conversation_id: number
sender_type: 'user' | 'admin' | 'system'
sender_id: number
sender_role: 'renter' | 'owner' | 'support' | 'system'
sender_name: string
sender_avatar: string
is_self: boolean
content_type: 'text' | 'system'
content: string
attachment_urls: string[]
created_at: string
}
export async function fetchChats(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatConversation>>>('/chats', {
params: { page, page_size: pageSize },
})
return data.data
}
export async function fetchChat(id: number) {
const { data } = await apiClient.get<ApiResponse<ChatConversation>>(`/chats/${id}`)
return data.data
}
export async function fetchOrderChat(orderId: number) {
const { data } = await apiClient.get<ApiResponse<ChatConversation>>(`/orders/${orderId}/chat`)
return data.data
}
export async function fetchChatMessages(id: number, page = 1, pageSize = 100) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatMessage>>>(`/chats/${id}/messages`, {
params: { page, page_size: pageSize },
})
return data.data
}
export async function sendChatMessage(id: number, content: string) {
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/chats/${id}/messages`, { content })
return data.data
}
export async function markChatRead(id: number) {
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/chats/${id}/read`)
return data.data
}
export async function fetchAdminChats(page = 1, pageSize = 50) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatConversation>>>('/admin/chats', {
params: { page, page_size: pageSize },
})
return data.data
}
export async function fetchAdminChat(id: number) {
const { data } = await apiClient.get<ApiResponse<ChatConversation>>(`/admin/chats/${id}`)
return data.data
}
export async function fetchAdminChatMessages(id: number, page = 1, pageSize = 100) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatMessage>>>(`/admin/chats/${id}/messages`, {
params: { page, page_size: pageSize },
})
return data.data
}
export async function sendAdminChatMessage(id: number, content: string) {
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/admin/chats/${id}/messages`, { content })
return data.data
}
export async function markAdminChatRead(id: number) {
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/admin/chats/${id}/read`)
return data.data
}
+2 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { DataLine, Document, DocumentChecked, Operation, ScaleToOriginal, Shop, SwitchButton, Tickets, User, Wallet } from '@element-plus/icons-vue' import { ChatDotRound, DataLine, Document, DocumentChecked, Operation, ScaleToOriginal, Shop, SwitchButton, Tickets, User, Wallet } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -17,6 +17,7 @@ const navItems = [
{ label: '商品管理', to: '/admin/listings', icon: Shop }, { label: '商品管理', to: '/admin/listings', icon: Shop },
{ label: '商品审核', to: '/admin/listings/review', icon: DocumentChecked }, { label: '商品审核', to: '/admin/listings/review', icon: DocumentChecked },
{ label: '仲裁中心', to: '/admin/disputes', icon: ScaleToOriginal }, { label: '仲裁中心', to: '/admin/disputes', icon: ScaleToOriginal },
{ label: '客服群聊', to: '/admin/chats', icon: ChatDotRound },
{ label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet }, { label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet },
{ label: '系统配置', to: '/admin/system-configs', icon: Operation }, { label: '系统配置', to: '/admin/system-configs', icon: Operation },
{ label: '审计日志', to: '/admin/audit-logs', icon: Document }, { label: '审计日志', to: '/admin/audit-logs', icon: Document },
+6
View File
@@ -58,6 +58,12 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/views/admin/AdminDisputesView.vue'), component: () => import('@/views/admin/AdminDisputesView.vue'),
meta: adminMeta, meta: adminMeta,
}, },
{
path: '/admin/chats',
name: 'admin-chats',
component: () => import('@/views/admin/AdminChatsView.vue'),
meta: adminMeta,
},
{ {
path: '/admin/wallet-ledger', path: '/admin/wallet-ledger',
name: 'admin-wallet-ledger', name: 'admin-wallet-ledger',
+6
View File
@@ -37,6 +37,12 @@ export const mobileRoutes: RouteRecordRaw[] = [
component: () => import('@/views/mobile/MobileMessagesView.vue'), component: () => import('@/views/mobile/MobileMessagesView.vue'),
meta: { layout: 'blank', requiresAuth: true }, meta: { layout: 'blank', requiresAuth: true },
}, },
{
path: '/m/chats/:id',
name: 'mobile-chat',
component: () => import('@/views/mobile/MobileChatView.vue'),
meta: { layout: 'blank', requiresAuth: true },
},
{ {
path: '/m/realname', path: '/m/realname',
name: 'mobile-realname', name: 'mobile-realname',
+377
View File
@@ -0,0 +1,377 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
fetchAdminChat,
fetchAdminChatMessages,
fetchAdminChats,
markAdminChatRead,
sendAdminChatMessage,
type ChatConversation,
type ChatMessage,
} from '@/api/chats'
import { formatDateMinute } from '@/utils/time'
const conversations = ref<ChatConversation[]>([])
const active = ref<ChatConversation | null>(null)
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const messageLoading = ref(false)
const sending = ref(false)
const content = ref('')
const listRef = ref<HTMLElement | null>(null)
let timer: number | undefined
const activeMembers = computed(() => {
const participants = active.value?.participants || []
return participants.map(item => `${roleLabel(item.role)}${item.display_name}`).join(' / ')
})
onMounted(async () => {
await loadConversations()
timer = window.setInterval(async () => {
await loadConversations(false)
if (active.value) await loadMessages(active.value.id, false)
}, 5000)
})
onBeforeUnmount(() => {
if (timer) window.clearInterval(timer)
})
async function loadConversations(showLoading = true) {
if (showLoading) loading.value = true
try {
const res = await fetchAdminChats(1, 100)
conversations.value = res.items
const first = conversations.value[0]
if (!active.value && first) {
await openConversation(first)
}
} catch {
ElMessage.error('会话加载失败')
} finally {
loading.value = false
}
}
async function openConversation(item: ChatConversation) {
messageLoading.value = true
try {
active.value = await fetchAdminChat(item.id)
await loadMessages(item.id)
await markAdminChatRead(item.id)
await loadConversations(false)
} catch {
ElMessage.error('会话详情加载失败')
} finally {
messageLoading.value = false
}
}
async function loadMessages(id: number, scroll = true) {
const res = await fetchAdminChatMessages(id, 1, 100)
messages.value = res.items
if (scroll) {
await nextTick()
scrollBottom()
}
}
async function handleSend() {
const text = content.value.trim()
if (!active.value || !text || sending.value) return
sending.value = true
try {
const message = await sendAdminChatMessage(active.value.id, text)
messages.value = [...messages.value, message]
content.value = ''
await nextTick()
scrollBottom()
await loadConversations(false)
} catch {
ElMessage.error('发送失败')
} finally {
sending.value = false
}
}
function scrollBottom() {
const el = listRef.value
if (!el) return
el.scrollTop = el.scrollHeight
}
function roleLabel(role: string) {
const map: Record<string, string> = {
renter: '租客',
owner: '号主',
support: '客服',
system: '系统',
}
return map[role] || '成员'
}
function senderLabel(item: ChatMessage) {
if (item.sender_type === 'system') return '系统'
return `${roleLabel(item.sender_role)} · ${item.sender_name}`
}
</script>
<template>
<section class="admin-page">
<div class="page-head">
<div>
<h1>客服群聊</h1>
<p>处理订单三方沟通</p>
</div>
<el-button :loading="loading" @click="loadConversations()">刷新</el-button>
</div>
<div class="chat-workbench">
<aside class="conversation-pane" v-loading="loading">
<button
v-for="item in conversations"
:key="item.id"
type="button"
class="conversation-row"
:class="{ active: active?.id === item.id }"
@click="openConversation(item)"
>
<div class="row-title">
<strong>{{ item.title }}</strong>
<span>{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
</div>
<p>{{ item.last_message_preview || '订单群聊已创建' }}</p>
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
</button>
<el-empty v-if="!loading && conversations.length === 0" description="暂无客服会话" />
</aside>
<main class="message-pane">
<template v-if="active">
<header class="message-head">
<div>
<h2>{{ active.title }}</h2>
<p>{{ activeMembers }}</p>
</div>
<RouterLink :to="`/admin/orders/${active.order_id}`">查看订单</RouterLink>
</header>
<div ref="listRef" class="message-list" v-loading="messageLoading">
<div
v-for="item in messages"
:key="item.id"
class="message-row"
:class="{ self: item.is_self, system: item.sender_type === 'system' }"
>
<template v-if="item.sender_type === 'system'">
<span>{{ item.content }}</span>
</template>
<template v-else>
<small>{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}</small>
<p>{{ item.content }}</p>
</template>
</div>
</div>
<footer class="composer">
<el-input
v-model="content"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="输入客服回复"
@keydown.enter.exact.prevent="handleSend"
/>
<el-button type="primary" :loading="sending" :disabled="!content.trim()" @click="handleSend">发送</el-button>
</footer>
</template>
<el-empty v-else description="请选择会话" />
</main>
</div>
</section>
</template>
<style scoped>
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-head h1 {
margin: 0;
font-size: 22px;
}
.page-head p {
margin: 6px 0 0;
color: #6b7280;
}
.chat-workbench {
display: grid;
min-height: 640px;
grid-template-columns: 330px minmax(0, 1fr);
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.conversation-pane {
overflow-y: auto;
border-right: 1px solid #e5e7eb;
background: #f8fafc;
}
.conversation-row {
position: relative;
display: block;
width: 100%;
padding: 14px;
border: 0;
border-bottom: 1px solid #e5e7eb;
background: transparent;
text-align: left;
}
.conversation-row.active {
background: #eef6ff;
}
.row-title {
display: flex;
gap: 10px;
align-items: center;
}
.row-title strong {
min-width: 0;
flex: 1;
overflow: hidden;
color: #111827;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-title span {
flex: none;
color: #9ca3af;
font-size: 12px;
}
.conversation-row p {
margin: 8px 24px 0 0;
overflow: hidden;
color: #6b7280;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-row em {
position: absolute;
right: 12px;
bottom: 12px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-style: normal;
font-weight: 700;
line-height: 18px;
text-align: center;
}
.message-pane {
display: grid;
min-width: 0;
grid-template-rows: auto minmax(0, 1fr) auto;
}
.message-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #e5e7eb;
}
.message-head h2 {
margin: 0;
font-size: 18px;
}
.message-head p {
margin: 6px 0 0;
color: #6b7280;
font-size: 13px;
}
.message-list {
overflow-y: auto;
padding: 18px;
background: #f3f6fa;
}
.message-row {
max-width: 70%;
margin-bottom: 14px;
}
.message-row.self {
margin-left: auto;
text-align: right;
}
.message-row.system {
max-width: none;
text-align: center;
}
.message-row small {
display: block;
margin-bottom: 5px;
color: #8a94a6;
}
.message-row p {
display: inline-block;
margin: 0;
padding: 10px 12px;
border-radius: 8px;
background: #fff;
color: #111827;
line-height: 1.5;
text-align: left;
}
.message-row.self p {
background: #dff5eb;
}
.message-row.system span {
display: inline-block;
padding: 5px 10px;
border-radius: 8px;
background: #e5e7eb;
color: #6b7280;
font-size: 12px;
}
.composer {
display: grid;
grid-template-columns: minmax(0, 1fr) 88px;
gap: 12px;
align-items: end;
padding: 14px;
border-top: 1px solid #e5e7eb;
}
</style>
@@ -0,0 +1,338 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import {
fetchChat,
fetchChatMessages,
markChatRead,
sendChatMessage,
type ChatConversation,
type ChatMessage,
} from '@/api/chats'
import { formatDateMinute } from '@/utils/time'
const route = useRoute()
const router = useRouter()
const conversation = ref<ChatConversation | null>(null)
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const sending = ref(false)
const content = ref('')
const listRef = ref<HTMLElement | null>(null)
let timer: number | undefined
const conversationID = computed(() => Number(route.params.id || 0))
const memberText = computed(() => {
const participants = conversation.value?.participants || []
if (participants.length === 0) return '订单群聊'
return participants.map(item => roleLabel(item.role)).join(' · ')
})
onMounted(async () => {
await loadAll()
timer = window.setInterval(() => {
loadMessages(false)
}, 5000)
})
onBeforeUnmount(() => {
if (timer) window.clearInterval(timer)
})
async function loadAll() {
if (!conversationID.value) return
loading.value = true
try {
const [chat] = await Promise.all([
fetchChat(conversationID.value),
loadMessages(false),
])
conversation.value = chat
await markChatRead(conversationID.value)
} catch {
showToast({ message: '加载会话失败', icon: 'cross' })
} finally {
loading.value = false
}
}
async function loadMessages(scrollToBottom = true) {
if (!conversationID.value) return
const res = await fetchChatMessages(conversationID.value, 1, 100)
messages.value = res.items
if (scrollToBottom) {
await nextTick()
scrollBottom()
}
}
async function handleSend() {
const text = content.value.trim()
if (!text || sending.value) return
sending.value = true
try {
const message = await sendChatMessage(conversationID.value, text)
content.value = ''
messages.value = [...messages.value, message]
await markChatRead(conversationID.value)
await nextTick()
scrollBottom()
} catch {
showToast({ message: '发送失败', icon: 'cross' })
} finally {
sending.value = false
}
}
function scrollBottom() {
const el = listRef.value
if (!el) return
el.scrollTop = el.scrollHeight
}
function roleLabel(role: string) {
const map: Record<string, string> = {
renter: '租客',
owner: '号主',
support: '客服',
system: '系统',
}
return map[role] || '成员'
}
function senderLabel(message: ChatMessage) {
if (message.sender_type === 'system') return '系统'
return `${roleLabel(message.sender_role)} · ${message.sender_name}`
}
</script>
<template>
<main class="mobile-chat">
<header class="chat-header">
<button class="icon-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<div class="chat-title">
<h1>{{ conversation?.title || '订单群聊' }}</h1>
<p>{{ memberText }}</p>
</div>
<button class="icon-btn" type="button" @click="conversation && router.push(`/m/orders/${conversation.order_id}`)">
<van-icon name="orders-o" :size="20" />
</button>
</header>
<section ref="listRef" class="message-list" :class="{ loading }">
<van-loading v-if="loading && messages.length === 0" class="loading-state" />
<div
v-for="item in messages"
:key="item.id"
class="message-row"
:class="{ self: item.is_self, system: item.sender_type === 'system' }"
>
<template v-if="item.sender_type === 'system'">
<span class="system-message">{{ item.content }}</span>
</template>
<template v-else>
<div class="avatar">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
<div class="bubble-wrap">
<span class="sender-name">{{ senderLabel(item) }}</span>
<div class="bubble">{{ item.content }}</div>
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
</div>
</template>
</div>
</section>
<footer class="composer">
<van-field
v-model="content"
class="composer-input"
type="textarea"
autosize
:maxlength="1000"
rows="1"
placeholder="发送消息"
@keydown.enter.prevent="handleSend"
/>
<button class="send-btn" type="button" :disabled="!content.trim() || sending" @click="handleSend">
<van-icon name="guide-o" :size="20" />
</button>
</footer>
</main>
</template>
<style scoped>
.mobile-chat {
display: grid;
grid-template-rows: 56px minmax(0, 1fr) auto;
height: 100dvh;
background: #f3f6fa;
}
.chat-header {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 44px;
align-items: center;
border-bottom: 1px solid #e7ecf2;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(10px);
}
.icon-btn {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border: 0;
background: transparent;
color: #374151;
}
.chat-title {
min-width: 0;
text-align: center;
}
.chat-title h1 {
margin: 0;
overflow: hidden;
color: #111827;
font-size: 15px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-title p {
margin: 3px 0 0;
overflow: hidden;
color: #6b7280;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-list {
min-height: 0;
overflow-y: auto;
padding: 14px 12px 18px;
}
.loading-state {
display: block;
margin: 70px auto;
}
.message-row {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.message-row.self {
flex-direction: row-reverse;
}
.message-row.system {
justify-content: center;
}
.avatar {
display: grid;
flex: none;
width: 34px;
height: 34px;
place-items: center;
border-radius: 50%;
background: #1477ff;
color: #fff;
font-size: 13px;
font-weight: 800;
}
.message-row.self .avatar {
background: #10b981;
}
.bubble-wrap {
display: flex;
max-width: min(76vw, 330px);
flex-direction: column;
align-items: flex-start;
}
.message-row.self .bubble-wrap {
align-items: flex-end;
}
.sender-name {
margin-bottom: 4px;
color: #8a94a6;
font-size: 11px;
}
.bubble {
max-width: 100%;
padding: 9px 11px;
border-radius: 8px;
background: #fff;
color: #111827;
font-size: 14px;
line-height: 1.45;
word-break: break-word;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.05);
}
.message-row.self .bubble {
background: #dff5eb;
}
.message-time {
margin-top: 4px;
color: #a1a8b4;
font-size: 10px;
}
.system-message {
max-width: 82%;
padding: 5px 9px;
border-radius: 8px;
background: #e6ebf2;
color: #6b7280;
font-size: 11px;
line-height: 1.4;
text-align: center;
}
.composer {
display: grid;
grid-template-columns: minmax(0, 1fr) 42px;
gap: 8px;
align-items: end;
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid #e7ecf2;
background: #fff;
}
.composer-input {
border: 1px solid #d9e0e8;
border-radius: 8px;
overflow: hidden;
}
.send-btn {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 0;
border-radius: 8px;
background: #1477ff;
color: #fff;
}
.send-btn:disabled {
background: #c8d1dd;
}
</style>
+185 -219
View File
@@ -1,47 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast } from 'vant' import { showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue' import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { fetchNotifications, markNotificationRead, type NotificationItem } from '@/api/notifications' import { fetchChats, type ChatConversation } from '@/api/chats'
import { formatDateMinute } from '@/utils/time' import { formatDateMinute } from '@/utils/time'
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
const refreshing = ref(false) const refreshing = ref(false)
const finished = ref(false) const finished = ref(false)
const notifications = ref<NotificationItem[]>([]) const conversations = ref<ChatConversation[]>([])
const page = ref(1) const page = ref(1)
const pageSize = 15 const pageSize = 20
const total = ref(0) const total = ref(0)
onMounted(() => { onMounted(() => {
onRefresh() onRefresh()
}) })
async function loadNotifications(isRefresh = false) { async function loadChats(isRefresh = false) {
if (isRefresh) { if (isRefresh) {
page.value = 1 page.value = 1
finished.value = false finished.value = false
} }
loading.value = true loading.value = true
try { try {
const res = await fetchNotifications(page.value, pageSize) const res = await fetchChats(page.value, pageSize)
if (isRefresh) { conversations.value = isRefresh ? res.items : [...conversations.value, ...res.items]
notifications.value = res.items
} else {
notifications.value = [...notifications.value, ...res.items]
}
total.value = res.total total.value = res.total
if (conversations.value.length >= res.total || res.items.length === 0) {
if (notifications.value.length >= res.total || res.items.length === 0) {
finished.value = true finished.value = true
} else { } else {
page.value += 1 page.value += 1
} }
} catch { } catch {
showToast({ message: '获取消息失败', icon: 'cross' }) showToast({ message: '获取会话失败', icon: 'cross' })
finished.value = true finished.value = true
} finally { } finally {
loading.value = false loading.value = false
@@ -51,136 +45,89 @@ async function loadNotifications(isRefresh = false) {
function onRefresh() { function onRefresh() {
refreshing.value = true refreshing.value = true
loadNotifications(true) loadChats(true)
} }
function onLoad() { function onLoad() {
if (loading.value || finished.value) return if (loading.value || finished.value) return
loadNotifications(false) loadChats(false)
} }
async function handleMarkRead(id: number) { function openConversation(item: ChatConversation) {
try { router.push(`/m/chats/${item.id}`)
await markNotificationRead(id) }
const index = notifications.value.findIndex(item => item.id === id)
if (index !== -1) { function roleLabel(role: string) {
// 局部更新状态,避免整站刷新 const map: Record<string, string> = {
const item = notifications.value[index] renter: '租客',
if (item) { owner: '号主',
item.read_at = new Date().toISOString() support: '客服',
}
}
} catch {
showToast({ message: '标记已读失败', icon: 'cross' })
} }
return map[role] || '成员'
} }
// 全部已读逻辑 function previewText(item: ChatConversation) {
const hasUnread = computed(() => notifications.value.some(item => !item.read_at)) return item.last_message_preview || '订单群聊已创建'
async function markAllRead() {
const unreadItems = notifications.value.filter(item => !item.read_at)
if (unreadItems.length === 0) return
showToast({
type: 'loading',
message: '处理中...',
forbidClick: true,
duration: 0
})
try {
await Promise.all(unreadItems.map(item => markNotificationRead(item.id)))
showToast({ message: '已全部标记为已读', icon: 'passed' })
onRefresh()
} catch {
showToast({ message: '操作失败,请重试', icon: 'cross' })
}
} }
function getNotificationBadge(type: string) { const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum + item.unread_count, 0))
const map: Record<string, { text: string; class: string }> = {
listing_review: { text: '上架审核', class: 'badge-review' },
listing_admin: { text: '后台管控', class: 'badge-admin' },
order_state: { text: '订单状态', class: 'badge-order' },
dispute_state: { text: '纠纷仲裁', class: 'badge-dispute' }
}
return map[type] || { text: '通知', class: 'badge-system' }
}
</script> </script>
<template> <template>
<main class="mobile-messages"> <main class="mobile-messages">
<!-- 顶部导航栏 -->
<header class="page-header"> <header class="page-header">
<button class="back-btn" @click="router.back()"> <button class="back-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" /> <van-icon name="arrow-left" :size="20" />
</button> </button>
<h1>消息中心</h1> <h1>消息</h1>
<button v-if="hasUnread" class="header-action-btn" @click="markAllRead"> <span class="header-count">{{ unreadTotal > 0 ? `${unreadTotal} 未读` : '' }}</span>
全部已读
</button>
<span v-else class="header-spacer"></span>
</header> </header>
<!-- 下拉刷新 + 上拉加载列表 --> <van-pull-refresh v-model="refreshing" class="scroll-container" @refresh="onRefresh">
<van-pull-refresh v-model="refreshing" @refresh="onRefresh" class="scroll-container">
<van-list <van-list
v-model:loading="loading" v-model:loading="loading"
:finished="finished" :finished="finished"
finished-text="没有更多消息" finished-text="没有更多会话"
@load="onLoad"
:immediate-check="false" :immediate-check="false"
@load="onLoad"
> >
<!-- 空状态 -->
<van-empty <van-empty
v-if="!loading && notifications.length === 0" v-if="!loading && conversations.length === 0"
description="暂无消息记录" description="暂无订单群聊"
class="empty-state" class="empty-state"
/> />
<div v-else class="messages-list"> <div v-else class="conversation-list">
<div <button
v-for="item in notifications" v-for="item in conversations"
:key="item.id" :key="item.id"
class="message-card" class="conversation-item"
:class="{ unread: !item.read_at }" type="button"
@click="!item.read_at && handleMarkRead(item.id)" @click="openConversation(item)"
> >
<!-- 头部类型与未读红点 --> <div class="avatar-stack">
<div class="card-header-row"> <span class="avatar main">{{ roleLabel(item.role).slice(0, 1) }}</span>
<span class="type-badge" :class="getNotificationBadge(item.type).class"> <span class="avatar support"></span>
{{ getNotificationBadge(item.type).text }}
</span>
<span class="time-label">{{ formatDateMinute(item.created_at) }}</span>
</div> </div>
<div class="conversation-main">
<!-- 消息主体 --> <div class="conversation-title-row">
<h3 class="message-title"> <h2>{{ item.title }}</h2>
<span v-if="!item.read_at" class="unread-dot"></span> <span class="conversation-time">{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
{{ item.title }} </div>
</h3> <div class="conversation-meta">
<p class="message-content">{{ item.content }}</p> <span class="role-chip">{{ roleLabel(item.role) }}</span>
<span>订单 #{{ item.order_id }}</span>
<!-- 操作按钮 --> </div>
<div class="card-actions" v-if="item.biz_type === 'order' && item.biz_id"> <p>{{ previewText(item) }}</p>
<van-button
size="mini"
type="primary"
plain
round
class="action-btn"
@click.stop="router.push(`/m/orders/${item.biz_id}`)"
>
查看订单
</van-button>
</div> </div>
</div> <span v-if="item.unread_count > 0" class="unread-badge">
{{ item.unread_count > 99 ? '99+' : item.unread_count }}
</span>
</button>
</div> </div>
</van-list> </van-list>
</van-pull-refresh> </van-pull-refresh>
<!-- 底部导航 -->
<MobileBottomNav /> <MobileBottomNav />
</main> </main>
</template> </template>
@@ -188,163 +135,182 @@ function getNotificationBadge(type: string) {
<style scoped> <style scoped>
.mobile-messages { .mobile-messages {
min-height: 100dvh; min-height: 100dvh;
background: #f6f8fa; background: #f5f7fb;
padding-bottom: calc(64px + env(safe-area-inset-bottom)); padding-bottom: calc(62px + env(safe-area-inset-bottom));
} }
/* ========== 顶部导航 ========== */
.page-header { .page-header {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
display: flex; display: grid;
grid-template-columns: 44px 1fr 72px;
align-items: center; align-items: center;
justify-content: space-between;
height: 48px; height: 48px;
padding: 0 12px; padding: 0 8px;
background: rgba(255, 255, 255, 0.94); background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid #edf0f5;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(243, 244, 246, 0.8);
} }
.page-header h1 { .page-header h1 {
flex: 1;
margin: 0; margin: 0;
font-size: 17px;
font-weight: 700;
color: #111827; color: #111827;
font-size: 17px;
font-weight: 800;
text-align: center; text-align: center;
} }
.back-btn { .back-btn {
display: grid; display: grid;
width: 36px; width: 40px;
height: 36px; height: 40px;
place-items: center; place-items: center;
border: none; border: 0;
background: none; background: transparent;
color: #374151; color: #374151;
cursor: pointer;
} }
.header-action-btn { .header-count {
border: none; color: #ef4444;
background: none; font-size: 12px;
color: #1477ff;
font-size: 13px;
font-weight: 700; font-weight: 700;
cursor: pointer; text-align: right;
padding: 0 8px;
white-space: nowrap;
} }
.header-spacer {
width: 36px;
}
/* ========== 列表内容 ========== */
.scroll-container { .scroll-container {
min-height: calc(100dvh - 48px - 64px - env(safe-area-inset-bottom)); min-height: calc(100dvh - 48px - 62px - env(safe-area-inset-bottom));
} }
.empty-state { .empty-state {
padding: 80px 0; padding-top: 90px;
} }
.messages-list { .conversation-list {
padding: 14px 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 10px;
padding: 12px;
} }
/* ========== 消息卡片 ========== */ .conversation-item {
.message-card {
background: #ffffff;
border-radius: 16px;
padding: 14px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.02), 0 1px 4px rgba(0, 0, 0, 0.02);
border: 1px solid rgba(243, 244, 246, 0.9);
transition: transform 0.15s ease, background 0.15s ease;
position: relative; position: relative;
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
gap: 10px;
width: 100%;
padding: 12px;
border: 1px solid #e8edf3;
border-radius: 8px;
background: #fff;
text-align: left;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
}
.conversation-item:active {
transform: scale(0.99);
}
.avatar-stack {
position: relative;
width: 48px;
height: 48px;
}
.avatar {
display: grid;
place-items: center;
border-radius: 50%;
color: #fff;
font-weight: 800;
}
.avatar.main {
width: 44px;
height: 44px;
background: #1477ff;
font-size: 16px;
}
.avatar.support {
position: absolute;
right: 0;
bottom: 0;
width: 22px;
height: 22px;
border: 2px solid #fff;
background: #10b981;
font-size: 11px;
}
.conversation-main {
min-width: 0;
}
.conversation-title-row {
display: flex; display: flex;
flex-direction: column;
gap: 6px;
}
.message-card:active {
transform: scale(0.98);
}
/* 未读态加点微弱阴影背景 */
.message-card.unread {
border-color: rgba(20, 119, 255, 0.15);
background: linear-gradient(135deg, #ffffff 0%, #fafcff 100%);
}
.card-header-row {
display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 8px;
} }
.type-badge { .conversation-title-row h2 {
font-size: 10px; flex: 1;
padding: 2px 6px; min-width: 0;
border-radius: 6px; margin: 0;
overflow: hidden;
color: #111827;
font-size: 15px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-time {
flex: none;
color: #9ca3af;
font-size: 11px;
}
.conversation-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 5px;
color: #6b7280;
font-size: 11px;
}
.role-chip {
padding: 1px 6px;
border-radius: 999px;
background: #eef6ff;
color: #1477ff;
font-weight: 700; font-weight: 700;
} }
/* 消息类型徽章颜色 */ .conversation-main p {
.badge-review { color: #f59e0b; background: rgba(245, 158, 11, 0.08); } margin: 7px 0 0;
.badge-admin { color: #ef4444; background: rgba(239, 68, 68, 0.08); } overflow: hidden;
.badge-order { color: #1477ff; background: rgba(20, 119, 255, 0.08); }
.badge-dispute { color: #8b5cf6; background: rgba(139, 92, 246, 0.08); }
.badge-system { color: #6b7280; background: rgba(107, 114, 128, 0.08); }
.time-label {
font-size: 10px;
color: #9ca3af;
}
.message-title {
margin: 2px 0 0;
font-size: 14px;
font-weight: 800;
color: #111827;
display: flex;
align-items: center;
gap: 6px;
}
.unread-dot {
width: 6px;
height: 6px;
background-color: #ef4444;
border-radius: 50%;
display: inline-block;
}
.message-content {
margin: 0;
font-size: 12px;
color: #4b5563; color: #4b5563;
line-height: 1.5; font-size: 13px;
word-break: break-all; line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
} }
.card-actions { .unread-badge {
display: flex; position: absolute;
justify-content: flex-end; right: 10px;
margin-top: 4px; bottom: 10px;
padding-top: 8px; min-width: 18px;
border-top: 1px solid #f3f4f6; height: 18px;
} padding: 0 5px;
border-radius: 9px;
.action-btn { background: #ef4444;
height: 24px !important; color: #fff;
padding: 0 10px !important; font-size: 10px;
font-size: 11px !important; font-weight: 800;
font-weight: 700 !important; line-height: 18px;
text-align: center;
} }
</style> </style>
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { showToast, showDialog } from "vant"; import { showToast, showDialog } from "vant";
import { fetchOrderChat } from "@/api/chats";
import { createDispute } from "@/api/disputes"; import { createDispute } from "@/api/disputes";
import { uploadFile } from "@/api/files"; import { uploadFile } from "@/api/files";
import { import {
@@ -38,6 +39,7 @@ const acceptingCheckout = ref(false);
const rejectingCheckout = ref(false); const rejectingCheckout = ref(false);
const disputing = ref(false); const disputing = ref(false);
const uploadingEvidence = ref(false); const uploadingEvidence = ref(false);
const openingChat = ref(false);
const order = ref<Order | null>(null); const order = ref<Order | null>(null);
const handoffRecords = ref<HandoffRecord[]>([]); const handoffRecords = ref<HandoffRecord[]>([]);
const handoffContent = ref(""); const handoffContent = ref("");
@@ -506,6 +508,19 @@ function linesToList(value: string) {
.filter(Boolean); .filter(Boolean);
} }
async function openOrderChat() {
if (!order.value || openingChat.value) return;
openingChat.value = true;
try {
const chat = await fetchOrderChat(order.value.id);
router.push(`/m/chats/${chat.id}`);
} catch {
showToast({ message: "订单群聊暂不可用", icon: "warning-o" });
} finally {
openingChat.value = false;
}
}
/* Status Theme Color Mapping */ /* Status Theme Color Mapping */
function getStatusTagType(status: string) { function getStatusTagType(status: string) {
if (["completed", "received"].includes(status)) return "success"; if (["completed", "received"].includes(status)) return "success";
@@ -523,7 +538,10 @@ function getStatusTagType(status: string) {
<van-icon name="arrow-left" :size="20" /> <van-icon name="arrow-left" :size="20" />
</button> </button>
<h1>订单详情</h1> <h1>订单详情</h1>
<span class="header-spacer"></span> <button v-if="order" class="chat-btn" type="button" :disabled="openingChat" @click="openOrderChat">
<van-icon name="chat-o" :size="19" />
</button>
<span v-else class="header-spacer"></span>
</header> </header>
<van-loading v-if="loading" class="center-loading" size="24px" vertical> <van-loading v-if="loading" class="center-loading" size="24px" vertical>
@@ -1070,7 +1088,8 @@ function getStatusTagType(status: string) {
text-align: center; text-align: center;
} }
.back-btn { .back-btn,
.chat-btn {
display: grid; display: grid;
width: 36px; width: 36px;
height: 36px; height: 36px;
@@ -1081,6 +1100,10 @@ function getStatusTagType(status: string) {
cursor: pointer; cursor: pointer;
} }
.chat-btn:disabled {
color: #a1a1aa;
}
.header-spacer { .header-spacer {
width: 36px; width: 36px;
} }
+16 -2
View File
@@ -71,9 +71,23 @@ init_database() {
if [[ "${table_count}" == "0" ]]; then if [[ "${table_count}" == "0" ]]; then
log "首次启动,初始化数据库结构..." log "首次启动,初始化数据库结构..."
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${ROOT_DIR}/backend/migrations/000001_init.sql" local migration
for migration in "${ROOT_DIR}"/backend/migrations/*.sql; do
log "执行迁移:$(basename "${migration}")"
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${migration}"
done
else else
log "数据库结构已存在,跳过迁移" local chat_table_count
chat_table_count="$(
docker exec hfb-mysql mysql -uhfb -psecret -N -s -e \
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'hfb_sys' AND table_name = 'chat_conversations';" 2>/dev/null
)"
if [[ "${chat_table_count}" == "0" ]]; then
log "补充聊天表迁移:000002_chat.sql"
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${ROOT_DIR}/backend/migrations/000002_chat.sql"
else
log "数据库结构已存在,跳过迁移"
fi
fi fi
} }