8d6c0f9ac0
CI / verify (pull_request) Has been cancelled
Add partner-facing proxy order list/detail; unlock SUPER_ADMIN order delete with cascade cleanup and type filter. Co-authored-by: Cursor <cursoragent@cursor.com>
311 lines
14 KiB
TypeScript
311 lines
14 KiB
TypeScript
import { useCallback, 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 PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
|
import { request } from '../lib/api';
|
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
|
import { contactSupport } from '../lib/contact';
|
|
import { isPrimaryAccount } from '../lib/partnerAccess';
|
|
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 = {
|
|
/** 子账号个人中心复用 */
|
|
variant?: 'primary' | 'sub';
|
|
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, applySession } = usePartnerSession();
|
|
const isPrimary = variant === 'primary' && isPrimaryAccount(account);
|
|
const [authorizing, setAuthorizing] = useState(false);
|
|
const [bills, setBills] = useState<PartnerBillDto[]>([]);
|
|
const [staffCount, setStaffCount] = useState(0);
|
|
|
|
useEffect(() => {
|
|
document.title = isPrimary ? '合伙人中心' : '个人中心';
|
|
}, [isPrimary]);
|
|
|
|
const loadCenter = useCallback(() => {
|
|
const tasks: Promise<unknown>[] = [Promise.resolve(refresh())];
|
|
if (isPrimary) {
|
|
tasks.push(
|
|
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
|
|
.then(setBills)
|
|
.catch(() => setBills([])),
|
|
listPartnerStaff()
|
|
.then((list) => setStaffCount(list.length))
|
|
.catch(() => setStaffCount(0)),
|
|
);
|
|
}
|
|
return Promise.all(tasks).then(() => undefined);
|
|
}, [isPrimary, refresh]);
|
|
|
|
useEffect(() => {
|
|
void loadCenter();
|
|
}, [loadCenter]);
|
|
|
|
const finance = useMemo(() => {
|
|
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
|
|
const settled = bills.filter((b) => b.status === 'PAID');
|
|
const paidCount = settled.length;
|
|
const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length;
|
|
return {
|
|
pendingBillCount: pending.length,
|
|
rejectedCount,
|
|
pendingTotal: bills
|
|
.filter((b) => b.status === 'AWAITING_CONFIRM' || b.status === 'UNPAID' || b.status === 'REJECTED')
|
|
.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]);
|
|
|
|
function handleContactPartner() {
|
|
if (isPrimary) {
|
|
if (!contactSupport()) toastError('暂无客服电话');
|
|
return;
|
|
}
|
|
if (!account?.primaryPhone) {
|
|
toastError('暂无合伙人联系方式');
|
|
return;
|
|
}
|
|
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 (
|
|
<PullToRefresh onRefresh={loadCenter} className="page partner-center-page partner-home--flush-top">
|
|
<section className="partner-profile-card">
|
|
<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 className="partner-profile-location">
|
|
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</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 }}>
|
|
{finance.rejectedCount > 0 ? '待处理账单' : '待确认账单'}
|
|
</p>
|
|
<p className="label-md text-primary" style={{ opacity: 0.85 }}>
|
|
{finance.rejectedCount > 0
|
|
? `您有 ${finance.pendingBillCount} 笔账单待处理(含 ${finance.rejectedCount} 笔已驳回)`
|
|
: `您有 ${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="/center/proxy-orders" className="partner-menu-item">
|
|
<div className="partner-menu-item-left">
|
|
<div className="partner-menu-icon">
|
|
<span className="material-symbols-outlined">receipt_long</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?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>
|
|
<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>
|
|
</div>
|
|
</section>
|
|
</>
|
|
)}
|
|
|
|
{!isPrimary && (
|
|
<section className="partner-menu-section">
|
|
<div className="partner-menu-card">
|
|
<Link to="/leaderboard" className="partner-menu-item">
|
|
<div className="partner-menu-item-left">
|
|
<div className="partner-menu-icon">
|
|
<span className="material-symbols-outlined">emoji_events</span>
|
|
</div>
|
|
<span className="body-md" style={{ fontSize: 16 }}>团队贡献榜</span>
|
|
</div>
|
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
|
</Link>
|
|
<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}>
|
|
<span className="material-symbols-outlined">logout</span>
|
|
退出登录
|
|
</button>
|
|
|
|
<div className="partner-center-footer">
|
|
<p className="label-md text-muted">传承千年 · 杜康好客</p>
|
|
</div>
|
|
</PullToRefresh>
|
|
);
|
|
}
|