feat: improve Banban UI and binding diagnostics

This commit is contained in:
stu2not
2026-09-23 20:19:14 +08:00
parent da3f71a2e3
commit 3fd750989f
34 changed files with 4247 additions and 61 deletions
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
`Muyao-Headers.ttf` is a subset of `Muyao-Softbrush.ttf` for the login and binding headings: 伴伴、补全绑定、添加设备卡片、扫码贴卡绑定设备. Glyph outlines and advances are unchanged. Only the subset is bundled so the mini-program stays below the main-package size limit.
When changing those headings, regenerate the subset with FontTools:
```sh
pyftsubset Muyao-Softbrush.ttf --text='伴伴补全绑定添加设备卡片扫码贴卡绑定设备' --output-file=Muyao-Headers.ttf --layout-features='*' --name-IDs='*' --name-legacy --name-languages='*'
```
+12 -2
View File
@@ -5,6 +5,7 @@
left: 0;
right: 0;
display: flex;
overflow: hidden;
padding: 14px 20px calc(12px + env(safe-area-inset-bottom));
background: #fffdf7;
border: 2px solid #df9e55;
@@ -15,11 +16,20 @@
.companion-tab {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
color: #d6b18a;
}
.companion-tab.active { color: #ad681d; }
.companion-tab-icon { width: 62px; height: 52px; }
.companion-tab-label { margin-top: 4px; font-size: 24px; font-weight: 600; }
.companion-tab-icon { display: block; flex-shrink: 0; width: 62px; height: 52px; }
.companion-tab-label { display: block; margin-top: 4px; font-size: 24px; font-weight: 600; line-height: 1.4; white-space: nowrap; }
.companion-tab-bar::-webkit-scrollbar,
.companion-tab::-webkit-scrollbar,
.companion-tab-label::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
+4
View File
@@ -511,7 +511,10 @@ export default function Bind() {
scanType: ['qrCode', 'barCode'],
success: (res) => {
setIsScanning(false)
console.info('[bind] scanCode success response:', res)
console.info('[bind] scanCode raw result:', res.result || '')
const parsed = parseBindingPayload(res.result || '')
console.info('[bind] scanCode parsed payload:', parsed)
resetBindSessionState()
if (parsed.deviceId) setDeviceId(parsed.deviceId)
if (parsed.serialNumber) setSerialNumber(parsed.serialNumber)
@@ -523,6 +526,7 @@ export default function Bind() {
},
fail: (err) => {
setIsScanning(false)
console.warn('[bind] scanCode failed response:', err)
if (err.errMsg && err.errMsg.includes('cancel')) return
Taro.showToast({
title: '扫码失败,请重试',
+14 -5
View File
@@ -32,13 +32,15 @@
.character-info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
.character-avatar {
width: 72px;
height: 72px;
background: linear-gradient(135deg, #FF8C42 0%, #FFB347 100%);
flex-shrink: 0;
background: #fff1d4;
border-radius: 18px;
display: flex;
align-items: center;
@@ -48,10 +50,16 @@
}
.character-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
.character-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 32px;
font-weight: 600;
color: #1A1A1A;
@@ -61,6 +69,7 @@
.character-desc {
font-size: 24px;
color: #999999;
word-break: break-word;
}
}
}
@@ -124,8 +133,8 @@
.user-avatar {
width: 68px;
height: 68px;
background: #FFE4C4;
border-radius: 50%;
background: #fff1d4;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
@@ -139,7 +148,7 @@
.peer-avatar {
width: 68px;
height: 68px;
background: #FEF3E8;
background: #fff1d4;
border-radius: 18px;
display: flex;
align-items: center;
@@ -253,6 +262,6 @@
letter-spacing: 2px;
}
.chat-avatar-art { width: 100%; height: 100%; }
.chat-avatar-art { display: block; width: 100%; height: 100%; }
.user-avatar { margin-left: 14px; }
.message-item.user .message-bubble { margin: 0; }
@@ -768,6 +768,8 @@ export default function ChatDetail() {
<ScrollView
className='chat-list'
scrollY
enhanced
showScrollbar={false}
scrollWithAnimation={scrollWithAnimation}
scrollIntoView={scrollTarget}
>
+20 -17
View File
@@ -6,20 +6,8 @@
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
.page-header {
padding: 80px 32px 24px;
.page-title {
font-size: 44px;
font-weight: 700;
color: #1A1A1A;
display: block;
line-height: 1.2;
}
}
.conversation-list {
height: calc(100vh - 200px);
height: calc(100vh - var(--bb-navigation-bar-top) - var(--bb-navigation-bar-height) - 144px - env(safe-area-inset-bottom));
padding: 0 24px;
box-sizing: border-box;
overflow: hidden;
@@ -113,7 +101,7 @@
height: 52px;
padding: 0 18px;
border-radius: 999px;
background: #FFF2E7;
background: #fff1d4;
&:active {
opacity: 0.72;
@@ -159,7 +147,7 @@
}
&.parent {
background: #E8F8F0;
background: #fff1d4;
text {
color: #16A34A;
@@ -262,17 +250,23 @@
color: #999999;
flex-shrink: 0;
margin-left: 16px;
white-space: nowrap;
}
}
.conversation-name-wrap {
flex: 1;
display: flex;
align-items: center;
flex-wrap: wrap;
min-width: 0;
}
.conversation-name {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 32px;
font-weight: 600;
color: #1A1A1A;
@@ -280,6 +274,7 @@
}
.conversation-tag {
flex-shrink: 0;
height: 40px;
padding: 0 14px;
border-radius: 999px;
@@ -301,12 +296,17 @@
}
.conversation-footer {
min-width: 0;
display: flex;
justify-content: space-between;
align-items: center;
.last-message {
display: block;
min-width: 0;
max-width: 100%;
font-size: 28px;
line-height: 1.5;
color: #666666;
flex: 1;
overflow: hidden;
@@ -318,6 +318,9 @@
.conversation-meta {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 10px;
font-size: 24px;
color: #A0A0A0;
@@ -369,5 +372,5 @@
color: #1A1A1A;
}
.conversation-avatar-art { width: 100%; height: 100%; }
.conversation-avatar-art { display: block; width: 100%; height: 100%; }
.conversation-avatar { background: #fff1d4; padding: 8px; box-sizing: border-box; }
+5 -4
View File
@@ -1,4 +1,5 @@
import { getParticipantArtwork } from '@/utils/artwork'
import { formatConversationPreview } from '@/utils/chat-preview'
import PageTitle from '@/components/page-title'
import { getPageStyle } from '@/utils/page-style'
import { setActiveTabRoute } from '@/utils/tab-bar'
@@ -172,7 +173,7 @@ export default function Chat() {
<Text className='empty-title'>当前宝贝暂无会话</Text>
</View>
) : (
<ScrollView className='conversation-list' scrollY>
<ScrollView className='conversation-list' scrollY enhanced showScrollbar={false}>
{conversations.map((conversation) => (
<View
key={conversation.key}
@@ -191,9 +192,9 @@ export default function Chat() {
<Text className='conversation-time'>{conversation.time}</Text>
</View>
<View className='conversation-footer'>
<Text className='last-message' numberOfLines={1}>
{conversation.lastMessage}
</Text>
<View className='last-message'>
{formatConversationPreview(conversation.lastMessage)}
</View>
</View>
{conversation.childName && <Text className='conversation-meta'>{conversation.childName}</Text>}
</View>
-12
View File
@@ -6,18 +6,6 @@
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
.page-header {
padding: 80px 32px 24px;
.page-title {
font-size: 44px;
font-weight: 700;
color: #1A1A1A;
display: block;
line-height: 1.2;
}
}
.mode-switch {
margin: 0 24px 20px;
padding: 8px;
-11
View File
@@ -6,17 +6,6 @@
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
.page-header {
padding: 80px 32px 24px;
.page-title {
font-size: 44px;
font-weight: 700;
color: #1A1A1A;
display: block;
}
}
.user-card {
background: #FFFFFF;
border-radius: 20px;
@@ -27,7 +27,13 @@ export default function TrafficBindingPage() {
setOperation(current)
if (action === 'unbind' && current.status === 'completed') clearSelectedBindingDeviceId()
setError('')
} catch (e: any) { setError(e.message || '暂时无法确认,请重试') }
} catch (e: any) {
console.error('[traffic-binding] recover:error', {
deviceId, action, operationId: operation?.operation_id || null,
code: e?.code || null, message: e?.message || String(e),
})
setError(e.message || '暂时无法确认,请重试')
}
finally { lock.current = false; setBusy(false) }
}
useDidShow(() => { void recover() })
@@ -49,7 +55,13 @@ export default function TrafficBindingPage() {
}
setOperation(current)
if (action === 'unbind' && current.status === 'completed') clearSelectedBindingDeviceId()
} catch (e: any) { setError(e.message || e.errMsg || '操作未完成,请继续重试') }
} catch (e: any) {
console.error('[traffic-binding] continue:error', {
deviceId, action, operationId: operation?.operation_id || null,
code: e?.code || null, message: e?.message || e?.errMsg || String(e),
})
setError(e.message || e.errMsg || '操作未完成,请继续重试')
}
finally { lock.current = false; setBusy(false) }
}
const completed = operation?.status === 'completed'
+33 -2
View File
@@ -3,6 +3,9 @@ import { request } from './api'
import { getCurrentUserId } from './session'
export const TRAFFIC_APP_ID = 'wx56e1cfc52aab630f'
function trafficLog(event: string, details: Record<string, unknown> = {}) {
console.info(`[traffic-binding] ${event}`, details)
}
export type TrafficAction = 'bind' | 'unbind'
export interface TrafficOperation {
operation_id: string
@@ -32,7 +35,11 @@ export function rememberOperation(operation: TrafficOperation) {
Taro.setStorageSync(key(), { operation, receipt: previous?.operation.operation_id === operation.operation_id ? previous.receipt : undefined })
}
export function captureTrafficReturn(options: any): boolean {
if (options?.referrerInfo?.appId !== TRAFFIC_APP_ID) return false
const sourceAppId = options?.referrerInfo?.appId
if (sourceAppId !== TRAFFIC_APP_ID) {
trafficLog('return:ignored-app', { sourceAppId: sourceAppId || null, expectedAppId: TRAFFIC_APP_ID })
return false
}
const data = options.referrerInfo.extraData
const pending = getPendingTraffic()
if (!pending || !data || data.operationId !== pending.operation.operation_id
@@ -104,10 +111,17 @@ export async function resumeTrafficOperation(deviceId: string, action: TrafficAc
return startTrafficOperation(deviceId, action)
}
export async function finishTrafficUnbind(operation: TrafficOperation) {
trafficLog('finish-unbind:start', {
operationId: operation.operation_id, deviceId: operation.device_id,
status: operation.status, trafficStatus: operation.traffic_status,
})
const user = getCurrentUserId()
const result = await request<TrafficOperation>(`/banban/joint-bindings/${operation.operation_id}/finish-unbind`, { method: 'POST' })
assertSameUser(user)
rememberOperation(result)
trafficLog('finish-unbind:response', {
operationId: result.operation_id, status: result.status, trafficStatus: result.traffic_status,
})
return result
}
function targetVersion(): 'trial' | 'release' {
@@ -126,11 +140,28 @@ async function navigate(options: any, withinApp = false) {
}
export async function jumpTraffic(operation: TrafficOperation) {
const user = getCurrentUserId()
trafficLog('continue:start', {
operationId: operation.operation_id, deviceId: operation.device_id,
action: operation.action, status: operation.status, trafficStatus: operation.traffic_status,
})
const data = await request<any>(`/banban/joint-bindings/${operation.operation_id}/prepare`, { method: 'POST' })
assertSameUser(user)
rememberOperation(data.operation)
const targetAppMatches = data.target_app_id === TRAFFIC_APP_ID
trafficLog('continue:prepare-response', {
operationId: operation.operation_id, action: operation.action,
status: data.operation?.status || null, trafficStatus: data.operation?.traffic_status || null,
targetAppId: data.target_app_id || null, expectedAppId: TRAFFIC_APP_ID,
targetAppMatches, hasHandoffToken: typeof data.handoff_token === 'string' && data.handoff_token.length > 0,
hasRequestId: typeof data.request_id === 'string' && data.request_id.length > 0,
})
if (data.operation.status === 'completed' || data.operation.traffic_status === 'server_confirmed') return data.operation as TrafficOperation
if (data.target_app_id !== TRAFFIC_APP_ID) throw new Error('目标小程序配置不一致')
if (!targetAppMatches) {
trafficLog('continue:target-app-mismatch', {
operationId: operation.operation_id, targetAppId: data.target_app_id || null, expectedAppId: TRAFFIC_APP_ID,
})
throw new Error('目标小程序配置不一致')
}
await navigate({
appId: TRAFFIC_APP_ID, path: 'pages/handoff/index', envVersion: targetVersion(),
extraData: { operationId: operation.operation_id, action: operation.action,
+1 -1
View File
@@ -1,6 +1,6 @@
@font-face {
font-family: 'MuyaoSoftbrush';
src: url('../assets/fonts/Muyao-Softbrush.ttf') format('truetype');
src: url('../assets/fonts/Muyao-Headers.ttf') format('truetype');
font-weight: 400;
font-style: normal;
}
+6 -4
View File
@@ -1,15 +1,17 @@
const roleArtwork: Record<string, string> = {
banban: require('../assets/ui/role-banban.png'),
banban: require('../assets/ui/role-tuntunzai.png'),
baikeboshi: require('../assets/ui/role-baike.png'),
zhuli: require('../assets/ui/role-zhuli.png'),
tuntunzai: require('../assets/ui/role-tuntunzai.png'),
}
export function getRoleArtwork(roleKey?: string): string {
return roleArtwork[String(roleKey || '').toLowerCase()] || roleArtwork.banban
return roleArtwork[String(roleKey || '').trim().toLowerCase()] || roleArtwork.banban
}
export function getParticipantArtwork(kind?: string, _roleKey?: string): string {
if (kind === 'parent') return require('../assets/ui/avatar-parent.png')
export function getParticipantArtwork(kind?: string, roleKey?: string): string {
const participantKind = String(kind || '').trim().toLowerCase()
if (participantKind === 'parent') return require('../assets/ui/avatar-parent.png')
if (participantKind === 'ai') return getRoleArtwork(roleKey)
return roleArtwork.tuntunzai
}
+4
View File
@@ -0,0 +1,4 @@
// Keep line breaks in message history; only flatten the conversation-list preview.
export function formatConversationPreview(value?: string | null): string {
return String(value || '').replace(/\s+/g, ' ').trim()
}
@@ -0,0 +1,79 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { test } = require('node:test')
const ts = require('typescript')
function loadTypeScript(file, mocks = {}) {
const source = fs.readFileSync(path.join(__dirname, '../src', file), 'utf8')
const { outputText } = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017, esModuleInterop: true },
})
const module = { exports: {} }
new Function('require', 'module', 'exports', outputText)((id) => {
if (Object.hasOwn(mocks, id)) return mocks[id]
if (id.endsWith('.png')) return path.basename(id)
return require(id)
}, module, module.exports)
return module.exports
}
const { getParticipantArtwork, getRoleArtwork } = loadTypeScript('utils/artwork.ts')
const { formatConversationPreview } = loadTypeScript('utils/chat-preview.ts')
const story = '好呀,我给你讲一个《小水滴的旅行》的故事。\r\n\r\n有一天,小水滴在云妈妈的怀抱里睡着了。\n小水滴落到了一片荷叶上。\u2028小水滴继续向前,来到了大河里。\n这个故事告诉我们,水是不断循环的。💧'
const roles = ['Banban', 'Baikeboshi', 'Zhuli', 'Tuntunzai']
const { getConversations, getMessages } = loadTypeScript('services/chat.ts', {
'@tarojs/taro': {},
'./auth': { getCurrentUserId: () => 1 },
'./binding': { loadCurrentChildBindingContext: async () => ({
currentChild: { child_id: 7, child_name: '拌拌' },
currentBinding: { device_id: 'fixture-device' },
}) },
'./session': {},
'./api': { request: async (url) => {
if (url.endsWith('/roles')) return roles.map(role_key => ({ role_key, name: role_key }))
if (url.includes('/ai-conversations')) return { items: roles.map((role_key, index) => ({
conversation_id: 10 + index, role_key, message_count: 1, last_message_preview: story,
})) }
if (url.includes('/conversations?')) return { items: [
{ conversation_id: 1, conversation_type_name: 'parent_child', peer_type: 'parent', peer_id: '1' },
{ conversation_id: 2, conversation_type_name: 'parent_child', peer_type: 'parent', peer_id: '2', peer_name: '右右' },
{ conversation_id: 3, conversation_type_name: 'child_peer', peer_type: 'child', peer_id: '42', peer_name: '伴伴2号' },
] }
if (url.includes('/messages?')) return { items: [{
id: 100, conversation_id: 11, role_key: 'Baikeboshi', is_user: false, content: story,
}] }
throw new Error(`Unexpected fixture request: ${url}`)
} },
})
test('real conversation mapping gives both parent entries the flower avatar and preserves specialist AI roles', async () => {
const conversations = await getConversations()
const artworkById = Object.fromEntries(conversations.map(item => [
item.id, getParticipantArtwork(item.peerKind, item.roleKey),
]))
assert.equal(artworkById[1], 'avatar-parent.png')
assert.equal(artworkById[2], 'avatar-parent.png')
assert.equal(artworkById[3], 'role-tuntunzai.png')
assert.equal(artworkById[10], 'role-tuntunzai.png')
assert.equal(artworkById[11], 'role-baike.png')
assert.equal(artworkById[12], 'role-zhuli.png')
})
test('the role chooser and conversation avatars agree for each AI role', () => {
for (const role of roles) {
assert.equal(getParticipantArtwork('ai', role), getRoleArtwork(role))
assert.equal(getParticipantArtwork('ai', ` ${role.toUpperCase()} `), getRoleArtwork(role))
}
})
test('a multiline story becomes a single-line list preview while message history keeps its paragraphs', async () => {
const conversation = (await getConversations()).find(item => item.id === 11)
const preview = formatConversationPreview(conversation.lastMessage)
assert.doesNotMatch(preview, /[\r\n\u2028\u2029]/)
assert.match(preview, /故事。 有一天/)
assert.ok(preview.endsWith('💧'))
assert.equal(conversation.lastMessage, story)
const messages = await getMessages(11, 'ai')
assert.equal(messages[0].content, story)
})