完善多赛道联调与全局产品架构

This commit is contained in:
2026-04-02 18:11:43 +08:00
parent 6964e26ec9
commit 0e28f70bad
45 changed files with 4819 additions and 282 deletions

View File

@@ -0,0 +1,575 @@
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import { getEventPlay, launchEvent, type BackendCourseVariantSummary, type BackendEventPlayResult } from '../../utils/backendApi'
import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter'
import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch'
import { HeartRateController } from '../../engine/sensor/heartRateController'
const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice'
const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1'
const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1'
type EventPreparePageData = {
eventId: string
loading: boolean
titleText: string
summaryText: string
releaseText: string
actionText: string
statusText: string
assignmentMode: string
variantModeText: string
variantSummaryText: string
selectedVariantId: string
selectedVariantText: string
selectableVariants: Array<{
id: string
name: string
routeCodeText: string
descriptionText: string
selected: boolean
}>
locationStatusText: string
heartRateStatusText: string
heartRateDeviceText: string
heartRateScanText: string
heartRateConnected: boolean
showHeartRateDevicePicker: boolean
locationPermissionGranted: boolean
locationBackgroundPermissionGranted: boolean
heartRateDiscoveredDevices: Array<{
deviceId: string
name: string
rssiText: string
preferred: boolean
connected: boolean
}>
mockSourceStatusText: string
}
function formatAssignmentMode(mode?: string | null): string {
if (mode === 'manual') {
return '手动选择'
}
if (mode === 'random') {
return '随机分配'
}
if (mode === 'server-assigned') {
return '后台指定'
}
return '默认单赛道'
}
function formatVariantSummary(result: BackendEventPlayResult): string {
const variants = result.play.courseVariants || []
if (!variants.length) {
return '当前未声明额外赛道版本,启动时按默认赛道进入。'
}
const preview = variants.map((item) => {
const title = item.routeCode || item.name
return item.selectable === false ? `${title}(固定)` : title
}).join(' / ')
if (result.play.assignmentMode === 'manual') {
return `当前活动支持 ${variants.length} 条赛道。本阶段前端先展示赛道信息,最终绑定以后端 launch 返回为准:${preview}`
}
if (result.play.assignmentMode === 'random') {
return `当前活动支持 ${variants.length} 条赛道,进入地图前由后端随机绑定:${preview}`
}
if (result.play.assignmentMode === 'server-assigned') {
return `当前活动赛道由后台预先指定:${preview}`
}
return preview
}
function resolveSelectedVariantId(
currentVariantId: string,
assignmentMode?: string | null,
variants?: BackendCourseVariantSummary[] | null,
): string {
if (assignmentMode !== 'manual' || !variants || !variants.length) {
return ''
}
const selectable = variants.filter((item) => item.selectable !== false)
if (!selectable.length) {
return ''
}
const currentStillExists = selectable.some((item) => item.id === currentVariantId)
if (currentVariantId && currentStillExists) {
return currentVariantId
}
return selectable[0].id
}
function buildSelectableVariants(
selectedVariantId: string,
assignmentMode?: string | null,
variants?: BackendCourseVariantSummary[] | null,
) {
if (assignmentMode !== 'manual' || !variants || !variants.length) {
return []
}
return variants
.filter((item) => item.selectable !== false)
.map((item) => ({
id: item.id,
name: item.name,
routeCodeText: item.routeCode || '默认编码',
descriptionText: item.description || '暂无赛道说明',
selected: item.id === selectedVariantId,
}))
}
let prepareHeartRateController: HeartRateController | null = null
function getAccessToken(): string | null {
const app = getApp<IAppOption>()
const tokens = app.globalData && app.globalData.backendAuthTokens
? app.globalData.backendAuthTokens
: loadBackendAuthTokens()
return tokens && tokens.accessToken ? tokens.accessToken : null
}
function loadPreferredHeartRateDeviceName(): string | null {
try {
const stored = wx.getStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY)
if (!stored || typeof stored !== 'object') {
return null
}
const normalized = stored as { name?: unknown }
return typeof normalized.name === 'string' && normalized.name.trim().length > 0
? normalized.name.trim()
: '心率带'
} catch (_error) {
return null
}
}
function loadStoredMockChannelId(): string {
try {
const stored = wx.getStorageSync(DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY)
if (typeof stored === 'string' && stored.trim().length > 0) {
return stored.trim()
}
} catch (_error) {
return 'default'
}
return 'default'
}
function loadMockAutoConnectEnabled(): boolean {
try {
return wx.getStorageSync(DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY) === true
} catch (_error) {
return false
}
}
Page({
data: {
eventId: '',
loading: false,
titleText: '开始前准备',
summaryText: '未加载',
releaseText: '--',
actionText: '--',
statusText: '待加载',
assignmentMode: '',
variantModeText: '--',
variantSummaryText: '--',
selectedVariantId: '',
selectedVariantText: '当前无需手动指定赛道',
selectableVariants: [],
locationStatusText: '待进入地图后校验定位权限与实时精度',
heartRateStatusText: '局前心率带连接入口待接入,本轮先保留骨架',
heartRateDeviceText: '--',
heartRateScanText: '未扫描',
heartRateConnected: false,
showHeartRateDevicePicker: false,
locationPermissionGranted: false,
locationBackgroundPermissionGranted: false,
heartRateDiscoveredDevices: [],
mockSourceStatusText: '模拟源调试仍在地图页调试面板中使用',
} as EventPreparePageData,
onLoad(query: { eventId?: string }) {
const eventId = query && query.eventId ? decodeURIComponent(query.eventId) : ''
if (!eventId) {
this.setData({
statusText: '缺少 eventId',
})
return
}
this.setData({ eventId })
this.ensurePrepareHeartRateController()
this.refreshPreparationDeviceState()
this.loadEventPlay(eventId)
},
onShow() {
this.refreshPreparationDeviceState()
},
onUnload() {
if (prepareHeartRateController) {
prepareHeartRateController.destroy()
prepareHeartRateController = null
}
},
async loadEventPlay(eventId?: string) {
const targetEventId = eventId || this.data.eventId
const accessToken = getAccessToken()
if (!accessToken) {
wx.redirectTo({ url: '/pages/login/login' })
return
}
this.setData({
loading: true,
statusText: '正在加载局前准备信息',
})
try {
const result = await getEventPlay({
baseUrl: loadBackendBaseUrl(),
eventId: targetEventId,
accessToken,
})
this.applyEventPlay(result)
} catch (error) {
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
this.setData({
loading: false,
statusText: `局前准备加载失败:${message}`,
})
}
},
applyEventPlay(result: BackendEventPlayResult) {
const selectedVariantId = resolveSelectedVariantId(
this.data.selectedVariantId,
result.play.assignmentMode,
result.play.courseVariants,
)
const selectableVariants = buildSelectableVariants(
selectedVariantId,
result.play.assignmentMode,
result.play.courseVariants,
)
const selectedVariant = selectableVariants.find((item) => item.id === selectedVariantId) || null
this.setData({
loading: false,
titleText: `${result.event.displayName} / 开始前准备`,
summaryText: result.event.summary || '暂无活动简介',
releaseText: result.resolvedRelease
? `${result.resolvedRelease.configLabel} / ${result.resolvedRelease.releaseId}`
: '当前无可用 release',
actionText: `${result.play.primaryAction} / ${result.play.reason}`,
statusText: result.play.canLaunch ? '准备完成,可进入地图' : '当前不可启动',
assignmentMode: result.play.assignmentMode || '',
variantModeText: formatAssignmentMode(result.play.assignmentMode),
variantSummaryText: formatVariantSummary(result),
selectedVariantId,
selectedVariantText: selectedVariant
? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
: '当前无需手动指定赛道',
selectableVariants,
})
},
refreshPreparationDeviceState() {
this.refreshLocationPermissionStatus()
this.refreshHeartRatePreparationStatus()
this.refreshMockSourcePreparationStatus()
},
ensurePrepareHeartRateController() {
if (prepareHeartRateController) {
return prepareHeartRateController
}
prepareHeartRateController = new HeartRateController({
onHeartRate: () => {},
onStatus: (message) => {
this.setData({
heartRateStatusText: message,
})
},
onError: (message) => {
this.setData({
heartRateStatusText: message,
})
},
onConnectionChange: (connected, deviceName) => {
this.setData({
heartRateConnected: connected,
heartRateDeviceText: connected ? (deviceName || '心率带') : (deviceName || '--'),
})
this.refreshHeartRatePreparationStatus()
},
onDeviceListChange: (devices) => {
this.setData({
heartRateScanText: devices.length ? `已发现 ${devices.length} 个设备` : '未扫描',
heartRateDiscoveredDevices: devices.map((device) => ({
deviceId: device.deviceId,
name: device.name,
rssiText: typeof device.rssi === 'number' ? `${device.rssi} dBm` : 'RSSI --',
preferred: !!device.isPreferred,
connected: !!prepareHeartRateController
&& !!prepareHeartRateController.currentDeviceId
&& prepareHeartRateController.currentDeviceId === device.deviceId
&& prepareHeartRateController.connected,
})),
})
},
})
return prepareHeartRateController
},
refreshLocationPermissionStatus() {
wx.getSetting({
success: (result) => {
const authSetting = result && result.authSetting
? result.authSetting as Record<string, boolean | undefined>
: {}
const hasForeground = authSetting['scope.userLocation'] === true
const hasBackground = authSetting['scope.userLocationBackground'] === true
let locationStatusText = '未请求定位权限'
if (hasForeground && hasBackground) {
locationStatusText = '已授权前后台定位'
} else if (hasForeground) {
locationStatusText = '已授权前台定位'
} else if (authSetting['scope.userLocation'] === false) {
locationStatusText = '定位权限被拒绝'
}
this.setData({
locationStatusText,
locationPermissionGranted: hasForeground,
locationBackgroundPermissionGranted: hasBackground,
})
},
fail: () => {
this.setData({
locationStatusText: '无法读取定位权限状态',
locationPermissionGranted: false,
locationBackgroundPermissionGranted: false,
})
},
})
},
handleRequestLocationPermission() {
wx.authorize({
scope: 'scope.userLocation',
success: () => {
this.refreshLocationPermissionStatus()
wx.showToast({
title: '前台定位已授权',
icon: 'none',
})
},
fail: () => {
this.refreshLocationPermissionStatus()
wx.showToast({
title: '请在设置中开启定位权限',
icon: 'none',
})
},
})
},
handleOpenLocationSettings() {
wx.openSetting({
success: () => {
this.refreshLocationPermissionStatus()
},
fail: () => {
wx.showToast({
title: '无法打开设置面板',
icon: 'none',
})
},
})
},
refreshHeartRatePreparationStatus() {
const controller = this.ensurePrepareHeartRateController()
const preferredDeviceName = loadPreferredHeartRateDeviceName()
this.setData({
heartRateStatusText: controller.connected
? '局前心率带已连接'
: preferredDeviceName
? `已记住首选设备:${preferredDeviceName}`
: '未设置首选设备,可在此连接或进入地图后连接',
heartRateDeviceText: controller.currentDeviceName || preferredDeviceName || '--',
heartRateScanText: controller.scanning
? '扫描中'
: (controller.discoveredDevices.length ? `已发现 ${controller.discoveredDevices.length} 个设备` : '未扫描'),
heartRateConnected: controller.connected,
heartRateDiscoveredDevices: controller.discoveredDevices.map((device) => ({
deviceId: device.deviceId,
name: device.name,
rssiText: typeof device.rssi === 'number' ? `${device.rssi} dBm` : 'RSSI --',
preferred: !!device.isPreferred,
connected: !!controller.currentDeviceId && controller.currentDeviceId === device.deviceId && controller.connected,
})),
})
},
refreshMockSourcePreparationStatus() {
const channelId = loadStoredMockChannelId()
const autoConnect = loadMockAutoConnectEnabled()
this.setData({
mockSourceStatusText: autoConnect
? `自动连接已开启 / 通道 ${channelId}`
: `自动连接未开启 / 通道 ${channelId}`,
})
},
handleRefresh() {
this.loadEventPlay()
},
handleBack() {
wx.navigateBack()
},
handlePrepareHeartRateConnect() {
const controller = this.ensurePrepareHeartRateController()
controller.startScanAndConnect()
this.refreshHeartRatePreparationStatus()
},
handleOpenHeartRateDevicePicker() {
const controller = this.ensurePrepareHeartRateController()
this.setData({
showHeartRateDevicePicker: true,
})
if (!controller.scanning) {
controller.startScanAndConnect()
}
this.refreshHeartRatePreparationStatus()
},
handleCloseHeartRateDevicePicker() {
this.setData({
showHeartRateDevicePicker: false,
})
},
handlePrepareHeartRateDeviceConnect(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) {
const deviceId = event.currentTarget.dataset.deviceId
if (!deviceId) {
return
}
const controller = this.ensurePrepareHeartRateController()
controller.connectToDiscoveredDevice(deviceId)
this.setData({
showHeartRateDevicePicker: false,
})
this.refreshHeartRatePreparationStatus()
},
handlePrepareHeartRateDisconnect() {
if (!prepareHeartRateController) {
return
}
prepareHeartRateController.disconnect()
this.setData({
heartRateConnected: false,
})
this.refreshHeartRatePreparationStatus()
},
handlePrepareHeartRateClearPreferred() {
const controller = this.ensurePrepareHeartRateController()
controller.clearPreferredDevice()
this.refreshHeartRatePreparationStatus()
},
handleSelectVariant(event: WechatMiniprogram.BaseEvent<{ variantId?: string }>) {
const variantId = event.currentTarget.dataset.variantId
if (!variantId) {
return
}
const selectableVariants = this.data.selectableVariants.map((item) => ({
...item,
selected: item.id === variantId,
}))
const selectedVariant = selectableVariants.find((item) => item.id === variantId) || null
this.setData({
selectedVariantId: variantId,
selectedVariantText: selectedVariant
? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
: '当前无需手动指定赛道',
selectableVariants,
})
},
async handleLaunch() {
const accessToken = getAccessToken()
if (!accessToken) {
wx.redirectTo({ url: '/pages/login/login' })
return
}
if (!this.data.locationPermissionGranted) {
this.setData({
statusText: '进入地图前请先完成定位授权',
})
wx.showToast({
title: '请先授权定位',
icon: 'none',
})
return
}
this.setData({
statusText: '正在创建 session 并进入地图',
})
try {
const app = getApp<IAppOption>()
if (app.globalData) {
const pendingDeviceName = prepareHeartRateController && prepareHeartRateController.currentDeviceName
? prepareHeartRateController.currentDeviceName
: loadPreferredHeartRateDeviceName()
app.globalData.pendingHeartRateAutoConnect = {
enabled: !!pendingDeviceName,
deviceName: pendingDeviceName || null,
}
}
if (prepareHeartRateController) {
prepareHeartRateController.destroy()
prepareHeartRateController = null
}
const result = await launchEvent({
baseUrl: loadBackendBaseUrl(),
eventId: this.data.eventId,
accessToken,
variantId: this.data.assignmentMode === 'manual' ? this.data.selectedVariantId : undefined,
clientType: 'wechat',
deviceKey: 'mini-dev-device-001',
})
const envelope = adaptBackendLaunchResultToEnvelope(result)
wx.navigateTo({
url: prepareMapPageUrlForLaunch(envelope),
})
} catch (error) {
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
this.setData({
statusText: `launch 失败:${message}`,
})
}
},
})

View File

@@ -0,0 +1,105 @@
<scroll-view class="page" scroll-y>
<view class="shell">
<view class="hero">
<view class="hero__eyebrow">Prepare</view>
<view class="hero__title">{{titleText}}</view>
<view class="hero__desc">{{summaryText}}</view>
</view>
<view class="panel">
<view class="panel__title">活动与发布</view>
<view class="summary">Release{{releaseText}}</view>
<view class="summary">主动作:{{actionText}}</view>
<view class="summary">状态:{{statusText}}</view>
<view class="summary">赛道模式:{{variantModeText}}</view>
<view class="summary">赛道摘要:{{variantSummaryText}}</view>
<view class="summary">当前选择:{{selectedVariantText}}</view>
</view>
<view class="panel" wx:if="{{assignmentMode === 'manual' && selectableVariants.length}}">
<view class="panel__title">赛道选择</view>
<view class="summary">当前活动要求手动指定赛道。这里的选择会随 launch 一起带给后端,最终绑定以后端返回为准。</view>
<view class="variant-list">
<view wx:for="{{selectableVariants}}" wx:key="id" class="variant-card {{item.selected ? 'variant-card--active' : ''}}" data-variant-id="{{item.id}}" bindtap="handleSelectVariant">
<view class="variant-card__main">
<view class="variant-card__title-row">
<text class="variant-card__name">{{item.name}}</text>
<text class="variant-card__badge" wx:if="{{item.selected}}">已选中</text>
</view>
<text class="variant-card__meta">{{item.routeCodeText}}</text>
<text class="variant-card__meta">{{item.descriptionText}}</text>
</view>
</view>
</view>
</view>
<view class="panel">
<view class="panel__title">设备准备</view>
<view class="summary">这一页现在负责局前设备准备。定位权限先在这里确认,心率带支持先连后进图,地图内仍保留局中快速重连入口。</view>
<view class="row">
<view class="row__label">定位状态</view>
<view class="row__value">{{locationStatusText}}</view>
</view>
<view class="summary" wx:if="{{locationPermissionGranted && !locationBackgroundPermissionGranted}}">已完成前台定位授权;如果后续需要后台持续定位,请在系统设置中补齐后台权限。</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleRequestLocationPermission">申请定位权限</button>
<button class="btn btn--ghost" bindtap="handleOpenLocationSettings">打开系统设置</button>
</view>
<view class="row">
<view class="row__label">心率带</view>
<view class="row__value">{{heartRateStatusText}}</view>
</view>
<view class="row">
<view class="row__label">当前设备</view>
<view class="row__value">{{heartRateDeviceText}}</view>
</view>
<view class="row">
<view class="row__label">扫描状态</view>
<view class="row__value">{{heartRateScanText}}</view>
</view>
<view class="row">
<view class="row__label">模拟源</view>
<view class="row__value">{{mockSourceStatusText}}</view>
</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleOpenHeartRateDevicePicker">选择设备</button>
<button class="btn btn--ghost" bindtap="handlePrepareHeartRateConnect">重新扫描</button>
<button class="btn btn--ghost" bindtap="handlePrepareHeartRateDisconnect">断开连接</button>
<button class="btn btn--ghost" bindtap="handlePrepareHeartRateClearPreferred">清除首选</button>
</view>
</view>
<view class="panel">
<view class="panel__title">开始比赛</view>
<view class="summary">这一页先承担局前准备壳子,后面会继续接定位权限、心率带局前连接和设备检查。</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleBack">返回活动页</button>
<button class="btn btn--ghost" bindtap="handleRefresh">刷新</button>
<button class="btn btn--primary" bindtap="handleLaunch">进入地图</button>
</view>
</view>
</view>
<view wx:if="{{showHeartRateDevicePicker}}" class="picker-mask" bindtap="handleCloseHeartRateDevicePicker"></view>
<view wx:if="{{showHeartRateDevicePicker}}" class="picker-sheet">
<view class="picker-sheet__header">
<view class="picker-sheet__title">选择心率带设备</view>
<button class="picker-sheet__close" bindtap="handleCloseHeartRateDevicePicker">关闭</button>
</view>
<view class="summary">扫描状态:{{heartRateScanText}}</view>
<view wx:if="{{!heartRateDiscoveredDevices.length}}" class="summary">当前还没有发现设备,可先点“重新扫描”。</view>
<view wx:if="{{heartRateDiscoveredDevices.length}}" class="device-list">
<view wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId" class="device-card">
<view class="device-card__main">
<view class="device-card__title-row">
<text class="device-card__name">{{item.name}}</text>
<text class="device-card__badge" wx:if="{{item.preferred}}">首选</text>
<text class="device-card__badge device-card__badge--active" wx:if="{{item.connected}}">已连接</text>
</view>
<text class="device-card__meta">{{item.rssiText}}</text>
</view>
<button class="btn {{item.connected ? 'btn--ghost' : 'btn--secondary'}} device-card__action" data-device-id="{{item.deviceId}}" bindtap="handlePrepareHeartRateDeviceConnect">{{item.connected ? '已连接' : '连接'}}</button>
</view>
</view>
</view>
</scroll-view>

View File

@@ -0,0 +1,276 @@
page {
min-height: 100vh;
background: linear-gradient(180deg, #eff4fb 0%, #e8eff7 100%);
}
.page {
min-height: 100vh;
}
.shell {
display: grid;
gap: 24rpx;
padding: 28rpx 24rpx 40rpx;
}
.hero,
.panel {
display: grid;
gap: 16rpx;
padding: 24rpx;
border-radius: 24rpx;
}
.hero {
background: linear-gradient(135deg, #163a66 0%, #1f5da1 100%);
color: #ffffff;
}
.hero__eyebrow {
font-size: 22rpx;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.72);
}
.hero__title {
font-size: 40rpx;
font-weight: 700;
}
.hero__desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.84);
line-height: 1.6;
}
.panel {
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08);
}
.panel__title {
font-size: 30rpx;
font-weight: 700;
color: #17345a;
}
.summary,
.row__label,
.row__value {
font-size: 24rpx;
line-height: 1.6;
color: #30465f;
}
.row {
display: flex;
justify-content: space-between;
gap: 16rpx;
padding: 10rpx 0;
border-bottom: 2rpx solid #edf2f7;
}
.row:last-child {
border-bottom: 0;
}
.row__value {
max-width: 70%;
text-align: right;
font-weight: 700;
color: #17345a;
}
.actions {
display: flex;
gap: 16rpx;
flex-wrap: wrap;
}
.device-list {
display: grid;
gap: 14rpx;
}
.variant-list {
display: grid;
gap: 14rpx;
}
.variant-card {
display: grid;
gap: 8rpx;
padding: 18rpx;
border-radius: 18rpx;
background: #f6f9fc;
border: 2rpx solid transparent;
}
.variant-card--active {
background: #edf5ff;
border-color: #4f86c9;
}
.variant-card__main {
display: grid;
gap: 8rpx;
}
.variant-card__title-row {
display: flex;
gap: 10rpx;
align-items: center;
flex-wrap: wrap;
}
.variant-card__name {
font-size: 26rpx;
font-weight: 700;
color: #17345a;
}
.variant-card__badge {
padding: 4rpx 10rpx;
border-radius: 999rpx;
background: #dff3e8;
color: #1f6a45;
font-size: 20rpx;
}
.variant-card__meta {
font-size: 22rpx;
color: #5c7288;
line-height: 1.5;
}
.picker-mask {
position: fixed;
inset: 0;
background: rgba(10, 22, 38, 0.42);
z-index: 30;
}
.picker-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 31;
display: grid;
gap: 16rpx;
padding: 24rpx 24rpx 36rpx;
border-top-left-radius: 28rpx;
border-top-right-radius: 28rpx;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 -14rpx 36rpx rgba(22, 43, 71, 0.18);
}
.picker-sheet__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.picker-sheet__title {
font-size: 30rpx;
font-weight: 700;
color: #17345a;
}
.picker-sheet__close {
margin: 0;
min-height: 60rpx;
padding: 0 18rpx;
line-height: 60rpx;
border-radius: 999rpx;
font-size: 22rpx;
background: #eef3f8;
color: #455a72;
}
.picker-sheet__close::after {
border: 0;
}
.device-card {
display: flex;
justify-content: space-between;
gap: 16rpx;
align-items: center;
padding: 18rpx;
border-radius: 18rpx;
background: #f6f9fc;
}
.device-card__main {
display: grid;
gap: 8rpx;
min-width: 0;
flex: 1;
}
.device-card__title-row {
display: flex;
gap: 10rpx;
align-items: center;
flex-wrap: wrap;
}
.device-card__name {
font-size: 26rpx;
font-weight: 700;
color: #17345a;
}
.device-card__badge {
padding: 4rpx 10rpx;
border-radius: 999rpx;
background: #e1ecfa;
color: #35567d;
font-size: 20rpx;
}
.device-card__badge--active {
background: #dff3e8;
color: #1f6a45;
}
.device-card__meta {
font-size: 22rpx;
color: #5c7288;
}
.device-card__action {
flex: none;
}
.btn {
margin: 0;
min-height: 76rpx;
padding: 0 24rpx;
line-height: 76rpx;
border-radius: 18rpx;
font-size: 26rpx;
}
.btn::after {
border: 0;
}
.btn--primary {
background: #173d73;
color: #ffffff;
}
.btn--secondary {
background: #dfeaf8;
color: #173d73;
}
.btn--ghost {
background: #ffffff;
color: #52657d;
border: 2rpx solid #d8e2ec;
}