@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user