From ac9d669107cdae1230148c5b1692d92c8c538c11 Mon Sep 17 00:00:00 2001 From: stu2not Date: Mon, 8 Jun 2026 11:46:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AE=B6=E5=BA=AD=E6=88=90?= =?UTF-8?q?=E5=91=98=E6=98=BE=E7=A4=BA=E5=90=8D=E7=BC=96=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 | 25 ++++++- banban-mini/src/pages/sleep/index.tsx | 92 ++++++++++++++++++++++++-- banban-mini/src/services/family.ts | 13 ++++ database/talkingq_shared_schema.sql | 1 + talkingq-url/banban/dao/family.py | 43 +++++++++++- talkingq-url/banban/routers/family.py | 36 ++++++++++ talkingq-url/banban/service/family.py | 41 ++++++++++++ talkingq-url/database/init_db.py | 8 +++ talkingq-url/database/models.py | 1 + talkingq-url/mysql/init/02-init.sql | 1 + 10 files changed, 255 insertions(+), 6 deletions(-) diff --git a/banban-mini/src/pages/sleep/index.scss b/banban-mini/src/pages/sleep/index.scss index ab87936..4c94b73 100644 --- a/banban-mini/src/pages/sleep/index.scss +++ b/banban-mini/src/pages/sleep/index.scss @@ -825,13 +825,28 @@ color: #666666; } +.family-member-actions { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 18px; +} + +.family-member-edit, .family-member-remove { flex-shrink: 0; font-size: 26px; - color: #E5484D; font-weight: 600; } +.family-member-edit { + color: #FF8C42; +} + +.family-member-remove { + color: #E5484D; +} + .family-primary-action, .family-danger-action { height: 84px; @@ -878,6 +893,14 @@ text-align: center; } +.family-name-modal-tip { + display: block; + margin-top: 14px; + font-size: 24px; + line-height: 1.5; + color: #94A3B8; +} + .family-copy-action { display: block; margin-top: 14px; diff --git a/banban-mini/src/pages/sleep/index.tsx b/banban-mini/src/pages/sleep/index.tsx index 13e4920..4de552f 100644 --- a/banban-mini/src/pages/sleep/index.tsx +++ b/banban-mini/src/pages/sleep/index.tsx @@ -23,6 +23,7 @@ import { getFamilyMembers, leaveFamily, removeFamilyMember, + updateFamilyMemberDisplayName, } from '@/services/family' import { DeviceCurrentRole, @@ -67,6 +68,7 @@ export default function Sleep() { const [isLoadingFamily, setIsLoadingFamily] = useState(false) const [isCreatingInvite, setIsCreatingInvite] = useState(false) const [isUpdatingFamily, setIsUpdatingFamily] = useState(false) + const [isUpdatingFamilyName, setIsUpdatingFamilyName] = useState(false) const [familyInvitePath, setFamilyInvitePath] = useState('') const [familyInviteExpiresAt, setFamilyInviteExpiresAt] = useState('') const [showModal, setShowModal] = useState(false) @@ -74,6 +76,7 @@ export default function Sleep() { const [showDeleteChildModal, setShowDeleteChildModal] = useState(false) const [showRoleModal, setShowRoleModal] = useState(false) const [showFamilyModal, setShowFamilyModal] = useState(false) + const [showFamilyNameModal, setShowFamilyNameModal] = useState(false) const [showAssignDeviceModal, setShowAssignDeviceModal] = useState(false) const [showCardNameModal, setShowCardNameModal] = useState(false) const [modalType, setModalType] = useState<'add' | 'edit'>('add') @@ -82,6 +85,8 @@ export default function Sleep() { const [editingCard, setEditingCard] = useState(null) const [cardName, setCardName] = useState('') const [isUpdatingCardName, setIsUpdatingCardName] = useState(false) + const [editingFamilyMember, setEditingFamilyMember] = useState(null) + const [familyMemberDisplayName, setFamilyMemberDisplayName] = useState('') const [parentInfo, setParentInfo] = useState(null) const [isAuthorizingPhone, setIsAuthorizingPhone] = useState(false) @@ -318,6 +323,49 @@ export default function Sleep() { }) } + const handleOpenFamilyNameModal = (member: FamilyMember) => { + if (!binding?.device_id || !isFamilyOwner) return + setEditingFamilyMember(member) + setFamilyMemberDisplayName(member.display_name || '') + setShowFamilyNameModal(true) + } + + const handleCloseFamilyNameModal = () => { + if (isUpdatingFamilyName) return + setShowFamilyNameModal(false) + setEditingFamilyMember(null) + setFamilyMemberDisplayName('') + } + + const handleSubmitFamilyName = async () => { + if (!binding?.device_id || !editingFamilyMember || isUpdatingFamilyName) return + + const normalizedName = familyMemberDisplayName.trim() + if (normalizedName.length > 64) { + Taro.showToast({ title: '显示名最多 64 个字', icon: 'none' }) + return + } + + setIsUpdatingFamilyName(true) + try { + await updateFamilyMemberDisplayName(binding.device_id, editingFamilyMember.user_id, { + display_name: normalizedName || null, + }) + Taro.showToast({ title: '已保存', icon: 'success' }) + setShowFamilyNameModal(false) + setEditingFamilyMember(null) + setFamilyMemberDisplayName('') + await loadFamilyData(binding.device_id) + } catch (error: any) { + Taro.showToast({ + title: error?.message || '显示名保存失败', + icon: 'none', + }) + } finally { + setIsUpdatingFamilyName(false) + } + } + const handleLeaveFamily = () => { if (!binding?.device_id || isUpdatingFamily) return @@ -1246,6 +1294,7 @@ export default function Sleep() { {familyInfo.items.map((member) => { const canRemove = isFamilyOwner && !member.is_owner && !isUpdatingFamily + const canEditName = isFamilyOwner && !isUpdatingFamilyName const isMe = currentUserId === member.user_id return ( @@ -1266,10 +1315,19 @@ export default function Sleep() { {member.is_owner ? '设备归属账号' : '家庭共享成员'} - {canRemove && ( - handleRemoveFamilyMember(member)}> - 移除 - + {(canEditName || canRemove) && ( + + {canEditName && ( + handleOpenFamilyNameModal(member)}> + 编辑 + + )} + {canRemove && ( + handleRemoveFamilyMember(member)}> + 移除 + + )} + )} ) @@ -1320,6 +1378,32 @@ export default function Sleep() { )} + {showFamilyNameModal && ( + + + 编辑家庭内显示名 + + setFamilyMemberDisplayName(event.detail.value)} + /> + + 留空保存后会显示账号昵称 + + + 取消 + + + {isUpdatingFamilyName ? '保存中...' : '确认'} + + + + + )} {systemBanner} ) diff --git a/banban-mini/src/services/family.ts b/banban-mini/src/services/family.ts index 7c1de19..770a675 100644 --- a/banban-mini/src/services/family.ts +++ b/banban-mini/src/services/family.ts @@ -3,6 +3,8 @@ import { request } from './api' export interface FamilyMember { user_id: number nickname?: string | null + display_name?: string | null + account_nickname?: string | null avatar_url?: string | null role: 'owner' | 'member' is_owner: boolean @@ -64,6 +66,17 @@ export async function removeFamilyMember(deviceId: string, memberUserId: number) }) } +export async function updateFamilyMemberDisplayName( + deviceId: string, + memberUserId: number, + data: { display_name?: string | null } +): Promise { + return request(`/banban/family/devices/${deviceId}/members/${memberUserId}`, { + method: 'PATCH', + data, + }) +} + export async function leaveFamily(deviceId: string): Promise { return request(`/banban/family/devices/${deviceId}/me`, { method: 'DELETE', diff --git a/database/talkingq_shared_schema.sql b/database/talkingq_shared_schema.sql index 85b4f58..17e72c4 100644 --- a/database/talkingq_shared_schema.sql +++ b/database/talkingq_shared_schema.sql @@ -277,6 +277,7 @@ CREATE TABLE IF NOT EXISTS `device_family_members` ( `device_id` VARCHAR(64) NOT NULL, `user_id` BIGINT NOT NULL, `role` TINYINT NOT NULL DEFAULT 2 COMMENT '1=owner,2=member', + `display_name` VARCHAR(64) NULL, `status` TINYINT NOT NULL DEFAULT 1, `invited_by_user_id` BIGINT NULL, `joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/talkingq-url/banban/dao/family.py b/talkingq-url/banban/dao/family.py index 4117a14..bf8e8c2 100644 --- a/talkingq-url/banban/dao/family.py +++ b/talkingq-url/banban/dao/family.py @@ -286,10 +286,12 @@ class FamilyDAO(BaseDAO): dfm.device_id, dfm.user_id, dfm.role, + dfm.display_name, dfm.status, dfm.joined_at, dfm.invited_by_user_id, - p.nickname, + p.nickname AS account_nickname, + COALESCE(NULLIF(dfm.display_name, ''), p.nickname) AS nickname, p.avatar_url, CASE WHEN db.owner_user_id = dfm.user_id THEN 1 ELSE 0 END AS is_owner FROM device_family_members AS dfm @@ -311,6 +313,45 @@ class FamilyDAO(BaseDAO): ).mappings().all() return list(rows) + async def update_member_display_name( + self, + *, + device_id: str, + member_user_id: int, + owner_user_id: int, + display_name: str | None, + ) -> Mapping | None: + owner_binding = await self.get_binding_for_owner(device_id=device_id, user_id=owner_user_id) + if owner_binding is None: + return None + + result = await self.execute( + text( + """ + UPDATE device_family_members + SET display_name = :display_name, + updated_at = CURRENT_TIMESTAMP + WHERE device_id = :device_id + AND user_id = :member_user_id + AND status = :active_status + """ + ), + { + "device_id": device_id, + "member_user_id": member_user_id, + "display_name": display_name, + "active_status": FAMILY_STATUS_ACTIVE, + }, + ) + if not result.rowcount: + return None + + rows = await self.list_members(device_id=device_id, user_id=owner_user_id) + for row in rows: + if int(row["user_id"]) == member_user_id: + return row + return None + async def list_active_member_user_ids(self, *, device_id: str) -> list[int]: await self.ensure_owner_member(device_id=device_id) rows = ( diff --git a/talkingq-url/banban/routers/family.py b/talkingq-url/banban/routers/family.py index 42a1524..308e567 100644 --- a/talkingq-url/banban/routers/family.py +++ b/talkingq-url/banban/routers/family.py @@ -13,12 +13,18 @@ router = APIRouter(prefix="/family", tags=["family"]) class FamilyMemberItem(BaseModel): user_id: int nickname: str | None = None + display_name: str | None = None + account_nickname: str | None = None avatar_url: str | None = None role: str is_owner: bool joined_at: datetime | None = None +class FamilyMemberUpdateRequest(BaseModel): + display_name: str | None = None + + class FamilyMemberListResponse(BaseModel): device_id: str max_members: int @@ -77,6 +83,8 @@ async def list_family_members( FamilyMemberItem( user_id=int(row["user_id"]), nickname=row.get("nickname"), + display_name=row.get("display_name"), + account_nickname=row.get("account_nickname"), avatar_url=row.get("avatar_url"), role=role, is_owner=is_owner, @@ -133,6 +141,34 @@ async def accept_family_invitation( return FamilyInvitationAcceptResponse(**result) +@router.patch("/devices/{device_id}/members/{member_user_id}", response_model=FamilyMemberItem) +async def update_family_member( + device_id: str, + member_user_id: int, + payload: FamilyMemberUpdateRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> FamilyMemberItem: + del request + row = await family_service.update_member_display_name( + device_id=device_id, + member_user_id=member_user_id, + display_name=payload.display_name, + user_id=current_user_id, + ) + is_owner = bool(row["is_owner"]) + return FamilyMemberItem( + user_id=int(row["user_id"]), + nickname=row.get("nickname"), + display_name=row.get("display_name"), + account_nickname=row.get("account_nickname"), + avatar_url=row.get("avatar_url"), + role=_role_name(row["role"], is_owner=is_owner), + is_owner=is_owner, + joined_at=row.get("joined_at"), + ) + + @router.delete("/devices/{device_id}/members/{member_user_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_family_member( device_id: str, diff --git a/talkingq-url/banban/service/family.py b/talkingq-url/banban/service/family.py index 0dc0752..d11114c 100644 --- a/talkingq-url/banban/service/family.py +++ b/talkingq-url/banban/service/family.py @@ -16,6 +16,14 @@ class FamilyService(DatabaseServiceBase): def __init__(self): super().__init__(service_name="family_service") + def _normalize_display_name(self, display_name: str | None) -> str | None: + normalized = str(display_name or "").strip() + if not normalized: + return None + if len(normalized) > 64: + raise HTTPException(status_code=400, detail="display name must be 64 characters or fewer") + return normalized + async def ensure_device_access(self, *, device_id: str, user_id: int) -> Mapping: db_session = await self.get_session() try: @@ -184,6 +192,39 @@ class FamilyService(DatabaseServiceBase): finally: await db_session.close() + async def update_member_display_name( + self, + *, + device_id: str, + member_user_id: int, + display_name: str | None, + user_id: int, + ) -> Mapping: + normalized_display_name = self._normalize_display_name(display_name) + db_session = await self.get_session() + try: + dao = FamilyDAO(db_session) + owner_binding = await dao.get_binding_for_owner(device_id=device_id, user_id=user_id) + if owner_binding is None: + raise HTTPException(status_code=403, detail="only owner can edit family member display names") + + member = await dao.update_member_display_name( + device_id=device_id, + member_user_id=member_user_id, + owner_user_id=user_id, + display_name=normalized_display_name, + ) + if member is None: + raise HTTPException(status_code=404, detail="family member not found") + + await db_session.commit() + return member + except Exception: + await db_session.rollback() + raise + finally: + await db_session.close() + async def leave_family(self, *, device_id: str, user_id: int) -> bool: db_session = await self.get_session() try: diff --git a/talkingq-url/database/init_db.py b/talkingq-url/database/init_db.py index b6c1a4f..bbfa32b 100644 --- a/talkingq-url/database/init_db.py +++ b/talkingq-url/database/init_db.py @@ -170,6 +170,7 @@ async def _ensure_device_family_tables(conn) -> None: device_id VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, role TINYINT NOT NULL DEFAULT 2, + display_name VARCHAR(64) NULL, status TINYINT NOT NULL DEFAULT 1, invited_by_user_id BIGINT NULL, joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -195,6 +196,13 @@ async def _ensure_device_family_tables(conn) -> None: """ ) ) + await _ensure_columns( + conn, + table_name="device_family_members", + columns=[ + ("display_name", "display_name VARCHAR(64) NULL AFTER role"), + ], + ) await conn.execute( text( """ diff --git a/talkingq-url/database/models.py b/talkingq-url/database/models.py index 172bce4..219d7b1 100644 --- a/talkingq-url/database/models.py +++ b/talkingq-url/database/models.py @@ -242,6 +242,7 @@ class DeviceFamilyMember(Base): device_id: Mapped[str] = mapped_column(String(64), ForeignKey("device_auth.device_id"), nullable=False) user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("parents.user_id"), nullable=False) role: Mapped[int] = mapped_column(Integer, server_default="2") + display_name: Mapped[Optional[str]] = mapped_column(String(64)) status: Mapped[int] = mapped_column(Integer, server_default="1") invited_by_user_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("parents.user_id", ondelete="SET NULL")) joined_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) diff --git a/talkingq-url/mysql/init/02-init.sql b/talkingq-url/mysql/init/02-init.sql index 85b4f58..17e72c4 100644 --- a/talkingq-url/mysql/init/02-init.sql +++ b/talkingq-url/mysql/init/02-init.sql @@ -277,6 +277,7 @@ CREATE TABLE IF NOT EXISTS `device_family_members` ( `device_id` VARCHAR(64) NOT NULL, `user_id` BIGINT NOT NULL, `role` TINYINT NOT NULL DEFAULT 2 COMMENT '1=owner,2=member', + `display_name` VARCHAR(64) NULL, `status` TINYINT NOT NULL DEFAULT 1, `invited_by_user_id` BIGINT NULL, `joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,