Skip to content

Commit 87cc2d7

Browse files
authored
Merge pull request #39 from H3CoF6/feat/biz
粗略解析微信支付记录,独立展示公众号服务号消息
2 parents 010c9ce + a618c46 commit 87cc2d7

6 files changed

Lines changed: 724 additions & 26 deletions

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
<template>
2+
<div class="biz-page h-screen flex overflow-hidden" style="background-color: var(--app-shell-bg)">
3+
4+
<div :class="['w-[300px] lg:w-[320px] border-r flex flex-col flex-shrink-0 z-10', isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-200']">
5+
<div class="p-3 border-b" :class="isDark ? 'border-[#333]' : 'border-gray-200'" style="background-color: var(--app-surface-muted)">
6+
<div class="contact-search-wrapper flex-1">
7+
<input
8+
v-model="searchQuery"
9+
type="text"
10+
class="contact-search-input"
11+
placeholder="搜索服务号"
12+
/>
13+
</div>
14+
</div>
15+
16+
<div class="flex-1 overflow-y-auto min-h-0">
17+
<div v-if="loadingAccounts" class="flex justify-center py-4">
18+
<span class="text-sm" :class="isDark ? 'text-gray-500' : 'text-gray-400'">加载中...</span>
19+
</div>
20+
<div v-else>
21+
<div
22+
v-for="item in filteredAccounts"
23+
:key="item.username"
24+
@click="selectAccount(item)"
25+
class="flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors border-b"
26+
:class="[
27+
isDark ? 'border-[#333]' : 'border-gray-50',
28+
selectedAccount?.username === item.username
29+
? (isDark ? 'bg-[#333]' : 'bg-[#E5E5E5]') // 选中状态
30+
: item.username === 'gh_3dfda90e39d6'
31+
? (isDark ? 'bg-[#2a2a2a] hover:bg-[#333]' : 'bg-[#F2F2F2] hover:bg-[#EAEAEA]') // 微信支付专门的底色
32+
: (isDark ? 'hover:bg-[#252525]' : 'hover:bg-gray-50') // 普通悬浮色
33+
]"
34+
>
35+
<img v-if="item.avatar" :src="api.getBizProxyImageUrl(item.avatar)" :class="['w-10 h-10 rounded-md object-cover flex-shrink-0', isDark ? 'bg-[#333]' : 'bg-gray-200']" alt=""/>
36+
<div v-else class="w-10 h-10 rounded-md bg-[#03C160] text-white flex items-center justify-center text-lg font-medium flex-shrink-0 shadow-sm">
37+
{{ (item.name || item.username).charAt(0).toUpperCase() }}
38+
</div>
39+
40+
<div class="flex-1 min-w-0 flex flex-col justify-center gap-0.5">
41+
<div class="flex justify-between items-center">
42+
<h3 class="text-sm truncate" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ item.name || item.username }}</h3>
43+
<span v-if="item.formatted_last_time" class="text-[11px] flex-shrink-0 ml-2" :class="isDark ? 'text-gray-500' : 'text-gray-400'">
44+
{{ item.formatted_last_time }}
45+
</span>
46+
</div>
47+
48+
<div
49+
class="text-[10px] px-1.5 py-0.5 rounded w-max mt-0.5"
50+
:class="[
51+
item.type === 1 ? (isDark ? 'text-[#03C160] bg-[#03C160]/20' : 'text-[#03C160] bg-[#03C160]/10') : // 服务号
52+
item.type === 0 ? (isDark ? 'text-blue-400 bg-blue-900/40' : 'text-blue-500 bg-blue-50') : // 公众号
53+
item.type === 2 ? (isDark ? 'text-orange-400 bg-orange-900/40' : 'text-orange-500 bg-orange-50') : // 企业号
54+
(isDark ? 'text-gray-400 bg-gray-700/50' : 'text-gray-400 bg-gray-100') // 未知
55+
]"
56+
>
57+
{{ {1: '服务号', 0: '公众号', 2: '企业号', 3: '未知'}[item.type] || '未知' }}
58+
</div>
59+
</div>
60+
</div>
61+
</div>
62+
</div>
63+
</div>
64+
65+
<div class="flex-1 flex flex-col min-h-0 min-w-0" :class="isDark ? 'bg-[#121212]' : 'bg-[#F5F5F5]'">
66+
<div v-if="selectedAccount" class="flex-1 flex flex-col min-h-0 relative">
67+
<div class="h-14 border-b flex items-center px-5 shrink-0 z-10" :class="isDark ? 'bg-[#121212] border-[#333]' : 'bg-[#F5F5F5] border-gray-200'">
68+
<h2 class="text-base" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ selectedAccount.name }}</h2>
69+
</div>
70+
71+
<div class="flex-1 overflow-y-auto px-4 py-6 flex flex-col-reverse" @scroll="handleScroll" ref="messageListRef">
72+
<div v-if="!hasMore" class="text-center text-xs py-4 w-full" :class="isDark ? 'text-gray-500' : 'text-gray-400'">没有更多消息了</div>
73+
<div v-if="loadingMessages" class="text-center text-xs py-4 w-full" :class="isDark ? 'text-gray-500' : 'text-gray-400'">正在加载...</div>
74+
75+
<div class="w-full max-w-[400px] mx-auto flex flex-col-reverse gap-6">
76+
<div v-for="msg in messages" :key="msg.local_id" class="w-full">
77+
78+
<div v-if="selectedAccount.username === 'gh_3dfda90e39d6'" class="rounded-xl shadow-sm p-5 border" :class="isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-100'">
79+
<div class="flex items-center text-sm mb-5" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
80+
<img v-if="msg.merchant_icon" :src="api.getBizProxyImageUrl(msg.merchant_icon)" class="w-6 h-6 rounded-full mr-2 object-cover" alt=""/>
81+
<div v-else class="w-6 h-6 rounded-full mr-2 flex items-center justify-center" :class="isDark ? 'bg-green-900/40 text-green-400' : 'bg-green-100 text-green-600'">¥</div>
82+
<span>{{ msg.merchant_name || '微信支付' }}</span>
83+
</div>
84+
<div class="text-center mb-6">
85+
<h3 class="text-[22px] font-medium mb-1" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ msg.title }}</h3>
86+
</div>
87+
<div class="text-[13px] whitespace-pre-wrap leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
88+
{{ msg.description }}
89+
</div>
90+
<div class="mt-4 pt-3 border-t text-[12px] text-right" :class="isDark ? 'border-[#333] text-gray-500' : 'border-gray-100 text-gray-400'">
91+
{{ msg.formatted_time }}
92+
</div>
93+
</div>
94+
95+
<div v-else class="rounded-xl shadow-sm overflow-hidden border" :class="isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-100'">
96+
<a :href="msg.url" target="_blank" class="block relative group cursor-pointer">
97+
<img :src="msg.cover ? api.getBizProxyImageUrl(msg.cover) : defaultImage" :class="['w-full h-[180px] object-cover', isDark ? 'bg-[#333]' : 'bg-gray-100']" alt=""/>
98+
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3 pt-8">
99+
<h3 class="text-white text-[15px] font-medium leading-snug line-clamp-2 group-hover:underline">
100+
{{ msg.title }}
101+
</h3>
102+
</div>
103+
</a>
104+
105+
<div v-if="msg.des" class="px-4 py-3 text-[13px] border-b" :class="isDark ? 'text-gray-400 border-[#333]' : 'text-gray-500 border-gray-50'">
106+
{{ msg.des }}
107+
</div>
108+
109+
<div v-if="msg.content_list && msg.content_list.length > 1" class="flex flex-col">
110+
<a
111+
v-for="(item, idx) in msg.content_list.slice(1)"
112+
:key="idx"
113+
:href="item.url"
114+
target="_blank"
115+
class="flex items-center justify-between p-3 border-t hover:bg-opacity-50 cursor-pointer group"
116+
:class="isDark ? 'border-[#333] hover:bg-[#252525]' : 'border-gray-100 hover:bg-gray-50'"
117+
>
118+
<span class="text-[14px] leading-snug line-clamp-2 pr-3 group-hover:underline" :class="isDark ? 'text-gray-200' : 'text-gray-800'">
119+
{{ item.title }}
120+
</span>
121+
<img :src="item.cover ? api.getBizProxyImageUrl(item.cover) : defaultImage" :class="['w-12 h-12 rounded object-cover flex-shrink-0 border', isDark ? 'bg-[#333] border-[#444]' : 'bg-gray-100 border-gray-100']" alt=""/>
122+
</a>
123+
</div>
124+
</div>
125+
126+
</div>
127+
</div>
128+
</div>
129+
</div>
130+
131+
<div v-else class="flex-1 flex items-center justify-center">
132+
<div class="text-center">
133+
<div class="w-20 h-20 mx-auto mb-5 rounded-2xl flex items-center justify-center" :class="isDark ? 'bg-[#2a2a2a]' : 'bg-gray-200/50'">
134+
<svg class="w-10 h-10" :class="isDark ? 'text-gray-600' : 'text-gray-400'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
135+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5L18.5 7H20" />
136+
</svg>
137+
</div>
138+
<p class="text-sm" :class="isDark ? 'text-gray-500' : 'text-gray-400'">请选择一个服务号查看消息</p>
139+
</div>
140+
</div>
141+
</div>
142+
</div>
143+
</template>
144+
145+
<script setup>
146+
import { ref, computed, onMounted } from 'vue'
147+
148+
import { useApi } from '~/composables/useApi'
149+
const api = useApi()
150+
151+
import { storeToRefs } from 'pinia'
152+
import { useThemeStore } from '~/stores/theme'
153+
154+
const accounts = ref([])
155+
const loadingAccounts = ref(false)
156+
const searchQuery = ref('')
157+
const selectedAccount = ref(null)
158+
159+
const themeStore = useThemeStore()
160+
const { isDark } = storeToRefs(themeStore)
161+
const messages = ref([])
162+
const loadingMessages = ref(false)
163+
const offset = ref(0)
164+
const limit = 20
165+
const hasMore = ref(true)
166+
167+
const messageListRef = ref(null)
168+
169+
// 默认占位图
170+
// const defaultAvatar = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiNlNWU3ZWIiLz48L3N2Zz4='
171+
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg=='
172+
173+
const fetchAccounts = async () => {
174+
loadingAccounts.value = true
175+
try {
176+
const res = await api.listBizAccounts()
177+
if (res && res.data) {
178+
accounts.value = res.data
179+
}
180+
} catch (err) {
181+
console.error('获取服务号失败:', err)
182+
} finally {
183+
loadingAccounts.value = false
184+
}
185+
}
186+
187+
// 搜索过滤
188+
const filteredAccounts = computed(() => {
189+
if (!searchQuery.value) return accounts.value
190+
const q = searchQuery.value.toLowerCase()
191+
return accounts.value.filter(a =>
192+
(a.name && a.name.toLowerCase().includes(q)) ||
193+
(a.username && a.username.toLowerCase().includes(q))
194+
)
195+
})
196+
197+
// 点击选择服务号
198+
const selectAccount = (account) => {
199+
if (selectedAccount.value?.username === account.username) return
200+
selectedAccount.value = account
201+
202+
// 重置消息状态
203+
messages.value = []
204+
offset.value = 0
205+
hasMore.value = true
206+
207+
loadMessages()
208+
}
209+
210+
// 加载消息
211+
const loadMessages = async () => {
212+
if (loadingMessages.value || !hasMore.value || !selectedAccount.value) return
213+
214+
loadingMessages.value = true
215+
try {
216+
const username = selectedAccount.value.username
217+
const params = { username, offset: offset.value, limit }
218+
219+
let res
220+
if (username === 'gh_3dfda90e39d6') {
221+
res = await api.listBizPayRecords(params)
222+
} else {
223+
res = await api.listBizMessages(params)
224+
}
225+
226+
if (res && res.data) {
227+
if (res.data.length < limit) {
228+
hasMore.value = false
229+
}
230+
// 追加数据
231+
messages.value.push(...res.data)
232+
offset.value += limit
233+
}
234+
} catch (err) {
235+
console.error('加载消息失败:', err)
236+
} finally {
237+
loadingMessages.value = false
238+
}
239+
}
240+
241+
// 向上滚动加载逻辑
242+
// 因为容器设置了 flex-col-reverse,所以 scrollTop 越靠近负值(或0取决于浏览器)越是到了历史消息端
243+
// 但比较通用兼容的做法是监听 scroll,距离顶部或底部小于阈值时触发
244+
const handleScroll = (e) => {
245+
const target = e.target
246+
// 针对 flex-col-reverse: 滚动到底部实际上是视觉上的最上方(历史消息)
247+
// 当 scrollHeight - Math.abs(scrollTop) - clientHeight < 50 时加载
248+
if (target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight < 50) {
249+
loadMessages()
250+
}
251+
}
252+
253+
onMounted(() => {
254+
fetchAccounts()
255+
})
256+
</script>
257+
258+
<style scoped>
259+
/* 隐藏滚动条但允许滚动(可选) */
260+
.overflow-y-auto::-webkit-scrollbar {
261+
width: 6px;
262+
}
263+
.overflow-y-auto::-webkit-scrollbar-track {
264+
background: transparent;
265+
}
266+
.overflow-y-auto::-webkit-scrollbar-thumb {
267+
background-color: rgba(0,0,0,0.1);
268+
border-radius: 10px;
269+
}
270+
</style>

frontend/components/SidebarRail.vue

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,21 @@
101101
</div>
102102
</div>
103103

104+
<div
105+
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
106+
title="服务号"
107+
@click="goBiz"
108+
>
109+
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
110+
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isBizRoute }">
111+
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
112+
<path d="M11 5L6 9H2v6h4l5 4V5z"></path>
113+
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
114+
</svg>
115+
</div>
116+
</div>
117+
</div>
118+
104119
<!-- Wrapped -->
105120
<div
106121
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@@ -479,34 +494,17 @@ const isChatRoute = computed(() => route.path?.startsWith('/chat'))
479494
const isEditsRoute = computed(() => route.path?.startsWith('/edits'))
480495
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
481496
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
497+
const isBizRoute = computed(() => route.path?.startsWith('/biz')) // 新增
482498
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
483-
const goChat = async () => {
484-
await navigateTo('/chat')
485-
}
486499
487-
const goEdits = async () => {
488-
await navigateTo('/edits')
489-
}
490-
491-
const goSns = async () => {
492-
await navigateTo('/sns')
493-
}
494-
495-
const goContacts = async () => {
496-
await navigateTo('/contacts')
497-
}
498-
499-
const goWrapped = async () => {
500-
await navigateTo('/wrapped')
501-
}
502-
503-
const goGuide = async () => {
504-
await navigateTo('/')
505-
}
506-
507-
const goSettings = () => {
508-
openSettingsDialog()
509-
}
500+
const goChat = async () => { await navigateTo('/chat') }
501+
const goEdits = async () => { await navigateTo('/edits') }
502+
const goSns = async () => { await navigateTo('/sns') }
503+
const goContacts = async () => { await navigateTo('/contacts') }
504+
const goBiz = async () => { await navigateTo('/biz') }
505+
const goWrapped = async () => { await navigateTo('/wrapped') }
506+
const goGuide = async () => { await navigateTo('/') }
507+
const goSettings = () => { openSettingsDialog() }
510508
511509
const onWindowKeydown = (event) => {
512510
if (event?.key !== 'Escape') return

frontend/composables/useApi.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,44 @@ export const useApi = () => {
561561
return await request('/get_image_key')
562562
}
563563

564+
// 枚举服务号信息
565+
const listBizAccounts = async (params = {}) => {
566+
const query = new URLSearchParams()
567+
if (params && params.account) query.set('account', params.account)
568+
const url = '/biz/list' + (query.toString() ? `?${query.toString()}` : '')
569+
return await request(url)
570+
}
571+
572+
// 获取普通服务号消息
573+
const listBizMessages = async (params = {}) => {
574+
const query = new URLSearchParams()
575+
if (params && params.account) query.set('account', params.account)
576+
if (params && params.username) query.set('username', params.username)
577+
if (params && params.limit != null) query.set('limit', String(params.limit))
578+
if (params && params.offset != null) query.set('offset', String(params.offset))
579+
const url = '/biz/messages' + (query.toString() ? `?${query.toString()}` : '')
580+
return await request(url)
581+
}
582+
583+
// 获取微信支付记录
584+
const listBizPayRecords = async (params = {}) => {
585+
const query = new URLSearchParams()
586+
if (params && params.account) query.set('account', params.account)
587+
if (params && params.limit != null) query.set('limit', String(params.limit))
588+
if (params && params.offset != null) query.set('offset', String(params.offset))
589+
const url = '/biz/pay_records' + (query.toString() ? `?${query.toString()}` : '')
590+
return await request(url)
591+
}
592+
593+
const getBizProxyImageUrl = (url) => {
594+
if (!url) return ''
595+
if (url.startsWith('data:')) return url // 如果已经是 base64,不处理
596+
const query = new URLSearchParams()
597+
query.set('url', url)
598+
const base = baseURL ? baseURL.replace(/\/$/, '') : ''
599+
return `${base}/biz/proxy_image?${query.toString()}`
600+
}
601+
564602
return {
565603
detectWechat,
566604
detectCurrentAccount,
@@ -616,5 +654,9 @@ export const useApi = () => {
616654
getKeys,
617655
getImageKey,
618656
getWxStatus,
657+
listBizAccounts,
658+
listBizMessages,
659+
listBizPayRecords,
660+
getBizProxyImageUrl,
619661
}
620662
}

0 commit comments

Comments
 (0)