优化了消息数量,筛选和顶部部分
This commit is contained in:
@@ -51,6 +51,10 @@ type MessageDTO struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UnreadCountDTO struct {
|
||||||
|
UnreadCount int64 `json:"unread_count"`
|
||||||
|
}
|
||||||
|
|
||||||
type SendMessageRequest struct {
|
type SendMessageRequest struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
AttachmentURLS []string `json:"attachment_urls"`
|
AttachmentURLS []string `json:"attachment_urls"`
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
h.list(c, Principal{Type: "user", ID: userID})
|
h.list(c, Principal{Type: "user", ID: userID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UnreadCount(c *gin.Context) {
|
||||||
|
userID, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.service.CountUnreadMessages(c.Request.Context(), Principal{Type: "user", ID: userID})
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) Detail(c *gin.Context) {
|
func (h *Handler) Detail(c *gin.Context) {
|
||||||
userID, ok := currentUserID(c)
|
userID, ok := currentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ func (r *Repository) conversationQuery(ctx context.Context, principal Principal)
|
|||||||
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.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)
|
Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) CountUnreadMessages(ctx context.Context, principal Principal) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
err := r.db.WithContext(ctx).Table("chat_messages AS cm").
|
||||||
|
Joins("JOIN chat_participants AS cp ON cp.conversation_id = cm.conversation_id").
|
||||||
|
Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID).
|
||||||
|
Where("NOT (cm.sender_type = ? AND cm.sender_id = ?)", principal.Type, principal.ID).
|
||||||
|
Where("(cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)").
|
||||||
|
Count(&total).Error
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) findParticipant(tx *gorm.DB, principal Principal, conversationID uint64, lock bool) (*model.ChatParticipant, error) {
|
func (r *Repository) findParticipant(tx *gorm.DB, principal Principal, conversationID uint64, lock bool) (*model.ChatParticipant, error) {
|
||||||
var participant model.ChatParticipant
|
var participant model.ChatParticipant
|
||||||
db := tx
|
db := tx
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ func (s *Service) ListConversations(ctx context.Context, principal Principal, pa
|
|||||||
return s.repo.ListConversations(ctx, principal, page, pageSize)
|
return s.repo.ListConversations(ctx, principal, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) CountUnreadMessages(ctx context.Context, principal Principal) (*UnreadCountDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
total, err := s.repo.CountUnreadMessages(ctx, principal)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &UnreadCountDTO{UnreadCount: total}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) FindConversation(ctx context.Context, principal Principal, id uint64) (*ConversationDTO, error) {
|
func (s *Service) FindConversation(ctx context.Context, principal Principal, id uint64) (*ConversationDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -403,6 +403,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
chatRoutes.GET("/events", chatHubHandler.UserEvents)
|
chatRoutes.GET("/events", chatHubHandler.UserEvents)
|
||||||
}
|
}
|
||||||
chatRoutes.POST("/support", chatHandler.EnsureSupportConversation)
|
chatRoutes.POST("/support", chatHandler.EnsureSupportConversation)
|
||||||
|
chatRoutes.GET("/unread-count", chatHandler.UnreadCount)
|
||||||
chatRoutes.GET("", chatHandler.List)
|
chatRoutes.GET("", chatHandler.List)
|
||||||
chatRoutes.GET("/:id", chatHandler.Detail)
|
chatRoutes.GET("/:id", chatHandler.Detail)
|
||||||
chatRoutes.GET("/:id/messages", chatHandler.Messages)
|
chatRoutes.GET("/:id/messages", chatHandler.Messages)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRoute, RouterLink } from 'vue-router'
|
import { useRoute, RouterLink } from 'vue-router'
|
||||||
|
import { useChatUnreadCount } from '@/features/chats/composables/useChatUnreadCount'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { unreadCount, unreadLabel } = useChatUnreadCount(route)
|
||||||
|
|
||||||
function isNavActive(path: string) {
|
function isNavActive(path: string) {
|
||||||
if (path === '/m') return route.path === '/m'
|
if (path === '/m') return route.path === '/m'
|
||||||
@@ -16,7 +18,10 @@ function isNavActive(path: string) {
|
|||||||
<span>首页</span>
|
<span>首页</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/m/messages" class="nav-item" :class="{ active: isNavActive('/m/messages') }">
|
<RouterLink to="/m/messages" class="nav-item" :class="{ active: isNavActive('/m/messages') }">
|
||||||
<van-icon name="chat-o" :size="22" />
|
<span class="nav-icon-wrap">
|
||||||
|
<van-icon name="chat-o" :size="22" />
|
||||||
|
<em v-if="unreadCount > 0" class="nav-badge">{{ unreadLabel }}</em>
|
||||||
|
</span>
|
||||||
<span>消息</span>
|
<span>消息</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/m/seller/listings/create" class="nav-item nav-publish">
|
<RouterLink to="/m/seller/listings/create" class="nav-item nav-publish">
|
||||||
@@ -39,16 +44,26 @@ function isNavActive(path: string) {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: -1px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-top: 1px solid #eee;
|
border-top: 1px solid #eee;
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
height: calc(50px + env(safe-area-inset-bottom));
|
height: calc(52px + env(safe-area-inset-bottom));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-nav::after {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: -18px;
|
||||||
|
left: 0;
|
||||||
|
height: 18px;
|
||||||
|
background: #fff;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -69,6 +84,33 @@ function isNavActive(path: string) {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-icon-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
right: -10px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.publish-pill {
|
.publish-pill {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ export async function fetchChats(page = 1, pageSize = 20) {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchUnreadChatCount() {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<{ unread_count: number }>>(
|
||||||
|
'/chats/unread-count',
|
||||||
|
{
|
||||||
|
silent: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data.data.unread_count
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchChat(id: number) {
|
export async function fetchChat(id: number) {
|
||||||
const { data } = await apiClient.get<ApiResponse<ChatConversation>>(`/chats/${id}`)
|
const { data } = await apiClient.get<ApiResponse<ChatConversation>>(`/chats/${id}`)
|
||||||
return data.data
|
return data.data
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||||
|
|
||||||
|
import { fetchUnreadChatCount } from '@/features/chats/api/chats'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
|
||||||
|
export function useChatUnreadCount(route: RouteLocationNormalizedLoaded) {
|
||||||
|
const session = useSessionStore()
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
let timer: number | null = null
|
||||||
|
let requestID = 0
|
||||||
|
|
||||||
|
const unreadLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)))
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (timer) {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUnreadCount() {
|
||||||
|
if (!session.isLoggedIn) {
|
||||||
|
unreadCount.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentID = ++requestID
|
||||||
|
try {
|
||||||
|
const count = await fetchUnreadChatCount()
|
||||||
|
if (currentID === requestID) {
|
||||||
|
unreadCount.value = count
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 底部导航不弹错误提示,保持上一次未读数即可。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling()
|
||||||
|
void loadUnreadCount()
|
||||||
|
timer = window.setInterval(loadUnreadCount, 30_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (session.isLoggedIn) {
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(stopPolling)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => session.isLoggedIn,
|
||||||
|
loggedIn => {
|
||||||
|
if (loggedIn) {
|
||||||
|
startPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopPolling()
|
||||||
|
unreadCount.value = 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
void loadUnreadCount()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
unreadCount,
|
||||||
|
unreadLabel,
|
||||||
|
refreshUnreadCount: loadUnreadCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ const emit = defineEmits<{
|
|||||||
'update:show': [value: boolean]
|
'update:show': [value: boolean]
|
||||||
'update:selectedFilters': [value: SelectedFilters]
|
'update:selectedFilters': [value: SelectedFilters]
|
||||||
'update:rangeFilters': [value: RangeFilters]
|
'update:rangeFilters': [value: RangeFilters]
|
||||||
|
reset: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const activeCount = computed(() => {
|
const activeCount = computed(() => {
|
||||||
@@ -105,8 +106,7 @@ function isRangePresetActive(sectionKey: string, min: string, max: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
emit('update:selectedFilters', {})
|
emit('reset')
|
||||||
emit('update:rangeFilters', {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToSection(sectionKey: string) {
|
function scrollToSection(sectionKey: string) {
|
||||||
|
|||||||
@@ -3,15 +3,12 @@
|
|||||||
max-width: 430px;
|
max-width: 430px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding-bottom: 68px;
|
padding-bottom: calc(72px + env(safe-area-inset-bottom));
|
||||||
background: #f6f8fb;
|
background: #f6f8fb;
|
||||||
color: #17233d;
|
color: #17233d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-hero {
|
.mobile-hero {
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 20;
|
|
||||||
padding: 12px 12px 10px;
|
padding: 12px 12px 10px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.06);
|
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.06);
|
||||||
|
|||||||
@@ -825,6 +825,7 @@ syncMobileHomeQuery()
|
|||||||
v-model:range-filters="rangeFilters"
|
v-model:range-filters="rangeFilters"
|
||||||
:sections="filterSections"
|
:sections="filterSections"
|
||||||
:range-presets="rangePresets"
|
:range-presets="rangePresets"
|
||||||
|
@reset="clearFilters"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRoute, RouterLink } from 'vue-router'
|
import { useRoute, RouterLink } from 'vue-router'
|
||||||
|
import { useChatUnreadCount } from '@/features/chats/composables/useChatUnreadCount'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { unreadCount, unreadLabel } = useChatUnreadCount(route)
|
||||||
|
|
||||||
function isNavActive(path: string) {
|
function isNavActive(path: string) {
|
||||||
if (path === '/m') return route.path === '/m'
|
if (path === '/m') return route.path === '/m'
|
||||||
@@ -16,7 +18,10 @@ function isNavActive(path: string) {
|
|||||||
<span>首页</span>
|
<span>首页</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/m/messages" class="nav-item" :class="{ active: isNavActive('/m/messages') }">
|
<RouterLink to="/m/messages" class="nav-item" :class="{ active: isNavActive('/m/messages') }">
|
||||||
<van-icon name="chat-o" :size="22" />
|
<span class="nav-icon-wrap">
|
||||||
|
<van-icon name="chat-o" :size="22" />
|
||||||
|
<em v-if="unreadCount > 0" class="nav-badge">{{ unreadLabel }}</em>
|
||||||
|
</span>
|
||||||
<span>消息</span>
|
<span>消息</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/m/seller/listings/create" class="nav-item nav-publish">
|
<RouterLink to="/m/seller/listings/create" class="nav-item nav-publish">
|
||||||
@@ -39,16 +44,26 @@ function isNavActive(path: string) {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: -1px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-top: 1px solid #eee;
|
border-top: 1px solid #eee;
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
height: calc(50px + env(safe-area-inset-bottom));
|
height: calc(52px + env(safe-area-inset-bottom));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-nav::after {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: -18px;
|
||||||
|
left: 0;
|
||||||
|
height: 18px;
|
||||||
|
background: #fff;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -69,6 +84,33 @@ function isNavActive(path: string) {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-icon-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
right: -10px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.publish-pill {
|
.publish-pill {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
|
|||||||
Reference in New Issue
Block a user