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 3d21ab4..85a8a6f 100644 --- a/apps/admin-web/src/pages/PartnerAccountsPage.tsx +++ b/apps/admin-web/src/pages/PartnerAccountsPage.tsx @@ -1,16 +1,17 @@ -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 PartnerOption = { id: string; companyName: string }; type ParentAccount = { id: string; name: string; phone: string }; -type Row = { +type AccountTreeRow = { id: string; phone: string; name: string; @@ -20,7 +21,9 @@ type Row = { parentAccountId?: string | null; parent?: ParentAccount | null; createdAt: string; + lastLoginAt?: string | null; partner?: { id: string; companyName: string }; + children?: AccountTreeRow[]; }; type BillRow = { @@ -33,15 +36,34 @@ 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, +})); -const ACCOUNT_TYPE_OPTIONS = [ - { value: '', label: '全部' }, - { value: '1', label: '主账号' }, - { value: '0', 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[] => { + const result: AccountTreeRow[] = []; + for (const row of list) { + const children = row.children?.length ? walk(row.children) : undefined; + if (match(row) || (children && children.length > 0)) { + result.push({ ...row, children: children?.length ? children : undefined }); + } + } + return result; + }; + + return walk(rows); +} function accountTypeLabel(isPrimary: number) { return isPrimary === 1 ? '主账号' : '子账号'; @@ -56,33 +78,61 @@ 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); - if (filters.isPrimary) qs.set('isPrimary', filters.isPrimary); - 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() { @@ -91,18 +141,67 @@ 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: '开城合伙人', + width: 140, + ellipsis: true, + render: (_, row) => row.partner?.companyName || '—', + }, { title: '账号类型', dataIndex: 'isPrimary', width: 90, - render: (v) => {accountTypeLabel(v)}, + render: (v, row) => ( + + {row.parentAccountId ? '子账号' : accountTypeLabel(v)} + + ), }, { title: '角色', @@ -114,7 +213,7 @@ export default function PartnerAccountsPage() { title: '所属主账号', width: 140, render: (_, row) => ( - row.isPrimary === 1 + row.isPrimary === 1 || !row.parentAccountId ? '-' : (row.parent ? `${row.parent.name} / ${row.parent.phone}` : '-') ), @@ -122,11 +221,19 @@ export default function PartnerAccountsPage() { { 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} ), }, @@ -152,26 +259,70 @@ export default function PartnerAccountsPage() { return (
- 开城合伙人账户 +
+ 开城合伙人账户 + 主账号下可展开/收起子账号,支持添加与删除子账号 +
-
{ setFilters(v); setPage(1); }}> + { + setFilters({ + phone: v.phone?.trim() || undefined, + status: v.status || undefined, + partnerId: v.partnerId || undefined, + }); + }} + > + + item.value !== '')} /> + + + + + + - - { setPage(p); setPageSize(ps); } }} /> + + rowKey="id" + className="admin-table-nowrap" + loading={loading} + columns={columns} + dataSource={treeData} + scroll={{ x: 1200 }} + pagination={false} + expandable={{ + expandedRowKeys, + onExpandedRowsChange: (keys) => setExpandedRowKeys(keys as string[]), + defaultExpandAllRows: false, + }} + /> setDrawerOpen(false)} + onClose={() => { + setDrawerOpen(false); + setDetail(null); + }} + destroyOnClose extra={} > {detail && ( @@ -180,24 +331,39 @@ export default function PartnerAccountsPage() { key: 'info', label: '基本信息', children: ( -
+ - + - - - - {detail.isPrimary !== 1 && ( - - + {detail.staffRole ? ( + + - )} + ) : null} + {detail.parentAccountId && detail.parent ? ( + + + + ) : null} @@ -262,6 +428,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 a64faaa..657db83 100644 --- a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx +++ b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx @@ -1,77 +1,156 @@ -import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; -import { toAppPath } from '@dukang/weixin-sdk'; -import { clearAuth, isLoggedIn, request, saveAuth, type PartnerAuthPayload } 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 type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types'; +import { + clearAuth, + ensureSession, + request, + saveAuth, + type PartnerSessionPayload, + type PartnerSessionProfile, +} from '../lib/api'; +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; - applySession: (session: PartnerAuthPayload) => void; + /** @deprecated 使用 ready */ + loading: boolean; + applySession: (session: PartnerSessionPayload) => void; refresh: () => Promise; logout: () => void; }; const PartnerSessionContext = createContext(null); -export function PartnerSessionProvider({ children }: { children: ReactNode }) { - const [account, setAccount] = useState(null); - const [loading, setLoading] = useState(true); - const [loggedIn, setLoggedIn] = useState(() => isLoggedIn()); +function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount { + return { + id: profile.id, + name: profile.name, + phone: profile.phone, + companyName: profile.companyName ?? '', + isPrimary: profile.isPrimary ?? true, + staffRole: profile.staffRole, + }; +} - const applySession = useCallback((session: PartnerAuthPayload) => { +export function PartnerSessionProvider({ children }: { children: ReactNode }) { + const [ready, setReady] = useState(false); + const [authenticated, setAuthenticated] = useState(false); + const [account, setAccount] = useState(null); + + const applySession = useCallback((session: PartnerSessionPayload) => { saveAuth(session); - setLoggedIn(true); - setLoading(false); + setAuthenticated(true); if (session.partner) { - setAccount(session.partner as PartnerAccount); + setAccount(accountFromProfile(session.partner)); } }, []); 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); - if (!isLoggedIn()) { - setLoggedIn(false); - } + 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} ); @@ -82,3 +161,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 5bc722b..8d4612c 100644 --- a/apps/h5-partner/src/lib/api.ts +++ b/apps/h5-partner/src/lib/api.ts @@ -1,18 +1,34 @@ import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types'; 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 & { - isPrimary?: PartnerMe['isPrimary']; +/** 微信验证通过后的免登录时长 */ +export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +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 & { staffRole?: PartnerStaffRole; }; -export type PartnerAuthPayload = { +export type PartnerSessionPayload = { accessToken: string; + refreshToken: string; partner?: PartnerSessionProfile; }; @@ -34,54 +50,189 @@ 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 rawMessage = json.message; - const message = Array.isArray(rawMessage) - ? rawMessage.join(';') - : (rawMessage || (res.status === 401 ? '登录已过期,请重新登录' : '请求失败')); - - if (res.status === 401 || json.code === 401) { - if (token && localStorage.getItem('accessToken') === token) { - clearAuth(); - if (typeof window !== 'undefined' && !isOnAppPath('/login')) { - window.location.href = toAppPath('/login'); - } - } - throw new Error(message); - } - if (json.code !== 0) { - 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, + staffRole: me.staffRole ?? undefined, + }; +} + +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/partnerAccess.ts b/apps/h5-partner/src/lib/partnerAccess.ts index efcbe32..c2093ea 100644 --- a/apps/h5-partner/src/lib/partnerAccess.ts +++ b/apps/h5-partner/src/lib/partnerAccess.ts @@ -9,7 +9,7 @@ export function isSubAccount(account: PartnerMe | null | undefined): boolean { } export function partnerHomePath(account: PartnerMe | null | undefined): string { - return isSubAccount(account) ? '/stores/new' : '/'; + return isSubAccount(account) ? '/stores/new?step=1' : '/'; } export const SUB_ACCOUNT_ALLOWED_PREFIXES = ['/stores', '/login']; diff --git a/apps/h5-partner/src/lib/storeDraft.ts b/apps/h5-partner/src/lib/storeDraft.ts index dc10a23..6451c6c 100644 --- a/apps/h5-partner/src/lib/storeDraft.ts +++ b/apps/h5-partner/src/lib/storeDraft.ts @@ -23,6 +23,10 @@ export type StoreDraft = { export const STORE_DRAFT_KEY = 'partner_store_draft_v2'; +export function storeDraftKey(accountId?: string): string { + return accountId ? `${STORE_DRAFT_KEY}_${accountId}` : STORE_DRAFT_KEY; +} + export const defaultStoreForm = (): StoreDraftForm => ({ regionCodes: [], cityId: '', @@ -72,9 +76,17 @@ function normalizeForm(raw: Record): StoreDraftForm { }; } -export function loadStoreDraft(): StoreDraft | null { +export function loadStoreDraft(accountId?: string): StoreDraft | null { try { - const raw = localStorage.getItem(STORE_DRAFT_KEY); + const key = storeDraftKey(accountId); + let raw = localStorage.getItem(key); + if (!raw && accountId) { + raw = localStorage.getItem(STORE_DRAFT_KEY); + if (raw) { + localStorage.setItem(key, raw); + localStorage.removeItem(STORE_DRAFT_KEY); + } + } if (!raw) return null; const parsed = JSON.parse(raw) as Record; if (parsed.form && typeof parsed.form === 'object') { @@ -89,12 +101,12 @@ export function loadStoreDraft(): StoreDraft | null { } } -export function saveStoreDraft(draft: StoreDraft) { - localStorage.setItem(STORE_DRAFT_KEY, JSON.stringify(draft)); +export function saveStoreDraft(draft: StoreDraft, accountId?: string) { + localStorage.setItem(storeDraftKey(accountId), JSON.stringify(draft)); } -export function clearStoreDraft() { - localStorage.removeItem(STORE_DRAFT_KEY); +export function clearStoreDraft(accountId?: string) { + localStorage.removeItem(storeDraftKey(accountId)); } const PHONE_RE = /^1\d{10}$/; diff --git a/apps/h5-partner/src/lib/wechat-auth.ts b/apps/h5-partner/src/lib/wechat-auth.ts index c32cd01..745173e 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, type PartnerSessionProfile } 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,27 +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({ +export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessionPayload | null { + if (!result.accessToken || !result.refreshToken) return null; + const partner = result.partner; + return { accessToken: result.accessToken, - partner: result.partner as PartnerSessionProfile | undefined, - }); - return true; + 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 { @@ -65,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); } @@ -78,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; @@ -87,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): PartnerSessionPayload | null { return handlePartnerWechatLoginResult(result); } diff --git a/apps/h5-partner/src/pages/LoginPage.tsx b/apps/h5-partner/src/pages/LoginPage.tsx index bcc662d..3b49fa9 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, type PartnerAuthPayload } from '../lib/api'; -import { usePartnerSession } from '../contexts/PartnerSessionContext'; -import type { PartnerAccount } from '../contexts/PartnerSessionContext'; -import { partnerHomePath } from '../lib/partnerAccess'; -import type { PartnerMe } from '@dukang/shared-types'; +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 { + bindPartnerWechatAfterSmsLogin, fetchClientConfig, - handlePartnerWechatCallback, 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'; const REMEMBER_PHONE_KEY = 'partner_remember_phone'; const REMEMBER_FLAG_KEY = 'partner_remember_account'; @@ -43,21 +48,17 @@ function formatPartnerError(e: unknown): string { return text; } -function toLoginSession(data: { accessToken: string; partner?: PartnerMe }): PartnerAuthPayload { - return { - accessToken: data.accessToken, - partner: data.partner, - }; -} - -function toPartnerAccount(partner?: Record): PartnerAccount | undefined { - if (!partner || typeof partner.id !== 'string') return undefined; - return partner as unknown as PartnerAccount; +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 { applySession } = usePartnerSession(); + const { applySession, refresh, account } = usePartnerSession(); const [params] = useSearchParams(); const quick = params.get('quick') === '1'; const savedProfile = getPartnerProfile(); @@ -82,29 +83,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((result) => { - if (!result?.accessToken) return; - const session: PartnerAuthPayload = { - accessToken: result.accessToken, - partner: toPartnerAccount(result.partner), - }; - applySession(session); - navigate(partnerHomePath(session.partner), { replace: true }); - }) - .catch((e) => setMsg(formatWechatError(e))); - }, [applySession, navigate, 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) { setMsg('请先勾选并同意用户协议'); @@ -152,28 +130,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, }); - const session = toLoginSession(data); - applySession(session); - persistRememberAccount(loginPhone); - navigate(partnerHomePath(session.partner), { replace: true }); + saveAuth(data); + applySession(data); + persistRememberAccount(phone); + if (isWechatEnv() && wxAuthorize) { + setMsg('登录成功,正在关联微信…'); + await bindPartnerWechatAfterSmsLogin(); + return; + } + await finishLoginNavigate(); } catch (e) { setMsg(formatPartnerError(e)); } finally { @@ -190,13 +170,10 @@ export default function LoginPage() { } setWxLoading(true); try { - const ok = await loginPartnerWithWechat(); - if (ok) { - applySession({ - accessToken: localStorage.getItem('accessToken') ?? '', - partner: getPartnerProfile() ?? undefined, - }); - navigate(partnerHomePath(getPartnerProfile()), { replace: true }); + const session = await loginPartnerWithWechat(); + if (session) { + applySession(session); + await finishLoginNavigate(); } } catch (e) { setMsg(formatWechatError(e)); @@ -206,6 +183,8 @@ export default function LoginPage() { } if (quick) { + const canWechatQuick = wxAuthorize && isWechatEnv() && hasPartnerWxSession() && !!savedProfile; + return (
@@ -236,17 +215,37 @@ export default function LoginPage() {
verified_user - Secured by Dukang Heritage + + {canWechatQuick ? '微信验证 · 7 天内免登录' : 'Secured by Dukang Heritage'} +
@@ -316,13 +315,13 @@ export default function LoginPage() { {wxAuthorize && ( <> -
其他登录方式
- +
其他登录方式
+ )} @@ -335,7 +334,9 @@ export default function LoginPage() { - 快捷登录 + {hasPartnerWxSession() && savedProfile && ( + 微信快捷登录 + )}
diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index 56e3271..a0de627 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -17,19 +17,9 @@ import { checkStorePhoneAvailable } from '../lib/storePhone'; import { fetchPartnerCities, type OpenCityOption } from '../lib/upload'; -import { - - fetchPartnerProfile, - - handlePartnerWechatCallback, - - savePartnerWechatAuth, - -} from '../lib/wechat-auth'; - import { isWechatEnv, weixinSdk } from '../lib/weixin'; -import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk'; +import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { @@ -87,9 +77,15 @@ export default function StoreCreatePage() { const navigate = useNavigate(); + const { account, refresh } = usePartnerSession(); + + const accountId = account?.id; + + const wechatReady = !!account?.hasWechat; + const [params, setParams] = useSearchParams(); - const saved = loadStoreDraft(); + const saved = loadStoreDraft(accountId); const [form, setForm] = useState(saved?.form ?? defaultStoreForm()); @@ -105,8 +101,6 @@ export default function StoreCreatePage() { const [citiesError, setCitiesError] = useState(''); - const [wechatReady, setWechatReady] = useState(false); - function reportFormError(message: string) { setSubmitError(message); } @@ -131,9 +125,9 @@ export default function StoreCreatePage() { useEffect(() => { - saveStoreDraft({ step, form }); + saveStoreDraft({ step, form }, accountId); - }, [step, form]); + }, [step, form, accountId]); @@ -141,11 +135,7 @@ export default function StoreCreatePage() { if (step !== 2 || !isWechatEnv()) return; - void fetchPartnerProfile() - - .then((me) => setWechatReady(!!me.hasWechat)) - - .catch(() => setWechatReady(false)); + void refresh(); void weixinSdk.init().catch(() => { @@ -153,45 +143,7 @@ export default function StoreCreatePage() { }); - }, [step]); - - - - useEffect(() => { - - if (!isWechatEnv() || !params.get('code')) return; - - void handlePartnerWechatCallback() - - .then((result) => { - - if (result && savePartnerWechatAuth(result)) { - - setWechatReady(true); - - } - - stripOAuthParamsFromLocation(); - - const next = new URLSearchParams(params); - - next.delete('code'); - - next.delete('state'); - - setParams(next, { replace: true }); - - void weixinSdk.init().catch(() => { - - /* OssUploadField 点击时会再次初始化 */ - - }); - - }) - - .catch((e) => reportFormError(e instanceof Error ? e.message : '微信授权失败')); - - }, [params, setParams]); + }, [step, refresh]); @@ -468,7 +420,7 @@ export default function StoreCreatePage() { }); - clearStoreDraft(); + clearStoreDraft(accountId); toastSuccess('门店录入成功'); navigate(`/stores/${result.store.id}`); } catch (e) { @@ -717,7 +669,7 @@ export default function StoreCreatePage() { wechatReady={wechatReady} - onWechatReadyChange={setWechatReady} + onWechatReadyChange={() => { void refresh(); }} onChange={(coverUrl) => patchForm({ coverUrl })} @@ -753,7 +705,7 @@ export default function StoreCreatePage() { wechatReady={wechatReady} - onWechatReadyChange={setWechatReady} + onWechatReadyChange={() => { void refresh(); }} onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)} @@ -785,7 +737,7 @@ export default function StoreCreatePage() { wechatReady={wechatReady} - onWechatReadyChange={setWechatReady} + onWechatReadyChange={() => { void refresh(); }} onChange={(contractUrl) => patchForm({ contractUrl })} diff --git a/apps/h5-user/src/pages/OrderDetailPage.tsx b/apps/h5-user/src/pages/OrderDetailPage.tsx index f2c2b3f..daa0676 100644 --- a/apps/h5-user/src/pages/OrderDetailPage.tsx +++ b/apps/h5-user/src/pages/OrderDetailPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { Link, useNavigate, useParams } from 'react-router-dom'; import AppImage from '@dukang/shared-ui/AppImage'; import AppToast from '../components/AppToast'; import { request } from '../lib/api'; @@ -118,7 +118,6 @@ function fullReceiverAddress(order: Order) { export default function OrderDetailPage() { const { id } = useParams(); - const [params] = useSearchParams(); const navigate = useNavigate(); const [order, setOrder] = useState(null); const [showCs, setShowCs] = useState(false); @@ -126,7 +125,7 @@ export default function OrderDetailPage() { const [shareToast, setShareToast] = useState(''); const [confirming, setConfirming] = useState(false); - const isReship = order?.orderType === 'RESHIPMENT' || params.get('type') === 'reship'; + const isReship = order?.orderType === 'RESHIPMENT'; async function loadOrder() { if (!id) return; diff --git a/apps/h5-user/src/pages/OrderListPage.tsx b/apps/h5-user/src/pages/OrderListPage.tsx index 3abb72f..89fc3b6 100644 --- a/apps/h5-user/src/pages/OrderListPage.tsx +++ b/apps/h5-user/src/pages/OrderListPage.tsx @@ -20,9 +20,17 @@ const STATUS_LABEL: Record = { SHIPPING: '配送中', PENDING_RECEIVE: '待收货', COMPLETED: '已完成', - RESHIP: '补发中', }; +function isReshipOrder(order: Record) { + return String(order.orderType) === 'RESHIPMENT'; +} + +function orderStatusLabel(order: Record) { + if (isReshipOrder(order)) return '补发中'; + return STATUS_LABEL[String(order.status)] || String(order.status); +} + export default function OrderListPage() { const [params, setParams] = useSearchParams(); const tab = params.get('tab') || 'all'; @@ -38,18 +46,24 @@ export default function OrderListPage() { navigate('/mine')} /> setParams({ tab: key })} /> {data.list.length === 0 &&
暂无订单
} - {data.list.map((o, i) => { + {data.list.map((o) => { const items = (o.items as Array>) || []; const item = items[0]; - const isReshipDemo = i === 0 && tab === 'all'; + const isReship = isReshipOrder(o); const isPendingPay = String(o.status) === 'PENDING_PAY'; return (
- 订单号: {String(o.orderNo)} - {STATUS_LABEL[String(o.status)] || String(o.status)} + + 订单号: {String(o.orderNo)} + {isReship && ( + + 补发单 + + )} + + {orderStatusLabel(o)}
- {isReshipDemo && 补发示例} {item && (
)} - - {isReshipDemo ? '查看进度' : '查看详情'} + + 查看详情
diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index 92b71f1..985a6ce 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -6,6 +6,7 @@ export const HqOperationAction = { PARTNER_UPDATE: 'PARTNER_UPDATE', PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE', PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE', + PARTNER_ACCOUNT_DELETE: 'PARTNER_ACCOUNT_DELETE', HQ_ACCOUNT_CREATE: 'HQ_ACCOUNT_CREATE', HQ_ACCOUNT_UPDATE: 'HQ_ACCOUNT_UPDATE', HQ_PERMISSION_UPDATE: 'HQ_PERMISSION_UPDATE', @@ -50,6 +51,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人', [HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户', [HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户', + [HqOperationAction.PARTNER_ACCOUNT_DELETE]: '删除合伙人子账号', [HqOperationAction.HQ_ACCOUNT_CREATE]: '新增 HQ 管理员', [HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员', [HqOperationAction.HQ_PERMISSION_UPDATE]: '配置 HQ 权限', diff --git a/server/dukang-api/src/modules/iam/auth.controller.ts b/server/dukang-api/src/modules/iam/auth.controller.ts index 0b51547..29856b7 100644 --- a/server/dukang-api/src/modules/iam/auth.controller.ts +++ b/server/dukang-api/src/modules/iam/auth.controller.ts @@ -172,6 +172,11 @@ export class PartnerAuthController { } return this.authService.loginPartnerWechat(dto.code, ClientApp.PARTNER_H5, dto.platform ?? 'h5'); } + + @Post('token/refresh') + refresh(@Body() dto: RefreshTokenDto) { + return this.authService.refreshAccessToken(dto.refreshToken, ClientApp.PARTNER_H5); + } } @Controller('user') diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 95e5d08..038343f 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -385,6 +385,9 @@ export class AuthService { if (payload.actorType === 'STORE' && clientApp === ClientApp.SHOP_H5) { return this.buildStoreSessionResponse(BigInt(payload.actorId), clientApp); } + if (payload.actorType === 'PARTNER' && clientApp === ClientApp.PARTNER_H5) { + return this.buildPartnerSessionResponse(BigInt(payload.actorId), clientApp); + } throw new UnauthorizedException('Invalid refresh token'); } catch (err) { if (err instanceof UnauthorizedException) throw err; @@ -409,6 +412,25 @@ export class AuthService { }); } + private async buildPartnerSessionResponse(accountId: bigint, clientApp: ClientApp) { + const account = await this.prisma.partnerAccount.findUnique({ + where: { id: accountId }, + include: { partner: true }, + }); + if (!account || account.status !== 'ACTIVE') { + throw new UnauthorizedException('Invalid refresh token'); + } + return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, { + id: account.id.toString(), + partnerId: account.partnerId.toString(), + name: account.name, + phone: account.phone, + isPrimary: account.isPrimary === 1, + staffRole: account.staffRole ?? undefined, + companyName: account.partner.companyName, + }); + } + async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) { const normalizedPhone = this.assertMobilePhone(phone); const existingUser = await this.prisma.user.findUnique({ @@ -1393,7 +1415,7 @@ export class AuthService { phoneVerified, }; const accessToken = this.jwtService.sign(payload); - const refreshExpiresIn = actorType === 'STORE' ? '7d' : '30d'; + const refreshExpiresIn = actorType === 'STORE' || actorType === 'PARTNER' ? '7d' : '30d'; const refreshToken = this.jwtService.sign(payload, { expiresIn: refreshExpiresIn }); return { accessToken, diff --git a/server/dukang-api/src/modules/ops/admin-partners.controller.ts b/server/dukang-api/src/modules/ops/admin-partners.controller.ts index bbf8edc..a27be3c 100644 --- a/server/dukang-api/src/modules/ops/admin-partners.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-partners.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; @@ -54,6 +54,11 @@ export class AdminPartnersController { export class AdminPartnerAccountsController { constructor(private readonly service: AdminPartnersService) {} + @Get('tree') + tree(@Query('partnerId') partnerId?: string) { + return this.service.listPartnerAccountTree(partnerId ? BigInt(partnerId) : undefined); + } + @Get() list(@Query() query: AdminPartnerAccountsQueryDto) { return this.service.listPartnerAccounts(query); @@ -85,4 +90,14 @@ export class AdminPartnerAccountsController { update(@Param('id') id: string, @Body() dto: UpdatePartnerAccountDto) { return this.service.updatePartnerAccount(BigInt(id), dto); } + + @Delete(':id') + @HqOperation({ + action: HqOperationAction.PARTNER_ACCOUNT_DELETE, + refType: 'PARTNER_ACCOUNT', + refIdParam: 'id', + }) + remove(@Param('id') id: string) { + return this.service.deletePartnerSubAccount(BigInt(id)); + } } diff --git a/server/dukang-api/src/modules/ops/admin-partners.service.ts b/server/dukang-api/src/modules/ops/admin-partners.service.ts index 7c51dc1..4415794 100644 --- a/server/dukang-api/src/modules/ops/admin-partners.service.ts +++ b/server/dukang-api/src/modules/ops/admin-partners.service.ts @@ -98,11 +98,64 @@ export class AdminPartnersService { return serializeBigInt({ items, total, page, pageSize }); } + async listPartnerAccountTree(partnerId?: bigint) { + const where: Prisma.PartnerAccountWhereInput = {}; + if (partnerId) where.partnerId = partnerId; + + const accounts = await this.prisma.partnerAccount.findMany({ + where, + orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }], + include: { partner: { select: { id: true, companyName: true } } }, + }); + + type TreeNode = (typeof accounts)[number] & { children: TreeNode[] }; + const nodeMap = new Map(); + const roots: TreeNode[] = []; + + for (const account of accounts) { + nodeMap.set(account.id.toString(), { ...account, children: [] }); + } + + for (const account of accounts) { + const node = nodeMap.get(account.id.toString())!; + if (account.parentAccountId) { + const parent = nodeMap.get(account.parentAccountId.toString()); + if (parent) parent.children.push(node); + else roots.push(node); + } else { + roots.push(node); + } + } + + const mapNode = (node: TreeNode) => ({ + id: node.id, + phone: node.phone, + name: node.name, + status: node.status, + isPrimary: node.isPrimary, + staffRole: node.staffRole, + parentAccountId: node.parentAccountId, + partner: node.partner, + createdAt: node.createdAt, + lastLoginAt: node.lastLoginAt, + children: node.children.length ? node.children.map(mapNode) : undefined, + }); + + return serializeBigInt(roots.map(mapNode)); + } + async detailPartnerAccount(id: bigint) { const account = await this.prisma.partnerAccount.findUnique({ where: { id }, include: { - partner: true, + partner: { + select: { + id: true, + companyName: true, + contactPhone: true, + address: true, + }, + }, parent: { select: { id: true, name: true, phone: true } }, }, }); @@ -133,16 +186,49 @@ export class AdminPartnersService { } async createPartnerAccount(dto: CreatePartnerAccountDto) { - const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId) } }); + const phone = dto.phone.trim(); + if (!/^1[3-9]\d{9}$/.test(phone)) { + throw new BadRequestException('请输入正确的手机号码'); + } + const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } }); + if (phoneTaken) throw new BadRequestException('该手机号已被使用'); + + if (dto.parentAccountId) { + const parent = await this.prisma.partnerAccount.findUnique({ + where: { id: BigInt(dto.parentAccountId) }, + }); + if (!parent) throw new BadRequestException('主账号不存在'); + if (parent.isPrimary !== 1) throw new BadRequestException('仅可向主账号添加子账号'); + if (dto.partnerId && dto.partnerId !== parent.partnerId.toString()) { + throw new BadRequestException('开城合伙人与主账号不匹配'); + } + + const account = await this.prisma.partnerAccount.create({ + data: { + partnerId: parent.partnerId, + phone, + name: dto.name.trim(), + staffRole: (dto.staffRole ?? 'INTERNAL') as 'PARTNER' | 'INTERNAL' | 'PROMOTER', + isPrimary: 0, + parentAccountId: parent.id, + status: 'ACTIVE', + }, + include: { partner: { select: { id: true, companyName: true } } }, + }); + return serializeBigInt(account); + } + + const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId!) } }); if (!partner) throw new BadRequestException('开城合伙人不存在'); const account = await this.prisma.partnerAccount.create({ data: { partnerId: partner.id, - phone: dto.phone, - name: dto.name, + phone, + name: dto.name.trim(), staffRole: dto.staffRole ? (dto.staffRole as 'PARTNER' | 'INTERNAL' | 'PROMOTER') : undefined, isPrimary: 0, }, + include: { partner: { select: { id: true, companyName: true } } }, }); return serializeBigInt(account); } @@ -169,4 +255,14 @@ export class AdminPartnersService { const account = await this.prisma.partnerAccount.update({ where: { id }, data }); return serializeBigInt(account); } + + async deletePartnerSubAccount(id: bigint) { + const account = await this.prisma.partnerAccount.findUnique({ where: { id } }); + if (!account) throw new NotFoundException('开城合伙人账号不存在'); + if (!account.parentAccountId) { + throw new BadRequestException('仅可删除子账号'); + } + await this.prisma.partnerAccount.delete({ where: { id } }); + return { ok: true }; + } } diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 71f0694..4e2c4cd 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -211,9 +211,10 @@ export class UpdatePartnerAccountDto { } export class CreatePartnerAccountDto { + @ValidateIf((o: CreatePartnerAccountDto) => !o.parentAccountId) @IsString() @IsNotEmpty() - partnerId: string; + partnerId?: string; @IsString() @IsNotEmpty() @@ -226,6 +227,11 @@ export class CreatePartnerAccountDto { @IsOptional() @IsIn(['PARTNER', 'INTERNAL', 'PROMOTER']) staffRole?: string; + + /** 主账号 ID;传入则创建子账号 */ + @IsOptional() + @IsString() + parentAccountId?: string; } export class CreateCityDto {