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:
co-authored by
Claude Opus 4.7
parent
10acca637e
commit
b5903a169f
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Wallet 模块统一导出
|
||||
export * from './api/wallet'
|
||||
export * from './composables/useWallet'
|
||||
export type * from './types'
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user