支持绑定卡片管理和名称编辑
This commit is contained in:
@@ -328,6 +328,123 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bound-cards-card {
|
||||||
|
margin: 0 24px 24px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bound-cards-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bound-cards-heading {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bound-cards-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1F2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bound-cards-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #6B7280;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bound-cards-count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #14532D;
|
||||||
|
background: #DCFCE7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bound-card-list {
|
||||||
|
padding: 4px 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bound-card-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 18px 0;
|
||||||
|
border-top: 1px dashed #E5E7EB;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bound-card-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bound-card-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1F2937;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bound-card-uuid {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #6B7280;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bound-card-edit {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #FFF2E7;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #FF8C42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bound-cards-empty {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bound-cards-empty-text {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #9CA3AF;
|
||||||
|
}
|
||||||
|
|
||||||
.menu-item.disabled {
|
.menu-item.disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
@@ -410,6 +527,18 @@
|
|||||||
color: #FF8C42;
|
color: #FF8C42;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: #CBD5E1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name-modal-tip {
|
||||||
|
display: block;
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #94A3B8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-switch-card {
|
.device-switch-card {
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import { DEVICE_UNAVAILABLE_MESSAGE } from '@/services/api'
|
|||||||
import { getCurrentUserId } from '@/services/session'
|
import { getCurrentUserId } from '@/services/session'
|
||||||
import {
|
import {
|
||||||
BindingListItem,
|
BindingListItem,
|
||||||
|
BindingCardItem,
|
||||||
|
BindingCardGroupItem,
|
||||||
clearSelectedBindingDeviceId,
|
clearSelectedBindingDeviceId,
|
||||||
|
getBindingCardGroups,
|
||||||
loadCurrentChildBindingContext,
|
loadCurrentChildBindingContext,
|
||||||
setSelectedBindingDeviceId,
|
setSelectedBindingDeviceId,
|
||||||
|
updateBindingCardName,
|
||||||
unbindDevice,
|
unbindDevice,
|
||||||
} from '@/services/binding'
|
} from '@/services/binding'
|
||||||
import { Child, clearSelectedChildId, createChild, deleteChild, setSelectedChildId, updateChild } from '@/services/child'
|
import { Child, clearSelectedChildId, createChild, deleteChild, setSelectedChildId, updateChild } from '@/services/child'
|
||||||
@@ -53,6 +57,8 @@ export default function Sleep() {
|
|||||||
const [firmwareStatus, setFirmwareStatus] = useState<DeviceFirmwareStatus | null>(null)
|
const [firmwareStatus, setFirmwareStatus] = useState<DeviceFirmwareStatus | null>(null)
|
||||||
const [deviceRole, setDeviceRole] = useState<DeviceCurrentRole | null>(null)
|
const [deviceRole, setDeviceRole] = useState<DeviceCurrentRole | null>(null)
|
||||||
const [roles, setRoles] = useState<DeviceRoleSummary[]>([])
|
const [roles, setRoles] = useState<DeviceRoleSummary[]>([])
|
||||||
|
const [currentCardGroup, setCurrentCardGroup] = useState<BindingCardGroupItem | null>(null)
|
||||||
|
const [isLoadingCards, setIsLoadingCards] = useState(false)
|
||||||
const [isLoadingFirmware, setIsLoadingFirmware] = useState(false)
|
const [isLoadingFirmware, setIsLoadingFirmware] = useState(false)
|
||||||
const [isUpdatingFirmware, setIsUpdatingFirmware] = useState(false)
|
const [isUpdatingFirmware, setIsUpdatingFirmware] = useState(false)
|
||||||
const [isLoadingRoles, setIsLoadingRoles] = useState(false)
|
const [isLoadingRoles, setIsLoadingRoles] = useState(false)
|
||||||
@@ -69,9 +75,13 @@ export default function Sleep() {
|
|||||||
const [showRoleModal, setShowRoleModal] = useState(false)
|
const [showRoleModal, setShowRoleModal] = useState(false)
|
||||||
const [showFamilyModal, setShowFamilyModal] = useState(false)
|
const [showFamilyModal, setShowFamilyModal] = useState(false)
|
||||||
const [showAssignDeviceModal, setShowAssignDeviceModal] = useState(false)
|
const [showAssignDeviceModal, setShowAssignDeviceModal] = useState(false)
|
||||||
|
const [showCardNameModal, setShowCardNameModal] = useState(false)
|
||||||
const [modalType, setModalType] = useState<'add' | 'edit'>('add')
|
const [modalType, setModalType] = useState<'add' | 'edit'>('add')
|
||||||
const [childName, setChildName] = useState('')
|
const [childName, setChildName] = useState('')
|
||||||
const [editingChildId, setEditingChildId] = useState<number | null>(null)
|
const [editingChildId, setEditingChildId] = useState<number | null>(null)
|
||||||
|
const [editingCard, setEditingCard] = useState<BindingCardItem | null>(null)
|
||||||
|
const [cardName, setCardName] = useState('')
|
||||||
|
const [isUpdatingCardName, setIsUpdatingCardName] = useState(false)
|
||||||
const [parentInfo, setParentInfo] = useState<Parent | null>(null)
|
const [parentInfo, setParentInfo] = useState<Parent | null>(null)
|
||||||
const [isAuthorizingPhone, setIsAuthorizingPhone] = useState(false)
|
const [isAuthorizingPhone, setIsAuthorizingPhone] = useState(false)
|
||||||
|
|
||||||
@@ -107,6 +117,7 @@ export default function Sleep() {
|
|||||||
void loadFirmwareStatus(nextBinding?.device_id)
|
void loadFirmwareStatus(nextBinding?.device_id)
|
||||||
void loadRoleData(nextBinding?.device_id)
|
void loadRoleData(nextBinding?.device_id)
|
||||||
void loadFamilyData(nextBinding?.device_id)
|
void loadFamilyData(nextBinding?.device_id)
|
||||||
|
void loadCurrentDeviceCards(nextBinding?.device_id)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[manage] load failed:', error)
|
console.error('[manage] load failed:', error)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
@@ -215,6 +226,31 @@ export default function Sleep() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadCurrentDeviceCards = async (deviceId?: string | null) => {
|
||||||
|
const normalizedDeviceId = String(deviceId || '').trim()
|
||||||
|
if (!normalizedDeviceId) {
|
||||||
|
setCurrentCardGroup(null)
|
||||||
|
setIsLoadingCards(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingCards(true)
|
||||||
|
try {
|
||||||
|
const nextCardGroups = await getBindingCardGroups()
|
||||||
|
const nextCurrentGroup = nextCardGroups.items.find((item) => item.device_id === normalizedDeviceId) || null
|
||||||
|
setCurrentCardGroup(nextCurrentGroup)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[manage] card groups load failed:', error)
|
||||||
|
setCurrentCardGroup(null)
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '卡片信息加载失败',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoadingCards(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleOpenFamilyModal = () => {
|
const handleOpenFamilyModal = () => {
|
||||||
if (!binding?.device_id) {
|
if (!binding?.device_id) {
|
||||||
Taro.showToast({ title: '当前没有可用设备', icon: 'none' })
|
Taro.showToast({ title: '当前没有可用设备', icon: 'none' })
|
||||||
@@ -338,6 +374,7 @@ export default function Sleep() {
|
|||||||
void loadFirmwareStatus(nextBinding?.device_id)
|
void loadFirmwareStatus(nextBinding?.device_id)
|
||||||
void loadRoleData(nextBinding?.device_id)
|
void loadRoleData(nextBinding?.device_id)
|
||||||
void loadFamilyData(nextBinding?.device_id)
|
void loadFamilyData(nextBinding?.device_id)
|
||||||
|
void loadCurrentDeviceCards(nextBinding?.device_id)
|
||||||
setShowChildModal(false)
|
setShowChildModal(false)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
title: '已切换当前孩子',
|
title: '已切换当前孩子',
|
||||||
@@ -351,6 +388,48 @@ export default function Sleep() {
|
|||||||
handleOpenModal('add')
|
handleOpenModal('add')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOpenCardNameModal = (card: BindingCardItem) => {
|
||||||
|
setEditingCard(card)
|
||||||
|
setCardName(card.card_name || '')
|
||||||
|
setShowCardNameModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseCardNameModal = () => {
|
||||||
|
if (isUpdatingCardName) return
|
||||||
|
setShowCardNameModal(false)
|
||||||
|
setEditingCard(null)
|
||||||
|
setCardName('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitCardName = async () => {
|
||||||
|
if (!editingCard || isUpdatingCardName) return
|
||||||
|
|
||||||
|
const normalizedName = cardName.trim()
|
||||||
|
if (normalizedName.length > 64) {
|
||||||
|
Taro.showToast({ title: '卡片名称最多 64 个字', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingCardName(true)
|
||||||
|
try {
|
||||||
|
await updateBindingCardName(editingCard.card_id, {
|
||||||
|
card_name: normalizedName || null,
|
||||||
|
})
|
||||||
|
Taro.showToast({ title: '已保存', icon: 'success' })
|
||||||
|
setShowCardNameModal(false)
|
||||||
|
setEditingCard(null)
|
||||||
|
setCardName('')
|
||||||
|
await loadCurrentDeviceCards(binding?.device_id)
|
||||||
|
} catch (error: any) {
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '卡片名称保存失败',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingCardName(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleAssignExistingDevice = (targetBinding: BindingListItem) => {
|
const handleAssignExistingDevice = (targetBinding: BindingListItem) => {
|
||||||
if (!targetBinding?.device_id) return
|
if (!targetBinding?.device_id) return
|
||||||
setSelectedBindingDeviceId(targetBinding.device_id)
|
setSelectedBindingDeviceId(targetBinding.device_id)
|
||||||
@@ -654,6 +733,12 @@ export default function Sleep() {
|
|||||||
const latestFirmwareLabel = firmwareStatus?.latest_version || '--'
|
const latestFirmwareLabel = firmwareStatus?.latest_version || '--'
|
||||||
const currentUserId = getCurrentUserId()
|
const currentUserId = getCurrentUserId()
|
||||||
const isFamilyOwner = familyInfo?.current_user_role === 'owner'
|
const isFamilyOwner = familyInfo?.current_user_role === 'owner'
|
||||||
|
const canEditCardName = Boolean(
|
||||||
|
binding?.device_id &&
|
||||||
|
currentCardGroup?.device_id === binding.device_id &&
|
||||||
|
familyInfo?.device_id === binding.device_id &&
|
||||||
|
isFamilyOwner
|
||||||
|
)
|
||||||
const canUpdateFirmware = Boolean(binding?.device_id && firmwareStatus?.can_update && !isLoadingFirmware && !isUpdatingFirmware)
|
const canUpdateFirmware = Boolean(binding?.device_id && firmwareStatus?.can_update && !isLoadingFirmware && !isUpdatingFirmware)
|
||||||
const firmwareActionText = isLoadingFirmware ? '查询中' : isUpdatingFirmware ? '发送中' : '更新'
|
const firmwareActionText = isLoadingFirmware ? '查询中' : isUpdatingFirmware ? '发送中' : '更新'
|
||||||
const roleValue = !binding?.device_id
|
const roleValue = !binding?.device_id
|
||||||
@@ -832,6 +917,50 @@ export default function Sleep() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className={`bound-cards-card ${binding?.device_id ? '' : 'disabled'}`}>
|
||||||
|
<View className='bound-cards-header'>
|
||||||
|
<View className='bound-cards-heading'>
|
||||||
|
<Text className='bound-cards-title'>已绑卡片</Text>
|
||||||
|
<Text className='bound-cards-subtitle'>{binding?.device_id || '当前未绑定设备'}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='bound-cards-count'>
|
||||||
|
{isLoadingCards ? '查询中' : `${currentCardGroup?.card_count || 0} 张`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!binding?.device_id ? (
|
||||||
|
<View className='bound-cards-empty'>
|
||||||
|
<Text className='bound-cards-empty-text'>绑定设备后可查看卡片 UUID</Text>
|
||||||
|
</View>
|
||||||
|
) : isLoadingCards ? (
|
||||||
|
<View className='bound-cards-empty'>
|
||||||
|
<Text className='bound-cards-empty-text'>卡片加载中...</Text>
|
||||||
|
</View>
|
||||||
|
) : currentCardGroup?.cards.length ? (
|
||||||
|
<View className='bound-card-list'>
|
||||||
|
{currentCardGroup.cards.map((card) => (
|
||||||
|
<View className='bound-card-item' key={`${currentCardGroup.device_id}-${card.card_id}`}>
|
||||||
|
<View className='bound-card-main'>
|
||||||
|
<Text className={`bound-card-name ${card.card_name ? '' : 'empty'}`}>
|
||||||
|
{card.card_name || '未命名卡片'}
|
||||||
|
</Text>
|
||||||
|
<Text className='bound-card-uuid'>{card.card_uuid}</Text>
|
||||||
|
</View>
|
||||||
|
{canEditCardName && (
|
||||||
|
<Text className='bound-card-edit' onClick={() => handleOpenCardNameModal(card)}>
|
||||||
|
编辑
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className='bound-cards-empty'>
|
||||||
|
<Text className='bound-cards-empty-text'>当前设备暂无已绑卡片</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='menu-card'>
|
<View className='menu-card'>
|
||||||
{menuItems.map((item, index) => (
|
{menuItems.map((item, index) => (
|
||||||
<View key={index}>
|
<View key={index}>
|
||||||
@@ -1018,6 +1147,32 @@ export default function Sleep() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{showCardNameModal && (
|
||||||
|
<View className='modal-mask'>
|
||||||
|
<View className='modal-card'>
|
||||||
|
<Text className='modal-title'>编辑卡片名称</Text>
|
||||||
|
<View className='modal-input-wrap'>
|
||||||
|
<Input
|
||||||
|
className='modal-input'
|
||||||
|
value={cardName}
|
||||||
|
placeholder='例如 奶奶的卡'
|
||||||
|
focus
|
||||||
|
disabled={isUpdatingCardName}
|
||||||
|
onInput={(event) => setCardName(event.detail.value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text className='card-name-modal-tip'>留空保存后会显示为未命名卡片</Text>
|
||||||
|
<View className='modal-actions'>
|
||||||
|
<Text className='modal-action cancel' onClick={handleCloseCardNameModal}>
|
||||||
|
取消
|
||||||
|
</Text>
|
||||||
|
<Text className={`modal-action confirm ${isUpdatingCardName ? 'disabled' : ''}`} onClick={handleSubmitCardName}>
|
||||||
|
{isUpdatingCardName ? '保存中...' : '确认'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{showRoleModal && (
|
{showRoleModal && (
|
||||||
<View className='modal-mask' onClick={() => setShowRoleModal(false)}>
|
<View className='modal-mask' onClick={() => setShowRoleModal(false)}>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -28,6 +28,29 @@ export interface BindingListResponse {
|
|||||||
next_cursor?: number | null
|
next_cursor?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BindingCardItem {
|
||||||
|
card_id: number
|
||||||
|
card_uuid: string
|
||||||
|
card_name?: string | null
|
||||||
|
status: number
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BindingCardGroupItem {
|
||||||
|
device_id: string
|
||||||
|
child_id: number | null
|
||||||
|
child_name?: string | null
|
||||||
|
bound_at: string
|
||||||
|
card_count: number
|
||||||
|
cards: BindingCardItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BindingCardGroupListResponse {
|
||||||
|
items: BindingCardGroupItem[]
|
||||||
|
total_devices: number
|
||||||
|
total_cards: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface BindingChildContext {
|
export interface BindingChildContext {
|
||||||
currentChild: Child | null
|
currentChild: Child | null
|
||||||
currentBinding: Binding | null
|
currentBinding: Binding | null
|
||||||
@@ -111,6 +134,20 @@ export async function getBindings(cursor?: number, limit: number = 20): Promise<
|
|||||||
return request<BindingListResponse>(`/banban/bindings?${query}`)
|
return request<BindingListResponse>(`/banban/bindings?${query}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getBindingCardGroups(): Promise<BindingCardGroupListResponse> {
|
||||||
|
return request<BindingCardGroupListResponse>('/banban/bindings/cards')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBindingCardName(
|
||||||
|
cardId: number,
|
||||||
|
data: { card_name?: string | null }
|
||||||
|
): Promise<BindingCardItem> {
|
||||||
|
return request<BindingCardItem>(`/banban/bindings/cards/${cardId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBinding(deviceId: string): Promise<Binding | null> {
|
export async function getBinding(deviceId: string): Promise<Binding | null> {
|
||||||
try {
|
try {
|
||||||
return await request<Binding>(`/banban/bindings/${deviceId}`)
|
return await request<Binding>(`/banban/bindings/${deviceId}`)
|
||||||
|
|||||||
@@ -464,6 +464,85 @@ class BindingDAO(BaseDAO):
|
|||||||
).mappings().all()
|
).mappings().all()
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
async def list_card_groups_by_user(self, user_id: int) -> list[Mapping]:
|
||||||
|
return (
|
||||||
|
await self.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
db.device_id,
|
||||||
|
db.child_id,
|
||||||
|
c.child_name,
|
||||||
|
db.bound_at,
|
||||||
|
card.card_id,
|
||||||
|
card.card_uuid,
|
||||||
|
card.card_name,
|
||||||
|
card.status AS card_status,
|
||||||
|
card.updated_at AS card_updated_at
|
||||||
|
FROM device_bindings AS db
|
||||||
|
LEFT JOIN children AS c
|
||||||
|
ON c.child_id = db.child_id
|
||||||
|
AND c.status = 1
|
||||||
|
LEFT JOIN device_family_members AS dfm
|
||||||
|
ON dfm.device_id = db.device_id
|
||||||
|
AND dfm.user_id = :user_id
|
||||||
|
AND dfm.status = 1
|
||||||
|
LEFT JOIN cards AS card
|
||||||
|
ON card.device_id = db.device_id
|
||||||
|
AND card.status = 1
|
||||||
|
WHERE db.status = 1
|
||||||
|
AND (
|
||||||
|
db.owner_user_id = :user_id
|
||||||
|
OR dfm.id IS NOT NULL
|
||||||
|
)
|
||||||
|
ORDER BY db.bound_at DESC, db.device_id ASC, card.updated_at DESC, card.card_id DESC
|
||||||
|
""",
|
||||||
|
{"user_id": user_id},
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
|
||||||
|
async def get_visible_card_by_id(self, card_id: int, user_id: int) -> Optional[Mapping]:
|
||||||
|
return (
|
||||||
|
await self.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
card.card_id,
|
||||||
|
card.card_uuid,
|
||||||
|
card.card_name,
|
||||||
|
card.status AS card_status,
|
||||||
|
card.updated_at AS card_updated_at,
|
||||||
|
db.device_id,
|
||||||
|
db.owner_user_id
|
||||||
|
FROM cards AS card
|
||||||
|
JOIN device_bindings AS db
|
||||||
|
ON db.device_id = card.device_id
|
||||||
|
AND db.status = 1
|
||||||
|
LEFT JOIN device_family_members AS dfm
|
||||||
|
ON dfm.device_id = db.device_id
|
||||||
|
AND dfm.user_id = :user_id
|
||||||
|
AND dfm.status = 1
|
||||||
|
WHERE card.card_id = :card_id
|
||||||
|
AND card.status = 1
|
||||||
|
AND (
|
||||||
|
db.owner_user_id = :user_id
|
||||||
|
OR dfm.id IS NOT NULL
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
{"card_id": card_id, "user_id": user_id},
|
||||||
|
)
|
||||||
|
).mappings().first()
|
||||||
|
|
||||||
|
async def update_card_name(self, card_id: int, card_name: Optional[str]) -> None:
|
||||||
|
await self.execute(
|
||||||
|
"""
|
||||||
|
UPDATE cards
|
||||||
|
SET card_name = :card_name,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE card_id = :card_id
|
||||||
|
""",
|
||||||
|
{"card_id": card_id, "card_name": card_name},
|
||||||
|
)
|
||||||
|
|
||||||
async def get_by_device(self, device_id: str, user_id: int) -> Optional[Mapping]:
|
async def get_by_device(self, device_id: str, user_id: int) -> Optional[Mapping]:
|
||||||
return (
|
return (
|
||||||
await self.execute(
|
await self.execute(
|
||||||
|
|||||||
@@ -75,6 +75,33 @@ class BindingListResponse(BaseModel):
|
|||||||
next_cursor: int | None = None
|
next_cursor: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BindingCardItem(BaseModel):
|
||||||
|
card_id: int
|
||||||
|
card_uuid: str
|
||||||
|
card_name: str | None = None
|
||||||
|
status: int
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BindingCardUpdateRequest(BaseModel):
|
||||||
|
card_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BindingCardGroupItem(BaseModel):
|
||||||
|
device_id: str
|
||||||
|
child_id: int | None = None
|
||||||
|
child_name: str | None = None
|
||||||
|
bound_at: datetime
|
||||||
|
card_count: int
|
||||||
|
cards: list[BindingCardItem]
|
||||||
|
|
||||||
|
|
||||||
|
class BindingCardGroupListResponse(BaseModel):
|
||||||
|
items: list[BindingCardGroupItem]
|
||||||
|
total_devices: int
|
||||||
|
total_cards: int
|
||||||
|
|
||||||
|
|
||||||
class BindHistoryItem(BaseModel):
|
class BindHistoryItem(BaseModel):
|
||||||
device_id: str
|
device_id: str
|
||||||
child_id: int | None
|
child_id: int | None
|
||||||
@@ -252,6 +279,50 @@ async def list_bindings(
|
|||||||
return BindingListResponse(items=items, total=len(items), next_cursor=next_cursor)
|
return BindingListResponse(items=items, total=len(items), next_cursor=next_cursor)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cards", response_model=BindingCardGroupListResponse)
|
||||||
|
async def list_binding_cards(
|
||||||
|
request: Request,
|
||||||
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
|
) -> BindingCardGroupListResponse:
|
||||||
|
del request
|
||||||
|
groups = await binding_service.list_card_groups(current_user_id)
|
||||||
|
items = [
|
||||||
|
BindingCardGroupItem(
|
||||||
|
device_id=group["device_id"],
|
||||||
|
child_id=group["child_id"],
|
||||||
|
child_name=group["child_name"],
|
||||||
|
bound_at=group["bound_at"],
|
||||||
|
card_count=len(group["cards"]),
|
||||||
|
cards=[BindingCardItem(**card) for card in group["cards"]],
|
||||||
|
)
|
||||||
|
for group in groups
|
||||||
|
]
|
||||||
|
return BindingCardGroupListResponse(
|
||||||
|
items=items,
|
||||||
|
total_devices=len(items),
|
||||||
|
total_cards=sum(item.card_count for item in items),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/cards/{card_id}", response_model=BindingCardItem)
|
||||||
|
async def update_binding_card(
|
||||||
|
card_id: int,
|
||||||
|
payload: BindingCardUpdateRequest,
|
||||||
|
request: Request,
|
||||||
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
|
) -> BindingCardItem:
|
||||||
|
del request
|
||||||
|
try:
|
||||||
|
card = await binding_service.update_card_name(
|
||||||
|
card_id=card_id,
|
||||||
|
card_name=payload.card_name,
|
||||||
|
user_id=current_user_id,
|
||||||
|
)
|
||||||
|
except BindingError as exc:
|
||||||
|
raise HTTPException(status_code=exc.status_code, detail=str(exc))
|
||||||
|
return BindingCardItem(**card)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{device_id}", response_model=BindingGetResponse)
|
@router.get("/{device_id}", response_model=BindingGetResponse)
|
||||||
async def get_binding(
|
async def get_binding(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from banban.dao.binding import (
|
from banban.dao.binding import (
|
||||||
BIND_MODE_CARD,
|
BIND_MODE_CARD,
|
||||||
@@ -26,6 +26,14 @@ class BindingService(DatabaseServiceBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(service_name="binding_service")
|
super().__init__(service_name="binding_service")
|
||||||
|
|
||||||
|
def _normalize_card_name(self, card_name: str | None) -> str | None:
|
||||||
|
normalized = str(card_name or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
if len(normalized) > 64:
|
||||||
|
raise BindingError("card name must be 64 characters or fewer", status_code=400)
|
||||||
|
return normalized
|
||||||
|
|
||||||
async def _ensure_device_unbound(self, db_session, device_id: str) -> None:
|
async def _ensure_device_unbound(self, db_session, device_id: str) -> None:
|
||||||
dao = BindingDAO(db_session)
|
dao = BindingDAO(db_session)
|
||||||
active_binding = await dao.get_active_binding_by_device(device_id)
|
active_binding = await dao.get_active_binding_by_device(device_id)
|
||||||
@@ -221,6 +229,67 @@ class BindingService(DatabaseServiceBase):
|
|||||||
finally:
|
finally:
|
||||||
await db_session.close()
|
await db_session.close()
|
||||||
|
|
||||||
|
async def list_card_groups(self, user_id: int) -> list[Mapping]:
|
||||||
|
db_session = await self.get_session()
|
||||||
|
try:
|
||||||
|
dao = BindingDAO(db_session)
|
||||||
|
rows = await dao.list_card_groups_by_user(user_id)
|
||||||
|
groups: dict[str, dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
device_id = str(row["device_id"])
|
||||||
|
group = groups.get(device_id)
|
||||||
|
if group is None:
|
||||||
|
group = {
|
||||||
|
"device_id": device_id,
|
||||||
|
"child_id": row["child_id"],
|
||||||
|
"child_name": row["child_name"],
|
||||||
|
"bound_at": row["bound_at"],
|
||||||
|
"cards": [],
|
||||||
|
}
|
||||||
|
groups[device_id] = group
|
||||||
|
|
||||||
|
if row["card_id"] is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
group["cards"].append(
|
||||||
|
{
|
||||||
|
"card_id": int(row["card_id"]),
|
||||||
|
"card_uuid": row["card_uuid"],
|
||||||
|
"card_name": row["card_name"],
|
||||||
|
"status": int(row["card_status"]),
|
||||||
|
"updated_at": row["card_updated_at"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return list(groups.values())
|
||||||
|
finally:
|
||||||
|
await db_session.close()
|
||||||
|
|
||||||
|
async def update_card_name(self, *, card_id: int, card_name: str | None, user_id: int) -> Mapping:
|
||||||
|
normalized_name = self._normalize_card_name(card_name)
|
||||||
|
db_session = await self.get_session()
|
||||||
|
try:
|
||||||
|
dao = BindingDAO(db_session)
|
||||||
|
card = await dao.get_visible_card_by_id(card_id=card_id, user_id=user_id)
|
||||||
|
if card is None:
|
||||||
|
raise BindingError("card not found", status_code=404)
|
||||||
|
if int(card["owner_user_id"]) != user_id:
|
||||||
|
raise BindingError("only the device owner can rename cards", status_code=403)
|
||||||
|
|
||||||
|
await dao.update_card_name(card_id=card_id, card_name=normalized_name)
|
||||||
|
updated_card = await dao.get_visible_card_by_id(card_id=card_id, user_id=user_id)
|
||||||
|
await db_session.commit()
|
||||||
|
card = updated_card or card
|
||||||
|
return {
|
||||||
|
"card_id": int(card["card_id"]),
|
||||||
|
"card_uuid": card["card_uuid"],
|
||||||
|
"card_name": normalized_name,
|
||||||
|
"status": int(card["card_status"]),
|
||||||
|
"updated_at": card["card_updated_at"],
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await db_session.close()
|
||||||
|
|
||||||
async def direct_bind(
|
async def direct_bind(
|
||||||
self,
|
self,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user