城市合伙人端页面优化
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-07-17 07:45:20 +08:00
parent bde85a7b82
commit 7fa2c6fbed
12 changed files with 601 additions and 201 deletions
@@ -53,6 +53,8 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
primaryPhone: profile.primaryPhone, primaryPhone: profile.primaryPhone,
primaryName: profile.primaryName, primaryName: profile.primaryName,
hasWechat: profile.hasWechat, hasWechat: profile.hasWechat,
wxNickname: profile.wxNickname,
wxAvatarUrl: profile.wxAvatarUrl,
}; };
} }
+14 -1
View File
@@ -24,7 +24,18 @@ const AUTH_RECOVERY_EXEMPT_PATHS = [
export type PartnerSessionProfile = Pick< export type PartnerSessionProfile = Pick<
PartnerMe, 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; staffRole?: PartnerStaffRole;
}; };
@@ -112,6 +123,8 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile {
primaryPhone: me.primaryPhone, primaryPhone: me.primaryPhone,
primaryName: me.primaryName, primaryName: me.primaryName,
hasWechat: me.hasWechat, hasWechat: me.hasWechat,
wxNickname: me.wxNickname,
wxAvatarUrl: me.wxAvatarUrl,
}; };
} }
+4
View File
@@ -14,6 +14,8 @@ export type PartnerProfile = {
phone: string; phone: string;
companyName?: string; companyName?: string;
hasWechat?: boolean; hasWechat?: boolean;
wxNickname?: string | null;
wxAvatarUrl?: string | null;
isPrimary?: boolean; isPrimary?: boolean;
}; };
@@ -82,6 +84,8 @@ export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessio
companyName: partner.companyName ? String(partner.companyName) : undefined, companyName: partner.companyName ? String(partner.companyName) : undefined,
isPrimary: partner.isPrimary !== false, isPrimary: partner.isPrimary !== false,
hasWechat: true, hasWechat: true,
wxNickname: partner.wxNickname ? String(partner.wxNickname) : undefined,
wxAvatarUrl: partner.wxAvatarUrl ? String(partner.wxAvatarUrl) : undefined,
} }
: undefined, : undefined,
}; };
+239 -68
View File
@@ -1,10 +1,19 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; 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 { request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { contactSupport } from '../lib/contact'; import { contactSupport } from '../lib/contact';
import { isPrimaryAccount } from '../lib/partnerAccess'; 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 = { type CenterPageProps = {
/** 子账号个人中心复用 */ /** 子账号个人中心复用 */
@@ -12,11 +21,18 @@ type CenterPageProps = {
roleLabel?: string; roleLabel?: string;
}; };
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) { 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 isPrimary = variant === 'primary' && isPrimaryAccount(account);
const [name, setName] = useState(account?.name ?? ''); const [name, setName] = useState(account?.name ?? '');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [authorizing, setAuthorizing] = useState(false);
const [bills, setBills] = useState<PartnerBillDto[]>([]);
const [staffCount, setStaffCount] = useState(0);
useEffect(() => { useEffect(() => {
document.title = isPrimary ? '合伙人中心' : '个人中心'; document.title = isPrimary ? '合伙人中心' : '个人中心';
@@ -26,6 +42,29 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
setName(account?.name ?? ''); setName(account?.name ?? '');
}, [account?.name]); }, [account?.name]);
useEffect(() => {
if (!isPrimary) return;
void request<PartnerBillDto[]>('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() { async function handleSave() {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) { if (!trimmed) {
@@ -57,86 +96,218 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
window.location.href = `tel:${account.primaryPhone}`; 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 ( return (
<div className="page partner-center-page partner-home--flush-top"> <div className="page partner-center-page partner-home--flush-top">
<section className="partner-profile-card"> <section className="partner-profile-card">
<div className="partner-profile-avatar"> <button
<span className="material-symbols-outlined">person</span> type="button"
</div> className="partner-profile-avatar"
<div style={{ flex: 1 }}> onClick={() => void handleAvatarAuth()}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> disabled={authorizing}
<h2 className="headline-lg" style={{ fontSize: 20 }}>{account?.name || '合伙人'}</h2> aria-label={avatarUrl ? '更新微信头像' : '微信授权获取头像'}
<span className="partner-role-badge">{roleLabel || (isPrimary ? '城市合伙人' : '拓店员')}</span> >
{avatarUrl ? (
<img src={avatarUrl} alt="" className="partner-profile-avatar-img" referrerPolicy="no-referrer" />
) : (
<span className="material-symbols-outlined">person</span>
)}
{!avatarUrl && (
<span className="partner-profile-avatar-hint"></span>
)}
</button>
<div className="partner-profile-main">
<div className="partner-profile-title-row">
<h2 className="partner-profile-name">{displayName}</h2>
<span className="partner-role-badge">{badgeLabel}</span>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, color: 'var(--color-subtle-gray)' }}> <div className="partner-profile-location">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span> <span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span>
<span className="body-md">{account?.companyName || '—'}</span> <span className="body-md line-2-clamp">{locationLabel}</span>
</div> </div>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{account?.phone || ''}</p> {!isPrimary && (
</div> <p className="label-md text-muted" style={{ marginTop: 4 }}>{account?.phone || ''}</p>
</section>
<section className="partner-menu-section">
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<div className="partner-menu-card" style={{ padding: '16px' }}>
<label className="partner-form-label" htmlFor="center-name"></label>
<input
id="center-name"
className="partner-form-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="请输入姓名"
maxLength={32}
/>
<label className="partner-form-label" style={{ marginTop: 16 }}></label>
<input className="partner-form-input" value={account?.phone ?? ''} readOnly disabled />
<p className="label-md text-muted" style={{ marginTop: 4 }}></p>
<button
type="button"
className="partner-btn-primary"
style={{ width: '100%', marginTop: 16 }}
disabled={saving}
onClick={() => void handleSave()}
>
{saving ? '保存中…' : '保存资料'}
</button>
</div>
</section>
<section className="partner-menu-section">
<div className="partner-menu-card">
<button type="button" className="partner-menu-item" onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}>
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">support_agent</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</button>
<button type="button" className="partner-menu-item" onClick={handleContactPartner}>
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">call</span></div>
<span className="body-md" style={{ fontSize: 16 }}>{isPrimary ? '联系总部' : '联系合伙人'}</span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</button>
{isPrimary && (
<Link to="/center/staff" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">group</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
)} )}
</div> </div>
</section> </section>
{isPrimary && (
<>
<section className="partner-assets-section">
<div className="partner-section-head">
<h3 className="headline-md"></h3>
<Link to="/center/settlement" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
</Link>
</div>
{finance.pendingBillCount > 0 && (
<Link to="/center/bills" className="partner-bills-banner">
<div className="partner-bills-banner-left">
<div className="partner-bills-icon">
<span className="material-symbols-outlined">receipt_long</span>
</div>
<div>
<p className="body-md text-primary" style={{ fontWeight: 600 }}></p>
<p className="label-md text-primary" style={{ opacity: 0.85 }}>
{finance.pendingBillCount}
</p>
</div>
</div>
<span className="material-symbols-outlined text-primary">chevron_right</span>
</Link>
)}
<div className="partner-finance-grid">
<div className="partner-finance-card partner-finance-card--hero">
<p className="partner-finance-hero-label"> ()</p>
<p className="partner-finance-hero-amount">{fmtMoney(finance.balance)}</p>
</div>
<Link to="/center/settlement" className="partner-finance-card">
<p className="label-md text-muted"></p>
<p className="partner-finance-value text-primary">¥ {fmtMoney(finance.pendingTotal)}</p>
</Link>
<Link to="/center/settlement" className="partner-finance-card">
<p className="label-md text-muted" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>history</span>
</p>
<p className="partner-finance-value">{finance.paidCount} </p>
</Link>
</div>
</section>
<section className="partner-menu-section">
<div className="partner-section-head">
<h3 className="headline-md"></h3>
</div>
<div className="partner-menu-card">
<Link to="/center/staff" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">badge</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<div className="partner-menu-item-right">
{staffCount > 0 && <span className="partner-menu-badge">{staffCount}</span>}
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<Link to="/stores?audit=pending" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">storefront</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/stores" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">description</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<div className="partner-menu-item-right">
<span className="partner-menu-status"></span>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
</div>
</section>
</>
)}
{!isPrimary && (
<>
<section className="partner-menu-section">
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<div className="partner-menu-card" style={{ padding: '16px' }}>
<label className="partner-form-label" htmlFor="center-name"></label>
<input
id="center-name"
className="partner-form-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="请输入姓名"
maxLength={32}
/>
<label className="partner-form-label" style={{ marginTop: 16 }}></label>
<input className="partner-form-input" value={account?.phone ?? ''} readOnly disabled />
<p className="label-md text-muted" style={{ marginTop: 4 }}></p>
<button
type="button"
className="partner-btn-primary"
style={{ width: '100%', marginTop: 16 }}
disabled={saving}
onClick={() => void handleSave()}
>
{saving ? '保存中…' : '保存资料'}
</button>
</div>
</section>
<section className="partner-menu-section">
<div className="partner-menu-card">
<button type="button" className="partner-menu-item" onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}>
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">support_agent</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</button>
<button type="button" className="partner-menu-item" onClick={handleContactPartner}>
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">call</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</button>
</div>
</section>
</>
)}
<button type="button" className="partner-logout-btn" onClick={logout}> <button type="button" className="partner-logout-btn" onClick={logout}>
<span className="material-symbols-outlined">logout</span> <span className="material-symbols-outlined">logout</span>
退 退
</button> </button>
<div style={{ textAlign: 'center', opacity: 0.3, padding: '32px 0' }}> <div className="partner-center-footer">
<p className="label-md text-muted"> · </p> <p className="label-md text-muted"> · </p>
</div> </div>
</div> </div>
+47 -93
View File
@@ -5,12 +5,10 @@ import { isLoggedIn, request } from '../lib/api';
import { fetchPartnerLeaderboard } from '../lib/leaderboard'; import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import { getPartnerNavKind } from '../lib/partnerAccess'; import { getPartnerNavKind } from '../lib/partnerAccess';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { storeStatusLabel, storeStatusPillClass } from '../lib/storeStatus';
function fmtMoney(n: number) { function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
const HOME_STORE_PREVIEW_LIMIT = 6;
const LEADERBOARD_PREVIEW_LIMIT = 3; const LEADERBOARD_PREVIEW_LIMIT = 3;
@@ -92,67 +90,42 @@ function summarizeOrders(orders: Array<Record<string, unknown>>) {
return { pendingShip, shipping, completed, todayCount }; return { pendingShip, shipping, completed, todayCount };
} }
function LeaderboardPreview({ function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] }) {
entries,
self,
}: {
entries: PartnerLeaderboardEntry[];
self?: PartnerLeaderboardEntry;
}) {
const selfInList = self ? entries.some((e) => e.accountId === self.accountId) : false;
return ( return (
<div className="partner-leaderboard-section"> <section className="partner-bento-card partner-leaderboard-preview">
<div className="partner-leaderboard-header"> <div className="partner-leaderboard-header">
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 18 }}>military_tech</span> <span className="material-symbols-outlined text-primary" style={{ fontSize: 20 }}>emoji_events</span>
</h3> </h3>
<Link to="/leaderboard" className="label-md text-primary"></Link> <Link to="/leaderboard" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
</Link>
</div> </div>
{entries.length === 0 && !self ? ( {entries.length === 0 ? (
<p className="label-md text-muted"></p> <p className="label-md text-muted"></p>
) : ( ) : (
<> entries.map((entry) => (
{entries.map((entry) => ( <div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
<div <div className={`partner-leaderboard-avatar partner-leaderboard-avatar--rank-${Math.min(entry.rank, 3)}`}>
key={entry.accountId} <span className="material-symbols-outlined">person</span>
className={`partner-leaderboard-row partner-leaderboard-row--compact${entry.isSelf ? ' partner-leaderboard-row--self' : ''}`} <span className="partner-leaderboard-avatar-badge">{entry.rank}</span>
>
<div className="partner-leaderboard-rank">{entry.rank}</div>
<div className="partner-leaderboard-info">
<p className="body-md" style={{ fontWeight: 600 }}>
{entry.name}
{entry.isSelf ? <span className="partner-leaderboard-self-tag"></span> : null}
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
</p>
<p className="label-md text-muted"> {entry.totalStores} </p>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted"></p>
</div>
</div> </div>
))} <div className="partner-leaderboard-info">
{self && !selfInList ? ( <p className="body-md" style={{ fontWeight: 600 }}>
<div className="partner-leaderboard-row partner-leaderboard-row--compact partner-leaderboard-row--self"> {entry.name}
<div className="partner-leaderboard-rank">{self.rank}</div> <span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
<div className="partner-leaderboard-info"> </p>
<p className="body-md" style={{ fontWeight: 600 }}> <p className="label-md text-muted">: {entry.totalStores} </p>
{self.name}
<span className="partner-leaderboard-self-tag"></span>
</p>
<p className="label-md text-muted"> {self.totalStores} </p>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{self.periodStores} </p>
<p className="label-md text-muted"></p>
</div>
</div> </div>
) : null} <div className="partner-leaderboard-stat">
</> <p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted"></p>
</div>
</div>
))
)} )}
</div> </section>
); );
} }
@@ -167,7 +140,6 @@ export default function HomePage() {
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]); const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]); const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]); const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
const [leaderboardSelf, setLeaderboardSelf] = useState<PartnerLeaderboardEntry | undefined>();
useEffect(() => { useEffect(() => {
document.title = '工作台'; document.title = '工作台';
@@ -186,11 +158,9 @@ export default function HomePage() {
fetchPartnerLeaderboard('month') fetchPartnerLeaderboard('month')
.then((data) => { .then((data) => {
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT)); setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
setLeaderboardSelf(data.self);
}) })
.catch(() => { .catch(() => {
setLeaderboardEntries([]); setLeaderboardEntries([]);
setLeaderboardSelf(undefined);
}); });
} else { } else {
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {}); request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {});
@@ -207,8 +177,16 @@ export default function HomePage() {
const now = new Date(); const now = new Date();
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear(); return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
}).length; }).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 orderCount = Number(dash?.orderCount || orderStats.todayCount || 0);
const revenue = orderCount * 128.45; const revenue = orderCount * 128.45;
const profit = revenue * 0.25; const profit = revenue * 0.25;
@@ -309,55 +287,31 @@ export default function HomePage() {
</div> </div>
</section> </section>
<section className="partner-home-store-section">
<div className="partner-home-store-header">
<h2 className="headline-md"></h2>
<Link to="/stores" className="label-md text-primary"></Link>
</div>
{previewStores.length === 0 ? (
<div className="partner-home-store-empty">
<p className="label-md text-muted"></p>
<Link to={isPrimary ? '/stores/new' : '/stores/new?step=1'} className="label-md text-primary"></Link>
</div>
) : (
previewStores.map((s) => {
const storeId = String(s.id);
const status = String(s.status || 'PAUSED').toUpperCase();
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
return (
<Link key={storeId} to={`/stores/${storeId}`} className="partner-home-store-row">
<div className="partner-home-store-info">
<p className="headline-md">{String(s.name || '未命名门店')}</p>
<p className="label-md text-muted">{String(s.address || s.district || '')}</p>
</div>
<span className={`partner-status-pill ${audit !== 'APPROVED' ? storeStatusPillClass(audit === 'REJECTED' ? 'CLOSED' : 'PAUSED') : storeStatusPillClass(status)}`}>
{audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)}
</span>
</Link>
);
})
)}
</section>
<section className="partner-expansion-card"> <section className="partner-expansion-card">
<h2 className="headline-md" style={{ marginBottom: 16 }}></h2> <h2 className="headline-md" style={{ marginBottom: 16 }}></h2>
<div className="partner-expansion-split"> <div className="partner-expansion-split">
<div> <div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p> <p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span> <div className="partner-expansion-value-row">
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
{monthGrowthPct !== 0 && (
<span className={`partner-expansion-trend${monthGrowthPct > 0 ? ' partner-expansion-trend--up' : ' partner-expansion-trend--down'}`}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>
{monthGrowthPct > 0 ? 'trending_up' : 'trending_down'}
</span>
{Math.abs(monthGrowthPct)}%
</span>
)}
</div>
</div> </div>
<div> <div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p> <p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span> <span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
</div> </div>
</div> </div>
<LeaderboardPreview entries={leaderboardEntries} self={leaderboardSelf} />
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
{String(dash?.companyName || '郑州合伙人')} ·
</p>
</section> </section>
<LeaderboardPreview entries={leaderboardEntries} />
</> </>
)} )}
</main> </main>
+4 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; 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 { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess'; import { isSubAccount } from '../lib/partnerAccess';
@@ -25,11 +25,13 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
export default function StoreListPage() { export default function StoreListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { account } = usePartnerSession(); const { account } = usePartnerSession();
const readonly = isSubAccount(account); const readonly = isSubAccount(account);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]); const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [filter, setFilter] = useState<StatusFilter>('ALL'); const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
const [filter, setFilter] = useState<StatusFilter>(initialFilter);
const [updatingId, setUpdatingId] = useState<string | null>(null); const [updatingId, setUpdatingId] = useState<string | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
+238 -35
View File
@@ -621,9 +621,6 @@ html {
.partner-expansion-split { .partner-expansion-split {
display: flex; 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 { .partner-expansion-split > div {
@@ -635,6 +632,28 @@ html {
border-left: 1px solid rgba(226, 190, 188, 0.2); 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 { .partner-form-error {
margin: 0 20px 12px; margin: 0 20px 12px;
padding: 10px 12px; padding: 10px 12px;
@@ -845,6 +864,57 @@ html {
color: #fff; 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 { .partner-leaderboard-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -2402,6 +2472,14 @@ nav.app-tabbar .app-tabbar-label {
justify-content: center; justify-content: center;
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
padding: 0;
cursor: pointer;
overflow: hidden;
}
.partner-profile-avatar:disabled {
opacity: 0.7;
cursor: wait;
} }
.partner-profile-avatar .material-symbols-outlined { .partner-profile-avatar .material-symbols-outlined {
@@ -2410,22 +2488,81 @@ nav.app-tabbar .app-tabbar-label {
font-variation-settings: 'FILL' 1; 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 { .partner-role-badge {
flex-shrink: 0;
background: var(--color-secondary-container); background: var(--color-secondary-container);
color: var(--color-on-secondary-container); color: var(--color-on-secondary-container);
padding: 4px 8px; padding: 4px 8px;
border-radius: 8px; border-radius: 8px;
font-family: var(--font-label); font-family: var(--font-label);
font-size: 10px; font-size: 10px;
text-transform: uppercase; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.02em;
white-space: nowrap;
line-height: 1.2;
}
.partner-assets-section {
margin-top: var(--space-lg);
} }
.partner-bills-banner { .partner-bills-banner {
margin: 0 var(--space-page) 12px; margin: 0 var(--space-page) 12px;
padding: 16px; padding: 16px;
background: rgba(166, 29, 36, 0.05); background: rgba(166, 29, 36, 0.06);
border: 1px solid rgba(166, 29, 36, 0.1); border: 1px solid rgba(166, 29, 36, 0.12);
border-radius: var(--radius-md); border-radius: var(--radius-md);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2464,13 +2601,100 @@ nav.app-tabbar .app-tabbar-label {
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
border: 1px solid rgba(166, 29, 36, 0.05); border: 1px solid rgba(166, 29, 36, 0.05);
text-decoration: none;
color: inherit;
display: block;
} }
.partner-finance-card--hero { .partner-finance-card--hero {
grid-column: span 2; grid-column: 1 / -1;
background: var(--color-heritage-red); background: linear-gradient(135deg, #a61d24 0%, #820012 100%);
color: #fff; color: #fff;
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.25); 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 { .partner-menu-section {
@@ -2514,24 +2738,6 @@ nav.app-tabbar .app-tabbar-label {
color: var(--color-heritage-red); 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 ── */ /* ── Settlement page ── */
.partner-settlement-page { .partner-settlement-page {
min-height: 100vh; min-height: 100vh;
@@ -3067,22 +3273,19 @@ body {
border-top: 1px dashed rgba(166, 29, 36, 0.15); 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-store-page,
.partner-home--flush-top.partner-center-page { .partner-home--flush-top.partner-center-page {
padding-top: 0; padding-top: 12px;
} }
.partner-home--flush-top .partner-sticky-filter { .partner-home--flush-top .partner-sticky-filter {
padding-top: 12px; 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 { .partner-leaderboard-self-tag {
display: inline-block; display: inline-block;
margin-left: 6px; margin-left: 6px;
+4
View File
@@ -7,6 +7,10 @@ export interface PartnerMe {
isPrimary: boolean; isPrimary: boolean;
companyName?: string; companyName?: string;
hasWechat?: boolean; hasWechat?: boolean;
/** 微信授权后的昵称 */
wxNickname?: string | null;
/** 微信授权后的头像 URL */
wxAvatarUrl?: string | null;
staffRole?: PartnerStaffRole; staffRole?: PartnerStaffRole;
permissions?: string[]; permissions?: string[];
primaryAccountId?: string; primaryAccountId?: string;
+2
View File
@@ -567,6 +567,8 @@ model PartnerAccount {
name String @db.VarChar(64) name String @db.VarChar(64)
wxOpenId String? @map("wx_open_id") @db.VarChar(64) wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_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 isPrimary Int @default(0) @map("is_primary") @db.TinyInt
parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
staffRole PartnerStaffRole? @map("staff_role") staffRole PartnerStaffRole? @map("staff_role")
@@ -48,7 +48,7 @@ export class WechatMockProvider implements IWechatProvider {
return { return {
openId, openId,
nickname: 'Mock微信用户', nickname: 'Mock微信用户',
headImgUrl: undefined, headImgUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(openId)}`,
}; };
} }
@@ -231,6 +231,9 @@ export class AuthService {
isPrimary: number; isPrimary: number;
staffRole: string | null; staffRole: string | null;
permissions?: unknown; permissions?: unknown;
wxOpenId?: string | null;
wxNickname?: string | null;
wxAvatarUrl?: string | null;
}, },
primary: { id: bigint; companyName: string | null }, primary: { id: bigint; companyName: string | null },
) { ) {
@@ -243,9 +246,37 @@ export class AuthService {
staffRole: account.staffRole ?? undefined, staffRole: account.staffRole ?? undefined,
companyName: primary.companyName ?? undefined, companyName: primary.companyName ?? undefined,
permissions: Array.isArray(account.permissions) ? account.permissions : 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) { private async assertPartnerAccountByPhone(phone: string) {
const account = await this.prisma.partnerAccount.findUnique({ const account = await this.prisma.partnerAccount.findUnique({
where: { phone }, where: { phone },
@@ -1547,7 +1578,7 @@ export class AuthService {
throw new BadRequestException('该微信已绑定其他合伙人账号'); throw new BadRequestException('该微信已绑定其他合伙人账号');
} }
const updated = await this.prisma.partnerAccount.update({ let updated = await this.prisma.partnerAccount.update({
where: { id: partnerAccountId }, where: { id: partnerAccountId },
data: { data: {
wxOpenId: session.openId, wxOpenId: session.openId,
@@ -1555,6 +1586,12 @@ export class AuthService {
lastLoginAt: new Date(), 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); const primary = await this.resolvePrimaryAccount(updated.id);
this.trackPartnerEvent(updated.id, primary.id, clientApp, 'partner_wechat_bind', { platform }); this.trackPartnerEvent(updated.id, primary.id, clientApp, 'partner_wechat_bind', { platform });
@@ -1593,6 +1630,12 @@ export class AuthService {
lastLoginAt: new Date(), 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); const primary = await this.resolvePrimaryAccount(account.id);
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_wechat_login', { platform }); this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_wechat_login', { platform });
@@ -267,6 +267,8 @@ export class PartnerMeController {
primaryName: primary.name, primaryName: primary.name,
companyName: primary.companyName ?? undefined, companyName: primary.companyName ?? undefined,
hasWechat: !!account.wxOpenId, hasWechat: !!account.wxOpenId,
wxNickname: account.wxNickname ?? undefined,
wxAvatarUrl: account.wxAvatarUrl ?? undefined,
}; };
} }
} }