新增订单群聊
This commit is contained in:
@@ -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`。
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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", "聊天服务暂时不可用")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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{
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user