@@ -53,6 +53,8 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
|
||||
primaryPhone: profile.primaryPhone,
|
||||
primaryName: profile.primaryName,
|
||||
hasWechat: profile.hasWechat,
|
||||
wxNickname: profile.wxNickname,
|
||||
wxAvatarUrl: profile.wxAvatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<PartnerBillDto[]>([]);
|
||||
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<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() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
@@ -57,25 +96,164 @@ 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 (
|
||||
<div className="page partner-center-page partner-home--flush-top">
|
||||
<section className="partner-profile-card">
|
||||
<div className="partner-profile-avatar">
|
||||
<button
|
||||
type="button"
|
||||
className="partner-profile-avatar"
|
||||
onClick={() => void handleAvatarAuth()}
|
||||
disabled={authorizing}
|
||||
aria-label={avatarUrl ? '更新微信头像' : '微信授权获取头像'}
|
||||
>
|
||||
{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 style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 className="headline-lg" style={{ fontSize: 20 }}>{account?.name || '合伙人'}</h2>
|
||||
<span className="partner-role-badge">{roleLabel || (isPrimary ? '城市合伙人' : '拓店员')}</span>
|
||||
</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="body-md">{account?.companyName || '—'}</span>
|
||||
<span className="body-md line-2-clamp">{locationLabel}</span>
|
||||
</div>
|
||||
{!isPrimary && (
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{account?.phone || ''}</p>
|
||||
)}
|
||||
</div>
|
||||
</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' }}>
|
||||
@@ -115,28 +293,21 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
<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>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>联系合伙人</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>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button type="button" className="partner-logout-btn" onClick={logout}>
|
||||
<span className="material-symbols-outlined">logout</span>
|
||||
退出登录
|
||||
</button>
|
||||
|
||||
<div style={{ textAlign: 'center', opacity: 0.3, padding: '32px 0' }}>
|
||||
<div className="partner-center-footer">
|
||||
<p className="label-md text-muted">传承千年 · 杜康好客</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<Record<string, unknown>>) {
|
||||
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 (
|
||||
<div className="partner-leaderboard-section">
|
||||
<section className="partner-bento-card partner-leaderboard-preview">
|
||||
<div className="partner-leaderboard-header">
|
||||
<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>
|
||||
<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>
|
||||
{entries.length === 0 && !self ? (
|
||||
{entries.length === 0 ? (
|
||||
<p className="label-md text-muted">暂无排行数据</p>
|
||||
) : (
|
||||
<>
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.accountId}
|
||||
className={`partner-leaderboard-row partner-leaderboard-row--compact${entry.isSelf ? ' partner-leaderboard-row--self' : ''}`}
|
||||
>
|
||||
<div className="partner-leaderboard-rank">{entry.rank}</div>
|
||||
entries.map((entry) => (
|
||||
<div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
|
||||
<div className={`partner-leaderboard-avatar partner-leaderboard-avatar--rank-${Math.min(entry.rank, 3)}`}>
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
<span className="partner-leaderboard-avatar-badge">{entry.rank}</span>
|
||||
</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>
|
||||
<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>
|
||||
))}
|
||||
{self && !selfInList ? (
|
||||
<div className="partner-leaderboard-row partner-leaderboard-row--compact partner-leaderboard-row--self">
|
||||
<div className="partner-leaderboard-rank">{self.rank}</div>
|
||||
<div className="partner-leaderboard-info">
|
||||
<p className="body-md" style={{ fontWeight: 600 }}>
|
||||
{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>
|
||||
) : null}
|
||||
</>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,7 +140,6 @@ export default function HomePage() {
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
|
||||
const [leaderboardSelf, setLeaderboardSelf] = useState<PartnerLeaderboardEntry | undefined>();
|
||||
|
||||
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<Record<string, unknown>>('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() {
|
||||
</div>
|
||||
</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">
|
||||
<h2 className="headline-md" style={{ marginBottom: 16 }}>拓店情况</h2>
|
||||
<div className="partner-expansion-split">
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>本月新增签约</p>
|
||||
<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>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待审核门店</p>
|
||||
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
|
||||
</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>
|
||||
|
||||
<LeaderboardPreview entries={leaderboardEntries} />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -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<Array<Record<string, unknown>>>([]);
|
||||
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 [error, setError] = useState('');
|
||||
|
||||
|
||||
+238
-35
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user