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

## 完成的工作

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yml2213
2026-06-04 08:38:36 +08:00
co-authored by Claude Opus 4.7
parent 10acca637e
commit b5903a169f
42 changed files with 7957 additions and 0 deletions
@@ -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>