diff --git a/src/http/client.ts b/src/http/client.ts index 96d4c6e..922d089 100644 --- a/src/http/client.ts +++ b/src/http/client.ts @@ -9,7 +9,7 @@ import { ApiError, BusinessError, AuthExpiredError } from './errors' // 网关分服务前缀:登录走 user 服务,其余业务接口走 biz 服务(后端约定 2026-09-17) const GATEWAY = process.env.NODE_ENV === 'development' ? 'http://192.168.10.11:39101' - : 'https://PROD_PLACEHOLDER' // 正式域名占位符,上线前必须替换(spec D2) + : 'https://aiotcloud.celex-technology.com' // 线上网关域名(2026-09-22 接入) const USER_BASE = `${GATEWAY}/api/user` const BIZ_BASE = `${GATEWAY}/api/biz` @@ -122,13 +122,16 @@ export async function request( throw new BusinessError(body.Message || '业务错误', bizCode) } // 200 成功内的子状态(Message 携带,如 needUserInfo/noMobile),附到返回值供调用方分支处理 + // 首登场景 Content 为 null(仅返回绑定凭证),需先初始化才能挂字段 const subState = body.Message if (subState && subState !== '') { + if (!body.Content) body.Content = {} as T & { message?: string } ;(body.Content as Record).message = subState } - // 后端把登录 token 放响应头 Token(仅登录接口返回;Content.code 恒为 null)——附到返回值供登录流程落地 + // 后端把登录 token 放响应头 Token(仅登录接口返回)——附到返回值供登录流程落地 const resToken = pickResponseToken(res.header) if (resToken) { + if (!body.Content) body.Content = {} as T & { message?: string } ;(body.Content as Record).token = resToken } return body.Content diff --git a/src/pages/device/index.tsx b/src/pages/device/index.tsx index 45f687e..50ecea7 100644 --- a/src/pages/device/index.tsx +++ b/src/pages/device/index.tsx @@ -6,6 +6,8 @@ import { Navbar } from '@/components/navbar' import { Loading } from '@/components/loading' import { StateView } from '@/components/state-view' import { BottomSheet } from '@/components/bottom-sheet' +import { store } from '@/store' +import { storage } from '@/utils/storage' import { getHomeDeviceList, type HomeDevice } from '@/api/homeDevice' import { getRoomList, type HomeRoom } from '@/api/homeRoom' import { getHomeList, type HomeInfo } from '@/api/home' @@ -47,6 +49,11 @@ export default function DeviceList() { const [rooms, setRooms] = useState([]) const [filterRoomId, setFilterRoomId] = useState('') + // 游客浏览:未登录不拉数据,展示引导登录(微信审核要求不强制授权) + const isGuest = () => + !store.getState().auth.token && !storage.get('auth_token') + const isGuestEmpty = isGuest() + // 设备列表随家庭 + 房间筛选联动 const loadDevices = useCallback(async (homeId?: string, roomId?: string) => { const targetHomeId = homeId ?? currentFamily?.homeId @@ -109,6 +116,12 @@ export default function DeviceList() { } useDidShow(() => { + if (isGuest()) { + setDeviceList([]) + setLoading(false) + setLoadError(false) + return + } loadDevices(undefined, filterRoomId) loadFamilies() loadRooms() @@ -124,6 +137,7 @@ export default function DeviceList() { } const goAdd = () => navigateTo(ROUTES.DEVICE_ADD) const goRoom = () => navigateTo(ROUTES.SCENE_SELECT) + const goLogin = () => navigateTo(ROUTES.LOGIN) const openFamilySheet = () => { setPendingFamily(currentFamily) @@ -213,7 +227,7 @@ export default function DeviceList() { )} {/* 「我的设备」分区标题:数量随筛选联动 */} - {!loading && !loadError && ( + {!loading && !loadError && !isGuestEmpty && ( 我的设备 {deviceList.length} @@ -223,6 +237,8 @@ export default function DeviceList() { {/* 设备网格 */} {loading ? ( + ) : isGuestEmpty ? ( + ) : loadError ? ( loadDevices()} /> ) : deviceList.length === 0 ? ( diff --git a/src/pages/event/index.tsx b/src/pages/event/index.tsx index 87500a5..5e0bc3b 100644 --- a/src/pages/event/index.tsx +++ b/src/pages/event/index.tsx @@ -3,6 +3,8 @@ import { useDidShow } from '@tarojs/taro' import { View, Text } from '@tarojs/components' import { Navbar } from '@/components/navbar' import { Empty } from '@/components/empty' +import { store } from '@/store' +import { storage } from '@/utils/storage' import { getHomeList, type HomeInfo } from '@/api/home' import { resolveCurrentHome } from '@/store/currentHome' import { getRoomList, type HomeRoom } from '@/api/homeRoom' @@ -48,8 +50,15 @@ export default function Event() { ] // 当前家庭 + 其房间列表:与智能设备页共享选择(resolveCurrentHome 读持久化), - // 失败时保留"全部"选项不阻塞筛选 + // 失败时保留"全部"选项不阻塞筛选;游客(未登录)跳过全部请求 + const [guest, setGuest] = useState(false) + useEffect(() => { + if (!store.getState().auth.token && !storage.get('auth_token')) { + setGuest(true) + setLoading(false) + return + } getHomeList() .then(async (list) => { const first = resolveCurrentHome(list) @@ -62,6 +71,7 @@ export default function Event() { }, []) useDidShow(() => { + if (guest) return // 进页首拉:home 就绪与否都调一次,home 为空时后端会报错并保持空态 if (home) loadAlarms(filters) }) @@ -176,7 +186,16 @@ export default function Event() { {/* 数据层:抽屉锚点(筛选栏下缘展开,遮罩盖住本层全部) */} - {alarmList.length === 0 ? ( + {guest ? ( + <> + + + + + 浏览功能无需登录,查看家人告警时再登录 + + + ) : alarmList.length === 0 ? ( <> diff --git a/src/pages/index/index.module.scss b/src/pages/index/index.module.scss index 6cdffe3..101ea2d 100644 --- a/src/pages/index/index.module.scss +++ b/src/pages/index/index.module.scss @@ -12,6 +12,28 @@ align-items: center; } +// 游客浏览引导登录按钮(微信审核要求:不强制授权,用户自行选择) +.guestLoginBtn { + margin-top: 32px; + padding: 0 64px; + height: 80px; + background: $color-primary-grad; + border-radius: $radius-pill; + display: flex; + align-items: center; + justify-content: center; +} + +.guestLoginBtnPressed { + opacity: 0.85; +} + +.guestLoginText { + font-size: 28px; + font-weight: 600; + color: #ffffff; +} + // ===== 家庭圈入口:渐变横幅卡 ===== .familyEntry { margin: 24px; diff --git a/src/pages/index/index.tsx b/src/pages/index/index.tsx index f544ed7..1e9df15 100644 --- a/src/pages/index/index.tsx +++ b/src/pages/index/index.tsx @@ -11,7 +11,7 @@ import { getAlarmList, type HomeAlarm } from '@/api/homeAlarm' import { store } from '@/store' import { storage } from '@/utils/storage' import { ROUTES } from '@/constants/routes' -import { navigateTo, reLaunch } from '@/utils/navigation' +import { navigateTo } from '@/utils/navigation' import { splitAlerts } from './splitAlerts' import styles from './index.module.scss' @@ -19,6 +19,8 @@ export default function Index() { const [alertList, setAlertList] = useState([]) const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState(false) + // 游客浏览标记:未登录不拉数据、不弹错误,展示引导登录空态 + const [guest, setGuest] = useState(false) // 每次进入页面重拉默认家庭的全部告警(与事件页"全部"筛选同参数) const loadAlerts = useCallback(async () => { @@ -41,12 +43,16 @@ export default function Index() { } }, []) - // 冷启动直达兜底:小程序入口页不受 addInterceptor 保护,未登录时踢到登录页 + // 每次进入页面重拉当前家庭告警;未登录(游客浏览)展示引导登录空态,不强制跳登录 useDidShow(() => { if (!store.getState().auth.token && !storage.get('auth_token')) { - reLaunch(ROUTES.LOGIN) + setAlertList([]) + setLoading(false) + setLoadError(false) + setGuest(true) return } + setGuest(false) loadAlerts() }) @@ -61,6 +67,13 @@ export default function Index() { {loading ? ( + ) : guest ? ( + + + navigateTo(ROUTES.LOGIN)}> + 去登录 + + ) : loadError ? ( ) : ( diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 1110373..ee48082 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -55,11 +55,9 @@ export default function Login() { // 手机号授权暂缓(后端字段未定):提示并保持当前态,等后端确认后接续 throw new Error('后端要求绑定手机号,该流程暂未开通') } - // openId 兜底链:响应 openId → 本地缓存(needUserInfo 首次已写入)→ 当前 state - const openIdNext = - result.openId ?? storage.get(WX_OPENID_STORAGE_KEY) ?? openId - if (!openIdNext) throw new Error('响应缺少 openId,无法继续授权') - setOpenId(openIdNext) + // needUserInfo 实测 Content 为 null(无 openId),授权重调靠新 code 即可, + // openId 仅作有则传的加速字段,缺失不再阻断 + setOpenId(result.openId ?? storage.get(WX_OPENID_STORAGE_KEY) ?? '') setNeedAuth(true) setLoading(false) return @@ -85,14 +83,15 @@ export default function Login() { } } - // needUserInfo:getUserInfo 授权用户资料(后端文档约定的授权接口) + // needUserInfo 二次授权:wx.getUserInfo 拿 iv/encryptedData(后端文档约定, + // getUserProfile 的资料后端不认;getUserInfo 不受 tap 上下文限制,授权弹窗由微信拉起) const handleAuthUserInfo = async () => { - if (loading || !openId) return + if (loading) return setLoading(true) try { - applyResult(await completeUserInfoLogin(openId)) + applyResult(await completeUserInfoLogin(openId || undefined)) } catch (e) { - // 授权失败/取消:保留待授权态,用户可重试 + // 授权/登录失败:保留待授权态,用户可重试 console.error('[login-page] completeUserInfoLogin 失败', e) toast.error(e instanceof Error && e.message ? e.message : '授权失败') setLoading(false) diff --git a/src/pages/login/wxLoginFlow.ts b/src/pages/login/wxLoginFlow.ts index 8547ad2..9f329bd 100644 --- a/src/pages/login/wxLoginFlow.ts +++ b/src/pages/login/wxLoginFlow.ts @@ -41,8 +41,9 @@ export async function performWxLogin(): Promise { } // 重调登录接口的公共尾部:换新 code(一次性)→ 组装参数 → 调用 → 缓存 openId +// openId 可缺省:needUserInfo 首登响应 Content 为 null(无 openId),靠 code + 授权资料即可重调 async function reloginWith( - openId: string, + openId: string | undefined, extra: { iv?: string; encryptedData?: string; phoneCode?: string } ): Promise { let code: string @@ -52,7 +53,7 @@ async function reloginWith( } catch { throw new Error('获取微信登录凭证失败,请重试') } - const params = { appId: resolveAppId(), code, openId, ...extra } + const params = { appId: resolveAppId(), code, ...(openId ? { openId } : {}), ...extra } const result = await wxMiniappLogin(params) if (result.openId) storage.set(WX_OPENID_STORAGE_KEY, result.openId) return result @@ -60,7 +61,7 @@ async function reloginWith( // needUserInfo 二次授权:getUserInfo 拿 iv/encryptedData → 重调登录接口补用户资料 // 后端文档约定:wx.login 返回 needUserInfo 时须用 wx.getUserInfo 授权(getUserProfile 拿的资料后端不认) -export async function completeUserInfoLogin(openId: string): Promise { +export async function completeUserInfoLogin(openId: string | undefined): Promise { let profile: { iv: string; encryptedData: string } try { const r = await Taro.getUserInfo({ withCredentials: true }) diff --git a/src/pages/mine/index.tsx b/src/pages/mine/index.tsx index d8dd49b..bdcb692 100644 --- a/src/pages/mine/index.tsx +++ b/src/pages/mine/index.tsx @@ -45,13 +45,20 @@ export default function Mine() { - - navigateTo(ROUTES.DEVICE_BLE_CONFIG)} /> - navigateTo(ROUTES.SCENE_SELECT)} /> - - - - + {loggedOut ? ( + + {/* 游客态:菜单收起,只留一个登录入口(微信审核要求不强制授权) */} + + + ) : ( + + navigateTo(ROUTES.DEVICE_BLE_CONFIG)} /> + navigateTo(ROUTES.SCENE_SELECT)} /> + + + + + )} ) diff --git a/src/utils/auth.ts b/src/utils/auth.ts index a224369..74a6d07 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -5,8 +5,15 @@ import { ROUTES } from '@/constants/routes' // 在拦截器内改写 url 会把 http 请求地址误改成页面路径(request:fail invalid url), // 故不用全局拦截器,改为在 navigation 统一出口做跳转前校验。 -// 登录页及无需登录即可访问的页面(放行名单) -export const PUBLIC_PAGES = new Set([ROUTES.LOGIN]) +// 游客可浏览页面(微信审核要求:进入首页即要求授权登录会被拒, +// tab 页与登录页放开,操作型页面按需登录) +export const PUBLIC_PAGES = new Set([ + ROUTES.LOGIN, + ROUTES.HOME, + ROUTES.DEVICE_LIST, + ROUTES.EVENT, + ROUTES.MINE +]) // 登录页路径(navigation 守卫改投目标) export const ROUTES_LOGIN = ROUTES.LOGIN diff --git a/tests/unit/login/wxLoginFlow.spec.ts b/tests/unit/login/wxLoginFlow.spec.ts index d852857..9e3c1b5 100644 --- a/tests/unit/login/wxLoginFlow.spec.ts +++ b/tests/unit/login/wxLoginFlow.spec.ts @@ -136,6 +136,8 @@ describe('needUserInfo 首次登录分支', () => { }) describe('completeUserInfoLogin 二次授权', () => { + // 2026-09-24 定稿:内部走 wx.getUserInfo 取资料(后端不认 getUserProfile), + // openId 可缺省(needUserInfo 首登响应 Content 为 null) it('getUserInfo(withCredentials) → 换新 code → 带 iv/encryptedData/openId 重调登录', async () => { mockGetUserInfo.mockResolvedValue({ iv: 'iv-1', encryptedData: 'enc-1' }) mockWxMiniappLogin.mockResolvedValue(result({ openId: 'o-need' })) @@ -151,6 +153,18 @@ describe('completeUserInfoLogin 二次授权', () => { }) }) + it('openId 缺省时请求体不带 openId 字段(needUserInfo 首登无 openId 场景)', async () => { + mockGetUserInfo.mockResolvedValue({ iv: 'iv-1', encryptedData: 'enc-1' }) + mockWxMiniappLogin.mockResolvedValue(result()) + await completeUserInfoLogin(undefined) + expect(mockWxMiniappLogin).toHaveBeenCalledWith({ + appId: 'wx-appid-live', + code: 'wx-code-1', + iv: 'iv-1', + encryptedData: 'enc-1' + }) + }) + it('用户拒绝授权(errMsg 形状 reject)抛带 message 的 Error', async () => { mockGetUserInfo.mockRejectedValue({ errMsg: 'getUserInfo:fail auth deny' }) await expect(completeUserInfoLogin('o-need')).rejects.toThrow('getUserInfo:fail auth deny') diff --git a/tests/unit/utils/auth.spec.ts b/tests/unit/utils/auth.spec.ts index e1e7324..449574d 100644 --- a/tests/unit/utils/auth.spec.ts +++ b/tests/unit/utils/auth.spec.ts @@ -52,13 +52,16 @@ describe('navigation 登录守卫', () => { expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN }) }) - it('未登录 reLaunch/redirectTo/switchTab 同样被守卫', () => { - reLaunch(ROUTES.HOME) - redirectTo(ROUTES.DEVICE_ADD) - switchTab(ROUTES.EVENT) + it('未登录 reLaunch/redirectTo 到受保护页被守卫(tab 页在游客白名单放行)', () => { + reLaunch(ROUTES.DEVICE_ADD) + redirectTo(ROUTES.FAMILY) expect(mockReLaunch).toHaveBeenCalledWith({ url: ROUTES.LOGIN }) expect(mockRedirectTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN }) - expect(mockSwitchTab).toHaveBeenCalledWith({ url: ROUTES.LOGIN }) + }) + + it('未登录 switchTab 到 tab 页:放行(游客模式白名单,微信审核要求)', () => { + switchTab(ROUTES.EVENT) + expect(mockSwitchTab).toHaveBeenCalledWith({ url: ROUTES.EVENT }) }) it('已登录 navigateTo:原样放行', () => { @@ -85,9 +88,13 @@ describe('navigation 登录守卫', () => { }) describe('isProtectedPage', () => { - it('登录页返回 false,其余页面返回 true', () => { + it('登录页与四个 tab 页返回 false(游客可浏览),其余页面返回 true', () => { expect(isProtectedPage(ROUTES.LOGIN)).toBe(false) - expect(isProtectedPage(ROUTES.HOME)).toBe(true) + expect(isProtectedPage(ROUTES.HOME)).toBe(false) + expect(isProtectedPage(ROUTES.DEVICE_LIST)).toBe(false) + expect(isProtectedPage(ROUTES.EVENT)).toBe(false) + expect(isProtectedPage(ROUTES.MINE)).toBe(false) + expect(isProtectedPage(ROUTES.FAMILY)).toBe(true) expect(isProtectedPage(`${ROUTES.LOGIN}?from=xxx`)).toBe(false) }) })