From dcff337f433b40597d32f1cc9418e16ab3f2c0a0 Mon Sep 17 00:00:00 2001 From: stu2not Date: Mon, 8 Jun 2026 10:48:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=BB=91=E5=AE=9A=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E7=AE=A1=E7=90=86=E5=92=8C=E5=90=8D=E7=A7=B0=E7=BC=96?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- banban-mini/src/pages/sleep/index.scss | 129 ++++++++++++++++++++ banban-mini/src/pages/sleep/index.tsx | 155 ++++++++++++++++++++++++ banban-mini/src/services/binding.ts | 37 ++++++ talkingq-url/banban/dao/binding.py | 79 ++++++++++++ talkingq-url/banban/routers/bindings.py | 71 +++++++++++ talkingq-url/banban/service/binding.py | 71 ++++++++++- 6 files changed, 541 insertions(+), 1 deletion(-) diff --git a/banban-mini/src/pages/sleep/index.scss b/banban-mini/src/pages/sleep/index.scss index 81a90c2..ab87936 100644 --- a/banban-mini/src/pages/sleep/index.scss +++ b/banban-mini/src/pages/sleep/index.scss @@ -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 { opacity: 0.45; } @@ -410,6 +527,18 @@ color: #FF8C42; 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 { diff --git a/banban-mini/src/pages/sleep/index.tsx b/banban-mini/src/pages/sleep/index.tsx index 46d6929..13e4920 100644 --- a/banban-mini/src/pages/sleep/index.tsx +++ b/banban-mini/src/pages/sleep/index.tsx @@ -6,9 +6,13 @@ import { DEVICE_UNAVAILABLE_MESSAGE } from '@/services/api' import { getCurrentUserId } from '@/services/session' import { BindingListItem, + BindingCardItem, + BindingCardGroupItem, clearSelectedBindingDeviceId, + getBindingCardGroups, loadCurrentChildBindingContext, setSelectedBindingDeviceId, + updateBindingCardName, unbindDevice, } from '@/services/binding' import { Child, clearSelectedChildId, createChild, deleteChild, setSelectedChildId, updateChild } from '@/services/child' @@ -53,6 +57,8 @@ export default function Sleep() { const [firmwareStatus, setFirmwareStatus] = useState(null) const [deviceRole, setDeviceRole] = useState(null) const [roles, setRoles] = useState([]) + const [currentCardGroup, setCurrentCardGroup] = useState(null) + const [isLoadingCards, setIsLoadingCards] = useState(false) const [isLoadingFirmware, setIsLoadingFirmware] = useState(false) const [isUpdatingFirmware, setIsUpdatingFirmware] = useState(false) const [isLoadingRoles, setIsLoadingRoles] = useState(false) @@ -69,9 +75,13 @@ export default function Sleep() { const [showRoleModal, setShowRoleModal] = useState(false) const [showFamilyModal, setShowFamilyModal] = useState(false) const [showAssignDeviceModal, setShowAssignDeviceModal] = useState(false) + const [showCardNameModal, setShowCardNameModal] = useState(false) const [modalType, setModalType] = useState<'add' | 'edit'>('add') const [childName, setChildName] = useState('') const [editingChildId, setEditingChildId] = useState(null) + const [editingCard, setEditingCard] = useState(null) + const [cardName, setCardName] = useState('') + const [isUpdatingCardName, setIsUpdatingCardName] = useState(false) const [parentInfo, setParentInfo] = useState(null) const [isAuthorizingPhone, setIsAuthorizingPhone] = useState(false) @@ -107,6 +117,7 @@ export default function Sleep() { void loadFirmwareStatus(nextBinding?.device_id) void loadRoleData(nextBinding?.device_id) void loadFamilyData(nextBinding?.device_id) + void loadCurrentDeviceCards(nextBinding?.device_id) } catch (error: any) { console.error('[manage] load failed:', error) 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 = () => { if (!binding?.device_id) { Taro.showToast({ title: '当前没有可用设备', icon: 'none' }) @@ -338,6 +374,7 @@ export default function Sleep() { void loadFirmwareStatus(nextBinding?.device_id) void loadRoleData(nextBinding?.device_id) void loadFamilyData(nextBinding?.device_id) + void loadCurrentDeviceCards(nextBinding?.device_id) setShowChildModal(false) Taro.showToast({ title: '已切换当前孩子', @@ -351,6 +388,48 @@ export default function Sleep() { 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) => { if (!targetBinding?.device_id) return setSelectedBindingDeviceId(targetBinding.device_id) @@ -654,6 +733,12 @@ export default function Sleep() { const latestFirmwareLabel = firmwareStatus?.latest_version || '--' const currentUserId = getCurrentUserId() 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 firmwareActionText = isLoadingFirmware ? '查询中' : isUpdatingFirmware ? '发送中' : '更新' const roleValue = !binding?.device_id @@ -832,6 +917,50 @@ export default function Sleep() { + + + + 已绑卡片 + {binding?.device_id || '当前未绑定设备'} + + + {isLoadingCards ? '查询中' : `${currentCardGroup?.card_count || 0} 张`} + + + + {!binding?.device_id ? ( + + 绑定设备后可查看卡片 UUID + + ) : isLoadingCards ? ( + + 卡片加载中... + + ) : currentCardGroup?.cards.length ? ( + + {currentCardGroup.cards.map((card) => ( + + + + {card.card_name || '未命名卡片'} + + {card.card_uuid} + + {canEditCardName && ( + handleOpenCardNameModal(card)}> + 编辑 + + )} + + ))} + + ) : ( + + 当前设备暂无已绑卡片 + + )} + + {menuItems.map((item, index) => ( @@ -1018,6 +1147,32 @@ export default function Sleep() { )} + {showCardNameModal && ( + + + 编辑卡片名称 + + setCardName(event.detail.value)} + /> + + 留空保存后会显示为未命名卡片 + + + 取消 + + + {isUpdatingCardName ? '保存中...' : '确认'} + + + + + )} {showRoleModal && ( setShowRoleModal(false)}> (`/banban/bindings?${query}`) } +export async function getBindingCardGroups(): Promise { + return request('/banban/bindings/cards') +} + +export async function updateBindingCardName( + cardId: number, + data: { card_name?: string | null } +): Promise { + return request(`/banban/bindings/cards/${cardId}`, { + method: 'PATCH', + data, + }) +} + export async function getBinding(deviceId: string): Promise { try { return await request(`/banban/bindings/${deviceId}`) diff --git a/talkingq-url/banban/dao/binding.py b/talkingq-url/banban/dao/binding.py index 817cc5b..178b4cf 100644 --- a/talkingq-url/banban/dao/binding.py +++ b/talkingq-url/banban/dao/binding.py @@ -464,6 +464,85 @@ class BindingDAO(BaseDAO): ).mappings().all() 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]: return ( await self.execute( diff --git a/talkingq-url/banban/routers/bindings.py b/talkingq-url/banban/routers/bindings.py index 15933f1..ce90983 100644 --- a/talkingq-url/banban/routers/bindings.py +++ b/talkingq-url/banban/routers/bindings.py @@ -75,6 +75,33 @@ class BindingListResponse(BaseModel): 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): device_id: str child_id: int | None @@ -252,6 +279,50 @@ async def list_bindings( 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) async def get_binding( device_id: str, diff --git a/talkingq-url/banban/service/binding.py b/talkingq-url/banban/service/binding.py index ce61c3a..d3d9799 100644 --- a/talkingq-url/banban/service/binding.py +++ b/talkingq-url/banban/service/binding.py @@ -1,6 +1,6 @@ from collections.abc import Mapping from datetime import datetime -from typing import Optional +from typing import Any, Optional from banban.dao.binding import ( BIND_MODE_CARD, @@ -26,6 +26,14 @@ class BindingService(DatabaseServiceBase): def __init__(self): 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: dao = BindingDAO(db_session) active_binding = await dao.get_active_binding_by_device(device_id) @@ -221,6 +229,67 @@ class BindingService(DatabaseServiceBase): finally: 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( self, device_id: str,