From 2368121972d86fa6fd3c6a728a3a8a5f8100f7e5 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Fri, 10 Jul 2026 10:36:01 +0800 Subject: [PATCH 1/4] =?UTF-8?q?webadmin=E5=A2=9E=E5=8A=A0=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=AD=90=E8=B4=A6=E5=8F=B7=E6=98=BE=E7=A4=BA=E5=88=97?= =?UTF-8?q?=E8=A1=A8=20=E4=BF=AE=E5=A4=8Dapi=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/lib/hq-log.ts | 1 + .../src/pages/PartnerAccountsPage.tsx | 312 +++++++++++++++--- .../hq-operation/hq-operation.constants.ts | 2 + .../modules/ops/admin-partners.controller.ts | 17 +- .../src/modules/ops/admin-partners.service.ts | 106 +++++- .../src/modules/ops/dto/admin-mutate.dto.ts | 8 +- 6 files changed, 401 insertions(+), 45 deletions(-) diff --git a/apps/admin-web/src/lib/hq-log.ts b/apps/admin-web/src/lib/hq-log.ts index 9eebfc8..9122d68 100644 --- a/apps/admin-web/src/lib/hq-log.ts +++ b/apps/admin-web/src/lib/hq-log.ts @@ -5,6 +5,7 @@ export const HQ_OPERATION_ACTION_OPTIONS = [ { value: 'PARTNER_UPDATE', label: '编辑城市合伙人' }, { value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' }, { value: 'PARTNER_ACCOUNT_UPDATE', label: '编辑合伙人账户' }, + { value: 'PARTNER_ACCOUNT_DELETE', label: '删除合伙人子账号' }, { value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' }, { value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' }, { value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' }, diff --git a/apps/admin-web/src/pages/PartnerAccountsPage.tsx b/apps/admin-web/src/pages/PartnerAccountsPage.tsx index 8fb3d31..3177781 100644 --- a/apps/admin-web/src/pages/PartnerAccountsPage.tsx +++ b/apps/admin-web/src/pages/PartnerAccountsPage.tsx @@ -1,15 +1,27 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { - Button, Drawer, Form, Input, Modal, Select, Space, Table, Tabs, Tag, Typography, message, + Button, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; +import { PARTNER_STAFF_ROLE_LABELS } from '@dukang/shared-types'; import { request, type Paginated } from '../lib/api'; +import { AdminCellLine } from '../components/AdminCellLine'; import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants'; -import { useAdminList } from '../lib/useAdminList'; -type Row = { - id: string; phone: string; name: string; status: string; isPrimary: number; createdAt: string; +type PartnerOption = { id: string; companyName: string }; + +type AccountTreeRow = { + id: string; + phone: string; + name: string; + status: string; + isPrimary: number; + staffRole: string | null; + parentAccountId: string | null; + createdAt: string; + lastLoginAt: string | null; partner?: { id: string; companyName: string }; + children?: AccountTreeRow[]; }; type BillRow = { @@ -22,40 +34,94 @@ type OrderRow = { user?: { userNo: string; phone: string | null }; }; -type PartnerOption = { id: string; companyName: string }; +type Detail = AccountTreeRow & { bills?: BillRow[]; orders?: OrderRow[] }; -type Detail = Row & { bills?: BillRow[]; orders?: OrderRow[] }; +const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS).map(([value, label]) => ({ + value, + label, +})); + +function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): AccountTreeRow[] { + const phoneQ = phone?.trim(); + const match = (row: AccountTreeRow) => { + const phoneOk = !phoneQ || row.phone.includes(phoneQ); + const statusOk = !status || row.status === status; + return phoneOk && statusOk; + }; + + const walk = (list: AccountTreeRow[]): AccountTreeRow[] => + list + .map((row) => { + const children = row.children?.length ? walk(row.children) : undefined; + if (match(row) || (children && children.length > 0)) { + return { ...row, children: children?.length ? children : undefined }; + } + return null; + }) + .filter((row): row is AccountTreeRow => row !== null); + + return walk(rows); +} export default function PartnerAccountsPage() { const [form] = Form.useForm(); const [editForm] = Form.useForm(); const [createForm] = Form.useForm(); - const [filters, setFilters] = useState>({}); - const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( - '/admin/partner-accounts', - () => { - const qs = new URLSearchParams(); - if (filters.phone) qs.set('phone', filters.phone); - if (filters.status) qs.set('status', filters.status); - return qs; - }, - [filters], - ); + const [subForm] = Form.useForm(); + const [filters, setFilters] = useState<{ phone?: string; status?: string; partnerId?: string }>({}); + const [treeData, setTreeData] = useState([]); + const [loading, setLoading] = useState(false); + const [expandedRowKeys, setExpandedRowKeys] = useState([]); const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); + const [subOpen, setSubOpen] = useState(false); + const [subParent, setSubParent] = useState(null); const [partners, setPartners] = useState([]); + const loadTree = useCallback(async () => { + setLoading(true); + try { + const qs = new URLSearchParams(); + if (filters.partnerId) qs.set('partnerId', filters.partnerId); + const path = qs.toString() ? `/admin/partner-accounts/tree?${qs}` : '/admin/partner-accounts/tree'; + const res = await request(path); + setTreeData(filterTree(res, filters.phone, filters.status)); + } finally { + setLoading(false); + } + }, [filters]); + + useEffect(() => { + void loadPartners(); + }, []); + + useEffect(() => { + void loadTree(); + }, [loadTree]); + + useEffect(() => { + if (!drawerOpen || !detail) return; + editForm.setFieldsValue({ + name: detail.name, + phone: detail.phone, + status: detail.status, + }); + }, [drawerOpen, detail, editForm]); + async function loadPartners() { const res = await request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); setPartners(res.items); } async function openAccount(id: string) { - const d = await request(`/admin/partner-accounts/${id}`); - setDetail(d); - editForm.setFieldsValue({ name: d.name, phone: d.phone, status: d.status }); - setDrawerOpen(true); + try { + const d = await request(`/admin/partner-accounts/${id}`); + setDetail(d); + setDrawerOpen(true); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载账户详情失败'); + } } async function saveAccount() { @@ -64,22 +130,86 @@ export default function PartnerAccountsPage() { await request(`/admin/partner-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) }); message.success('已保存'); setDrawerOpen(false); - void reload(); + void loadTree(); } - const columns: ColumnsType = [ - { title: '姓名', dataIndex: 'name', width: 100 }, + async function deleteSubAccount(row: AccountTreeRow) { + await request(`/admin/partner-accounts/${row.id}`, { method: 'DELETE' }); + message.success('子账号已删除'); + void loadTree(); + } + + function openAddSub(parent: AccountTreeRow) { + setSubParent(parent); + subForm.resetFields(); + subForm.setFieldsValue({ staffRole: 'INTERNAL' }); + setSubOpen(true); + } + + function collectExpandableKeys(rows: AccountTreeRow[]): string[] { + const keys: string[] = []; + for (const row of rows) { + if (row.children?.length) { + keys.push(row.id); + keys.push(...collectExpandableKeys(row.children)); + } + } + return keys; + } + + const columns: ColumnsType = [ + { + title: '姓名 / 类型', + width: 200, + ellipsis: true, + render: (_, row) => ( + + ), + }, { title: '手机', dataIndex: 'phone', width: 120 }, - { title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 140 }, - { title: '主账号', dataIndex: 'isPrimary', width: 80, render: (v) => (v === 1 ? '是' : '否') }, + { + title: '开城合伙人', + width: 140, + ellipsis: true, + render: (_, row) => row.partner?.companyName || '—', + }, + { + title: '标识', + width: 100, + render: (_, row) => + row.parentAccountId ? ( + 子账号 + ) : row.isPrimary === 1 ? ( + 主账号 + ) : ( + 账号 + ), + }, { title: '状态', dataIndex: 'status', width: 80, render: (s) => {ACCOUNT_STATUS_LABELS[s] || s} }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { - title: '操作', width: 120, + title: '操作', + width: 180, render: (_, row) => ( - + e.stopPropagation()}> - + {!row.parentAccountId && row.isPrimary === 1 ? ( + + ) : null} + {row.parentAccountId ? ( + void deleteSubAccount(row)}> + + + ) : null} ), }, @@ -105,23 +235,70 @@ export default function PartnerAccountsPage() { return (
- 开城合伙人账户 +
+ 开城合伙人账户 + 主账号下可展开/收起子账号,支持添加与删除子账号 +
-
{ setFilters(v); setPage(1); }}> + { + setFilters({ + phone: v.phone?.trim() || undefined, + status: v.status || undefined, + partnerId: v.partnerId || undefined, + }); + }} + > + + + {detail.parentAccountId ? ( + + + + ) : null} @@ -198,6 +392,46 @@ export default function PartnerAccountsPage() { + setSubOpen(false)} + onOk={async () => { + if (!subParent) return; + const v = await subForm.validateFields(); + await request('/admin/partner-accounts', { + method: 'POST', + body: JSON.stringify({ + partnerId: subParent.partner?.id, + parentAccountId: subParent.id, + name: v.name, + phone: v.phone, + staffRole: v.staffRole, + }), + }); + message.success('子账号已创建'); + setSubOpen(false); + setExpandedRowKeys((keys) => [...new Set([...keys, subParent.id])]); + void loadTree(); + }} + > +
+ + + + + + { - const file = e.target.files?.[0]; - if (file) void uploadSelectedFile(file); - }} - /> + {!useWechatPicker && ( + { + const file = e.target.files?.[0]; + if (file) void uploadSelectedFile(file); + }} + /> + )} {isImage ? ( )} diff --git a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx index b772723..2675131 100644 --- a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx +++ b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx @@ -1,17 +1,39 @@ -import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; -import { toAppPath } from '@dukang/weixin-sdk'; -import { clearAuth, isLoggedIn, request } from '../lib/api'; - +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { stripOAuthParamsFromLocation, toAppPath } from '@dukang/weixin-sdk'; +import { isWxAuthorizeEnabled } from '@dukang/shared-types'; +import { + clearAuth, + ensureSession, + request, + saveAuth, + type PartnerSessionPayload, + type PartnerSessionProfile, +} from '../lib/api'; import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types'; +import { fetchClientConfig, processPartnerWechatOAuthCallback } from '../lib/wechat-auth'; +import { isWechatEnv } from '../lib/weixin'; export type PartnerAccount = PartnerMe & { staffRole?: PartnerStaffRole; }; type PartnerSessionValue = { + ready: boolean; + authenticated: boolean; account: PartnerAccount | null; - loading: boolean; + /** @deprecated 使用 authenticated */ loggedIn: boolean; + /** @deprecated 使用 ready */ + loading: boolean; + applySession: (session: PartnerSessionPayload) => void; refresh: () => Promise; logout: () => void; }; @@ -19,46 +41,111 @@ type PartnerSessionValue = { const PartnerSessionContext = createContext(null); export function PartnerSessionProvider({ children }: { children: ReactNode }) { + const [ready, setReady] = useState(false); + const [authenticated, setAuthenticated] = useState(false); const [account, setAccount] = useState(null); - const [loading, setLoading] = useState(true); - const [loggedIn, setLoggedIn] = useState(() => isLoggedIn()); + + const applySession = useCallback((session: PartnerSessionPayload) => { + saveAuth(session); + setAuthenticated(true); + if (session.partner) { + setAccount({ + id: session.partner.id, + name: session.partner.name, + phone: session.partner.phone, + companyName: session.partner.companyName, + isPrimary: session.partner.isPrimary, + }); + } + }, []); const refresh = useCallback(async (): Promise => { - if (!isLoggedIn()) { - setAccount(null); - setLoggedIn(false); - setLoading(false); - return null; - } - setLoggedIn(true); - setLoading(true); try { - const data = await request('PARTNER_H5', '/partner/me', { silent: true }); - setAccount(data); - return data; + const result = await ensureSession(); + setAuthenticated(result.authenticated); + if (!result.authenticated || !result.partner) { + setAccount(null); + return null; + } + const me = await request('PARTNER_H5', '/partner/me', { silent: true }); + setAccount(me); + return me; } catch { setAccount(null); + setAuthenticated(false); return null; - } finally { - setLoading(false); } }, []); const logout = useCallback(() => { clearAuth(); setAccount(null); - setLoggedIn(false); + setAuthenticated(false); window.location.href = toAppPath('/login'); }, []); useEffect(() => { - void refresh(); - }, [refresh]); + let cancelled = false; + (async () => { + try { + const params = new URLSearchParams(window.location.search); + if (isWechatEnv() && params.get('code')) { + try { + const config = await fetchClientConfig(); + if (isWxAuthorizeEnabled(config)) { + const session = await processPartnerWechatOAuthCallback(); + if (session && !cancelled) { + applySession(session); + const me = await request('PARTNER_H5', '/partner/me', { silent: true }).catch(() => null); + if (me && !cancelled) setAccount(me); + } + stripOAuthParamsFromLocation(); + } + } catch { + stripOAuthParamsFromLocation(); + } + } + + const result = await ensureSession(); + if (cancelled) return; + setAuthenticated(result.authenticated); + if (result.authenticated) { + const me = await request('PARTNER_H5', '/partner/me', { silent: true }).catch(() => null); + if (!cancelled) setAccount(me); + } else { + setAccount(null); + } + } catch { + if (!cancelled) { + clearAuth({ keepProfile: true }); + setAuthenticated(false); + setAccount(null); + } + } finally { + if (!cancelled) setReady(true); + } + })(); + return () => { + cancelled = true; + }; + }, [applySession]); + + const value = useMemo( + () => ({ + ready, + authenticated, + account, + loggedIn: authenticated, + loading: !ready, + applySession, + refresh, + logout, + }), + [ready, authenticated, account, applySession, refresh, logout], + ); return ( - + {children} ); @@ -69,3 +156,5 @@ export function usePartnerSession(): PartnerSessionValue { if (!ctx) throw new Error('usePartnerSession 必须在 PartnerSessionProvider 内使用'); return ctx; } + +export type { PartnerSessionProfile }; diff --git a/apps/h5-partner/src/lib/api.ts b/apps/h5-partner/src/lib/api.ts index 0060d14..5f705a9 100644 --- a/apps/h5-partner/src/lib/api.ts +++ b/apps/h5-partner/src/lib/api.ts @@ -3,14 +3,30 @@ import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk'; import { showPartnerToast } from './toast'; export const apiBase = '/api/v1'; +const CLIENT_APP = 'PARTNER_H5'; +const ACCESS_TOKEN = 'accessToken'; +const REFRESH_TOKEN = 'refreshToken'; const LAST_PHONE = 'partnerLastPhone'; const PARTNER_PROFILE = 'partnerProfile'; +const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt'; +export const PARTNER_WX_BOUND = 'partnerWxBound'; -export type PartnerSessionProfile = Pick; +/** 微信验证通过后的免登录时长 */ +export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; -export type PartnerAuthPayload = { +const AUTH_RECOVERY_EXEMPT_PATHS = [ + '/partner/auth/token/refresh', + '/partner/auth/sms/send', + '/partner/auth/login/sms', + '/partner/auth/login/wechat', +]; + +export type PartnerSessionProfile = Pick; + +export type PartnerSessionPayload = { accessToken: string; + refreshToken: string; partner?: PartnerSessionProfile; }; @@ -32,53 +48,188 @@ export function getPartnerProfile(): PartnerSessionProfile | null { } } -export async function request( - clientApp: string, - path: string, - options: ApiRequestOptions = {}, -): Promise { - const { silent, ...fetchOptions } = options; - const token = localStorage.getItem('accessToken'); - const headers: Record = { - 'Content-Type': 'application/json', - 'X-Client-App': clientApp, - ...(fetchOptions.headers as Record), - }; - if (token) headers.Authorization = `Bearer ${token}`; - const res = await fetch(`${apiBase}${path}`, { ...fetchOptions, headers }); - const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' })); - const message = json.message || (res.status === 401 ? '登录已过期,请重新登录' : '请求失败'); - - if (res.status === 401 || json.code === 401) { - if (token && localStorage.getItem('accessToken') === token) { - clearAuth(); - if (!silent) showPartnerToast(message, 'error'); - if (typeof window !== 'undefined' && !isOnAppPath('/login')) { - window.location.href = toAppPath('/login'); - } - } - throw new Error(message); - } - if (json.code !== 0) { - if (!silent) showPartnerToast(message, 'error'); - throw new Error(message); - } - return json.data as T; +export function hasPartnerWxSession() { + return localStorage.getItem(PARTNER_WX_BOUND) === '1'; } -export function saveAuth(data: PartnerAuthPayload) { - localStorage.setItem('accessToken', data.accessToken); +export function isPartnerSessionExpired() { + const raw = localStorage.getItem(SESSION_EXPIRES_AT); + if (!raw) return false; + return Date.now() > Number(raw); +} + +export function touchPartnerSession() { + if (!hasPartnerWxSession()) return; + localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS)); +} + +export function saveAuth(data: PartnerSessionPayload) { + localStorage.setItem(ACCESS_TOKEN, data.accessToken); + if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken); if (data.partner) { localStorage.setItem(PARTNER_PROFILE, JSON.stringify(data.partner)); localStorage.setItem(LAST_PHONE, data.partner.phone); } } -export function clearAuth() { - localStorage.removeItem('accessToken'); - localStorage.removeItem(PARTNER_PROFILE); +/** 微信登录/绑定成功后写入 7 天免登录 session */ +export function saveWechatSession(data: PartnerSessionPayload) { + saveAuth(data); + localStorage.setItem(PARTNER_WX_BOUND, '1'); + localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS)); +} + +export function clearAuth(options?: { keepProfile?: boolean }) { + localStorage.removeItem(ACCESS_TOKEN); + localStorage.removeItem(REFRESH_TOKEN); + localStorage.removeItem(SESSION_EXPIRES_AT); + localStorage.removeItem(PARTNER_WX_BOUND); + if (!options?.keepProfile) { + localStorage.removeItem(PARTNER_PROFILE); + localStorage.removeItem(LAST_PHONE); + } } export function isLoggedIn() { - return !!localStorage.getItem('accessToken'); + return !!localStorage.getItem(ACCESS_TOKEN); } + +function profileFromMe(me: PartnerMe): PartnerSessionProfile { + return { + id: me.id, + name: me.name, + phone: me.phone, + companyName: me.companyName, + isPrimary: me.isPrimary, + }; +} + +async function rawRequest( + path: string, + options: RequestInit = {}, + token?: string | null, +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Client-App': CLIENT_APP, + ...(options.headers as Record), + }; + const authToken = token ?? localStorage.getItem(ACCESS_TOKEN); + if (authToken) headers.Authorization = `Bearer ${authToken}`; + + const res = await fetch(`${apiBase}${path}`, { ...options, headers }); + const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' })); + if (json.code !== 0) { + const err = new Error(json.message || '请求失败') as Error & { status?: number }; + err.status = json.code === 401 ? 401 : json.code; + throw err; + } + return json.data as T; +} + +async function refreshSession(): Promise { + const refreshToken = localStorage.getItem(REFRESH_TOKEN); + if (!refreshToken) return null; + try { + const data = await rawRequest( + '/partner/auth/token/refresh', + { + method: 'POST', + body: JSON.stringify({ refreshToken }), + }, + null, + ); + saveAuth(data); + touchPartnerSession(); + return data; + } catch { + return null; + } +} + +async function requestWithAuthRetry( + path: string, + options: RequestInit = {}, + retried = false, +): Promise { + try { + return await rawRequest(path, options); + } catch (e) { + const err = e as Error & { status?: number }; + const canRecover = + err.status === 401 && + !retried && + !AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p)); + if (!canRecover) throw e; + const refreshed = await refreshSession(); + if (!refreshed) { + clearAuth({ keepProfile: true }); + throw e; + } + return requestWithAuthRetry(path, options, true); + } +} + +export async function request( + clientApp: string, + path: string, + options: ApiRequestOptions = {}, +): Promise { + void clientApp; + const { silent, ...fetchOptions } = options; + try { + return await requestWithAuthRetry(path, fetchOptions); + } catch (e) { + const err = e as Error & { status?: number }; + const message = err.message || '请求失败'; + if (err.status === 401) { + if (localStorage.getItem(ACCESS_TOKEN)) { + clearAuth({ keepProfile: true }); + if (!silent) showPartnerToast(message, 'error'); + if (typeof window !== 'undefined' && !isOnAppPath('/login')) { + window.location.href = toAppPath('/login'); + } + } + } else if (!silent) { + showPartnerToast(message, 'error'); + } + throw e; + } +} + +export async function ensureSession(): Promise<{ authenticated: boolean; partner: PartnerSessionProfile | null }> { + if (!isLoggedIn()) { + return { authenticated: false, partner: null }; + } + if (isPartnerSessionExpired()) { + clearAuth({ keepProfile: true }); + return { authenticated: false, partner: getPartnerProfile() }; + } + try { + const me = await rawRequest('/partner/me'); + const partner = profileFromMe(me); + saveAuth({ + accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '', + refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '', + partner, + }); + touchPartnerSession(); + return { authenticated: true, partner }; + } catch (e) { + const err = e as Error & { status?: number }; + if (err.status === 401) { + const refreshed = await refreshSession(); + if (refreshed?.partner) { + return { authenticated: true, partner: refreshed.partner }; + } + clearAuth({ keepProfile: true }); + return { authenticated: false, partner: getPartnerProfile() }; + } + const cached = getPartnerProfile(); + if (cached) return { authenticated: true, partner: cached }; + throw e; + } +} + +/** @deprecated 使用 PartnerSessionPayload */ +export type PartnerAuthPayload = PartnerSessionPayload; diff --git a/apps/h5-partner/src/lib/wechat-auth.ts b/apps/h5-partner/src/lib/wechat-auth.ts index ab434ab..d7986cb 100644 --- a/apps/h5-partner/src/lib/wechat-auth.ts +++ b/apps/h5-partner/src/lib/wechat-auth.ts @@ -2,14 +2,15 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-type import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk'; import { isWechatEnv, weixinSdk } from './weixin'; -import { request, saveAuth } from './api'; +import { request, saveWechatSession, type PartnerSessionPayload } from './api'; export type PartnerProfile = { id: string; name: string; phone: string; - companyName: string; + companyName?: string; hasWechat?: boolean; + isPrimary?: boolean; }; export async function fetchClientConfig(): Promise { @@ -20,7 +21,7 @@ export async function fetchPartnerProfile(): Promise { return request('PARTNER_H5', '/partner/me'); } -/** 微信内上传照片前需完成公众号授权绑定 */ +/** 微信内上传照片前需完成公众号 OAuth 绑定 */ export function needsWechatAuth( profile: PartnerProfile | null, config?: Pick | null, @@ -34,24 +35,30 @@ export async function checkNeedsWechatAuth(profile: PartnerProfile | null): Prom return needsWechatAuth(profile, config); } -/** 处理微信登录/绑定结果,返回是否已拿到 token 可进入首页 */ -export function handlePartnerWechatLoginResult(result: WechatLoginResult): boolean { - if (!result.accessToken) return false; - saveAuth({ accessToken: result.accessToken }); - return true; +export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessionPayload | null { + if (!result.accessToken || !result.refreshToken) return null; + const partner = result.partner; + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + partner: partner + ? { + id: String(partner.id ?? ''), + name: String(partner.name ?? ''), + phone: String(partner.phone ?? ''), + companyName: partner.companyName ? String(partner.companyName) : undefined, + isPrimary: partner.isPrimary !== false, + } + : undefined, + }; } -/** 微信登录成功后拉取并缓存合伙人资料,供一键登录页展示 */ -export async function persistPartnerProfileAfterLogin(): Promise { - try { - const profile = await fetchPartnerProfile(); - saveAuth({ - accessToken: localStorage.getItem('accessToken') ?? '', - partner: profile, - }); - } catch { - /* ignore */ - } +/** 处理微信登录/绑定结果,写入 7 天免登录 session */ +export function handlePartnerWechatLoginResult(result: WechatLoginResult): PartnerSessionPayload | null { + const session = sessionFromWechatLogin(result); + if (!session) return null; + saveWechatSession(session); + return session; } export async function handlePartnerWechatCallback(): Promise { @@ -62,12 +69,12 @@ export async function handlePartnerWechatCallback(): Promise { +export async function loginPartnerWithWechat(): Promise { const config = await fetchClientConfig(); - if (!isWxAuthorizeEnabled(config)) return false; + if (!isWxAuthorizeEnabled(config)) return null; if (!isWechatEnv()) { throw new Error(WECHAT_INAPP_REQUIRED_MSG); } @@ -75,6 +82,14 @@ export async function loginPartnerWithWechat(): Promise { if (result) return handlePartnerWechatLoginResult(result); } +/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */ +export async function bindPartnerWechatAfterSmsLogin(): Promise { + const config = await fetchClientConfig(); + if (!isWxAuthorizeEnabled(config)) return; + if (!isWechatEnv()) return; + await weixinSdk.login(); +} + export async function authorizePartnerWechat(): Promise { const config = await fetchClientConfig(); if (!isWxAuthorizeEnabled(config)) return; @@ -84,7 +99,14 @@ export async function authorizePartnerWechat(): Promise { + const result = await handlePartnerWechatCallback(); + if (!result) return null; return handlePartnerWechatLoginResult(result); } + +/** @deprecated 使用 handlePartnerWechatLoginResult */ +export function savePartnerWechatAuth(result: WechatLoginResult): boolean { + return !!handlePartnerWechatLoginResult(result); +} diff --git a/apps/h5-partner/src/pages/LoginPage.tsx b/apps/h5-partner/src/pages/LoginPage.tsx index cdb4a7c..04088a2 100644 --- a/apps/h5-partner/src/pages/LoginPage.tsx +++ b/apps/h5-partner/src/pages/LoginPage.tsx @@ -1,18 +1,23 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { request, getLastPhone, getPartnerProfile, saveAuth } from '../lib/api'; +import { isWxAuthorizeEnabled } from '@dukang/shared-types'; +import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk'; +import { + getLastPhone, + getPartnerProfile, + hasPartnerWxSession, + request, + saveAuth, + type PartnerSessionPayload, +} from '../lib/api'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { partnerHomePath } from '../lib/partnerAccess'; -import type { PartnerMe } from '@dukang/shared-types'; import { + bindPartnerWechatAfterSmsLogin, fetchClientConfig, - handlePartnerWechatCallback, - handlePartnerWechatLoginResult, loginPartnerWithWechat, } from '../lib/wechat-auth'; import { isWechatEnv } from '../lib/weixin'; -import { isWxAuthorizeEnabled } from '@dukang/shared-types'; -import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk'; import { toastError } from '../lib/toast'; const REMEMBER_PHONE_KEY = 'partner_remember_phone'; @@ -44,9 +49,17 @@ function formatPartnerError(e: unknown): string { return text; } +function formatWechatError(e: unknown): string { + const text = e instanceof Error ? e.message : '微信登录失败'; + if (text.includes('首次登录') || text.includes('手机验证码')) { + return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信'; + } + return text; +} + export default function LoginPage() { const navigate = useNavigate(); - const { refresh } = usePartnerSession(); + const { applySession, refresh, account } = usePartnerSession(); const [params] = useSearchParams(); const quick = params.get('quick') === '1'; const savedProfile = getPartnerProfile(); @@ -71,26 +84,6 @@ export default function LoginPage() { const quickCompany = savedProfile?.companyName ?? ''; const quickPhone = savedProfile?.phone || phone; - useEffect(() => { - if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return; - void handlePartnerWechatCallback() - .then(async (result) => { - if (!result) return; - if (!handlePartnerWechatLoginResult(result)) return; - const account = await refresh(); - navigate(partnerHomePath(account ?? undefined)); - }) - .catch((e) => setMsg(formatWechatError(e))); - }, [navigate, refresh, wxAuthorize, params]); - - function formatWechatError(e: unknown): string { - const text = e instanceof Error ? e.message : '微信登录失败'; - if (text.includes('首次登录') || text.includes('手机验证码')) { - return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信'; - } - return text; - } - function ensureAgreed() { if (!agreed) { const text = '请先勾选并同意用户协议'; @@ -142,28 +135,30 @@ export default function LoginPage() { } } - async function login(options?: { quick?: boolean }) { - if (!options?.quick && !ensureAgreed()) return; + async function finishLoginNavigate() { + const me = await refresh(); + navigate(partnerHomePath(me ?? account ?? savedProfile)); + } + + async function login() { + if (!ensureAgreed()) return; setLoading(true); setMsg(''); try { - if (options?.quick) { - await request('PARTNER_H5', '/partner/auth/sms/send', { - method: 'POST', - body: JSON.stringify({ phone: quickPhone, scene: 'PARTNER_LOGIN' }), - silent: true, - }); - } - const loginPhone = options?.quick ? quickPhone : phone; - const data = await request<{ accessToken: string; partner?: PartnerMe }>('PARTNER_H5', '/partner/auth/login/sms', { + const data = await request('PARTNER_H5', '/partner/auth/login/sms', { method: 'POST', - body: JSON.stringify({ phone: loginPhone, code }), + body: JSON.stringify({ phone, code }), silent: true, }); saveAuth(data); - persistRememberAccount(loginPhone); - const account = await refresh(); - navigate(partnerHomePath(account ?? data.partner)); + applySession(data); + persistRememberAccount(phone); + if (isWechatEnv() && wxAuthorize) { + setMsg('登录成功,正在关联微信…'); + await bindPartnerWechatAfterSmsLogin(); + return; + } + await finishLoginNavigate(); } catch (e) { const text = formatPartnerError(e); setMsg(text); @@ -183,10 +178,10 @@ export default function LoginPage() { } setWxLoading(true); try { - const ok = await loginPartnerWithWechat(); - if (ok) { - const account = await refresh(); - navigate(partnerHomePath(account ?? undefined)); + const session = await loginPartnerWithWechat(); + if (session) { + applySession(session); + await finishLoginNavigate(); } } catch (e) { const text = formatWechatError(e); @@ -198,6 +193,8 @@ export default function LoginPage() { } if (quick) { + const canWechatQuick = wxAuthorize && isWechatEnv() && hasPartnerWxSession() && !!savedProfile; + return (
@@ -228,17 +225,37 @@ export default function LoginPage() {
verified_user - Secured by Dukang Heritage + + {canWechatQuick ? '微信验证 · 7 天内免登录' : 'Secured by Dukang Heritage'} +
@@ -308,13 +325,13 @@ export default function LoginPage() { {wxAuthorize && ( <> -
其他登录方式
- +
其他登录方式
+ )} @@ -327,7 +344,9 @@ export default function LoginPage() {
- 快捷登录 + {hasPartnerWxSession() && savedProfile && ( + 微信快捷登录 + )}