feat(chat): 聊天页支持可选 username 路由

- 将聊天页迁移为 pages/chat/[[username]].vue(/chat 与 /chat/:username)

- 选中会话时同步 URL,支持路由直达指定会话

- 文件消息卡片补充文件类型图标与 WeChat PC 标识资源
This commit is contained in:
2977094657
2025-12-21 20:54:58 +08:00
parent 2dc355cca7
commit 41a2b546b8
6 changed files with 283 additions and 52 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-1 -1 52 43"><defs><linearGradient gradientUnits="userSpaceOnUse" gradientTransform="scale(1.06228 .94137)" id="a" y2=".1504" x2="17.2422" y1="32.4312" x1="17.2422"><stop offset="0%" stop-color="#78D431"/><stop offset="100%" stop-color="#9EEE69"/><stop offset="100%" stop-color="#9EEE69"/></linearGradient><linearGradient gradientUnits="userSpaceOnUse" gradientTransform="scale(1.05667 .94637)" id="b" y2="14.6966" x2="33.4727" y1="41.634" x1="33.4727"><stop offset="0%" stop-color="#E4E6E6"/><stop offset="100%" stop-color="#F5F5FF"/></linearGradient></defs><g fill="none"><path fill="url(#a)" d="M0 15.3063c0 4.5919 2.4846 8.787 6.3245 11.5648.3388.2267.5082.5669.5082 1.0204 0 .1134-.0565.2835-.0565.3968-.2823 1.1338-.7906 3.0046-.847 3.0613-.0565.17-.113.2834-.113.4535 0 .3402.2824.6236.6212.6236.113 0 .2259-.0567.3388-.1134l4.0093-2.3243c.2823-.17.6211-.2834.96-.2834.1693 0 .3952 0 .5646.0567 1.8635.5669 3.8963.8503 5.9857.8503 10.1078 0 18.2957-6.8595 18.2957-15.3063S28.4035 0 18.2958 0C8.1879 0 0 6.8595 0 15.3063"/><path fill="url(#b)" d="M35.3424 39.6205c1.7463 0 3.4363-.2284 4.9572-.6854.1127-.057.2817-.057.4507-.057.2817 0 .5633.1142.7887.2284l3.3236 1.942c.1126.057.169.1142.2816.1142.2817 0 .507-.2285.507-.514 0-.1143-.0563-.2285-.0563-.3999 0-.0571-.4507-1.5992-.676-2.5702-.0563-.1142-.0563-.2285-.0563-.3427 0-.3427.169-.6283.4506-.8568 3.211-2.3417 5.239-5.8258 5.239-9.7097 0-7.0824-6.8163-12.851-15.2098-12.851s-15.2097 5.7115-15.2097 12.851c0 7.0824 6.8162 12.8511 15.2097 12.8511z"/><path fill="#187E28" d="M14.5484 10.3647c0 1.3223-1.0389 2.369-2.3512 2.369-1.3124 0-2.3513-1.0467-2.3513-2.369 0-1.3223 1.039-2.369 2.3513-2.369 1.3123 0 2.3512 1.0467 2.3512 2.369m12.1972 0c0 1.3223-1.039 2.369-2.3513 2.369-1.3123 0-2.3512-1.0467-2.3512-2.369 0-1.3223 1.039-2.369 2.3512-2.369 1.3124 0 2.3513 1.0467 2.3513 2.369"/><path fill="#858C8C" d="M38.502 22.8023c0 1.1517.9143 2.073 2.0573 2.073 1.143 0 2.0573-.9213 2.0573-2.073 0-1.1516-.9144-2.0729-2.0573-2.0729-1.143 0-2.0574.9213-2.0574 2.073m-10.1398 0c0 1.1516.9144 2.0729 2.0573 2.0729 1.143 0 2.0574-.9213 2.0574-2.073 0-1.1516-.9144-2.0729-2.0574-2.0729-1.143 0-2.0573.9213-2.0573 2.073"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 B

View File

@@ -201,16 +201,20 @@
:from="message.from"
/>
<div v-else-if="message.renderType === 'file'"
class="max-w-80 py-2.5 pr-2 pl-4 flex items-start bg-white space-x-2.5 msg-radius cursor-pointer border border-neutral-200 hover:bg-gray-50 transition-colors"
class="wechat-redpacket-card wechat-special-card wechat-file-card msg-radius"
:class="message.isSent ? 'wechat-special-sent-side' : ''"
@click="onFileClick(message)"
@contextmenu="openMediaContextMenu($event, message, 'file')">
<div class="flex-1 min-w-0">
<h4 class="break-words font-medium text-sm text-gray-900">{{ message.title || message.content }}</h4>
<small class="text-neutral-500 text-xs" v-if="message.fileSize">{{ formatFileSize(message.fileSize) }}</small>
<div class="wechat-redpacket-content">
<div class="wechat-redpacket-info wechat-file-info">
<span class="wechat-file-name">{{ message.title || message.content || '文件' }}</span>
<span class="wechat-file-size" v-if="message.fileSize">{{ formatFileSize(message.fileSize) }}</span>
</div>
<img :src="getFileIconUrl(message.title)" alt="" class="wechat-file-icon" />
</div>
<div class="shrink-0 w-10 h-10 flex items-center justify-center">
<!-- 根据文件类型显示图标 -->
<component :is="getFileIcon(message.title || message.content)" class="w-8 h-8" />
<div class="wechat-redpacket-bottom wechat-file-bottom">
<img :src="wechatPcLogoUrl" alt="" class="wechat-file-logo" />
<span>微信电脑版</span>
</div>
</div>
<div v-else-if="message.renderType === 'image'"
@@ -333,13 +337,13 @@
</div>
</div>
<!-- 红包消息 - 微信风格橙色卡片 -->
<div v-else-if="message.renderType === 'redPacket'" class="wechat-redpacket-card msg-radius"
:class="{ 'wechat-redpacket-received': message.redPacketReceived }">
<div v-else-if="message.renderType === 'redPacket'" class="wechat-redpacket-card wechat-special-card msg-radius"
:class="[{ 'wechat-redpacket-received': message.redPacketReceived }, message.isSent ? 'wechat-special-sent-side' : '']">
<div class="wechat-redpacket-content">
<img src="/assets/images/wechat/wechat-trans-icon3.png" v-if="!message.redPacketReceived" class="wechat-redpacket-icon" alt="">
<img src="/assets/images/wechat/wechat-trans-icon4.png" v-else class="wechat-redpacket-icon" alt="">
<div class="wechat-redpacket-info">
<span class="wechat-redpacket-text">{{ message.content || '恭喜发财,大吉大利' }}</span>
<span class="wechat-redpacket-text">{{ getRedPacketText(message) }}</span>
<span class="wechat-redpacket-status" v-if="message.redPacketReceived">已领取</span>
</div>
</div>
@@ -419,16 +423,60 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, defineComponent, h } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted, nextTick, defineComponent, h } from 'vue'
definePageMeta({
key: 'chat'
})
import { useApi } from '~/composables/useApi'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
import wechatPcLogoUrl from '~/assets/images/wechat/WeChat-Icon-Logo.wine.svg'
import zipIconUrl from '~/assets/images/wechat/zip.png'
import pdfIconUrl from '~/assets/images/wechat/pdf.png'
import wordIconUrl from '~/assets/images/wechat/word.png'
import excelIconUrl from '~/assets/images/wechat/excel.png'
// URL
const getFileIconUrl = (fileName) => {
if (!fileName) return zipIconUrl
const ext = String(fileName).split('.').pop()?.toLowerCase() || ''
switch (ext) {
case 'pdf':
return pdfIconUrl
case 'doc':
case 'docx':
return wordIconUrl
case 'xls':
case 'xlsx':
case 'csv':
return excelIconUrl
case 'zip':
case 'rar':
case '7z':
case 'tar':
case 'gz':
default:
return zipIconUrl
}
}
//
useHead({
title: '聊天记录查看器 - 微信数据分析助手'
})
const route = useRoute()
const routeUsername = computed(() => {
const raw = route.params.username
return (Array.isArray(raw) ? raw[0] : raw) || ''
})
const buildChatPath = (username) => {
return username ? `/chat/${encodeURIComponent(username)}` : '/chat'
}
//
const selectedContact = ref(null)
@@ -611,23 +659,55 @@ const messages = computed(() => {
return allMessages.value[selectedContact.value.username] || []
})
const formatTimeDivider = (ts) => {
// " HH:MM""X HH:MM""MMDD HH:MM""YYYYMMDD HH:MM"
const formatSmartTime = (ts) => {
if (!ts) return ''
try {
const d = new Date(Number(ts) * 1000)
const now = new Date()
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
if (sameDay) return `${hh}:${mm}`
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${m}-${dd} ${hh}:${mm}`
const timeStr = `${hh}:${mm}`
// 24
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const targetStart = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const dayDiff = Math.floor((todayStart - targetStart) / (1000 * 60 * 60 * 24))
//
if (dayDiff === 0) {
return timeStr
}
//
if (dayDiff === 1) {
return `昨天 ${timeStr}`
}
// 2-6
if (dayDiff >= 2 && dayDiff <= 6) {
const weekDays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
return `${weekDays[d.getDay()]} ${timeStr}`
}
//
const month = d.getMonth() + 1
const day = d.getDate()
if (d.getFullYear() === now.getFullYear()) {
return `${month}${day}${timeStr}`
}
//
return `${d.getFullYear()}${month}${day}${timeStr}`
} catch {
return ''
}
}
const formatTimeDivider = (ts) => {
return formatSmartTime(ts)
}
const formatMessageTime = (ts) => {
if (!ts) return ''
try {
@@ -672,6 +752,12 @@ const formatTransferAmount = (amount) => {
return s.replace(/[¥¥]/g, '').trim()
}
const getRedPacketText = (message) => {
const text = String(message?.content ?? '').trim()
if (!text || text === '[Red Packet]') return '恭喜发财,大吉大利'
return text
}
//
const FileIconPdf = defineComponent({
render() {
@@ -845,9 +931,38 @@ const hasMoreMessages = computed(() => {
//
//
const selectContact = (contact) => {
const selectContact = async (contact, options = {}) => {
if (!contact) return
selectedContact.value = contact
loadMessages({ username: contact.username, reset: true })
const username = contact?.username || ''
if (!username) return
if (options.syncRoute !== false && username) {
const current = routeUsername.value || ''
if (current !== username) {
await navigateTo(buildChatPath(username), { replace: options.replaceRoute !== false })
}
}
loadMessages({ username, reset: true })
}
const applyRouteSelection = async () => {
if (!contacts.value || contacts.value.length === 0) {
selectedContact.value = null
return
}
const requested = routeUsername.value || ''
if (requested) {
const matched = contacts.value.find((c) => c.username === requested)
if (matched) {
if (selectedContact.value?.username !== matched.username) {
await selectContact(matched, { syncRoute: false })
}
return
}
}
await selectContact(contacts.value[0], { syncRoute: true, replaceRoute: true })
}
//
@@ -918,9 +1033,7 @@ const loadSessionsForSelectedAccount = async () => {
messagesError.value = ''
selectedContact.value = null
if (contacts.value.length > 0) {
selectContact(contacts.value[0])
}
await applyRouteSelection()
}
const onAccountChange = async () => {
@@ -1163,6 +1276,15 @@ const refreshSelectedMessages = async () => {
await loadMessages({ username: selectedContact.value.username, reset: true })
}
watch(
routeUsername,
async () => {
if (isLoadingContacts.value) return
await applyRouteSelection()
},
{ immediate: true }
)
const autoLoadReady = ref(true)
const onMessageScroll = async () => {
@@ -1516,9 +1638,32 @@ const LinkCard = defineComponent({
color: #1a1a1a;
}
/* 统一特殊消息尾巴(红包 / 文件等) */
.wechat-special-card {
position: relative;
overflow: visible;
}
.wechat-special-card::after {
content: '';
position: absolute;
top: 16px;
left: -4px;
width: 10px;
height: 10px;
background-color: inherit;
transform: rotate(45deg);
border-radius: 2px;
}
.wechat-special-sent-side::after {
left: auto;
right: -4px;
}
/* 转账消息样式 - 微信风格 */
.wechat-transfer-card {
width: 240px;
width: 210px;
background: #f79c46;
border-radius: var(--message-radius);
overflow: visible;
@@ -1545,8 +1690,8 @@ const LinkCard = defineComponent({
.wechat-transfer-content {
display: flex;
align-items: center;
padding: 12px 14px;
min-height: 56px;
padding: 10px 12px;
min-height: 58px;
}
.wechat-transfer-icon {
@@ -1583,11 +1728,22 @@ const LinkCard = defineComponent({
}
.wechat-transfer-bottom {
height: 24px;
height: 27px;
display: flex;
align-items: center;
padding: 0 14px;
border-top: 1px solid rgba(255,255,255,0.2);
padding: 0 12px;
border-top: none;
position: relative;
}
.wechat-transfer-bottom::before {
content: '';
position: absolute;
top: 0;
left: 13px;
right: 13px;
height: 1px;
background: rgba(255,255,255,0.2);
}
.wechat-transfer-bottom span {
@@ -1633,30 +1789,18 @@ const LinkCard = defineComponent({
/* 红包消息样式 - 微信风格 */
.wechat-redpacket-card {
width: 240px;
width: 210px;
background: #fa9d3b;
border-radius: var(--message-radius);
overflow: hidden;
overflow: visible;
position: relative;
}
.wechat-redpacket-card::after {
content: '';
position: absolute;
top: 16px;
left: -4px;
width: 10px;
height: 10px;
background: #fa9d3b;
transform: rotate(45deg);
border-radius: 2px;
}
.wechat-redpacket-content {
display: flex;
align-items: center;
padding: 12px 14px;
min-height: 56px;
padding: 10px 12px;
min-height: 58px;
}
.wechat-redpacket-icon {
@@ -1689,11 +1833,22 @@ const LinkCard = defineComponent({
}
.wechat-redpacket-bottom {
height: 24px;
height: 27px;
display: flex;
align-items: center;
padding: 0 14px;
border-top: 1px solid rgba(255,255,255,0.2);
padding: 0 12px;
border-top: none;
position: relative;
}
.wechat-redpacket-bottom::before {
content: '';
position: absolute;
top: 0;
left: 13px;
right: 13px;
height: 1px;
background: rgba(255,255,255,0.2);
}
.wechat-redpacket-bottom span {
@@ -1706,10 +1861,6 @@ const LinkCard = defineComponent({
background: #f8e2c6;
}
.wechat-redpacket-received::after {
background: #f8e2c6;
}
.wechat-redpacket-received .wechat-redpacket-text,
.wechat-redpacket-received .wechat-redpacket-status {
color: #b88550;
@@ -1719,6 +1870,85 @@ const LinkCard = defineComponent({
color: #c9a67a;
}
/* 文件消息样式 - 基于红包样式覆盖 */
.wechat-file-card {
width: 210px;
background: #fff;
cursor: pointer;
transition: background-color 0.15s ease;
}
.wechat-file-card .wechat-redpacket-content {
padding: 10px 12px;
min-height: 58px;
}
.wechat-file-card .wechat-redpacket-bottom {
height: 27px;
padding: 0 12px;
border-top: none;
position: relative;
}
.wechat-file-card .wechat-redpacket-bottom::before {
content: '';
position: absolute;
top: 0;
left: 13px;
right: 13px;
height: 1.5px;
background: #e8e8e8;
}
.wechat-file-card:hover {
background: #f5f5f5;
}
.wechat-file-card .wechat-file-info {
margin-left: 0;
margin-right: 10px;
}
.wechat-file-name {
font-size: 14px;
color: #1a1a1a;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
line-height: 1.4;
}
.wechat-file-size {
font-size: 12px;
color: #b2b2b2;
margin-top: 4px;
}
.wechat-file-icon {
width: 40px;
height: 40px;
flex-shrink: 0;
object-fit: contain;
}
.wechat-file-bottom {
border-top: 1px solid #e8e8e8;
}
.wechat-file-bottom span {
font-size: 12px;
color: #b2b2b2;
}
.wechat-file-logo {
width: 18px;
height: 18px;
object-fit: contain;
margin-right: 4px;
}
/* 隐私模式模糊效果 */
.privacy-blur {
filter: blur(9px);