支持家庭成员显示名编辑
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<BindingCardItem | null>(null)
|
||||
const [cardName, setCardName] = useState('')
|
||||
const [isUpdatingCardName, setIsUpdatingCardName] = useState(false)
|
||||
const [editingFamilyMember, setEditingFamilyMember] = useState<FamilyMember | null>(null)
|
||||
const [familyMemberDisplayName, setFamilyMemberDisplayName] = useState('')
|
||||
const [parentInfo, setParentInfo] = useState<Parent | null>(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() {
|
||||
<View className='family-list'>
|
||||
{familyInfo.items.map((member) => {
|
||||
const canRemove = isFamilyOwner && !member.is_owner && !isUpdatingFamily
|
||||
const canEditName = isFamilyOwner && !isUpdatingFamilyName
|
||||
const isMe = currentUserId === member.user_id
|
||||
return (
|
||||
<View key={member.user_id} className='family-member-item'>
|
||||
@@ -1266,10 +1315,19 @@ export default function Sleep() {
|
||||
</View>
|
||||
<Text className='family-member-role'>{member.is_owner ? '设备归属账号' : '家庭共享成员'}</Text>
|
||||
</View>
|
||||
{canRemove && (
|
||||
<Text className='family-member-remove' onClick={() => handleRemoveFamilyMember(member)}>
|
||||
移除
|
||||
</Text>
|
||||
{(canEditName || canRemove) && (
|
||||
<View className='family-member-actions'>
|
||||
{canEditName && (
|
||||
<Text className='family-member-edit' onClick={() => handleOpenFamilyNameModal(member)}>
|
||||
编辑
|
||||
</Text>
|
||||
)}
|
||||
{canRemove && (
|
||||
<Text className='family-member-remove' onClick={() => handleRemoveFamilyMember(member)}>
|
||||
移除
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
@@ -1320,6 +1378,32 @@ export default function Sleep() {
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{showFamilyNameModal && (
|
||||
<View className='modal-mask'>
|
||||
<View className='modal-card'>
|
||||
<Text className='modal-title'>编辑家庭内显示名</Text>
|
||||
<View className='modal-input-wrap'>
|
||||
<Input
|
||||
className='modal-input'
|
||||
value={familyMemberDisplayName}
|
||||
placeholder='例如 奶奶'
|
||||
focus
|
||||
disabled={isUpdatingFamilyName}
|
||||
onInput={(event) => setFamilyMemberDisplayName(event.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<Text className='family-name-modal-tip'>留空保存后会显示账号昵称</Text>
|
||||
<View className='modal-actions'>
|
||||
<Text className='modal-action cancel' onClick={handleCloseFamilyNameModal}>
|
||||
取消
|
||||
</Text>
|
||||
<Text className={`modal-action confirm ${isUpdatingFamilyName ? 'disabled' : ''}`} onClick={handleSubmitFamilyName}>
|
||||
{isUpdatingFamilyName ? '保存中...' : '确认'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{systemBanner}
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -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<FamilyMember> {
|
||||
return request<FamilyMember>(`/banban/family/devices/${deviceId}/members/${memberUserId}`, {
|
||||
method: 'PATCH',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export async function leaveFamily(deviceId: string): Promise<void> {
|
||||
return request<void>(`/banban/family/devices/${deviceId}/me`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user