refactor(api): 接口路径抽至 src/constants/endpoints 集中维护

- 新增 endpoints.ts:HOME/CARE_MEMBER/HOME_ROOM/HOME_DEVICE/HOME_ALARM/
  USER/THIRD_PLATFORM 七组路径 + GATEWAY_PREFIX(user/biz 网关分组)
- src/api/* 全部接入常量,http/client.ts 改用 GATEWAY_PREFIX.user 判定网关
- transferOwner 加上 TransferOwnerParams 类型约束(替代 Record<string,unknown>)
- FallStatusCard 接收 alarmType(2026-09-22 实测字段),status 保留兼容
- 家庭圈详情页"转让群主"入口接通:仅群主可见,选人 Modal + 接口调用
- request.get/post params 类型放宽到 object 以匹配新实参/结构
- 新增 tests/unit/home/transferOwner.spec.ts(路径/业务码/202 三分支)
This commit is contained in:
ozh 2026-09-22 17:56:31 +08:00
parent de8df8bfcd
commit 5e00280d2c
13 changed files with 312 additions and 39 deletions

View File

@ -1,4 +1,5 @@
import { request } from '@/http'
import { USER, THIRD_PLATFORM } from '@/constants/endpoints'
// 微信小程序登录请求参数(2026-09-14 文档版,openId 为二次登录回传字段)
export interface WxMiniappLoginParams {
@ -43,7 +44,7 @@ export type WxMiniappLoginResponse = WxLoginResult &
export function wxMiniappLogin(params: WxMiniappLoginParams): Promise<WxMiniappLoginResponse> {
return request.post<WxLoginResult>(
'/thirdPlatform/wxDevLogin/miniapp/login',
THIRD_PLATFORM.wxMiniappLogin,
params as unknown as Record<string, unknown>,
{ needAuth: false }
)
@ -52,5 +53,5 @@ export function wxMiniappLogin(params: WxMiniappLoginParams): Promise<WxMiniappL
// 冷启动登录态校验:token 有效返回登录信息,过期返回 202 由 http 层统一踢登录页。
// 响应 Content 结构(ResponseData<UserInfo>)联调实测后修正字段
export function getLoginInfo(): Promise<WxUserInfo> {
return request.get('/v0/app/users/getLoginInfo')
return request.get(USER.getLoginInfo)
}

View File

@ -1,4 +1,5 @@
import { request } from '@/http'
import { CARE_MEMBER } from '@/constants/endpoints'
// 关爱对象接口(apifox 2026-09-11 文档版)
@ -30,17 +31,17 @@ export interface CareMember {
}
export function saveCareMember(params: CareMemberSaveParams): Promise<void> {
return request.post('/yanglao/app/ylHomeCareMember/save', params as unknown as Record<string, unknown>)
return request.post(CARE_MEMBER.save, params as unknown as Record<string, unknown>)
}
export function getCareMemberList(homeId: string): Promise<CareMember[]> {
return request.post('/yanglao/app/ylHomeCareMember/getList', { homeId })
return request.post(CARE_MEMBER.getList, { homeId })
}
export function getCareMemberDetail(memberId: string): Promise<CareMember> {
return request.get(`/yanglao/app/ylHomeCareMember/get/${memberId}`)
return request.get(CARE_MEMBER.getDetail(memberId))
}
export function deleteCareMember(memberId: string): Promise<void> {
return request.post('/yanglao/app/ylHomeCareMember/delete', { memberId })
return request.post(CARE_MEMBER.delete, { memberId })
}

View File

@ -1,4 +1,5 @@
import { request } from '@/http'
import { HOME } from '@/constants/endpoints'
// 家庭圈接口(apifox 2026-09-11 文档版)
// 列表字段已按 2026-09-17 联调实测修正
@ -25,38 +26,42 @@ export interface HomeMember {
export function addHome(name: string): Promise<void> {
// name 可空,空则后端默认「我的家庭圈」(文档 YlHomeAddReq)
return request.post('/yanglao/app/ylHome/add', { name })
return request.post(HOME.add, { name })
}
export function updateHome(params: Record<string, unknown>): Promise<void> {
return request.post('/yanglao/app/ylHome/update', params)
return request.post(HOME.update, params)
}
export function dissolveHome(params: Record<string, unknown>): Promise<void> {
return request.post('/yanglao/app/ylHome/dissolve', params)
return request.post(HOME.dissolve, params)
}
export function getHomeList(): Promise<HomeInfo[]> {
return request.post('/yanglao/app/ylHome/getList')
return request.post(HOME.getList)
}
export function getHomeMemberList(homeId: string): Promise<HomeMember[]> {
return request.post('/yanglao/app/ylHome/getMemberList', { homeId })
return request.post(HOME.getMemberList, { homeId })
}
export function transferOwner(params: Record<string, unknown>): Promise<void> {
return request.post('/yanglao/app/ylHome/transferOwner', params)
export interface TransferOwnerParams {
memberId: string
}
export function transferOwner(params: TransferOwnerParams): Promise<void> {
return request.post(HOME.transferOwner, params)
}
export function updateMemberNickName(params: Record<string, unknown>): Promise<void> {
return request.post('/yanglao/app/ylHome/updateMemberNickName', params)
return request.post(HOME.updateMemberNickName, params)
}
export function getHomeDetail(homeId: string): Promise<HomeInfo> {
return request.get(`/yanglao/app/ylHome/get/${homeId}`)
return request.get(HOME.getDetail(homeId))
}
export function joinHome(code: string): Promise<void> {
// code 为家庭编号(文档 YlHomeJoinReq)
return request.post('/yanglao/app/ylHome/join', { code })
return request.post(HOME.join, { code })
}

View File

@ -1,4 +1,5 @@
import { request } from '@/http'
import { HOME_ALARM } from '@/constants/endpoints'
// 入参对齐 apifox YlHomeAlarmListReq(homeId 必填,其余可选筛选)
export interface AlarmListReq {
@ -24,9 +25,9 @@ export interface HomeAlarm {
}
export function getAlarmList(params: AlarmListReq): Promise<HomeAlarm[]> {
return request.post('/yanglao/app/ylHomeAlarm/getList', params as unknown as Record<string, unknown>)
return request.post(HOME_ALARM.getList, params as unknown as Record<string, unknown>)
}
export function getAlarmDetail(alarmId: string): Promise<HomeAlarm> {
return request.get(`/yanglao/app/ylHomeAlarm/get/${alarmId}`)
return request.get(HOME_ALARM.getDetail(alarmId))
}

View File

@ -1,4 +1,5 @@
import { request } from '@/http'
import { HOME_DEVICE } from '@/constants/endpoints'
// 入参对齐 apifox YlHomeDeviceListReq:homeId 与 roomId 至少填一个
export interface HomeDeviceListReq {
@ -30,11 +31,15 @@ export interface VitalSigns {
bodyMovement?: number
}
// ED719 跌倒检测快照(2026-09-21 实测:status 枚举见 FALL_STATUS_MAP;version 为固件版本)
// ED719 跌倒检测快照(2026-09-22 实测:alarmType 是实时跌倒状态枚举,编码同 FALL_STATUS_MAP;
// alarmTs 是事件 Unix 秒;version 为固件版本)
export interface FallDetectSigns {
lastTime?: string
status?: number
alarmType?: number
alarmTs?: number
version?: string
// 兼容旧字段名(detail 页若只取到 status 也能渲染)
status?: number
}
// 响应字段按 2026-09-17 联调实测
@ -57,12 +62,12 @@ export interface HomeDevice {
}
export function getHomeDeviceList(params: HomeDeviceListReq): Promise<HomeDevice[]> {
return request.post('/yanglao/app/ylHomeDevice/getList', params as unknown as Record<string, unknown>)
return request.post(HOME_DEVICE.getList, params as unknown as Record<string, unknown>)
}
// 单设备详情(GET + 路径参数,含 runTimeInfo 实时快照)
export function getHomeDeviceDetail(installId: string): Promise<HomeDevice> {
return request.get(`/yanglao/app/ylHomeDevice/get/${installId}`)
return request.get(HOME_DEVICE.getDetail(installId))
}
// —— 历史体征记录(web 端 IotPropertyRecord 同源接口,2026-09-18 实测)——
@ -105,13 +110,13 @@ export function getVitalRecords(deviceName: string, pageSize = 50): Promise<Vita
sortDirection: 'desc'
}
}
return request.post('/iot/propertyRecord/getPropertyRecords', params as unknown as Record<string, unknown>)
return request.post(HOME_DEVICE.getPropertyRecords, params as unknown as Record<string, unknown>)
}
export function addHomeDevice(params: HomeDeviceAddReq): Promise<void> {
return request.post('/yanglao/app/ylHomeDevice/add', params as unknown as Record<string, unknown>)
return request.post(HOME_DEVICE.add, params as unknown as Record<string, unknown>)
}
export function unbindHomeDevice(params: HomeDeviceUnbindReq): Promise<void> {
return request.post('/yanglao/app/ylHomeDevice/unbind', params as unknown as Record<string, unknown>)
return request.post(HOME_DEVICE.unbind, params as unknown as Record<string, unknown>)
}

View File

@ -1,4 +1,5 @@
import { request } from '@/http'
import { HOME_ROOM } from '@/constants/endpoints'
// 房间使用场景枚举(apifox YlHomeRoomAddReq.sence 推荐值)
export const ROOM_SENCE_MAP: Record<string, string> = {
@ -45,17 +46,17 @@ export interface HomeRoom {
// 文档路径为 POST + body { homeId }(YlHomeRoomListReq)
export function getRoomList(homeId: string): Promise<HomeRoom[]> {
return request.post('/yanglao/app/ylHomeRoom/getList', { homeId })
return request.post(HOME_ROOM.getList, { homeId })
}
export function addRoom(params: HomeRoomAddReq): Promise<void> {
return request.post('/yanglao/app/ylHomeRoom/add', params as unknown as Record<string, unknown>)
return request.post(HOME_ROOM.add, params as unknown as Record<string, unknown>)
}
export function updateRoom(params: HomeRoomUpdateReq): Promise<void> {
return request.post('/yanglao/app/ylHomeRoom/update', params as unknown as Record<string, unknown>)
return request.post(HOME_ROOM.update, params as unknown as Record<string, unknown>)
}
export function deleteRoom(params: HomeRoomDeleteReq): Promise<void> {
return request.post('/yanglao/app/ylHomeRoom/delete', params as unknown as Record<string, unknown>)
return request.post(HOME_ROOM.delete, params as unknown as Record<string, unknown>)
}

View File

@ -0,0 +1,65 @@
// 后端 API 路径集中表。集中维护便于:
// 1) 环境切换时只改本文件
// 2) 排查路由/联调时一处定位所有端点
// 3) 后续按网关/服务前缀批量调整
// —— 家庭 ylHome ——
export const HOME = {
add: '/yanglao/app/ylHome/add',
update: '/yanglao/app/ylHome/update',
dissolve: '/yanglao/app/ylHome/dissolve',
getList: '/yanglao/app/ylHome/getList',
getMemberList: '/yanglao/app/ylHome/getMemberList',
transferOwner: '/yanglao/app/ylHome/transferOwner',
updateMemberNickName: '/yanglao/app/ylHome/updateMemberNickName',
getDetail: (homeId: string): string => `/yanglao/app/ylHome/get/${homeId}`,
join: '/yanglao/app/ylHome/join'
}
// —— 关爱对象 ylHomeCareMember ——
export const CARE_MEMBER = {
save: '/yanglao/app/ylHomeCareMember/save',
getList: '/yanglao/app/ylHomeCareMember/getList',
getDetail: (memberId: string): string => `/yanglao/app/ylHomeCareMember/get/${memberId}`,
delete: '/yanglao/app/ylHomeCareMember/delete'
}
// —— 房间 ylHomeRoom ——
export const HOME_ROOM = {
getList: '/yanglao/app/ylHomeRoom/getList',
add: '/yanglao/app/ylHomeRoom/add',
update: '/yanglao/app/ylHomeRoom/update',
delete: '/yanglao/app/ylHomeRoom/delete'
}
// —— 设备 ylHomeDevice ——
export const HOME_DEVICE = {
getList: '/yanglao/app/ylHomeDevice/getList',
getDetail: (installId: string): string => `/yanglao/app/ylHomeDevice/get/${installId}`,
getPropertyRecords: '/iot/propertyRecord/getPropertyRecords',
add: '/yanglao/app/ylHomeDevice/add',
unbind: '/yanglao/app/ylHomeDevice/unbind'
}
// —— 告警 ylHomeAlarm ——
export const HOME_ALARM = {
getList: '/yanglao/app/ylHomeAlarm/getList',
getDetail: (alarmId: string): string => `/yanglao/app/ylHomeAlarm/get/${alarmId}`
}
// —— 用户 v0/app/users ——
export const USER = {
getLoginInfo: '/v0/app/users/getLoginInfo'
}
// —— 第三方平台登录 thirdPlatform/wxDevLogin ——
export const THIRD_PLATFORM = {
wxMiniappLogin: '/thirdPlatform/wxDevLogin/miniapp/login'
}
// 网关分组前缀:用于 client.ts 决定走 user 服务还是 biz 服务网关
// user 服务网关:登录 + 第三方平台 + 用户中心(/v0/、/thirdPlatform/)
// biz 服务网关:所有业务接口(/yanglao/、/iot/)
export const GATEWAY_PREFIX = {
user: ['/thirdPlatform/', '/v0/']
}

View File

@ -1,6 +1,7 @@
import Taro from '@tarojs/taro'
import { store } from '@/store'
import { clearAuth } from '@/store/authSlice'
import { GATEWAY_PREFIX } from '@/constants/endpoints'
import { ROUTES } from '@/constants/routes'
import { reLaunch } from '@/utils/navigation'
import { ApiError, BusinessError, AuthExpiredError } from './errors'
@ -73,7 +74,7 @@ export async function request<T>(
}
// 登录接口与 users 系列冷启动校验接口走 user 服务前缀,其余业务接口走 biz 前缀
const base = url.startsWith('/thirdPlatform/') || url.startsWith('/v0/') ? USER_BASE : BIZ_BASE
const base = GATEWAY_PREFIX.user.some((p) => url.startsWith(p)) ? USER_BASE : BIZ_BASE
let res: Taro.request.SuccessCallbackResult<ApiEnvelope<T>>
try {
@ -140,8 +141,8 @@ function pickResponseToken(header: Record<string, unknown> | undefined): string
return raw
}
request.get = <T>(url: string, params?: Record<string, unknown>, config?: RequestConfig) =>
request<T>(url, params, { ...config, method: 'GET' })
request.get = <T>(url: string, params?: object, config?: RequestConfig) =>
request<T>(url, params as Record<string, unknown> | undefined, { ...config, method: 'GET' })
request.post = <T>(url: string, params?: Record<string, unknown>, config?: RequestConfig) =>
request<T>(url, params, { ...config, method: 'POST' })
request.post = <T>(url: string, params?: object, config?: RequestConfig) =>
request<T>(url, params as Record<string, unknown> | undefined, { ...config, method: 'POST' })

View File

@ -147,6 +147,7 @@ export default function Detail() {
{isFallDetector ? (
<FallStatusCard
alarmType={device?.runTimeInfo?.properties?.alarmType}
status={device?.runTimeInfo?.properties?.status}
lastTime={device?.runTimeInfo?.properties?.lastTime}
version={device?.runTimeInfo?.properties?.version}

View File

@ -3,6 +3,8 @@ import { FALL_STATUS_MAP } from '@/constants/enums'
import styles from './fall-status-card.module.scss'
interface Props {
// 实时跌倒状态编码(见 FALL_STATUS_MAP)。优先 alarmType,status 为兼容字段
alarmType?: number
status?: number
lastTime?: string
version?: string
@ -18,9 +20,10 @@ const STATUS_CLS: Record<number, string> = {
}
// ED719 跌倒检测状态卡:实时状态大卡(参考 web 端 DeviceScgDashboard DeviceItem 布局)
export function FallStatusCard({ status, lastTime, version, online }: Props) {
const label = FALL_STATUS_MAP[status ?? -1] ?? (online ? '正常' : '离线')
const level = STATUS_CLS[status ?? -1] ?? (online ? 'normal' : 'offline')
export function FallStatusCard({ alarmType, status, lastTime, version, online }: Props) {
const code = alarmType ?? status
const label = FALL_STATUS_MAP[code ?? -1] ?? (online ? '正常' : '离线')
const level = STATUS_CLS[code ?? -1] ?? (online ? 'normal' : 'offline')
return (
<View className={`${styles.fallCard} ${styles[`fallCard${cap(level)}`]}`}>

View File

@ -124,3 +124,71 @@
font-size: 26px;
color: $color-text-tertiary;
}
// 转让群主选人弹层(Modal 内容)
.transferTitle {
display: block;
text-align: center;
font-size: 36px;
font-weight: 600;
color: $color-text;
margin-bottom: 24px;
}
.transferList {
margin-bottom: 16px;
max-height: 600px;
overflow-y: auto;
}
.transferEmpty {
display: block;
padding: 48px 16px;
text-align: center;
font-size: 26px;
color: $color-text-tertiary;
}
.transferItem {
padding: 20px 24px;
background: $color-bg-gray;
border-radius: $radius-md;
margin-bottom: 12px;
}
.transferItemPressed {
background: $color-bg-press;
}
.transferItemName {
display: block;
font-size: 30px;
font-weight: 600;
color: $color-text;
margin-bottom: 6px;
}
.transferItemMeta {
display: block;
font-size: 22px;
color: $color-text-secondary;
}
.transferCancel {
height: 88px;
border-radius: $radius-pill;
background: $color-bg-gray;
display: flex;
align-items: center;
justify-content: center;
margin-top: 8px;
}
.transferCancelPressed {
opacity: 0.85;
}
.transferCancelText {
font-size: 30px;
color: $color-text-secondary;
}

View File

@ -8,7 +8,8 @@ import { Loading } from '@/components/loading'
import { StateView } from '@/components/state-view'
import { FamilyCard } from '@/components/family-card'
import { ListRow, ListRowGroup } from '@/components/list-row'
import { getHomeDetail, getHomeMemberList, joinHome, type HomeInfo, type HomeMember } from '@/api/home'
import { Modal } from '@/components/modal'
import { getHomeDetail, getHomeMemberList, joinHome, transferOwner, type HomeInfo, type HomeMember } from '@/api/home'
import { getCareMemberList, type CareMember } from '@/api/careMember'
import { MAX_CARED_PER_FAMILY } from '@/constants/family'
import { ROUTES } from '@/constants/routes'
@ -41,6 +42,10 @@ export default function FamilyDetail() {
const [members, setMembers] = useState<HomeMember[]>([])
const [caredList, setCaredList] = useState<CareMember[]>([])
// 转让群主:底部入口仅群主可见,点击后弹选人 Modal
const [transferOpen, setTransferOpen] = useState(false)
const [transferring, setTransferring] = useState(false)
const load = async () => {
// 分享卡直达本页且未登录:先去登录,登录后回跳本页
if (!token) {
@ -106,6 +111,33 @@ export default function FamilyDetail() {
navigateTo(`${ROUTES.FAMILY_CONTACT_ADD}?familyId=${familyId}`)
}
// 我在成员列表里的角色:转让入口只对群主开放
const myMember = members.find((m) => m.memberId === myUserId) ?? null
const isOwner = myMember?.role === 'OWNER'
const openTransfer = () => {
if (!isOwner || transferring) return
setTransferOpen(true)
}
const closeTransfer = () => {
if (transferring) return
setTransferOpen(false)
}
const doTransfer = async (target: HomeMember) => {
if (transferring) return
setTransferring(true)
try {
await transferOwner({ memberId: target.memberId })
showToast('已转让群主')
setTransferOpen(false)
await load()
} catch (e) {
showToast(e instanceof Error ? e.message : '转让失败')
} finally {
setTransferring(false)
}
}
if (loading) {
return (
<View className={styles.page}>
@ -205,11 +237,40 @@ export default function FamilyDetail() {
<Text className={styles.shareLabel}>邀请他人</Text>
<Text className={styles.shareArrow}>›</Text>
</Button>
<ListRow label="转让群主" arrow onClick={stub('转让功能即将上线')} />
<ListRow label="转让群主" arrow onClick={isOwner ? openTransfer : stub('仅群主可转让')} />
<ListRow label="修改群昵称" arrow onClick={stub('修改昵称功能即将上线')} />
</ListRowGroup>
</View>
</View>
{/* 转让群主选人弹层:列出除自己外的成员,选中即调用 transferOwner */}
<Modal visible={transferOpen} onClose={closeTransfer}>
<Text className={styles.transferTitle}>转让群主给谁?</Text>
<View className={styles.transferList}>
{members.filter((m) => m.memberId !== myUserId).length === 0 ? (
<Text className={styles.transferEmpty}>暂无可转让的成员</Text>
) : (
members
.filter((m) => m.memberId !== myUserId)
.map((m) => (
<View
key={m.memberId}
className={styles.transferItem}
hoverClass={styles.transferItemPressed}
onClick={() => doTransfer(m)}
>
<Text className={styles.transferItemName}>{memberName(m)}</Text>
<Text className={styles.transferItemMeta}>
{m.role === 'OWNER' ? '群主' : '成员'} · 群昵称:{m.nickName ?? '未设置'}
</Text>
</View>
))
)}
</View>
<View className={styles.transferCancel} hoverClass={styles.transferCancelPressed} onClick={closeTransfer}>
<Text className={styles.transferCancelText}>取消</Text>
</View>
</Modal>
</View>
)
}

View File

@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// Taro.request mock(hoisted 保证 vi.mock 工厂能拿到同一引用)
const mockRequest = vi.hoisted(() => vi.fn())
vi.mock('@tarojs/taro', () => ({ default: { request: mockRequest } }))
const reLaunch = vi.hoisted(() => vi.fn())
vi.mock('@/utils/navigation', () => ({ reLaunch }))
vi.mock('@/store', () => ({
store: {
getState: () => ({ auth: { token: 'tk-test' } }),
dispatch: vi.fn()
}
}))
vi.mock('@/store/authSlice', () => ({ clearAuth: vi.fn() }))
import { transferOwner } from '@/api/home'
const okEnvelope = () => ({ statusCode: 200, data: { Code: '200', Message: '', Content: null }, header: {} })
beforeEach(() => {
mockRequest.mockReset()
reLaunch.mockReset()
mockRequest.mockResolvedValue(okEnvelope())
})
describe('transferOwner', () => {
it('POST /yanglao/app/ylHome/transferOwner 并把 { memberId } 作为请求体', async () => {
await transferOwner({ memberId: 'm-target-001' })
expect(mockRequest).toHaveBeenCalledTimes(1)
const arg = mockRequest.mock.calls[0][0]
expect(arg.url).toMatch(/\/api\/biz\/yanglao\/app\/ylHome\/transferOwner$/)
expect(arg.method).toBe('POST')
expect(arg.data).toEqual({ memberId: 'm-target-001' })
// 自动注入 token(gateway / biz 前缀走业务网关)
expect(arg.header.token).toBe('tk-test')
})
it('业务码非 200 抛出 BusinessError(不踢登录)', async () => {
mockRequest.mockResolvedValueOnce({
statusCode: 200,
data: { Code: '500', Message: '无权操作', Content: null },
header: {}
})
await expect(transferOwner({ memberId: 'm-2' })).rejects.toThrow('无权操作')
expect(reLaunch).not.toHaveBeenCalled()
})
it('业务码 202(未登录)触发踢回登录', async () => {
mockRequest.mockResolvedValueOnce({
statusCode: 200,
data: { Code: '202', Message: '未登录', Content: null },
header: {}
})
await expect(transferOwner({ memberId: 'm-3' })).rejects.toThrow()
expect(reLaunch).toHaveBeenCalled()
})
})