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,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>
|
||||
Reference in New Issue
Block a user