diff --git a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx index dd20eb2..3478f57 100644 --- a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx +++ b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx @@ -53,6 +53,8 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount { primaryPhone: profile.primaryPhone, primaryName: profile.primaryName, hasWechat: profile.hasWechat, + wxNickname: profile.wxNickname, + wxAvatarUrl: profile.wxAvatarUrl, }; } diff --git a/apps/h5-partner/src/lib/api.ts b/apps/h5-partner/src/lib/api.ts index 232f0fc..f8258e7 100644 --- a/apps/h5-partner/src/lib/api.ts +++ b/apps/h5-partner/src/lib/api.ts @@ -24,7 +24,18 @@ const AUTH_RECOVERY_EXEMPT_PATHS = [ export type PartnerSessionProfile = Pick< PartnerMe, - 'id' | 'name' | 'phone' | 'companyName' | 'isPrimary' | 'permissions' | 'primaryAccountId' | 'primaryPhone' | 'primaryName' | 'hasWechat' + | 'id' + | 'name' + | 'phone' + | 'companyName' + | 'isPrimary' + | 'permissions' + | 'primaryAccountId' + | 'primaryPhone' + | 'primaryName' + | 'hasWechat' + | 'wxNickname' + | 'wxAvatarUrl' > & { staffRole?: PartnerStaffRole; }; @@ -112,6 +123,8 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile { primaryPhone: me.primaryPhone, primaryName: me.primaryName, hasWechat: me.hasWechat, + wxNickname: me.wxNickname, + wxAvatarUrl: me.wxAvatarUrl, }; } diff --git a/apps/h5-partner/src/lib/wechat-auth.ts b/apps/h5-partner/src/lib/wechat-auth.ts index 47e721a..5a14d7b 100644 --- a/apps/h5-partner/src/lib/wechat-auth.ts +++ b/apps/h5-partner/src/lib/wechat-auth.ts @@ -14,6 +14,8 @@ export type PartnerProfile = { phone: string; companyName?: string; hasWechat?: boolean; + wxNickname?: string | null; + wxAvatarUrl?: string | null; isPrimary?: boolean; }; @@ -82,6 +84,8 @@ export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessio companyName: partner.companyName ? String(partner.companyName) : undefined, isPrimary: partner.isPrimary !== false, hasWechat: true, + wxNickname: partner.wxNickname ? String(partner.wxNickname) : undefined, + wxAvatarUrl: partner.wxAvatarUrl ? String(partner.wxAvatarUrl) : undefined, } : undefined, }; diff --git a/apps/h5-partner/src/pages/CenterPage.tsx b/apps/h5-partner/src/pages/CenterPage.tsx index fbb34f2..58535ad 100644 --- a/apps/h5-partner/src/pages/CenterPage.tsx +++ b/apps/h5-partner/src/pages/CenterPage.tsx @@ -1,10 +1,19 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import type { PartnerBillDto } from '@dukang/shared-types'; +import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { request } from '../lib/api'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { contactSupport } from '../lib/contact'; import { isPrimaryAccount } from '../lib/partnerAccess'; -import { toastError } from '../lib/toast'; +import { listPartnerStaff } from '../lib/staff'; +import { toastError, toastSuccess } from '../lib/toast'; +import { + authorizePartnerWechat, + fetchClientConfig, + handlePartnerWechatLoginResult, +} from '../lib/wechat-auth'; +import { isWechatEnv } from '../lib/weixin'; type CenterPageProps = { /** 子账号个人中心复用 */ @@ -12,11 +21,18 @@ type CenterPageProps = { roleLabel?: string; }; +function fmtMoney(n: number) { + return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) { - const { account, logout, refresh } = usePartnerSession(); + const { account, logout, refresh, applySession } = usePartnerSession(); const isPrimary = variant === 'primary' && isPrimaryAccount(account); const [name, setName] = useState(account?.name ?? ''); const [saving, setSaving] = useState(false); + const [authorizing, setAuthorizing] = useState(false); + const [bills, setBills] = useState([]); + const [staffCount, setStaffCount] = useState(0); useEffect(() => { document.title = isPrimary ? '合伙人中心' : '个人中心'; @@ -26,6 +42,29 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag setName(account?.name ?? ''); }, [account?.name]); + useEffect(() => { + if (!isPrimary) return; + void request('PARTNER_H5', '/partner/settlement/bills') + .then(setBills) + .catch(() => setBills([])); + void listPartnerStaff() + .then((list) => setStaffCount(list.length)) + .catch(() => setStaffCount(0)); + }, [isPrimary]); + + const finance = useMemo(() => { + const pending = bills.filter((b) => b.status === 'DRAFT'); + const settled = bills.filter((b) => b.status === 'PAID'); + const paidCount = settled.length; + return { + pendingBillCount: pending.length, + pendingTotal: pending.reduce((s, b) => s + Number(b.totalAmount || 0), 0), + settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0), + balance: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0), + paidCount, + }; + }, [bills]); + async function handleSave() { const trimmed = name.trim(); if (!trimmed) { @@ -57,86 +96,218 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag window.location.href = `tel:${account.primaryPhone}`; } + async function handleAvatarAuth() { + if (authorizing) return; + if (!isWechatEnv()) { + toastError('请在微信内打开以授权头像昵称'); + return; + } + setAuthorizing(true); + try { + const config = await fetchClientConfig(); + if (!isWxAuthorizeEnabled(config)) { + toastError('微信授权暂未开启'); + return; + } + const result = await authorizePartnerWechat(); + if (result) { + const session = handlePartnerWechatLoginResult(result); + if (session) applySession(session); + await refresh(); + toastSuccess('微信授权成功'); + } + // result 为空表示已跳转 OAuth,无需额外处理 + } catch (e) { + toastError(e instanceof Error ? e.message : '微信授权失败'); + } finally { + setAuthorizing(false); + } + } + + const locationLabel = [account?.companyName].filter(Boolean).join(' · ') || '—'; + const displayName = account?.wxNickname?.trim() || account?.name || '合伙人'; + const avatarUrl = account?.wxAvatarUrl?.trim() || ''; + const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员'); + return (
-
- person -
-
-
-

{account?.name || '合伙人'}

- {roleLabel || (isPrimary ? '城市合伙人' : '拓店员')} + +
+
+

{displayName}

+ {badgeLabel}
-
+
location_on - {account?.companyName || '—'} + {locationLabel}
-

{account?.phone || ''}

-
-
- -
-

个人信息设置

-
- - setName(e.target.value)} - placeholder="请输入姓名" - maxLength={32} - /> - - -

手机号由管理员维护,如需修改请联系总部

- -
-
- -
-
- - - {isPrimary && ( - -
-
group
- 子账号管理 -
- chevron_right - + {!isPrimary && ( +

{account?.phone || ''}

)}
+ {isPrimary && ( + <> +
+
+

资产概览

+ + 明细 chevron_right + +
+ + {finance.pendingBillCount > 0 && ( + +
+
+ receipt_long +
+
+

待确认账单

+

+ 您有 {finance.pendingBillCount} 笔账单待确认 +

+
+
+ chevron_right + + )} + +
+
+

账户余额 (元)

+

{fmtMoney(finance.balance)}

+
+ +

待结算

+

¥ {fmtMoney(finance.pendingTotal)}

+ + +

+ 提现记录 + history +

+

{finance.paidCount} 笔

+ +
+
+ +
+
+

运营管理

+
+
+ +
+
+ badge +
+ 我的员工 +
+
+ {staffCount > 0 && {staffCount}人} + chevron_right +
+ + +
+
+ storefront +
+ 门店审核记录 +
+ chevron_right + + +
+
+ description +
+ 合同管理 +
+
+ 待续约 + chevron_right +
+ +
+
+ + )} + + {!isPrimary && ( + <> +
+

个人信息设置

+
+ + setName(e.target.value)} + placeholder="请输入姓名" + maxLength={32} + /> + + +

手机号由管理员维护,如需修改请联系总部

+ +
+
+ +
+
+ + +
+
+ + )} + -
+

传承千年 · 杜康好客

diff --git a/apps/h5-partner/src/pages/HomePage.tsx b/apps/h5-partner/src/pages/HomePage.tsx index a84ae54..519d9b5 100644 --- a/apps/h5-partner/src/pages/HomePage.tsx +++ b/apps/h5-partner/src/pages/HomePage.tsx @@ -5,12 +5,10 @@ import { isLoggedIn, request } from '../lib/api'; import { fetchPartnerLeaderboard } from '../lib/leaderboard'; import { getPartnerNavKind } from '../lib/partnerAccess'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; -import { storeStatusLabel, storeStatusPillClass } from '../lib/storeStatus'; function fmtMoney(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } -const HOME_STORE_PREVIEW_LIMIT = 6; const LEADERBOARD_PREVIEW_LIMIT = 3; @@ -92,67 +90,42 @@ function summarizeOrders(orders: Array>) { return { pendingShip, shipping, completed, todayCount }; } -function LeaderboardPreview({ - entries, - self, -}: { - entries: PartnerLeaderboardEntry[]; - self?: PartnerLeaderboardEntry; -}) { - const selfInList = self ? entries.some((e) => e.accountId === self.accountId) : false; - +function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] }) { return ( -
+

- military_tech + emoji_events 合伙人贡献榜

- 查看全部 + + 查看全部 chevron_right +
- {entries.length === 0 && !self ? ( + {entries.length === 0 ? (

暂无排行数据

) : ( - <> - {entries.map((entry) => ( -
-
{entry.rank}
-
-

- {entry.name} - {entry.isSelf ? 我的排名 : null} - ({entry.roleLabel}) -

-

累计拓店 {entry.totalStores} 间

-
-
-

{entry.periodStores} 间

-

本月新增

-
+ entries.map((entry) => ( +
+
+ person + {entry.rank}
- ))} - {self && !selfInList ? ( -
-
{self.rank}
-
-

- {self.name} - 我的排名 -

-

累计拓店 {self.totalStores} 间

-
-
-

{self.periodStores} 间

-

本月新增

-
+
+

+ {entry.name} + ({entry.roleLabel}) +

+

累计拓店: {entry.totalStores} 间

- ) : null} - +
+

{entry.periodStores} 间

+

本月新增

+
+
+ )) )} -
+
); } @@ -167,7 +140,6 @@ export default function HomePage() { const [stores, setStores] = useState>>([]); const [orders, setOrders] = useState>>([]); const [leaderboardEntries, setLeaderboardEntries] = useState([]); - const [leaderboardSelf, setLeaderboardSelf] = useState(); useEffect(() => { document.title = '工作台'; @@ -186,11 +158,9 @@ export default function HomePage() { fetchPartnerLeaderboard('month') .then((data) => { setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT)); - setLeaderboardSelf(data.self); }) .catch(() => { setLeaderboardEntries([]); - setLeaderboardSelf(undefined); }); } else { request>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {}); @@ -207,8 +177,16 @@ export default function HomePage() { const now = new Date(); return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear(); }).length; + const lastMonthNew = stores.filter((s) => { + const created = new Date(String(s.createdAt || '')); + const now = new Date(); + const last = new Date(now.getFullYear(), now.getMonth() - 1, 1); + return created.getMonth() === last.getMonth() && created.getFullYear() === last.getFullYear(); + }).length; + const monthGrowthPct = lastMonthNew > 0 + ? Math.round(((monthNew - lastMonthNew) / lastMonthNew) * 100) + : (monthNew > 0 ? 100 : 0); - const previewStores = stores.slice(0, HOME_STORE_PREVIEW_LIMIT); const orderCount = Number(dash?.orderCount || orderStats.todayCount || 0); const revenue = orderCount * 128.45; const profit = revenue * 0.25; @@ -309,55 +287,31 @@ export default function HomePage() {
-
-
-

辖区门店

- 查看全部 -
- {previewStores.length === 0 ? ( -
-

暂无门店

- 录入新店 -
- ) : ( - previewStores.map((s) => { - const storeId = String(s.id); - const status = String(s.status || 'PAUSED').toUpperCase(); - const audit = String(s.auditStatus || 'APPROVED').toUpperCase(); - return ( - -
-

{String(s.name || '未命名门店')}

-

{String(s.address || s.district || '')}

-
- - {audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)} - - - ); - }) - )} -
-

拓店情况

本月新增签约

- {monthNew} +
+ {monthNew} + {monthGrowthPct !== 0 && ( + 0 ? ' partner-expansion-trend--up' : ' partner-expansion-trend--down'}`}> + + {monthGrowthPct > 0 ? 'trending_up' : 'trending_down'} + + {Math.abs(monthGrowthPct)}% + + )} +

待审核门店

{pendingAuditCount}
- - - -

- {String(dash?.companyName || '郑州合伙人')} · 辖区管理 -

+ + )} diff --git a/apps/h5-partner/src/pages/StoreListPage.tsx b/apps/h5-partner/src/pages/StoreListPage.tsx index de8ff34..006b07b 100644 --- a/apps/h5-partner/src/pages/StoreListPage.tsx +++ b/apps/h5-partner/src/pages/StoreListPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { isLoggedIn, request } from '../lib/api'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { isSubAccount } from '../lib/partnerAccess'; @@ -25,11 +25,13 @@ const FILTERS: { key: StatusFilter; label: string }[] = [ export default function StoreListPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { account } = usePartnerSession(); const readonly = isSubAccount(account); const [stores, setStores] = useState>>([]); const [q, setQ] = useState(''); - const [filter, setFilter] = useState('ALL'); + const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter; + const [filter, setFilter] = useState(initialFilter); const [updatingId, setUpdatingId] = useState(null); const [error, setError] = useState(''); diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css index be60113..b366bc0 100644 --- a/apps/h5-partner/src/styles.css +++ b/apps/h5-partner/src/styles.css @@ -621,9 +621,6 @@ html { .partner-expansion-split { display: flex; - border-bottom: 1px solid rgba(226, 190, 188, 0.2); - padding-bottom: var(--space-md); - margin-bottom: var(--space-md); } .partner-expansion-split > div { @@ -635,6 +632,28 @@ html { border-left: 1px solid rgba(226, 190, 188, 0.2); } +.partner-expansion-value-row { + display: flex; + align-items: center; + gap: 8px; +} + +.partner-expansion-trend { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 12px; + font-weight: 600; +} + +.partner-expansion-trend--up { + color: #16a34a; +} + +.partner-expansion-trend--down { + color: var(--color-heritage-red); +} + .partner-form-error { margin: 0 20px 12px; padding: 10px 12px; @@ -845,6 +864,57 @@ html { color: #fff; } +.partner-leaderboard-preview .partner-leaderboard-header { + margin-bottom: 12px; +} + +.partner-leaderboard-avatar { + position: relative; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--color-surface-container-low); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.partner-leaderboard-avatar .material-symbols-outlined { + font-size: 22px; + color: var(--color-subtle-gray); + font-variation-settings: 'FILL' 1; +} + +.partner-leaderboard-avatar-badge { + position: absolute; + left: -2px; + top: -2px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: #9ca3af; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 16px; + text-align: center; + box-shadow: 0 0 0 2px #fff; +} + +.partner-leaderboard-avatar--rank-1 .partner-leaderboard-avatar-badge { + background: #d4a017; +} + +.partner-leaderboard-avatar--rank-2 .partner-leaderboard-avatar-badge { + background: #9ca3af; +} + +.partner-leaderboard-avatar--rank-3 .partner-leaderboard-avatar-badge { + background: #cd7f32; +} + .partner-leaderboard-info { flex: 1; min-width: 0; @@ -2402,6 +2472,14 @@ nav.app-tabbar .app-tabbar-label { justify-content: center; position: relative; flex-shrink: 0; + padding: 0; + cursor: pointer; + overflow: hidden; +} + +.partner-profile-avatar:disabled { + opacity: 0.7; + cursor: wait; } .partner-profile-avatar .material-symbols-outlined { @@ -2410,22 +2488,81 @@ nav.app-tabbar .app-tabbar-label { font-variation-settings: 'FILL' 1; } +.partner-profile-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.partner-profile-avatar-hint { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 2px 0; + font-size: 10px; + font-weight: 600; + line-height: 1.2; + text-align: center; + color: #fff; + background: rgba(166, 29, 36, 0.82); +} + +.partner-profile-main { + flex: 1; + min-width: 0; +} + +.partner-profile-title-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.partner-profile-name { + margin: 0; + font-size: 20px; + font-weight: 700; + line-height: 1.3; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.partner-profile-location { + display: flex; + align-items: center; + gap: 4px; + margin-top: 4px; + color: var(--color-subtle-gray); +} + .partner-role-badge { + flex-shrink: 0; background: var(--color-secondary-container); color: var(--color-on-secondary-container); padding: 4px 8px; border-radius: 8px; font-family: var(--font-label); font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.05em; + font-weight: 600; + letter-spacing: 0.02em; + white-space: nowrap; + line-height: 1.2; +} + +.partner-assets-section { + margin-top: var(--space-lg); } .partner-bills-banner { margin: 0 var(--space-page) 12px; padding: 16px; - background: rgba(166, 29, 36, 0.05); - border: 1px solid rgba(166, 29, 36, 0.1); + background: rgba(166, 29, 36, 0.06); + border: 1px solid rgba(166, 29, 36, 0.12); border-radius: var(--radius-md); display: flex; align-items: center; @@ -2464,13 +2601,100 @@ nav.app-tabbar .app-tabbar-label { border-radius: var(--radius-md); box-shadow: var(--shadow-card); border: 1px solid rgba(166, 29, 36, 0.05); + text-decoration: none; + color: inherit; + display: block; } .partner-finance-card--hero { - grid-column: span 2; - background: var(--color-heritage-red); + grid-column: 1 / -1; + background: linear-gradient(135deg, #a61d24 0%, #820012 100%); color: #fff; box-shadow: 0 8px 24px rgba(166, 29, 36, 0.25); + border: none; + padding: 20px 24px; +} + +.partner-finance-hero-label { + font-size: 13px; + opacity: 0.88; + margin: 0 0 8px; +} + +.partner-finance-hero-amount { + font-size: 32px; + font-weight: 700; + letter-spacing: 0.02em; + margin: 0; + line-height: 1.2; +} + +.partner-finance-value { + margin-top: 8px; + font-size: 18px; + font-weight: 700; + color: var(--color-on-surface); +} + +.partner-section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 var(--space-page) 8px; +} + +.partner-menu-section .partner-section-head { + margin: 0 0 8px; +} + +.partner-menu-item-right { + display: flex; + align-items: center; + gap: 6px; +} + +.partner-menu-badge { + font-size: 12px; + font-weight: 600; + color: var(--color-heritage-red); + background: rgba(166, 29, 36, 0.08); + padding: 2px 8px; + border-radius: 999px; +} + +.partner-menu-status { + font-size: 12px; + color: #2563eb; + font-weight: 500; +} + +.partner-logout-btn { + width: calc(100% - 2 * var(--space-page)); + margin: var(--space-lg) var(--space-page) 0; + padding: 16px; + border: 1px solid rgba(166, 29, 36, 0.08); + border-radius: var(--radius-md); + background: var(--color-card); + box-shadow: var(--shadow-card); + color: var(--color-heritage-red); + font-family: var(--font-headline); + font-size: 16px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.partner-logout-btn .material-symbols-outlined { + color: var(--color-heritage-red); +} + +.partner-center-footer { + text-align: center; + opacity: 0.35; + padding: 24px 0 8px; } .partner-menu-section { @@ -2514,24 +2738,6 @@ nav.app-tabbar .app-tabbar-label { color: var(--color-heritage-red); } -.partner-logout-btn { - width: calc(100% - 40px); - margin: var(--space-lg) var(--space-page); - padding: 16px; - border: none; - border-radius: var(--radius-md); - background: var(--color-card); - box-shadow: var(--shadow-card); - color: var(--color-error); - font-size: 16px; - font-weight: 500; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; -} - /* ── Settlement page ── */ .partner-settlement-page { min-height: 100vh; @@ -3067,22 +3273,19 @@ body { border-top: 1px dashed rgba(166, 29, 36, 0.15); } -.partner-home--flush-top .partner-home-body, +.partner-home--flush-top .partner-home-body { + padding-top: 20px; +} + .partner-home--flush-top.partner-store-page, .partner-home--flush-top.partner-center-page { - padding-top: 0; + padding-top: 12px; } .partner-home--flush-top .partner-sticky-filter { padding-top: 12px; } -.partner-leaderboard-row--self { - background: rgba(166, 29, 36, 0.08); - border: 1px solid rgba(166, 29, 36, 0.18); - border-radius: 10px; -} - .partner-leaderboard-self-tag { display: inline-block; margin-left: 6px; diff --git a/packages/shared-types/src/partner.ts b/packages/shared-types/src/partner.ts index 28f339d..30cc540 100644 --- a/packages/shared-types/src/partner.ts +++ b/packages/shared-types/src/partner.ts @@ -7,6 +7,10 @@ export interface PartnerMe { isPrimary: boolean; companyName?: string; hasWechat?: boolean; + /** 微信授权后的昵称 */ + wxNickname?: string | null; + /** 微信授权后的头像 URL */ + wxAvatarUrl?: string | null; staffRole?: PartnerStaffRole; permissions?: string[]; primaryAccountId?: string; diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 856da00..b3fceef 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -567,6 +567,8 @@ model PartnerAccount { name String @db.VarChar(64) wxOpenId String? @map("wx_open_id") @db.VarChar(64) wxUnionId String? @map("wx_union_id") @db.VarChar(64) + wxNickname String? @map("wx_nickname") @db.VarChar(64) + wxAvatarUrl String? @map("wx_avatar_url") @db.VarChar(512) isPrimary Int @default(0) @map("is_primary") @db.TinyInt parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt staffRole PartnerStaffRole? @map("staff_role") diff --git a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts index ba44808..01e2e0d 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts @@ -48,7 +48,7 @@ export class WechatMockProvider implements IWechatProvider { return { openId, nickname: 'Mock微信用户', - headImgUrl: undefined, + headImgUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(openId)}`, }; } diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index fd67017..645bd8b 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -231,6 +231,9 @@ export class AuthService { isPrimary: number; staffRole: string | null; permissions?: unknown; + wxOpenId?: string | null; + wxNickname?: string | null; + wxAvatarUrl?: string | null; }, primary: { id: bigint; companyName: string | null }, ) { @@ -243,9 +246,37 @@ export class AuthService { staffRole: account.staffRole ?? undefined, companyName: primary.companyName ?? undefined, permissions: Array.isArray(account.permissions) ? account.permissions : undefined, + hasWechat: !!account.wxOpenId, + wxNickname: account.wxNickname ?? undefined, + wxAvatarUrl: account.wxAvatarUrl ?? undefined, }; } + /** 公众号 snsapi_userinfo:写入合伙人微信昵称与头像 */ + private async syncPartnerWechatProfile( + partnerAccountId: bigint, + accessToken: string | undefined, + openId: string, + ) { + if (!accessToken) return null; + try { + const info = await this.wechatProvider.fetchOAuthUserInfo(accessToken, openId, { + refType: 'PARTNER', + refId: partnerAccountId, + }); + const data: { wxNickname?: string; wxAvatarUrl?: string } = {}; + if (info.nickname?.trim()) data.wxNickname = info.nickname.trim().slice(0, 64); + if (info.headImgUrl?.trim()) data.wxAvatarUrl = info.headImgUrl.trim().slice(0, 512); + if (!data.wxNickname && !data.wxAvatarUrl) return null; + return this.prisma.partnerAccount.update({ + where: { id: partnerAccountId }, + data, + }); + } catch { + return null; + } + } + private async assertPartnerAccountByPhone(phone: string) { const account = await this.prisma.partnerAccount.findUnique({ where: { phone }, @@ -1547,7 +1578,7 @@ export class AuthService { throw new BadRequestException('该微信已绑定其他合伙人账号'); } - const updated = await this.prisma.partnerAccount.update({ + let updated = await this.prisma.partnerAccount.update({ where: { id: partnerAccountId }, data: { wxOpenId: session.openId, @@ -1555,6 +1586,12 @@ export class AuthService { lastLoginAt: new Date(), }, }); + const synced = await this.syncPartnerWechatProfile( + updated.id, + 'accessToken' in session ? session.accessToken : undefined, + session.openId, + ); + if (synced) updated = synced; const primary = await this.resolvePrimaryAccount(updated.id); this.trackPartnerEvent(updated.id, primary.id, clientApp, 'partner_wechat_bind', { platform }); @@ -1593,6 +1630,12 @@ export class AuthService { lastLoginAt: new Date(), }, }); + const synced = await this.syncPartnerWechatProfile( + account.id, + 'accessToken' in session ? session.accessToken : undefined, + session.openId, + ); + if (synced) account = synced; const primary = await this.resolvePrimaryAccount(account.id); this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_wechat_login', { platform }); diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index 1cdf07c..1256233 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -267,6 +267,8 @@ export class PartnerMeController { primaryName: primary.name, companyName: primary.companyName ?? undefined, hasWechat: !!account.wxOpenId, + wxNickname: account.wxNickname ?? undefined, + wxAvatarUrl: account.wxAvatarUrl ?? undefined, }; } }