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>
0 commit comments