feat: Features架构迁移 - P0和P1部分完成

## 完成的工作

### P0: 基础设施准备
- 创建 features/ 和 shared/ 目录结构
- 迁移共享资源:API基础设施、工具函数、类型定义
- 迁移通用composables:useMoney, useSmsCountdown, usePricingCalculator
- 迁移全局样式文件
- 建立模块化导出系统

### P1.1: 钱包模块 (wallet)
- 迁移 API: wallet.ts
- 迁移 Views: WalletView.vue
- 新增 Composable: useWallet.ts (封装钱包状态管理)
- 更新导入路径到 shared/

### P1.2: 聊天模块 (chats)
- 迁移 API: chats.ts
- 迁移 Views: ChatView, MessagesView (桌面+移动)
- 迁移 Composables: useChatSSE.ts
- 迁移 Components: ChatAttachmentImage.vue
- 更新导入路径到 shared/

## 技术改进
- 修复 shared/composables 导出问题 (default → 命名导出)
- 修复 shared/api/client.ts 类型导入路径
- 建立清晰的模块边界和导出规范

## 文档
- 添加完整的迁移计划文档
- 添加进度跟踪文档

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yml2213
2026-06-04 08:38:36 +08:00
co-authored by Claude Opus 4.7
parent 10acca637e
commit b5903a169f
42 changed files with 7957 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
export interface ChatParticipant {
id: number
conversation_id: number
participant_type: 'user' | 'admin'
participant_id: number
role: 'renter' | 'owner' | 'support' | 'customer'
remark: string
display_name: string
avatar_url: string
last_read_at?: string
joined_at: string
}
export interface ChatConversation {
id: number
order_id: number | null
type: string
title: string
status: string
role: 'renter' | 'owner' | 'support' | 'customer'
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' | 'customer' | '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 ensureSupportChat() {
const { data } = await apiClient.post<ApiResponse<ChatConversation>>('/chats/support')
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, attachmentUrls: string[] = []) {
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/chats/${id}/messages`, {
content,
attachment_urls: attachmentUrls,
})
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, filter = 'all') {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatConversation>>>('/admin/chats', {
params: { page, page_size: pageSize, filter },
})
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, attachmentUrls: string[] = []) {
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/admin/chats/${id}/messages`, {
content,
attachment_urls: attachmentUrls,
})
return data.data
}
export async function markAdminChatRead(id: number) {
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/admin/chats/${id}/read`)
return data.data
}
export interface SupportAdmin {
id: number
nickname: string
chat_count: number
}
export async function fetchSupportAdmins() {
const { data } = await apiClient.get<ApiResponse<SupportAdmin[]>>('/admin/chats/support-admins')
return data.data
}
export async function transferChat(id: number, toAdminId: number) {
const { data } = await apiClient.post<ApiResponse<{ transferred: boolean }>>(`/admin/chats/${id}/transfer`, {
to_admin_id: toAdminId,
})
return data.data
}
export async function updateChatRemark(id: number, remark: string) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/chats/${id}/remark`, { remark })
return data.data
}
export interface QuickReply {
id: number
admin_user_id: number
title: string
content: string
sort_order: number
is_global: boolean
}
export async function fetchQuickReplies() {
const { data } = await apiClient.get<ApiResponse<QuickReply[]>>('/admin/chats/quick-replies')
return data.data
}
export async function createQuickReply(title: string, content: string, sortOrder = 0, isGlobal = false) {
const { data } = await apiClient.post<ApiResponse<QuickReply>>('/admin/chats/quick-replies', {
title,
content,
sort_order: sortOrder,
is_global: isGlobal,
})
return data.data
}
export async function updateQuickReply(id: number, updates: { title?: string; content?: string; sort_order?: number }) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/chats/quick-replies/${id}`, updates)
return data.data
}
export async function deleteQuickReply(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(`/admin/chats/quick-replies/${id}`)
return data.data
}
export async function fetchAutoWelcomeMessage() {
const { data } = await apiClient.get<ApiResponse<{ message: string }>>('/admin/chats/auto-welcome')
return data.data.message
}
export async function updateAutoWelcomeMessage(message: string) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>('/admin/chats/auto-welcome', { message })
return data.data
}
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { fetchAdminFileBlob, fetchFileBlobByURL } from '@/api/files'
const props = defineProps<{
source: string
admin?: boolean
}>()
const objectURL = ref('')
const failed = ref(false)
function extractObjectKey(value: string) {
try {
const parsed = new URL(value, window.location.origin)
return parsed.searchParams.get('key') || ''
} catch {
return ''
}
}
function revokeCurrentURL() {
if (!objectURL.value) return
URL.revokeObjectURL(objectURL.value)
objectURL.value = ''
}
async function loadImage() {
revokeCurrentURL()
failed.value = false
if (!props.source) {
failed.value = true
return
}
try {
const key = extractObjectKey(props.source)
const blob = props.admin && key
? await fetchAdminFileBlob(key)
: await fetchFileBlobByURL(props.source)
objectURL.value = URL.createObjectURL(blob)
} catch {
failed.value = true
}
}
function openImage() {
if (!objectURL.value) return
window.open(objectURL.value, '_blank')
}
watch(() => [props.source, props.admin] as const, loadImage, { immediate: true })
onBeforeUnmount(revokeCurrentURL)
</script>
<template>
<button v-if="objectURL" class="chat-image-button" type="button" @click="openImage">
<img :src="objectURL" alt="聊天图片" loading="lazy" decoding="async">
</button>
<span v-else class="chat-image-fallback">{{ failed ? '图片加载失败' : '图片加载中' }}</span>
</template>
<style scoped>
.chat-image-button {
display: block;
max-width: 220px;
padding: 0;
overflow: hidden;
border: 0;
border-radius: 8px;
background: transparent;
cursor: zoom-in;
}
.chat-image-button img {
display: block;
width: 100%;
max-height: 260px;
object-fit: cover;
}
.chat-image-fallback {
display: inline-block;
padding: 8px 10px;
border-radius: 8px;
background: #eef2f7;
color: #6b7280;
font-size: 12px;
}
</style>
@@ -0,0 +1,118 @@
import { onBeforeUnmount, ref, type Ref } from 'vue'
import { refreshAccessToken } from '@/api/client'
import { getAccessToken, type AuthScope } from '@/utils/authStorage'
export interface SSEMessage {
id: number
conversation_id: number
sender_type: string
sender_id: number
sender_role: string
sender_name: string
content_type: string
content: string
attachment_urls: string[]
created_at: string
}
export interface ChatEvent {
type: 'new_message' | 'conversation_updated'
conversation_id: number
message?: SSEMessage
}
type EventHandler = (event: ChatEvent) => void
const reconnectDelay = 3000
export function useChatSSE(scope: AuthScope, endpoint: string) {
const connected: Ref<boolean> = ref(false)
let source: EventSource | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let stopped = false
let refreshing = false
const handlers: EventHandler[] = []
function onEvent(handler: EventHandler) {
handlers.push(handler)
}
function connect() {
if (stopped || source) return
const token = getAccessToken(scope)
if (!token) return
const url = `${endpoint}?token=${encodeURIComponent(token)}`
source = new EventSource(url)
source.addEventListener('connected', () => {
connected.value = true
})
source.addEventListener('new_message', (e) => {
try {
const data = JSON.parse((e as MessageEvent).data) as ChatEvent
handlers.forEach(h => h(data))
} catch { /* ignore */ }
})
source.addEventListener('conversation_updated', (e) => {
try {
const data = JSON.parse((e as MessageEvent).data) as ChatEvent
handlers.forEach(h => h(data))
} catch { /* ignore */ }
})
source.onerror = async () => {
connected.value = false
source?.close()
source = null
if (stopped || refreshing) return
refreshing = true
try {
await refreshAccessToken(scope)
if (!stopped) {
reconnectTimer = setTimeout(connect, reconnectDelay)
}
} catch {
closeSource()
} finally {
refreshing = false
}
}
}
function closeSource() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
source?.close()
source = null
connected.value = false
}
function handleAuthStorageChanged(event: Event) {
const detail = (event as CustomEvent<{ scope?: AuthScope }>).detail
if (detail?.scope !== scope || stopped) return
closeSource()
connect()
}
function disconnect() {
stopped = true
closeSource()
window.removeEventListener('auth-storage-changed', handleAuthStorageChanged)
}
window.addEventListener('auth-storage-changed', handleAuthStorageChanged)
onBeforeUnmount(() => {
disconnect()
})
connect()
return { connected, onEvent, disconnect }
}
+3
View File
@@ -0,0 +1,3 @@
// Chats 模块统一导出
export * from './api/chats'
export * from './composables/useChatSSE'
@@ -0,0 +1,540 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Close, Loading, Picture } from '@element-plus/icons-vue'
import {
fetchChat,
fetchChatMessages,
markChatRead,
sendChatMessage,
type ChatConversation,
type ChatMessage,
} from '@/api/chats'
import { uploadFile } from '@/api/files'
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
const currentUserId = Number(localStorage.getItem('user_id') || 0)
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 uploading = ref(false)
const content = ref('')
const attachments = ref<string[]>([])
const listRef = ref<HTMLElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const conversationID = computed(() => Number(route.params.id || 0))
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
const memberText = computed(() => {
const participants = conversation.value?.participants || []
if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
return participants.map(item => roleLabel(item.role)).join(' · ')
})
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'new_message' && event.conversation_id === conversationID.value) {
const msg = event.message
if (msg) appendMessage({
id: msg.id,
conversation_id: msg.conversation_id,
sender_type: msg.sender_type as ChatMessage['sender_type'],
sender_id: msg.sender_id,
sender_role: msg.sender_role as ChatMessage['sender_role'],
sender_name: msg.sender_name,
sender_avatar: '',
is_self: msg.sender_type === 'user' && msg.sender_id === currentUserId,
content_type: msg.content_type as ChatMessage['content_type'],
content: msg.content,
attachment_urls: msg.attachment_urls || [],
created_at: msg.created_at,
})
markChatRead(conversationID.value).catch(() => {})
}
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
loadConversation()
}
}
const { onEvent } = useChatSSE('user', '/api/chats/events')
onEvent(handleSSEEvent)
onMounted(async () => {
await loadAll()
})
watch(conversationID, async (id, oldId) => {
if (id && id !== oldId) {
await loadAll()
}
})
async function loadAll() {
if (!conversationID.value) return
loading.value = true
conversation.value = null
messages.value = []
try {
const [chat] = await Promise.all([
fetchChat(conversationID.value),
loadMessages(true),
])
conversation.value = chat
await markChatRead(conversationID.value)
} catch {
ElMessage.error('加载会话失败')
} finally {
loading.value = false
}
}
async function loadConversation() {
if (!conversationID.value) return
try {
conversation.value = await fetchChat(conversationID.value)
} catch { /* ignore */ }
}
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()
const imageUrls = [...attachments.value]
if ((!text && imageUrls.length === 0) || sending.value || uploading.value) return
sending.value = true
try {
const sent = await sendChatMessage(conversationID.value, text, imageUrls)
appendMessage(sent)
content.value = ''
attachments.value = []
await loadConversation()
} catch {
ElMessage.error('发送失败')
} finally {
sending.value = false
}
}
function pickImages() {
if (uploading.value || attachments.value.length >= 9) return
fileInputRef.value?.click()
}
async function handleImageChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
if (files.length === 0) return
const slots = 9 - attachments.value.length
if (slots <= 0) {
ElMessage.warning('每条消息最多发送 9 张图片')
return
}
uploading.value = true
try {
for (const file of files.slice(0, slots)) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 25 * 1024 * 1024) {
ElMessage.warning(`${file.name} 不符合图片规则`)
continue
}
const uploaded = await uploadFile(file, 'chat')
attachments.value.push(uploaded.url)
}
if (files.length > slots) {
ElMessage.warning('每条消息最多发送 9 张图片')
}
} catch {
ElMessage.error('图片上传失败')
} finally {
uploading.value = false
}
}
function removeAttachment(index: number) {
attachments.value.splice(index, 1)
}
function appendMessage(message: ChatMessage) {
if (messages.value.some(item => item.id === message.id)) return
messages.value = [...messages.value, message]
nextTick(() => scrollBottom())
}
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: '客服',
customer: '咨询',
system: '系统',
}
return map[role] || '成员'
}
function senderLabel(message: ChatMessage) {
if (message.sender_type === 'system') return '系统'
return `${roleLabel(message.sender_role)} · ${message.sender_name}`
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
</script>
<template>
<section class="page chat-page">
<div class="chat-workbench">
<!-- Header -->
<div class="chat-header">
<button class="back-btn" type="button" @click="router.push('/messages')">
<el-icon :size="18"><ArrowLeft /></el-icon>
<span>返回</span>
</button>
<div class="chat-title">
<h2>{{ conversation?.title || '客服会话' }}</h2>
<p>{{ memberText }}</p>
</div>
<el-button
v-if="conversation?.order_id"
type="primary"
link
@click="router.push(`/orders/${conversation.order_id}`)"
>
查看订单
</el-button>
</div>
<!-- Messages -->
<div ref="listRef" v-loading="loading" class="message-list">
<div v-if="loading && messages.length === 0" class="loading-placeholder">
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
</div>
<el-empty v-else-if="!loading && messages.length === 0" description="暂无消息" />
<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" :class="{ self: item.is_self }">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
<div class="bubble-wrap" :class="{ self: item.is_self }">
<span class="sender-name">{{ senderLabel(item) }}</span>
<div v-if="item.content" class="bubble">{{ item.content }}</div>
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
<ChatAttachmentImage
v-for="url in item.attachment_urls"
:key="url"
:source="url"
/>
</div>
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
</div>
</template>
</div>
</div>
<!-- Composer -->
<div class="composer">
<div v-if="attachments.length > 0" class="pending-attachments">
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
<ChatAttachmentImage :source="url" />
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
<el-icon :size="14"><Close /></el-icon>
</button>
</div>
</div>
<input
ref="fileInputRef"
class="hidden-file"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
@change="handleImageChange"
>
<el-input
v-model="content"
type="textarea"
:rows="3"
:maxlength="1000"
show-word-limit
placeholder="发送消息..."
resize="none"
@keydown="handleKeydown"
/>
<div class="composer-actions">
<span class="composer-hint">Enter 发送Shift+Enter 换行</span>
<div class="composer-buttons">
<el-button :icon="Picture" :loading="uploading" :disabled="attachments.length >= 9" @click="pickImages">
图片
</el-button>
<el-button type="primary" :disabled="!canSend || sending || uploading" :loading="sending" @click="handleSend">
发送
</el-button>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.chat-page {
max-width: 1720px;
margin: 0 auto;
}
.chat-workbench {
display: flex;
flex-direction: column;
height: calc(100vh - 56px - 40px);
border: 1px solid #e8edf3;
border-radius: 12px;
background: #fff;
overflow: hidden;
}
.chat-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #eef1f5;
background: #fafbfc;
flex-shrink: 0;
}
.back-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: #5a6577;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.back-btn:hover {
background: #f0f5ff;
color: #1477ff;
}
.chat-title {
flex: 1;
min-width: 0;
}
.chat-title h2 {
margin: 0;
overflow: hidden;
color: #17233d;
font-size: 15px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-title p {
margin: 2px 0 0;
color: #6b7785;
font-size: 12px;
}
.message-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
}
.loading-placeholder {
display: flex;
justify-content: center;
padding: 60px 0;
color: #a0aab6;
}
.message-row {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.message-row.self {
flex-direction: row-reverse;
}
.message-row.system {
justify-content: center;
}
.avatar {
display: grid;
flex: none;
width: 36px;
height: 36px;
place-items: center;
border-radius: 50%;
background: #1477ff;
color: #fff;
font-size: 14px;
font-weight: 800;
}
.avatar.self {
background: #10b981;
}
.bubble-wrap {
display: flex;
max-width: 60%;
flex-direction: column;
align-items: flex-start;
}
.bubble-wrap.self {
align-items: flex-end;
}
.sender-name {
margin-bottom: 4px;
color: #8a94a6;
font-size: 12px;
}
.bubble {
max-width: 100%;
padding: 10px 14px;
border-radius: 10px;
background: #f4f6f8;
color: #17233d;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
}
.message-row.self .bubble {
background: #dff5eb;
color: #0f5132;
}
.message-attachments {
display: grid;
gap: 6px;
margin-top: 6px;
}
.message-time {
margin-top: 4px;
color: #a1a8b4;
font-size: 11px;
}
.system-message {
max-width: 80%;
padding: 6px 12px;
border-radius: 8px;
background: #e6ebf2;
color: #6b7280;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
.composer {
flex-shrink: 0;
padding: 12px 16px;
border-top: 1px solid #eef1f5;
background: #fafbfc;
}
.pending-attachments {
display: flex;
gap: 8px;
margin-bottom: 10px;
overflow-x: auto;
}
.pending-item {
position: relative;
flex: none;
}
.pending-item :deep(.chat-image-button) {
width: 86px;
height: 86px;
}
.pending-item :deep(.chat-image-button img) {
height: 86px;
}
.remove-attachment {
position: absolute;
top: 4px;
right: 4px;
display: grid;
width: 22px;
height: 22px;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(17, 24, 39, 0.72);
color: #fff;
cursor: pointer;
}
.hidden-file {
display: none;
}
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.composer-buttons {
display: flex;
gap: 8px;
}
.composer-hint {
color: #a0aab6;
font-size: 12px;
}
</style>
@@ -0,0 +1,290 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ChatDotRound, Refresh, Tickets } from '@element-plus/icons-vue'
import { fetchChats, type ChatConversation } from '@/api/chats'
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
const router = useRouter()
const loading = ref(false)
const conversations = ref<ChatConversation[]>([])
const page = ref(1)
const pageSize = 20
const total = ref(0)
const hasMore = computed(() => conversations.value.length < total.value)
const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum + item.unread_count, 0))
const { onEvent } = useChatSSE('user', '/api/chats/events')
onEvent(handleSSEEvent)
onMounted(() => loadChats(true))
async function loadChats(isRefresh = false, showLoading = true) {
if (isRefresh) page.value = 1
if (loading.value) return
if (showLoading) loading.value = true
try {
const res = await fetchChats(page.value, pageSize)
conversations.value = isRefresh ? res.items : [...conversations.value, ...res.items]
total.value = res.total
if (conversations.value.length < res.total && res.items.length > 0) {
page.value += 1
}
} catch {
ElMessage.error('获取会话失败')
} finally {
loading.value = false
}
}
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'new_message' || event.type === 'conversation_updated') {
loadChats(true, false)
}
}
function openConversation(item: ChatConversation) {
router.push(`/messages/${item.id}`)
}
function roleLabel(role: string) {
const map: Record<string, string> = { renter: '租客', owner: '号主', support: '客服', customer: '咨询' }
return map[role] || '成员'
}
function previewText(item: ChatConversation) {
return item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
}
</script>
<template>
<section class="page messages-page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Messages</p>
<h1>消息</h1>
<p>{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊和平台客服消息。' }}</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadChats(true)">刷新</el-button>
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">我的订单</el-button>
</div>
</div>
<div v-if="loading && conversations.length === 0" class="message-loading" v-loading="loading" />
<div v-else-if="!loading && conversations.length === 0" class="empty-panel">
<el-empty description="暂无会话">
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">查看订单</el-button>
</el-empty>
</div>
<div v-else v-loading="loading" class="conversation-list">
<button
v-for="item in conversations"
:key="item.id"
class="conversation-item"
type="button"
@click="openConversation(item)"
>
<div class="avatar-stack">
<span class="avatar main">{{ roleLabel(item.role).slice(0, 1) }}</span>
<span class="avatar support"></span>
</div>
<div class="conversation-body">
<div class="conversation-head">
<h2>{{ item.title }}</h2>
<span class="conversation-time">{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
</div>
<div class="conversation-meta">
<span class="role-chip">{{ roleLabel(item.role) }}</span>
<span class="order-id">{{ item.order_id ? `订单 #${item.order_id}` : '平台客服' }}</span>
</div>
<p class="conversation-preview">{{ previewText(item) }}</p>
</div>
<span v-if="item.unread_count > 0" class="unread-badge">
{{ item.unread_count > 99 ? '99+' : item.unread_count }}
</span>
</button>
</div>
<div v-if="hasMore && conversations.length > 0" class="pagination-wrap">
<el-button :loading="loading" @click="loadChats(false)">加载更多</el-button>
</div>
</section>
</template>
<style scoped>
.messages-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.page-header-row {
align-items: flex-end;
}
.header-actions {
display: flex;
gap: 10px;
}
.message-loading,
.empty-panel {
min-height: 360px;
border: 1px solid #e8edf3;
border-radius: 8px;
background: #fff;
}
.empty-panel {
display: grid;
place-items: center;
}
.conversation-list {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 180px;
}
.conversation-item {
position: relative;
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
gap: 12px;
width: 100%;
padding: 14px 16px;
border: 1px solid #e8edf3;
border-radius: 10px;
background: #fff;
text-align: left;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.conversation-item:hover {
border-color: #1477ff;
box-shadow: 0 4px 16px rgba(20, 119, 255, 0.1);
}
.avatar-stack {
position: relative;
width: 48px;
height: 48px;
flex-shrink: 0;
}
.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-body {
min-width: 0;
}
.conversation-head {
display: flex;
align-items: center;
gap: 8px;
}
.conversation-head h2 {
flex: 1;
min-width: 0;
margin: 0;
overflow: hidden;
color: #17233d;
font-size: 15px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-time {
flex: none;
color: #9ca3af;
font-size: 12px;
}
.conversation-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
color: #6b7785;
font-size: 12px;
}
.role-chip {
padding: 1px 8px;
border-radius: 999px;
background: #eef6ff;
color: #1477ff;
font-size: 11px;
font-weight: 700;
}
.order-id {
color: #8a94a6;
}
.conversation-preview {
margin: 6px 0 0;
overflow: hidden;
color: #4b5563;
font-size: 13px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.unread-badge {
position: absolute;
right: 14px;
bottom: 14px;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-weight: 800;
line-height: 20px;
text-align: center;
}
.pagination-wrap {
display: flex;
justify-content: center;
}
</style>
@@ -0,0 +1,517 @@
<script setup lang="ts">
import { computed, nextTick, 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 { uploadFile } from '@/api/files'
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
const currentUserId = Number(localStorage.getItem('user_id') || 0)
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 uploading = ref(false)
const content = ref('')
const attachments = ref<string[]>([])
const listRef = ref<HTMLElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const conversationID = computed(() => Number(route.params.id || 0))
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
const memberText = computed(() => {
const participants = conversation.value?.participants || []
if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
return participants.map(item => roleLabel(item.role)).join(' · ')
})
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'new_message' && event.conversation_id === conversationID.value) {
const msg = event.message
if (msg && !messages.value.some(m => m.id === msg.id)) {
messages.value = [...messages.value, {
id: msg.id,
conversation_id: msg.conversation_id,
sender_type: msg.sender_type as ChatMessage['sender_type'],
sender_id: msg.sender_id,
sender_role: msg.sender_role as ChatMessage['sender_role'],
sender_name: msg.sender_name,
sender_avatar: '',
is_self: msg.sender_type === 'user' && msg.sender_id === currentUserId,
content_type: msg.content_type as ChatMessage['content_type'],
content: msg.content,
attachment_urls: msg.attachment_urls || [],
created_at: msg.created_at,
}]
nextTick(() => scrollBottom())
}
}
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
loadConversation()
}
}
const { onEvent } = useChatSSE('user', '/api/chats/events')
onEvent(handleSSEEvent)
onMounted(async () => {
await loadAll()
})
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 loadConversation() {
if (!conversationID.value) return
try {
conversation.value = await fetchChat(conversationID.value)
} catch { /* ignore */ }
}
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()
const imageUrls = [...attachments.value]
if ((!text && imageUrls.length === 0) || sending.value || uploading.value) return
sending.value = true
try {
const sent = await sendChatMessage(conversationID.value, text, imageUrls)
appendMessage(sent)
content.value = ''
attachments.value = []
} catch {
showToast({ message: '发送失败', icon: 'cross' })
} finally {
sending.value = false
}
}
function appendMessage(message: ChatMessage) {
if (messages.value.some(item => item.id === message.id)) return
messages.value = [...messages.value, message]
nextTick(() => scrollBottom())
}
function pickImages() {
if (uploading.value || attachments.value.length >= 9) return
fileInputRef.value?.click()
}
async function handleImageChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
if (files.length === 0) return
const slots = 9 - attachments.value.length
if (slots <= 0) {
showToast('每条消息最多发送 9 张图片')
return
}
uploading.value = true
try {
for (const file of files.slice(0, slots)) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 25 * 1024 * 1024) {
showToast(`${file.name} 不符合图片规则`)
continue
}
const uploaded = await uploadFile(file, 'chat')
attachments.value.push(uploaded.url)
}
if (files.length > slots) {
showToast('每条消息最多发送 9 张图片')
}
} catch {
showToast({ message: '图片上传失败', icon: 'cross' })
} finally {
uploading.value = false
}
}
function removeAttachment(index: number) {
attachments.value.splice(index, 1)
}
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: '客服',
customer: '咨询',
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 v-if="conversation?.order_id" class="icon-btn" type="button" @click="router.push(`/m/orders/${conversation.order_id}`)">
<van-icon name="orders-o" :size="20" />
</button>
<span v-else class="icon-placeholder"></span>
</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 v-if="item.content" class="bubble">{{ item.content }}</div>
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
<ChatAttachmentImage
v-for="url in item.attachment_urls"
:key="url"
:source="url"
/>
</div>
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
</div>
</template>
</div>
</section>
<footer class="composer">
<input
ref="fileInputRef"
class="hidden-file"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
@change="handleImageChange"
>
<div v-if="attachments.length > 0" class="pending-attachments">
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
<ChatAttachmentImage :source="url" />
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
<van-icon name="cross" :size="12" />
</button>
</div>
</div>
<button class="tool-btn" type="button" :disabled="uploading || attachments.length >= 9" @click="pickImages">
<van-icon name="photo-o" :size="20" />
</button>
<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="!canSend || sending || uploading" @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;
}
.icon-placeholder {
display: block;
width: 44px;
height: 44px;
}
.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-attachments {
display: grid;
gap: 6px;
margin-top: 6px;
}
.message-attachments :deep(.chat-image-button) {
max-width: min(62vw, 220px);
}
.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: 40px 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;
}
.hidden-file {
display: none;
}
.pending-attachments {
display: flex;
grid-column: 1 / -1;
gap: 8px;
overflow-x: auto;
}
.pending-item {
position: relative;
flex: none;
}
.pending-item :deep(.chat-image-button) {
width: 72px;
height: 72px;
}
.pending-item :deep(.chat-image-button img) {
height: 72px;
}
.remove-attachment {
position: absolute;
top: 3px;
right: 3px;
display: grid;
width: 20px;
height: 20px;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(17, 24, 39, 0.72);
color: #fff;
}
.composer-input {
border: 1px solid #d9e0e8;
border-radius: 8px;
overflow: hidden;
}
.tool-btn {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 0;
border-radius: 8px;
background: #eef4ff;
color: #1477ff;
}
.tool-btn:disabled {
color: #9ca3af;
}
.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>
@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { fetchChats, type ChatConversation } from '@/api/chats'
import { formatDateMinute } from '@/utils/time'
const router = useRouter()
const loading = ref(false)
const refreshing = ref(false)
const finished = ref(false)
const conversations = ref<ChatConversation[]>([])
const page = ref(1)
const pageSize = 20
const total = ref(0)
onMounted(() => {
onRefresh()
})
async function loadChats(isRefresh = false) {
if (isRefresh) {
page.value = 1
finished.value = false
}
loading.value = true
try {
const res = await fetchChats(page.value, pageSize)
conversations.value = isRefresh ? res.items : [...conversations.value, ...res.items]
total.value = res.total
if (conversations.value.length >= res.total || res.items.length === 0) {
finished.value = true
} else {
page.value += 1
}
} catch {
showToast({ message: '获取会话失败', icon: 'cross' })
finished.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
function onRefresh() {
refreshing.value = true
loadChats(true)
}
function onLoad() {
if (loading.value || finished.value) return
loadChats(false)
}
function openConversation(item: ChatConversation) {
router.push(`/m/chats/${item.id}`)
}
function roleLabel(role: string) {
const map: Record<string, string> = {
renter: '租客',
owner: '号主',
support: '客服',
customer: '咨询',
}
return map[role] || '成员'
}
function previewText(item: ChatConversation) {
return item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
}
const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum + item.unread_count, 0))
</script>
<template>
<main class="mobile-messages">
<header class="page-header">
<button class="back-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>消息</h1>
<span class="header-count">{{ unreadTotal > 0 ? `${unreadTotal} 未读` : '' }}</span>
</header>
<van-pull-refresh v-model="refreshing" class="scroll-container" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多会话了"
:immediate-check="false"
@load="onLoad"
>
<van-empty
v-if="!loading && conversations.length === 0"
description="暂无会话"
class="empty-state"
/>
<div v-else class="conversation-list">
<button
v-for="item in conversations"
:key="item.id"
class="conversation-item"
type="button"
@click="openConversation(item)"
>
<div class="avatar-stack">
<span class="avatar main">{{ roleLabel(item.role).slice(0, 1) }}</span>
<span class="avatar support"></span>
</div>
<div class="conversation-main">
<div class="conversation-title-row">
<h2>{{ item.title }}</h2>
<span class="conversation-time">{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
</div>
<div class="conversation-meta">
<span class="role-chip">{{ roleLabel(item.role) }}</span>
<span>{{ item.order_id ? `订单 #${item.order_id}` : '平台客服' }}</span>
</div>
<p>{{ previewText(item) }}</p>
</div>
<span v-if="item.unread_count > 0" class="unread-badge">
{{ item.unread_count > 99 ? '99+' : item.unread_count }}
</span>
</button>
</div>
</van-list>
</van-pull-refresh>
<MobileBottomNav />
</main>
</template>
<style scoped>
.mobile-messages {
min-height: 100dvh;
background: #f5f7fb;
padding-bottom: calc(62px + env(safe-area-inset-bottom));
}
.page-header {
position: sticky;
top: 0;
z-index: 100;
display: grid;
grid-template-columns: 44px 1fr 72px;
align-items: center;
height: 48px;
padding: 0 8px;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid #edf0f5;
backdrop-filter: blur(10px);
}
.page-header h1 {
margin: 0;
color: #111827;
font-size: 17px;
font-weight: 800;
text-align: center;
}
.back-btn {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 0;
background: transparent;
color: #374151;
}
.header-count {
color: #ef4444;
font-size: 12px;
font-weight: 700;
text-align: right;
}
.scroll-container {
min-height: calc(100dvh - 48px - 62px - env(safe-area-inset-bottom));
}
.empty-state {
padding-top: 90px;
}
.conversation-list {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
}
.conversation-item {
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;
align-items: center;
gap: 8px;
}
.conversation-title-row h2 {
flex: 1;
min-width: 0;
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;
}
.conversation-main p {
margin: 7px 0 0;
overflow: hidden;
color: #4b5563;
font-size: 13px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.unread-badge {
position: absolute;
right: 10px;
bottom: 10px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #ef4444;
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 18px;
text-align: center;
}
</style>
@@ -0,0 +1,54 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
import type { BalanceType, LedgerDirection, WalletStatus } from '@/shared/types/status'
import type { PaymentOrder } from '@/api/orders'
export interface WalletAccount {
user_id: number
available_balance: number
frozen_balance: number
status: WalletStatus
}
export interface WalletLedger {
id: number
ledger_no: string
user_id: number
order_id?: number
direction: LedgerDirection
amount: number
balance_after: number
balance_type: BalanceType
biz_type: string
biz_no: string
remark: string
created_at: string
}
export async function fetchWalletBalance() {
const { data } = await apiClient.get<ApiResponse<WalletAccount>>('/wallet/balance')
return data.data
}
export async function fetchWalletLedger(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<WalletLedger>>>('/wallet/ledger', {
params: { page, page_size: pageSize },
})
return data.data
}
export async function rechargeWallet(amount: number) {
const { data } = await apiClient.post<ApiResponse<WalletAccount>>('/wallet/recharge', { amount })
return data.data
}
export async function startWalletRechargePayment(amount: number) {
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>('/wallet/recharge/pay', { amount })
return data.data
}
export async function queryWalletRechargePayment(id: number) {
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>(`/wallet/recharge/pay/${id}/query`)
return data.data
}
@@ -0,0 +1,54 @@
import { ref, computed } from 'vue'
import { fetchWalletBalance, fetchWalletLedger } from '../api/wallet'
import type { WalletAccount, WalletLedger } from '../api/wallet'
export function useWallet() {
const balance = ref<WalletAccount | null>(null)
const ledgers = ref<WalletLedger[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const availableBalance = computed(() => balance.value?.available_balance ?? 0)
const frozenBalance = computed(() => balance.value?.frozen_balance ?? 0)
const totalBalance = computed(() => availableBalance.value + frozenBalance.value)
async function loadBalance() {
loading.value = true
error.value = null
try {
balance.value = await fetchWalletBalance()
} catch (err) {
error.value = err instanceof Error ? err.message : '加载余额失败'
throw err
} finally {
loading.value = false
}
}
async function loadLedger(page = 1, pageSize = 20) {
loading.value = true
error.value = null
try {
const result = await fetchWalletLedger(page, pageSize)
ledgers.value = result.items
return result
} catch (err) {
error.value = err instanceof Error ? err.message : '加载账单失败'
throw err
} finally {
loading.value = false
}
}
return {
balance,
ledgers,
loading,
error,
availableBalance,
frozenBalance,
totalBalance,
loadBalance,
loadLedger,
}
}
+4
View File
@@ -0,0 +1,4 @@
// Wallet 模块统一导出
export * from './api/wallet'
export * from './composables/useWallet'
export type * from './types'
+18
View File
@@ -0,0 +1,18 @@
// Wallet 模块类型定义
export interface WalletBalance {
balance: number
frozenBalance: number
}
export interface WalletTransaction {
id: number
type: string
amount: number
balance: number
description: string
createdAt: string
}
export interface RechargeRequest {
amount: number
}
@@ -0,0 +1,691 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { CircleCheck, Lock, Money, Refresh, Tickets, Wallet as WalletIcon } from '@element-plus/icons-vue'
import {
fetchWalletBalance,
fetchWalletLedger,
type WalletAccount,
type WalletLedger,
} from '@/api/wallet'
import { balanceTypeLabel, ledgerDirectionLabel, walletStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const account = ref<WalletAccount | null>(null)
const ledger = ref<WalletLedger[]>([])
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const walletMetrics = computed(() => {
if (!account.value) {
return []
}
return [
{
label: '可用余额',
value: formatMoney(account.value.available_balance),
hint: '卖家结算收入累计到此账户',
icon: WalletIcon,
tone: 'available',
},
{
label: '冻结余额',
value: formatMoney(account.value.frozen_balance),
hint: '当前暂无冻结资金使用',
icon: Lock,
tone: 'frozen',
},
{
label: '账户状态',
value: walletStatusLabel(account.value.status),
hint: account.value.status === 'active' ? '钱包可正常使用' : '请联系客服处理',
icon: CircleCheck,
tone: account.value.status === 'active' ? 'status' : 'warning',
},
]
})
onMounted(loadWallet)
async function loadWallet() {
loading.value = true
try {
const [balance, result] = await Promise.all([fetchWalletBalance(), fetchWalletLedger(currentPage.value, currentPageSize.value)])
account.value = balance
ledger.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function handleSizeChange() {
currentPage.value = 1
loadWallet()
}
function loadLedgerPage() {
loadWallet()
}
function handleWithdraw() {
ElMessage.info('提现功能待实现')
}
function formatMoney(value: number) {
return `¥${Number(value || 0).toFixed(2)}`
}
function walletBizTypeLabel(type: string) {
const map: Record<string, string> = {
dev_recharge: '测试充值',
channel_recharge: '渠道充值',
order_pay: '订单支付',
order_lock: '订单冻结',
channel_order_lock: '支付冻结',
order_cancel: '取消解冻',
order_cancel_refund: '取消退款',
admin_order_close: '客服关闭解冻',
admin_order_close_refund: '客服关闭退款',
order_settle: '订单结算',
owner_income: '号主收入',
deposit_compensation: '押金赔付',
rent_refund: '租金退款',
deposit_release: '押金释放',
arbitration_release_frozen: '仲裁解冻',
arbitration_renter_refund: '仲裁退款',
arbitration_owner_income: '仲裁收入',
cancel_refund: '取消退款',
checkout_refund: '结账退款',
channel_deposit_refund: '押金退还',
withdraw_apply: '申请提现',
}
return map[type] || type || '-'
}
function directionTone(direction: string) {
const map: Record<string, string> = {
in: 'success',
out: 'danger',
freeze: 'warning',
unfreeze: 'info',
}
return map[direction] || 'info'
}
function amountPrefix(direction: string) {
if (direction === 'in' || direction === 'unfreeze') return '+'
if (direction === 'out' || direction === 'freeze') return '-'
return ''
}
</script>
<template>
<section class="page wallet-page" v-loading="loading">
<div class="wallet-hero">
<div class="page-header">
<p class="eyebrow">我的钱包</p>
<h1>资金账户</h1>
<p>查看卖家结算收入可提现余额和每一笔资金变化</p>
</div>
<div class="wallet-hero-action">
<span>当前可用</span>
<strong>{{ account ? formatMoney(account.available_balance) : '¥0.00' }}</strong>
<el-button class="withdraw-button" :icon="Money" disabled @click="handleWithdraw">
申请提现
<el-tag size="small" type="info" effect="plain" class="withdraw-tag">待开发</el-tag>
</el-button>
</div>
</div>
<div v-if="account" class="wallet-metric-grid">
<div v-for="item in walletMetrics" :key="item.label" class="wallet-metric-card" :class="`is-${item.tone}`">
<div class="metric-icon">
<el-icon><component :is="item.icon" /></el-icon>
</div>
<div>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<small>{{ item.hint }}</small>
</div>
</div>
</div>
<div class="wallet-workspace">
<section class="ledger-summary-card">
<div class="panel-title">
<div class="panel-title-icon is-blue">
<el-icon><Tickets /></el-icon>
</div>
<div>
<h2>资金流水</h2>
<p> {{ total }} 条记录最近变动优先展示</p>
</div>
</div>
<el-button :icon="Refresh" :loading="loading" @click="loadWallet">刷新</el-button>
</section>
</div>
<section class="wallet-ledger-table" role="table" aria-label="资金流水">
<div class="ledger-grid ledger-header" role="row">
<span role="columnheader">流水号</span>
<span role="columnheader">业务</span>
<span role="columnheader">方向</span>
<span class="align-right" role="columnheader">金额</span>
<span role="columnheader">余额类型</span>
<span class="align-right" role="columnheader">变化后余额</span>
<span role="columnheader">备注</span>
<span role="columnheader">时间</span>
</div>
<div v-if="ledger.length === 0" class="ledger-empty">暂无资金流水</div>
<div v-else class="ledger-body">
<div v-for="row in ledger" :key="row.id" class="ledger-grid ledger-row" role="row">
<span class="ledger-cell ledger-no" :title="row.ledger_no">{{ row.ledger_no }}</span>
<span class="ledger-cell">
<el-tag effect="plain" class="biz-tag">{{ walletBizTypeLabel(row.biz_type) }}</el-tag>
</span>
<span class="ledger-cell">
<el-tag :type="directionTone(row.direction)" effect="light" round>
{{ ledgerDirectionLabel(row.direction) }}
</el-tag>
</span>
<span class="ledger-cell align-right amount-cell" :class="`is-${row.direction}`">
{{ amountPrefix(row.direction) }}{{ formatMoney(row.amount) }}
</span>
<span class="ledger-cell muted-cell">{{ balanceTypeLabel(row.balance_type) }}</span>
<span class="ledger-cell align-right">{{ formatMoney(row.balance_after) }}</span>
<span class="ledger-cell" :title="row.remark">{{ row.remark || '-' }}</span>
<span class="ledger-cell">{{ formatDateTime(row.created_at) }}</span>
</div>
</div>
</section>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadLedgerPage"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>
<style scoped>
.wallet-page {
display: grid;
gap: 18px;
}
.wallet-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 28px;
border: 1px solid #e6eaf2;
border-radius: 8px;
background:
linear-gradient(135deg, rgba(255, 122, 0, 0.08), rgba(15, 118, 110, 0.06)),
#ffffff;
box-shadow: 0 14px 36px rgba(17, 24, 39, 0.06);
}
.wallet-hero :deep(.page-header) {
max-width: 780px;
}
.wallet-hero-action {
min-width: 220px;
padding: 16px 18px;
border: 1px solid rgba(255, 122, 0, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.78);
text-align: right;
}
.wallet-hero-action span {
display: block;
color: #6b7280;
font-size: 13px;
}
.wallet-hero-action strong {
display: block;
margin-top: 6px;
color: #111a44;
font-size: 28px;
line-height: 1.15;
}
.withdraw-button {
width: 100%;
margin-top: 14px;
}
.withdraw-tag {
margin-left: 8px;
vertical-align: middle;
}
.wallet-metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.wallet-metric-card {
display: flex;
align-items: center;
gap: 16px;
min-height: 120px;
padding: 20px;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.05);
}
.metric-icon {
display: grid;
flex: 0 0 46px;
width: 46px;
height: 46px;
place-items: center;
border-radius: 8px;
color: #ffffff;
font-size: 22px;
}
.wallet-metric-card.is-available .metric-icon {
background: #ff6b00;
}
.wallet-metric-card.is-frozen .metric-icon {
background: #3b82f6;
}
.wallet-metric-card.is-status .metric-icon {
background: #0f766e;
}
.wallet-metric-card.is-warning .metric-icon {
background: #d97706;
}
.wallet-metric-card span,
.wallet-metric-card small {
display: block;
color: #64748b;
}
.wallet-metric-card span {
font-size: 13px;
font-weight: 600;
}
.wallet-metric-card strong {
display: block;
margin-top: 8px;
color: #111a44;
font-size: 26px;
line-height: 1.1;
}
.wallet-metric-card small {
margin-top: 8px;
font-size: 12px;
}
.wallet-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
.recharge-panel,
.ledger-summary-card {
display: grid;
gap: 18px;
padding: 20px;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.05);
}
.ledger-summary-card {
align-content: space-between;
}
.ledger-summary-card :deep(.el-button) {
justify-self: start;
min-width: 118px;
}
.panel-title {
display: flex;
align-items: center;
gap: 12px;
}
.panel-title-icon {
display: grid;
flex: 0 0 40px;
width: 40px;
height: 40px;
place-items: center;
border-radius: 8px;
background: #fff4ec;
color: #ff6b00;
font-size: 20px;
}
.panel-title-icon.is-blue {
background: #eff6ff;
color: #2563eb;
}
.panel-title h2 {
margin: 0;
color: #111827;
font-size: 17px;
}
.panel-title p {
margin: 5px 0 0;
color: #64748b;
font-size: 13px;
}
.quick-amounts {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.quick-amounts button {
min-width: 92px;
height: 36px;
border: 1px solid #d8dee9;
border-radius: 8px;
background: #f8fafc;
color: #334155;
font-weight: 600;
cursor: pointer;
}
.quick-amounts button.active,
.quick-amounts button:hover {
border-color: #ff8a3d;
background: #fff4ec;
color: #ea580c;
}
.recharge-action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.wallet-ledger-table {
overflow-x: auto;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.05);
}
.ledger-grid {
display: grid;
grid-template-columns:
minmax(200px, 2fr)
minmax(100px, 0.8fr)
minmax(80px, 0.6fr)
minmax(100px, 0.8fr)
minmax(100px, 0.8fr)
minmax(120px, 0.9fr)
minmax(140px, 1.2fr)
minmax(170px, 1.1fr);
align-items: center;
column-gap: clamp(10px, 1vw, 20px);
padding: 0 clamp(16px, 1.5vw, 24px);
}
.ledger-header {
min-height: 48px;
border-bottom: 1px solid #e6eaf2;
background: #f8fafc;
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.ledger-header span {
text-align: center;
}
.ledger-header span.align-right {
text-align: right;
}
.ledger-row {
min-height: 54px;
border-bottom: 1px solid #edf1f6;
color: #334155;
font-size: 14px;
transition: background 0.15s ease;
}
.ledger-row:last-child {
border-bottom: 0;
}
.ledger-row:hover {
background: #f8fafc;
}
.ledger-cell {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.ledger-cell.align-right {
text-align: right;
}
.ledger-no {
color: #475569;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
font-size: 12px;
letter-spacing: 0.02em;
text-align: center;
}
.align-right {
text-align: right;
}
.ledger-empty {
display: grid;
min-height: 80px;
place-items: center;
border-top: 1px solid #edf1f6;
color: #94a3b8;
font-size: 14px;
}
.biz-tag {
max-width: 96px;
height: 24px;
line-height: 22px;
}
.amount-cell {
font-weight: 700;
}
.amount-cell.is-in,
.amount-cell.is-unfreeze {
color: #047857;
}
.amount-cell.is-out,
.amount-cell.is-freeze {
color: #dc2626;
}
.muted-cell {
color: #64748b;
}
.pay-dialog-body {
display: grid;
gap: 14px;
}
.pay-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border-radius: 8px;
background: #f7f9fc;
color: #64748b;
}
.pay-summary strong {
color: #111a44;
font-size: 22px;
}
.pay-qr-panel {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
align-items: center;
gap: 20px;
padding: 18px;
border-radius: 8px;
background: #f8fafc;
}
.pay-qr-box {
width: 240px;
height: 240px;
display: grid;
place-items: center;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
}
.pay-qr-box img {
width: 220px;
height: 220px;
display: block;
}
.pay-scan-copy {
display: grid;
gap: 8px;
color: #334155;
}
.pay-scan-copy strong {
color: #111a44;
font-size: 18px;
}
.pay-scan-copy span {
color: #64748b;
line-height: 1.7;
}
.pay-hint {
margin: 0;
color: #64748b;
}
.pay-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
:global(.wallet-pay-dialog) {
position: relative;
z-index: 4001;
}
@media (max-width: 720px) {
.wallet-hero {
display: grid;
padding: 20px;
}
.wallet-hero-action {
min-width: 0;
text-align: left;
}
.wallet-metric-grid,
.wallet-workspace {
grid-template-columns: 1fr;
}
.wallet-metric-card {
min-height: auto;
}
.recharge-action-row :deep(.el-input-number) {
width: 100%;
}
.recharge-action-row :deep(.el-button) {
width: 100%;
}
.pay-qr-panel {
grid-template-columns: 1fr;
justify-items: center;
text-align: center;
}
.ledger-grid {
grid-template-columns:
minmax(160px, 1.5fr)
minmax(80px, 0.8fr)
minmax(64px, 0.6fr)
minmax(80px, 0.8fr)
minmax(80px, 0.8fr)
minmax(100px, 0.9fr)
minmax(120px, 1fr)
minmax(140px, 1fr);
column-gap: 8px;
padding: 0 12px;
font-size: 13px;
}
.ledger-header {
min-height: 40px;
font-size: 12px;
}
.ledger-row {
min-height: 48px;
font-size: 13px;
}
}
</style>