feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 deletions
+286
View File
@@ -0,0 +1,286 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerPageView } from '../lib/usePageView';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function billStatusLabel(status: string) {
switch (status) {
case 'AWAITING_CONFIRM': return '待确认';
case 'UNPAID': return '未打款';
case 'PAID': return '已打款';
case 'REJECTED': return '已驳回';
default: return status;
}
}
function canConfirm(status: string) {
return status === 'AWAITING_CONFIRM';
}
export default function BillsPage() {
usePartnerPageView('partner_bills_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [bills, setBills] = useState<PartnerBillDto[]>([]);
const [confirmed, setConfirmed] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
async function loadBills() {
setLoading(true);
try {
const list = await request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills');
setBills(Array.isArray(list) ? list : []);
} catch {
setBills([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
void loadBills();
}, [navigate]);
const billId = searchParams.get('id');
const actionable = useMemo(
() => bills.filter((b) => canConfirm(b.status)),
[bills],
);
const bill = billId
? bills.find((b) => String(b.id) === billId) ?? actionable[0] ?? bills[0]
: actionable[0] ?? bills[0];
const status = String(bill?.status || '');
const showApplyForm = !!bill && canConfirm(status);
const isUnpaid = status === 'UNPAID';
const isRejected = status === 'REJECTED';
const isPaid = status === 'PAID';
function askConfirm(ids: string[]) {
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
return;
}
void doConfirm(ids);
}
async function doConfirm(ids: string[]) {
setSubmitting(true);
try {
if (ids.length === 1) {
await request('PARTNER_H5', `/partner/settlement/bills/${ids[0]}/confirm`, { method: 'POST' });
} else {
await request('PARTNER_H5', '/partner/settlement/bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
toastSuccess('已确认,等待总部打款');
setConfirmed(false);
setSelectedIds([]);
await loadBills();
} catch (e) {
toastError(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false);
}
}
function toggleSelect(id: string) {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}
return (
<PullToRefresh onRefresh={loadBills} className="partner-bills-page">
<PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
<div className="partner-bill-stepper">
<div className="partner-stepper-inner">
<div className="partner-stepper-line" aria-hidden>
<div
className="partner-stepper-line-fill"
style={{ width: isPaid ? '100%' : isUnpaid || isRejected ? '75%' : '50%' }}
/>
</div>
<div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--done">
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
</div>
<span className="partner-step-label partner-step-label--active"></span>
</div>
<div className="partner-step">
<div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isUnpaid || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
{showApplyForm && !confirmed ? '2' : (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
)}
</div>
<span className="partner-step-label partner-step-label--active"></span>
</div>
<div className="partner-step">
<div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isUnpaid || isRejected ? ' partner-step-circle--active' : ''}`}>
{isPaid ? (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
) : '3'}
</div>
<span className={`partner-step-label${isUnpaid || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
{isRejected ? '已驳回' : isPaid ? '已打款' : '总部打款'}
</span>
</div>
</div>
</div>
{loading && <div className="empty"></div>}
{!loading && !bill && <div className="empty"></div>}
{bill && (
<section className="partner-bill-card">
<div className="partner-bill-card-bar" />
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2>
</div>
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isUnpaid ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
{billStatusLabel(status)}
</span>
</div>
{isRejected && bill.rejectReason && (
<div className="partner-info-banner" style={{ marginBottom: 16, background: 'rgba(166,29,36,0.06)' }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>error</span>
<div>
<p className="body-md text-primary" style={{ fontWeight: 600, marginBottom: 4 }}></p>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>{bill.rejectReason}</p>
</div>
</div>
)}
{isUnpaid && (
<div className="partner-info-banner" style={{ marginBottom: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>hourglass_top</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</p>
</div>
)}
<div className="partner-bill-amount">
<p className="text-muted body-md" style={{ marginBottom: 8 }}></p>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
<span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span>
<span className="amount-xl">{fmtMoney(Number(bill.totalAmount || 0))}</span>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="partner-info-row">
<span className="text-variant body-md"></span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.orderCommission || 0))}</span>
</div>
<div className="partner-info-row">
<span className="text-variant body-md"></span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.redeemCommission || 0))}</span>
</div>
</div>
</div>
</section>
)}
{bills.length > 0 && (
<div style={{ padding: '0 20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 className="headline-md" style={{ margin: 0 }}></h3>
{actionable.length > 1 && (
<button
type="button"
className="partner-fill-max"
style={{ fontSize: 13 }}
disabled={!selectedIds.length || submitting}
onClick={() => askConfirm(selectedIds)}
>
({selectedIds.length})
</button>
)}
</div>
{bills.map((b) => {
const id = String(b.id);
const selectable = canConfirm(b.status);
return (
<div
key={id}
className="partner-store-card"
style={{ margin: '0 0 12px', display: 'flex', gap: 10, alignItems: 'flex-start' }}
>
{selectable ? (
<input
type="checkbox"
checked={selectedIds.includes(id)}
onChange={() => toggleSelect(id)}
style={{ marginTop: 4 }}
/>
) : (
<span style={{ width: 16 }} />
)}
<button
type="button"
style={{ flex: 1, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
onClick={() => navigate(`/center/bills?id=${id}`)}
>
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div>
<p className="body-md">{b.billNo}</p>
<p className="label-md text-muted">{billStatusLabel(b.status)}</p>
{b.status === 'REJECTED' && b.rejectReason ? (
<p className="label-md text-primary" style={{ marginTop: 4 }}>{b.rejectReason}</p>
) : null}
</div>
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
</div>
</button>
</div>
);
})}
</div>
)}
{showApplyForm && (
<>
<div className="partner-info-banner" style={{ marginTop: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</p>
</div>
<footer className="partner-bill-footer">
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
<span></span>
</label>
<button
type="button"
className="partner-btn-primary"
disabled={!confirmed || submitting}
style={{ opacity: confirmed ? 1 : 0.5 }}
onClick={() => askConfirm([String(bill!.id)])}
>
{submitting ? '正在提交...' : '确认账单'}
{!submitting && <span className="material-symbols-outlined">payments</span>}
</button>
</footer>
</>
)}
</PullToRefresh>
);
}
+310
View File
@@ -0,0 +1,310 @@
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>
);
}
+371
View File
@@ -0,0 +1,371 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { usePartnerPageView } from '../lib/usePageView';
import { isLoggedIn, request } from '../lib/api';
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import {
canAccessPartnerDashboard,
canAccessPartnerOrders,
canAccessPartnerStores,
getPartnerNavKind,
hasWarehouseAccess,
} from '../lib/partnerAccess';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
const LEADERBOARD_PREVIEW_LIMIT = 3;
function OrderSummarySection({
title,
todayCount,
pendingShip,
shipping,
completed,
ordersLink = '/orders',
}: {
title?: string;
todayCount: number;
pendingShip: number;
shipping: number;
completed: number;
ordersLink?: string;
}) {
return (
<section>
<div className="partner-order-summary-header">
<h2 className="headline-md">{title || `今日订单 ${todayCount}`}</h2>
<Link to={ordersLink} 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 className="partner-order-stats">
<div className="partner-order-stat">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{pendingShip}</p>
</div>
<div className="partner-order-stat partner-order-stat--blue">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{shipping}</p>
</div>
<div className="partner-order-stat partner-order-stat--green">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{completed}</p>
</div>
</div>
</section>
);
}
function isPendingShip(status: string) {
const s = status.toUpperCase();
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
}
function isShipping(status: string) {
const s = status.toUpperCase();
return s.includes('SHIP') || s === 'OUT_WAREHOUSE' || s === 'PENDING_RECEIVE';
}
function isCompleted(status: string) {
return status.toUpperCase() === 'COMPLETED';
}
function isToday(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
function summarizeOrders(orders: Array<Record<string, unknown>>) {
let pendingShip = 0;
let shipping = 0;
let completed = 0;
let todayCount = 0;
for (const o of orders) {
const status = String(o.status || '');
if (isToday(String(o.createdAt || ''))) todayCount += 1;
if (isPendingShip(status)) pendingShip += 1;
else if (isShipping(status)) shipping += 1;
else if (isCompleted(status)) completed += 1;
}
return { pendingShip, shipping, completed, todayCount };
}
function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] }) {
return (
<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: 20 }}>emoji_events</span>
</h3>
<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 ? (
<p className="label-md text-muted"></p>
) : (
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}
<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>
))
)}
</section>
);
}
export default function HomePage() {
usePartnerPageView('partner_home_view');
const navigate = useNavigate();
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
const isPrimary = navKind === 'primary';
const isWarehouse = navKind === 'warehouse_staff';
const warehouseOk = hasWarehouseAccess(account);
const canOrders = canAccessPartnerOrders(account) && warehouseOk && (isPrimary || isWarehouse);
const canDashboard = canAccessPartnerDashboard(account) && !isWarehouse;
const canStores = canAccessPartnerStores(account) && !isWarehouse;
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
useEffect(() => {
document.title = '工作台';
}, []);
const loadHome = useCallback(() => {
if (!isLoggedIn()) {
navigate('/login');
return Promise.resolve();
}
if (!account) return Promise.resolve();
const tasks: Promise<unknown>[] = [];
if (canOrders) {
tasks.push(
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
.catch(() => setOrders([])),
);
} else {
setOrders([]);
}
if (canDashboard) {
tasks.push(
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
.then(setDash)
.catch(() => setDash(null)),
);
} else {
setDash(null);
}
if (canStores) {
tasks.push(
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
.then((data) => setStores(Array.isArray(data) ? data : []))
.catch(() => setStores([])),
);
} else {
setStores([]);
}
tasks.push(
fetchPartnerLeaderboard('month')
.then((data) => {
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
})
.catch(() => {
setLeaderboardEntries([]);
}),
);
return Promise.all(tasks).then(() => undefined);
}, [navigate, account, canOrders, canDashboard, canStores]);
useEffect(() => {
void loadHome();
}, [loadHome]);
const storeCount = Number(dash?.storeCount || stores.length || 0);
const orderStats = useMemo(() => summarizeOrders(orders), [orders]);
const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
const abnormalStores = Math.max(0, storeCount - activeStores);
const monthNew = stores.filter((s) => {
const created = new Date(String(s.createdAt || ''));
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 orderCount = Number(dash?.orderCount || orderStats.todayCount || 0);
const revenue = orderCount * 128.45;
const profit = revenue * 0.25;
return (
<PullToRefresh onRefresh={loadHome} className="page partner-home partner-home--flush-top">
<main className="partner-home-body">
{isPrimary && (
<>
<section className="partner-revenue-card">
<p className="partner-revenue-label">
(CNY)
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
</p>
<div className="partner-revenue-amount">{fmtMoney(revenue)}</div>
<div className="partner-revenue-grid">
<div>
<p className="partner-revenue-label"></p>
<p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
</div>
</div>
</section>
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
<div className="partner-quick-actions">
<Link to="/proxy-order" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--red">
<span className="material-symbols-outlined">shopping_cart_checkout</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/stores/new" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--red">
<span className="material-symbols-outlined">add_business</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/center/settlement" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--green">
<span className="material-symbols-outlined">account_balance</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/reports/weekly" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
<span className="material-symbols-outlined">monitoring</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
</div>
</section>
</>
)}
{isWarehouse && (
<p className="headline-md" style={{ marginBottom: 8 }}></p>
)}
{canOrders && (
<OrderSummarySection
title={isWarehouse ? '今日订单' : undefined}
todayCount={orderStats.todayCount}
pendingShip={orderStats.pendingShip}
shipping={orderStats.shipping}
completed={orderStats.completed}
/>
)}
{(isPrimary || isWarehouse) && !warehouseOk && (
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
<div className="partner-warehouse-denied">
<span className="material-symbols-outlined text-primary">warehouse</span>
<p className="body-md" style={{ fontWeight: 600, marginTop: 8 }}></p>
<p className="label-md text-muted" style={{ marginTop: 4, lineHeight: 1.5 }}>
</p>
</div>
</section>
)}
{canStores && (
<>
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
<div className="partner-bento-header">
<h2 className="headline-md"></h2>
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
</div>
<div className="partner-store-stats">
<div className="partner-store-stat">
<span className="partner-dot partner-dot--green" />
<div>
<p className="label-md text-muted"></p>
<p className="headline-md">{activeStores}</p>
</div>
</div>
<div className="partner-store-stat">
<span className="partner-dot partner-dot--red" />
<div>
<p className="label-md text-muted">/</p>
<p className="headline-md">{abnormalStores}</p>
</div>
</div>
</div>
</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>
</section>
</>
)}
<LeaderboardPreview entries={leaderboardEntries} />
</main>
</PullToRefresh>
);
}
@@ -0,0 +1,136 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isPrimaryAccount } from '../lib/partnerAccess';
import { usePartnerPageView } from '../lib/usePageView';
const PERIOD_TABS: { key: PartnerLeaderboardPeriod; label: string }[] = [
{ key: 'month', label: '本月' },
{ key: 'lastMonth', label: '上月' },
{ key: 'total', label: '累计' },
];
function periodStatLabel(period: PartnerLeaderboardPeriod): string {
if (period === 'month') return '本月拓店';
if (period === 'lastMonth') return '上月拓店';
return '累计拓店';
}
function periodSubLabel(period: PartnerLeaderboardPeriod): string {
if (period === 'total') return '累计';
if (period === 'month') return '本月新增';
return '上月新增';
}
export default function LeaderboardPage() {
usePartnerPageView('partner_leaderboard_view');
const navigate = useNavigate();
const { account } = usePartnerSession();
const showStaffFab = isPrimaryAccount(account);
const [period, setPeriod] = useState<PartnerLeaderboardPeriod>('month');
const [data, setData] = useState<PartnerLeaderboardResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const loadLeaderboard = useCallback(() => {
setLoading(true);
setError('');
return fetchPartnerLeaderboard(period)
.then(setData)
.catch((e) => {
setData(null);
setError(e instanceof Error ? e.message : '加载失败');
})
.finally(() => setLoading(false));
}, [period]);
useEffect(() => {
void loadLeaderboard();
}, [loadLeaderboard]);
return (
<PullToRefresh onRefresh={loadLeaderboard} className="page-no-tab">
<PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
<div className="partner-leaderboard-tabs">
{PERIOD_TABS.map((tab) => (
<button
key={tab.key}
type="button"
className={`partner-leaderboard-tab${period === tab.key ? ' active' : ''}`}
onClick={() => setPeriod(tab.key)}
>
{tab.label}
</button>
))}
</div>
<p className="label-md text-muted" style={{ padding: '0 20px 12px' }}>
</p>
{loading && <div className="empty">...</div>}
{!loading && error && <div className="empty">{error}</div>}
{!loading && !error && data && (
<div style={{ padding: '0 20px 96px' }}>
{data.self && (
<section className="partner-leaderboard-self-card">
<div className="partner-leaderboard-self-main">
<div className="partner-leaderboard-self-rank"> {data.self.rank} </div>
<div>
<p className="headline-md" style={{ color: '#fff' }}>
{data.self.name}
{data.self.isSelf ? ' (我)' : ''}
</p>
<p className="label-md" style={{ color: 'rgba(255,255,255,0.8)' }}>{data.self.roleLabel}</p>
</div>
</div>
<div className="partner-leaderboard-self-stats">
<span>{periodStatLabel(period)}: {data.self.periodStores} </span>
{data.self.beatPercent != null && (
<span> {data.self.beatPercent}% </span>
)}
</div>
</section>
)}
<h2 className="headline-md" style={{ margin: '16px 0 12px' }}></h2>
{data.list.length === 0 ? (
<div className="empty"></div>
) : (
data.list.map((entry) => (
<div key={entry.accountId} className="partner-leaderboard-row">
<div className="partner-leaderboard-rank">{entry.rank}</div>
<div className="partner-leaderboard-info">
<p className="headline-md">
{entry.name}
<span className="label-md text-muted" style={{ marginLeft: 6, fontWeight: 400 }}>
({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">{periodSubLabel(period)}</p>
</div>
</div>
))
)}
</div>
)}
{showStaffFab && (
<Link to="/center/staff" className="partner-fab-round" aria-label="子账号管理">
<span className="material-symbols-outlined">add</span>
</Link>
)}
</PullToRefresh>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom';
import { getLegalDocument, type LegalDocument } from '@dukang/shared-types';
type LegalPageProps = {
docId: LegalDocument['id'];
/** 返回登录页的路径,如 /login */
backTo?: string;
};
/** H5 各端共用的协议/隐私正文页 */
export default function LegalPage({ docId, backTo = '/login' }: LegalPageProps) {
const doc = getLegalDocument(docId);
return (
<div className="legal-h5-page">
<header className="legal-h5-header">
<Link to={backTo} className="legal-h5-back" aria-label="返回">
</Link>
<h1 className="legal-h5-title">{doc.title}</h1>
</header>
<main className="legal-h5-body">
<p className="legal-h5-updated">{doc.updatedAt}</p>
<p className="legal-h5-intro">{doc.intro}</p>
{doc.sections.map((section) => (
<section key={section.heading} className="legal-h5-section">
<h2>{section.heading}</h2>
{section.paragraphs.map((p, i) => (
<p key={`${section.heading}-${i}`}>{p}</p>
))}
</section>
))}
</main>
</div>
);
}
+410
View File
@@ -0,0 +1,410 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import {
getLastPhone,
getPartnerProfile,
hasPartnerWxSession,
request,
saveRememberedSession,
type PartnerSessionPayload,
} from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { partnerHomePath } from '../lib/partnerAccess';
import {
bindPartnerWechatAfterSmsLogin,
fetchClientConfig,
loginPartnerWithWechat,
} from '../lib/wechat-auth';
import { toastError } from '../lib/toast';
import { isWechatEnv } from '../lib/weixin';
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
const REMEMBER_FLAG_KEY = 'partner_remember_account';
function AgreementCheckbox({
agreed,
onChange,
inputRef,
}: {
agreed: boolean;
onChange: (next: boolean) => void;
inputRef?: RefObject<HTMLLabelElement>;
}) {
return (
<label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}>
<input type="checkbox" checked={agreed} onChange={(e) => onChange(e.target.checked)} />
<span>
{' '}
<Link to="/legal/user-agreement" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
</Link>{' '}
{' '}
<Link to="/legal/privacy-policy" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
</Link>
</span>
</label>
);
}
function maskPhone(phone: string) {
if (phone.length < 7) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
}
function loadRememberedPhone(): { phone: string; remember: boolean } {
try {
const remember = localStorage.getItem(REMEMBER_FLAG_KEY) === '1';
const phone = remember ? localStorage.getItem(REMEMBER_PHONE_KEY) || '' : '';
return { phone, remember };
} catch {
return { phone: '', remember: false };
}
}
function formatPartnerError(e: unknown): string {
const text = e instanceof Error ? e.message : '操作失败';
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
return '未找到合伙人账号';
}
if (text.includes('合伙人账号已停用')) {
return '合伙人账号已停用';
}
return text;
}
function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
}
return text;
}
export default function LoginPage() {
const navigate = useNavigate();
const { applySession, refresh, account } = usePartnerSession();
const [params] = useSearchParams();
const quick = params.get('quick') === '1';
const savedProfile = getPartnerProfile();
const remembered = loadRememberedPhone();
const [phone, setPhone] = useState(remembered.phone || getLastPhone());
const [code, setCode] = useState('');
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null);
useEffect(() => {
fetchClientConfig()
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false));
}, []);
const quickName = savedProfile?.name ?? '城市合伙人';
const quickCompany = savedProfile?.companyName ?? '';
const quickPhone = savedProfile?.phone || phone;
function ensureAgreed() {
if (!agreed) {
const tip = '请先勾选并同意用户协议';
setMsg(tip);
toastError(tip);
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false;
}
return true;
}
function persistRememberAccount(nextPhone: string) {
try {
if (rememberAccount) {
localStorage.setItem(REMEMBER_FLAG_KEY, '1');
localStorage.setItem(REMEMBER_PHONE_KEY, nextPhone);
} else {
localStorage.removeItem(REMEMBER_FLAG_KEY);
localStorage.removeItem(REMEMBER_PHONE_KEY);
}
} catch {
/* ignore */
}
}
async function sendCode() {
if (!ensureAgreed()) return;
setMsg('');
try {
await request('PARTNER_H5', '/partner/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
silent: true,
});
setMsg('验证码已发送');
setCodeCooldown(60);
const timer = setInterval(() => {
setCodeCooldown((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
} catch (e) {
setMsg(formatPartnerError(e));
}
}
async function finishLoginNavigate() {
const me = await refresh();
navigate(partnerHomePath(me ?? account ?? savedProfile));
}
async function login() {
if (!ensureAgreed()) return;
setLoading(true);
setMsg('');
try {
const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone, code }),
silent: true,
});
saveRememberedSession(data);
applySession(data);
persistRememberAccount(phone);
if (isWechatEnv() && wxAuthorize) {
setMsg('登录成功,正在关联微信…');
await bindPartnerWechatAfterSmsLogin();
return;
}
await finishLoginNavigate();
} catch (e) {
setMsg(formatPartnerError(e));
} finally {
setLoading(false);
}
}
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
if (!isWechatEnv()) {
setMsg(WECHAT_INAPP_REQUIRED_MSG);
toastError(WECHAT_INAPP_REQUIRED_MSG);
return;
}
setWxLoading(true);
try {
// 直接发起 OAuth;未绑定账号由后端返回「请先短信登录」提示
const session = await loginPartnerWithWechat();
if (session) {
applySession(session);
await finishLoginNavigate();
return;
}
if (session === null) {
const tip = '微信授权暂未开启,请使用验证码登录';
setMsg(tip);
toastError(tip);
}
// session === undefined:已跳转微信授权页
} catch (e) {
const tip = formatWechatError(e);
setMsg(tip);
toastError(tip);
} finally {
setWxLoading(false);
}
}
if (quick) {
const canWechatQuick =
wxAuthorize &&
isWechatEnv() &&
hasPartnerWxSession() &&
!!savedProfile &&
savedProfile.hasWechat === true;
return (
<div className="partner-auth-page partner-auth-page--quick">
<header className="partner-auth-brand">
<div className="partner-quick-avatar" style={{ width: 120, height: 120, margin: '0 auto 16px' }}>
<span className="material-symbols-outlined" style={{ fontSize: 48 }}>wine_bar</span>
</div>
<h1 className="partner-auth-title" style={{ fontSize: 20 }}></h1>
<p className="partner-auth-subtitle" style={{ fontSize: 12, letterSpacing: '0.2em', textTransform: 'uppercase' }}></p>
</header>
<section className="partner-glass-card">
<div className="partner-quick-badge"></div>
<div className="partner-quick-avatar">
<span className="material-symbols-outlined">person</span>
</div>
<div style={{ textAlign: 'center' }}>
<h2 className="headline-md">
{quickName}
{quickCompany ? (
<span className="text-muted body-md" style={{ fontWeight: 400 }}> ({quickCompany})</span>
) : null}
</h2>
<p className="text-muted body-md" style={{ letterSpacing: '0.1em', marginTop: 4 }}>
{quickPhone ? maskPhone(quickPhone) : '暂无已保存账号'}
</p>
</div>
</section>
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
<AgreementCheckbox
agreed={agreed}
onChange={setAgreed}
inputRef={agreementRef}
/>
{canWechatQuick ? (
<button
type="button"
className="partner-btn-primary"
style={{ marginTop: 16 }}
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
<span className="material-symbols-outlined">chat</span>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
) : (
<p className="partner-auth-msg" style={{ textAlign: 'center', marginTop: 12, marginBottom: 12 }}>
{wxAuthorize && !isWechatEnv()
? '请在微信内打开以使用一键登录'
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
</p>
)}
{!canWechatQuick && (
<Link to="/login" className="partner-btn-primary" style={{ display: 'block', textAlign: 'center', textDecoration: 'none', marginTop: 12 }}>
</Link>
)}
<Link to="/login" className="partner-btn-ghost" style={{ display: 'block', marginTop: 12 }}></Link>
</nav>
<footer className="partner-auth-footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="material-symbols-outlined">verified_user</span>
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase' }}>
{canWechatQuick ? '微信验证 · 7 天内免登录' : 'Secured by Dukang Heritage'}
</span>
</div>
</footer>
</div>
);
}
return (
<div className="partner-auth-page">
<div className="partner-auth-brand">
<div className="partner-auth-logo-circle">
<span className="material-symbols-outlined">wine_bar</span>
</div>
<h1 className="partner-auth-title"></h1>
<p className="partner-auth-subtitle"></p>
</div>
<main className="partner-auth-card">
<h2 className="partner-auth-card-title"></h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="partner-input-wrap">
<span className="material-symbols-outlined partner-input-icon">smartphone</span>
<input
className="partner-input"
type="tel"
maxLength={11}
placeholder="请输入手机号"
value={phone}
onChange={(e) => {
setPhone(e.target.value);
setMsg('');
}}
/>
</div>
<div className="partner-input-row">
<div className="partner-input-wrap">
<span className="material-symbols-outlined partner-input-icon">shield</span>
<input
className="partner-input"
type="text"
maxLength={6}
placeholder="验证码"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</div>
<button type="button" className="partner-code-btn" onClick={sendCode} disabled={codeCooldown > 0}>
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
</button>
</div>
<label className="partner-remember-row">
<input
type="checkbox"
checked={rememberAccount}
onChange={(e) => setRememberAccount(e.target.checked)}
/>
<span></span>
</label>
<AgreementCheckbox
agreed={agreed}
onChange={setAgreed}
inputRef={agreementRef}
/>
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
<button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}>
<span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button>
{wxAuthorize && (
<>
<div className="partner-auth-divider"><span></span></div>
<button type="button" className="partner-btn-wechat" onClick={() => void wechatLogin()} disabled={wxLoading}>
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
</svg>
<span>{wxLoading ? '跳转授权中...' : '微信一键登录'}</span>
</button>
{msg && (
<p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>
{msg}
</p>
)}
</>
)}
<p className="partner-auth-msg" style={{ textAlign: 'center' }}>
</p>
</div>
</main>
{hasPartnerWxSession() && savedProfile?.hasWechat && (
<Link to="/login?quick=1" className="partner-link"></Link>
)}
<footer className="partner-auth-footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="material-symbols-outlined">verified_user</span>
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.15em', textTransform: 'uppercase' }}>SECURED BY DUKANG HERITAGE</span>
</div>
</footer>
</div>
);
}
@@ -0,0 +1,194 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerPageView } from '../lib/usePageView';
const TIMELINE = [
{ key: 'confirm', label: '待确认' },
{ key: 'accepted', label: '已接单' },
{ key: 'delivering', label: '配送中' },
{ key: 'shipping', label: '运输中', desc: '包裹正在送往目的地' },
{ key: 'done', label: '已送达' },
];
function statusIndex(status: string) {
const s = String(status).toUpperCase();
if (s === 'COMPLETED') return 4;
if (s.includes('SHIP') || s === 'OUT_WAREHOUSE') return 3;
if (s === 'PENDING_RECEIVE') return 3;
if (s.includes('DELIVER')) return 2;
return 1;
}
function statusBanner(status: string) {
const s = String(status).toUpperCase();
if (s.includes('SHIP') || s === 'OUT_WAREHOUSE') return { title: '运输中', desc: '预计今日 18:00 前送达', icon: 'local_shipping' };
if (s === 'COMPLETED') return { title: '已完成', desc: '订单已送达', icon: 'check_circle' };
if (s.includes('PENDING')) return { title: '待发货', desc: '商家正在备货', icon: 'inventory_2' };
return { title: status, desc: '', icon: 'receipt_long' };
}
function canShip(status: string) {
const s = status.toUpperCase();
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
}
export default function OrderDetailPage() {
const { id } = useParams();
usePartnerPageView('partner_order_detail_view', id ? { orderId: id } : undefined);
const navigate = useNavigate();
const [order, setOrder] = useState<Record<string, unknown> | null>(null);
const [shipping, setShipping] = useState(false);
useEffect(() => {
document.title = '订单详情';
}, []);
useEffect(() => {
if (id) void request<Record<string, unknown>>('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
}, [id]);
async function reload() {
if (!id) return;
const next = await request<Record<string, unknown>>('PARTNER_H5', `/partner/orders/${id}`);
setOrder(next);
}
async function advance(status: string) {
if (!id) return;
setShipping(true);
try {
await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, {
method: 'POST',
body: JSON.stringify({ targetStatus: status }),
});
await reload();
toastSuccess('已更新配送状态');
} finally {
setShipping(false);
}
}
function handleContactCourier() {
const delivery = order?.delivery as Record<string, unknown> | undefined;
const trackingNo = String(delivery?.trackingNo || delivery?.providerOrderNo || '').trim();
if (trackingNo) {
void navigator.clipboard?.writeText(trackingNo);
toastSuccess(`运单号已复制:${trackingNo}`);
return;
}
toastError('暂无配送员联系方式');
}
if (!order) return <div className="empty">...</div>;
const banner = statusBanner(String(order.status));
const currentIdx = statusIndex(String(order.status));
const payAmount = Number(order.payAmount || 0);
const showShip = canShip(String(order.status));
return (
<div className="partner-order-detail">
<PageHeader title="订单详情" onBack={() => navigate('/orders')} />
<section className="partner-status-banner">
<div style={{ position: 'relative', zIndex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span className="material-symbols-outlined" style={{ fontVariationSettings: "'FILL' 1" }}>{banner.icon}</span>
<h2 className="headline-md" style={{ color: '#fff' }}>{banner.title}</h2>
</div>
{banner.desc && <p className="body-md" style={{ color: 'rgba(255,255,255,0.9)' }}>{banner.desc}</p>}
</div>
</section>
<section className="partner-timeline">
<div className="partner-timeline-line" />
{TIMELINE.map((item, i) => {
const done = i < currentIdx;
const current = i === currentIdx;
const pending = i > currentIdx;
return (
<div key={item.key} className="partner-timeline-item">
<div className={`partner-timeline-dot${done ? ' partner-timeline-dot--done' : ''}${current ? ' partner-timeline-dot--current' : ''}${pending ? ' partner-timeline-dot--pending' : ''}`}>
{done && <span className="material-symbols-outlined">check</span>}
</div>
<div>
<p className={current ? 'headline-md text-primary' : 'label-md'} style={{ fontWeight: current ? 700 : 500 }}>{item.label}</p>
{item.desc && current && <p className="text-muted body-md" style={{ marginTop: 4 }}>{item.desc}</p>}
</div>
</div>
);
})}
</section>
<section className="partner-detail-section partner-detail-section--accent">
<div style={{ display: 'flex', gap: 16 }}>
<div className="partner-order-product-img" style={{ width: 96, height: 96, position: 'relative' }}>
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-surface-container)' }}>
<span className="material-symbols-outlined text-muted" style={{ fontSize: 32 }}>liquor</span>
</div>
</div>
<div style={{ flex: 1 }}>
<h3 className="headline-md">{String(order.productName || '杜康好酒')}</h3>
<p className="text-muted body-md" style={{ marginTop: 4 }}>x{Number(order.quantity || 1)}</p>
<div style={{ marginTop: 8, display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span className="label-md text-primary">¥</span>
<span className="amount-lg">{payAmount.toFixed(2)}</span>
</div>
</div>
</div>
</section>
<section className="partner-detail-section">
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
</h4>
<div style={{ display: 'flex', gap: 12 }}>
<span className="material-symbols-outlined text-muted">location_on</span>
<div>
<p className="headline-md" style={{ fontSize: 16 }}>
{String(order.receiverName)} <span className="body-md text-muted" style={{ fontWeight: 400 }}>{String(order.receiverPhone)}</span>
</p>
<p className="text-muted body-md" style={{ marginTop: 4, lineHeight: 1.5 }}>{String(order.receiverAddress)}</p>
</div>
</div>
</section>
<footer className="partner-order-footer">
<div className="partner-order-footer-actions">
{showShip && (
<button
type="button"
className="btn btn-primary partner-order-ship-btn--footer"
disabled={shipping}
onClick={() => void advance('OUT_WAREHOUSE')}
>
{shipping ? '发货中…' : '确认发货'}
</button>
)}
<button
type="button"
className="btn btn-outline"
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}
onClick={handleContactCourier}
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
</button>
</div>
</footer>
{import.meta.env.DEV && (
<details className="partner-dev-tools">
<summary>Dev: Mock </summary>
{['OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE', 'COMPLETED'].map((s) => (
<button key={s} type="button" onClick={() => void advance(s)}>{s}</button>
))}
</details>
)}
</div>
);
}
+280
View File
@@ -0,0 +1,280 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { hasWarehouseAccess } from '../lib/partnerAccess';
import { usePartnerPageView } from '../lib/usePageView';
type DateFilter = 'today' | '7d' | '30d';
type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL';
function orderStatusLabel(status: string) {
const s = String(status).toUpperCase();
if (s.includes('SHIP') || s === 'OUT_WAREHOUSE') return { label: '运输中', color: 'var(--color-status-blue)' };
if (s === 'COMPLETED') return { label: '已完成', color: 'var(--color-success-green)' };
if (s.includes('PENDING') || s === 'PAID') return { label: '待发货', color: 'var(--color-secondary)' };
if (s.includes('ERROR') || s.includes('ABNORMAL')) return { label: '异常', color: 'var(--color-error)' };
return { label: status, color: 'var(--color-subtle-gray)' };
}
const DATE_FILTERS: { key: DateFilter; label: string }[] = [
{ key: 'today', label: '今日' },
{ key: '7d', label: '近7天' },
{ key: '30d', label: '近30天' },
];
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'ALL', label: '全部' },
{ key: 'PENDING_SHIP', label: '待发货' },
{ key: 'SHIPPING', label: '运输中' },
{ key: 'COMPLETED', label: '已完成' },
{ key: 'ABNORMAL', label: '异常' },
];
type OrderListPageProps = {
/** Tab 根页:无返回顶栏 */
tabRoot?: boolean;
};
type OrdersResponse = {
list: Array<Record<string, unknown>>;
hasWarehouseAccess?: boolean;
message?: string;
};
export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
usePartnerPageView('partner_order_list_view');
const navigate = useNavigate();
const { account } = usePartnerSession();
const warehouseOk = hasWarehouseAccess(account);
const [data, setData] = useState<OrdersResponse>({ list: [] });
const [tab, setTab] = useState<'orders' | 'coupons'>('orders');
const [dateFilter, setDateFilter] = useState<DateFilter>('today');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
const [shippingId, setShippingId] = useState<string | null>(null);
const [shipForm, setShipForm] = useState({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
const [shipModalId, setShipModalId] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
document.title = '订单管理';
}, []);
const loadOrders = useCallback(() => {
if (!isLoggedIn()) {
navigate('/login');
return Promise.resolve();
}
if (!warehouseOk) {
setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' });
setLoaded(true);
return Promise.resolve();
}
return request<OrdersResponse>('PARTNER_H5', '/partner/orders')
.then((res) => {
setData({
list: Array.isArray(res.list) ? res.list : [],
hasWarehouseAccess: res.hasWarehouseAccess,
message: res.message,
});
})
.catch(() => setData({ list: [] }))
.finally(() => setLoaded(true));
}, [navigate, warehouseOk]);
useEffect(() => {
void loadOrders();
}, [loadOrders]);
const filtered = useMemo(() => data.list.filter((o) => {
if (statusFilter === 'ALL') return true;
const s = String(o.status).toUpperCase();
if (statusFilter === 'PENDING_SHIP') return s.includes('PENDING') || s === 'PAID';
if (statusFilter === 'SHIPPING') return s.includes('SHIP') || s === 'OUT_WAREHOUSE';
if (statusFilter === 'COMPLETED') return s === 'COMPLETED';
if (statusFilter === 'ABNORMAL') return s.includes('ERROR') || s.includes('ABNORMAL');
return true;
}), [data.list, statusFilter]);
async function handleShip(orderId: string, e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
setShipModalId(orderId);
setShipForm({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
}
async function submitManualShip() {
if (!shipModalId) return;
if (!shipForm.logisticsCompany.trim() || !shipForm.trackingNo.trim()) {
window.alert('请填写快递公司和运单号');
return;
}
setShippingId(shipModalId);
try {
await request('PARTNER_H5', `/partner/orders/${shipModalId}/manual-ship`, {
method: 'POST',
body: JSON.stringify(shipForm),
});
setShipModalId(null);
const next = await request<OrdersResponse>('PARTNER_H5', '/partner/orders');
setData({
list: Array.isArray(next.list) ? next.list : [],
hasWarehouseAccess: next.hasWarehouseAccess,
message: next.message,
});
} catch (err) {
window.alert(err instanceof Error ? err.message : '发货失败');
} finally {
setShippingId(null);
}
}
function canShip(status: string) {
const s = status.toUpperCase();
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
}
if (loaded && !warehouseOk) {
return (
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
<div className="partner-warehouse-denied" style={{ margin: 20, padding: 32, textAlign: 'center' }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 40 }}>warehouse</span>
<p className="headline-md" style={{ marginTop: 12 }}></p>
<p className="body-md text-muted" style={{ marginTop: 8, lineHeight: 1.6 }}>
</p>
</div>
</div>
);
}
return (
<PullToRefresh onRefresh={loadOrders} className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
<div className="partner-segment">
<button type="button" className={tab === 'orders' ? 'active' : ''} onClick={() => setTab('orders')}></button>
{!tabRoot && (
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}></button>
)}
</div>
{tab === 'orders' && (
<>
<div className="partner-filter-row" style={{ marginBottom: 8 }}>
{DATE_FILTERS.map((f) => (
<button key={f.key} type="button" className={`partner-chip${dateFilter === f.key ? ' active' : ''}`} style={{ border: dateFilter === f.key ? 'none' : '1px solid var(--color-outline-variant)', background: dateFilter === f.key ? undefined : '#fff' }} onClick={() => setDateFilter(f.key)}>
{f.label}
</button>
))}
</div>
<div className="partner-filter-row" style={{ borderBottom: '1px solid var(--color-surface-container-high)', paddingBottom: 8, marginBottom: 16 }}>
{STATUS_FILTERS.map((f) => (
<button key={f.key} type="button" className={`partner-filter-tab${statusFilter === f.key ? ' active' : ''}`} onClick={() => setStatusFilter(f.key)}>
{f.label}
</button>
))}
</div>
{filtered.length === 0 && <div className="empty"></div>}
{filtered.map((o) => {
const st = orderStatusLabel(String(o.status));
const payAmount = Number(o.payAmount || 0);
const orderId = String(o.id);
return (
<div key={orderId} className="partner-order-card-wrap">
<Link to={`/orders/${orderId}`} className="partner-order-card">
<div className="partner-order-card-top">
<span className="label-md text-muted">NO. {String(o.orderNo)}</span>
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
</div>
<div className="partner-order-product">
<div className="partner-order-product-img" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="material-symbols-outlined text-muted">liquor</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
</div>
</div>
<div className="partner-order-address">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
<p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
</div>
</Link>
{canShip(String(o.status)) && !(o.delivery as { trackingNo?: string } | undefined)?.trackingNo && (
<button
type="button"
className="partner-order-ship-btn"
disabled={shippingId === orderId}
onClick={(e) => void handleShip(orderId, e)}
>
</button>
)}
</div>
);
})}
</>
)}
{tab === 'coupons' && !tabRoot && (
<div style={{ padding: '0 20px' }}>
<div className="partner-revenue-card" style={{ marginBottom: 16 }}>
<p className="partner-revenue-label"></p>
<div className="partner-revenue-amount" style={{ fontSize: 28 }}>42,800.00</div>
</div>
<p className="text-muted body-md text-center"> preV1 </p>
</div>
)}
{shipModalId && (
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setShipModalId(null)}>
<div className="partner-ship-modal" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3 className="headline-md"></h3>
<label className="partner-ship-field">
<span></span>
<input
value={shipForm.logisticsCompany}
onChange={(e) => setShipForm((f) => ({ ...f, logisticsCompany: e.target.value }))}
placeholder="如 顺丰速运"
/>
</label>
<label className="partner-ship-field">
<span></span>
<input
value={shipForm.trackingNo}
onChange={(e) => setShipForm((f) => ({ ...f, trackingNo: e.target.value }))}
/>
</label>
<label className="partner-ship-field">
<span></span>
<input
value={shipForm.manualQueryUrl}
onChange={(e) => setShipForm((f) => ({ ...f, manualQueryUrl: e.target.value }))}
placeholder="https://..."
/>
</label>
<div className="partner-ship-actions">
<button type="button" className="partner-btn-secondary" onClick={() => setShipModalId(null)}>
</button>
<button
type="button"
className="partner-btn-primary"
disabled={shippingId === shipModalId}
onClick={() => void submitManualShip()}
>
{shippingId === shipModalId ? '提交中…' : '确认发货'}
</button>
</div>
</div>
</div>
)}
</PullToRefresh>
);
}
@@ -0,0 +1,12 @@
import { getPartnerNavKind } from '../lib/partnerAccess';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import CenterPage from './CenterPage';
/** 子账号个人中心:复用精简版 Center 布局 */
export default function PartnerMePage() {
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
const isWarehouse = navKind === 'warehouse_staff';
return <CenterPage variant="sub" roleLabel={isWarehouse ? '仓库管理员' : '拓店员'} />;
}
@@ -0,0 +1,437 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import QRCode from 'qrcode';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { invokeWechatPay } from '@dukang/weixin-sdk';
import type { PartnerProxyOrderListItem, ProxyOrderPayResponse, ProxyPayMethod } from '@dukang/shared-types';
import { getToken, request } from '../lib/api';
import {
proxyOrderPayLabel,
proxyOrderStatusColor,
proxyOrderStatusLabel,
} from '../lib/proxyOrderStatus';
import { toastError, toastSuccess } from '../lib/toast';
import { isWechatEnv } from '../lib/weixin';
import { fetchPartnerProfile, partnerHasWechatBinding } from '../lib/wechat-auth';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function fmtTime(value: string | undefined) {
if (!value) return '—';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function deliveryLabel(order: PartnerProxyOrderListItem) {
const t = String(order.deliveryType || '').toUpperCase();
if (t === 'ON_SITE_PICKUP') return '现场提货';
if (t === 'CROSS_CITY') return '跨城配送';
if (t === 'LOCAL') return '同城配送';
return '代下单';
}
type TrackNode = {
time?: string;
status?: string;
description?: string;
};
type TrackResult = {
nodes?: TrackNode[];
trackingNo?: string | null;
provider?: string | null;
manualQueryUrl?: string | null;
};
export default function ProxyOrderDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [order, setOrder] = useState<PartnerProxyOrderListItem | null>(null);
const [error, setError] = useState('');
const [track, setTrack] = useState<TrackResult | null>(null);
const [trackError, setTrackError] = useState('');
const [paying, setPaying] = useState(false);
const [payMsg, setPayMsg] = useState('');
const [payMethod, setPayMethod] = useState<ProxyPayMethod>('NATIVE');
const [codeUrl, setCodeUrl] = useState<string | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
const pollRef = useRef<number | null>(null);
function stopPoll() {
if (pollRef.current != null) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
}
useEffect(() => () => stopPoll(), []);
useEffect(() => {
document.title = '代下单详情';
}, []);
useEffect(() => {
if (!id) return;
void request<PartnerProxyOrderListItem>('PARTNER_H5', `/partner/proxy-orders/${id}`)
.then((data) => {
setOrder(data);
if (data.proxyPayMethod) {
setPayMethod(data.proxyPayMethod);
}
})
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'));
}, [id]);
useEffect(() => {
if (!id || !order) return;
const dt = String(order.deliveryType || '').toUpperCase();
if (dt === 'ON_SITE_PICKUP' || order.payStatus !== 'PAID') {
setTrack(null);
return;
}
void request<TrackResult>('PARTNER_H5', `/partner/proxy-orders/${id}/track`, { silent: true })
.then(setTrack)
.catch((e) => setTrackError(e instanceof Error ? e.message : '物流暂不可用'));
}, [id, order]);
useEffect(() => {
if (!codeUrl) {
setQrDataUrl(null);
return;
}
void QRCode.toDataURL(codeUrl, { width: 220, margin: 1 })
.then(setQrDataUrl)
.catch(() => setQrDataUrl(null));
}, [codeUrl]);
const unpaid = order?.payStatus === 'UNPAID' || order?.status === 'PENDING_PAY';
function startPoll(orderId: string) {
stopPoll();
pollRef.current = window.setInterval(() => {
void request<{ payStatus: string; orderNo: string }>(
'PARTNER_H5',
`/partner/proxy-orders/${orderId}/pay-status`,
{ silent: true },
)
.then((st) => {
if (st.payStatus === 'PAID') {
stopPoll();
toastSuccess(`支付成功:${st.orderNo}`);
void request<PartnerProxyOrderListItem>('PARTNER_H5', `/partner/proxy-orders/${orderId}`)
.then(setOrder)
.catch(() => undefined);
setCodeUrl(null);
}
})
.catch(() => undefined);
}, 2000);
}
async function startPay(method: ProxyPayMethod) {
if (!id) return;
const locked = order?.proxyPayMethod;
if (locked && locked !== method) {
setPayMsg(
locked === 'JSAPI'
? '该订单已发起微信代付,请先取消支付后重新下单'
: '该订单已生成收款码,请先取消支付后重新下单',
);
return;
}
setPaying(true);
setPayMsg('');
setPayMethod(method);
try {
if (method === 'JSAPI') {
if (!isWechatEnv()) {
throw new Error('请在微信内打开合伙人端以使用微信代付');
}
const profile = await fetchPartnerProfile();
if (!partnerHasWechatBinding(profile)) {
throw new Error('请先绑定微信后再代付');
}
}
const pay = await request<ProxyOrderPayResponse>(
'PARTNER_H5',
`/partner/proxy-orders/${id}/pay`,
{
method: 'POST',
body: JSON.stringify({ payMethod: method }),
silent: true,
},
);
if (pay.mode === 'mock') {
toastSuccess('支付成功');
const refreshed = await request<PartnerProxyOrderListItem>(
'PARTNER_H5',
`/partner/proxy-orders/${id}`,
);
setOrder(refreshed);
setCodeUrl(null);
return;
}
if (pay.mode === 'native' && pay.codeUrl) {
setCodeUrl(pay.codeUrl);
setOrder((prev) => (prev ? { ...prev, proxyPayMethod: method } : prev));
startPoll(id);
return;
}
if (pay.mode === 'jsapi' && pay.prepay) {
setCodeUrl(null);
setOrder((prev) => (prev ? { ...prev, proxyPayMethod: method } : prev));
await invokeWechatPay(pay.prepay, {
apiBase: '/api/v1',
clientApp: 'PARTNER_H5',
getAccessToken: getToken,
platform: 'wechat-h5',
});
startPoll(id);
return;
}
throw new Error('支付发起失败');
} catch (e) {
setPayMsg(e instanceof Error ? e.message : '支付失败');
toastError(e instanceof Error ? e.message : '支付失败');
} finally {
setPaying(false);
}
}
async function cancelPay() {
if (!id) return;
setCancelling(true);
setPayMsg('');
stopPoll();
try {
await request('PARTNER_H5', `/partner/proxy-orders/${id}/cancel-pay`, {
method: 'POST',
body: '{}',
});
toastSuccess('已取消支付,可重新下单并选择其他支付方式');
navigate('/center/proxy-orders');
} catch (e) {
setPayMsg(e instanceof Error ? e.message : '取消失败');
toastError(e instanceof Error ? e.message : '取消失败');
} finally {
setCancelling(false);
}
}
const lockedPayMethod = order?.proxyPayMethod ?? null;
const img = order?.imageResource?.url || '';
const isOnSite = String(order?.deliveryType || '').toUpperCase() === 'ON_SITE_PICKUP';
return (
<div className="page-no-tab partner-orders-page">
<PageHeader title="代下单详情" onBack={() => navigate('/center/proxy-orders')} />
{error && (
<p className="partner-form-error" role="alert" style={{ margin: '16px 20px' }}>
{error}
</p>
)}
{!order && !error && <p className="label-md text-muted" style={{ margin: 20 }}></p>}
{order && (
<div style={{ padding: '0 16px 32px' }}>
<div className="partner-order-card" style={{ pointerEvents: 'none' }}>
<div className="partner-order-card-top">
<span className="label-md text-muted">NO. {order.orderNo}</span>
<span
className="label-md"
style={{ color: proxyOrderStatusColor(order.status), fontWeight: 600 }}
>
{proxyOrderStatusLabel(order.status)}
</span>
</div>
<div className="partner-order-product">
<div
className="partner-order-product-img"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
background: img ? `center/cover no-repeat url(${img})` : undefined,
}}
>
{!img && <span className="material-symbols-outlined text-muted">liquor</span>}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h3 className="headline-md">{order.productName || '杜康好酒'}</h3>
{order.productSpec && (
<p className="label-md text-muted" style={{ marginTop: 4 }}>
{order.productSpec}
</p>
)}
<p className="body-md" style={{ marginTop: 8 }}>
×{order.quantity} · ¥{fmtMoney(Number(order.payAmount || 0))}
</p>
<p className="label-md text-muted" style={{ marginTop: 4 }}>
¥{fmtMoney(Number(order.benefitAmount || 0))}
</p>
</div>
</div>
</div>
<section className="partner-form-card" style={{ marginTop: 12 }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
<p className="body-md">{order.receiverName || '—'}</p>
<p className="body-md" style={{ marginTop: 8 }}>{order.receiverPhone || '—'}</p>
<p className="body-md" style={{ marginTop: 8 }}>
{order.receiverAddress || (isOnSite ? '现场提货' : '—')}
</p>
</section>
<section className="partner-form-card" style={{ marginTop: 12 }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
<p className="body-md">{deliveryLabel(order)}</p>
<p className="body-md" style={{ marginTop: 8 }}>{fmtTime(order.createdAt)}</p>
<p className="body-md" style={{ marginTop: 8 }}>
{proxyOrderPayLabel(order.payStatus)}
</p>
<p className="body-md" style={{ marginTop: 8 }}>
{proxyOrderStatusLabel(order.status)}
</p>
</section>
{unpaid ? (
<section className="partner-form-card" style={{ marginTop: 12 }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
<div className="partner-proxy-mode-tabs">
<button
type="button"
className={`partner-proxy-mode-tab${payMethod === 'NATIVE' ? ' is-active' : ''}`}
disabled={!!lockedPayMethod && lockedPayMethod !== 'NATIVE'}
onClick={() => void startPay('NATIVE')}
>
</button>
<button
type="button"
className={`partner-proxy-mode-tab${payMethod === 'JSAPI' ? ' is-active' : ''}`}
disabled={!!lockedPayMethod && lockedPayMethod !== 'JSAPI'}
onClick={() => void startPay('JSAPI')}
>
</button>
</div>
{lockedPayMethod ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{lockedPayMethod === 'NATIVE' ? '收款码' : '微信代付'}
</p>
) : (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
)}
{payMethod === 'NATIVE' ? (
<div style={{ textAlign: 'center', marginTop: 16 }}>
{qrDataUrl ? (
<img src={qrDataUrl} alt="收款码" width={220} height={220} style={{ margin: '0 auto' }} />
) : (
<p className="label-md text-muted">{paying ? '生成收款码中…' : '暂无收款码'}</p>
)}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
使
</p>
<button
type="button"
className="btn btn-block"
style={{ marginTop: 12 }}
disabled={paying}
onClick={() => void startPay('NATIVE')}
>
{paying ? '生成中…' : '重新生成收款码'}
</button>
</div>
) : (
<div style={{ marginTop: 16 }}>
<p className="label-md text-muted"></p>
<button
type="button"
className="btn btn-primary btn-block"
style={{ marginTop: 12 }}
disabled={paying}
onClick={() => void startPay('JSAPI')}
>
{paying ? '支付中…' : '调起微信代付'}
</button>
</div>
)}
{payMsg ? (
<p className="partner-form-error" role="alert" style={{ marginTop: 12 }}>
{payMsg}
</p>
) : null}
<button
type="button"
className="btn btn-block"
style={{ marginTop: 16 }}
disabled={cancelling || paying}
onClick={() => void cancelPay()}
>
{cancelling ? '取消中…' : '取消支付'}
</button>
</section>
) : null}
{!isOnSite && order.payStatus === 'PAID' ? (
<section className="partner-form-card" style={{ marginTop: 12 }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
{trackError ? (
<p className="label-md text-muted">{trackError}</p>
) : !track ? (
<p className="label-md text-muted"></p>
) : (
<>
{track.trackingNo ? (
<p className="body-md" style={{ marginBottom: 8 }}>
{track.trackingNo}
{track.provider ? `${track.provider}` : ''}
</p>
) : null}
{track.manualQueryUrl ? (
<p className="body-md" style={{ marginBottom: 8 }}>
<a href={track.manualQueryUrl} target="_blank" rel="noreferrer">
</a>
</p>
) : null}
{(track.nodes ?? []).length === 0 ? (
<p className="label-md text-muted"></p>
) : (
<ul style={{ paddingLeft: 18, margin: 0 }}>
{(track.nodes ?? []).map((n, i) => (
<li key={`${n.time}-${i}`} className="body-md" style={{ marginBottom: 8 }}>
<div className="label-md text-muted">{fmtTime(n.time)}</div>
<div>{n.description || n.status || '—'}</div>
</li>
))}
</ul>
)}
</>
)}
</section>
) : null}
</div>
)}
</div>
);
}
@@ -0,0 +1,149 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerProxyOrderListItem, PartnerProxyOrderListResponse } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api';
import {
proxyOrderStatusColor,
proxyOrderStatusLabel,
} from '../lib/proxyOrderStatus';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function fmtTime(value: string | undefined) {
if (!value) return '—';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function deliveryLabel(item: PartnerProxyOrderListItem) {
const t = String(item.deliveryType || '').toUpperCase();
if (t === 'ON_SITE_PICKUP') return '现场提货';
if (t === 'CROSS_CITY') return '跨城配送';
if (t === 'LOCAL') return '同城配送';
return '线下代下单';
}
function coverUrl(item: PartnerProxyOrderListItem) {
return item.imageResource?.url || '';
}
export default function ProxyOrderListPage() {
const navigate = useNavigate();
const [list, setList] = useState<PartnerProxyOrderListItem[]>([]);
const [total, setTotal] = useState(0);
const [keyword, setKeyword] = useState('');
const [q, setQ] = useState('');
const [loading, setLoading] = useState(true);
const loadList = useCallback(() => {
if (!isLoggedIn()) {
navigate('/login');
return Promise.resolve();
}
setLoading(true);
const params = new URLSearchParams({ page: '1', pageSize: '50' });
if (q.trim()) params.set('keyword', q.trim());
return request<PartnerProxyOrderListResponse>('PARTNER_H5', `/partner/proxy-orders?${params}`)
.then((res) => {
setList(Array.isArray(res.list) ? res.list : []);
setTotal(Number(res.total) || 0);
})
.catch(() => {
setList([]);
setTotal(0);
})
.finally(() => setLoading(false));
}, [navigate, q]);
useEffect(() => {
document.title = '代下单列表';
}, []);
useEffect(() => {
void loadList();
}, [loadList]);
function submitSearch(e: FormEvent) {
e.preventDefault();
setQ(keyword.trim());
}
return (
<PullToRefresh onRefresh={loadList} className="page-no-tab partner-orders-page">
<PageHeader title="代下单列表" onBack={() => navigate('/center')} />
<div style={{ padding: '0 20px 12px' }}>
<form className="partner-search" onSubmit={submitSearch}>
<span className="material-symbols-outlined">search</span>
<input
placeholder="订单号 / 手机号 / 姓名 / 商品"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
</form>
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{loading ? '加载中…' : `${total} 笔代下单`}
</p>
</div>
<div style={{ padding: '0 16px 24px' }}>
{!loading && list.length === 0 && <div className="empty"></div>}
{list.map((o) => {
const orderId = String(o.id);
const img = coverUrl(o);
return (
<Link key={orderId} to={`/center/proxy-orders/${orderId}`} className="partner-order-card" style={{ marginBottom: 12 }}>
<div className="partner-order-card-top">
<span className="label-md text-muted">NO. {o.orderNo}</span>
<span
className="label-md"
style={{ color: proxyOrderStatusColor(o.status), fontWeight: 600 }}
>
{proxyOrderStatusLabel(o.status)}
</span>
</div>
<div className="partner-order-product">
<div
className="partner-order-product-img"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
background: img ? `center/cover no-repeat url(${img})` : undefined,
}}
>
{!img && <span className="material-symbols-outlined text-muted">liquor</span>}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h3 className="headline-md line-2-clamp">{o.productName || '杜康好酒'}</h3>
<p className="text-variant body-md" style={{ marginTop: 4 }}>
×{o.quantity} · ¥{fmtMoney(Number(o.payAmount || 0))}
</p>
<p className="label-md text-muted" style={{ marginTop: 4 }}>
{deliveryLabel(o)} · {fmtTime(o.createdAt)}
</p>
</div>
</div>
<div className="partner-order-address">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>person</span>
<p className="line-2-clamp">
{o.receiverName || '客户'}
{o.receiverPhone ? ` · ${o.receiverPhone}` : ''}
{o.receiverAddress ? ` · ${o.receiverAddress}` : ''}
</p>
</div>
</Link>
);
})}
</div>
</PullToRefresh>
);
}
@@ -0,0 +1,726 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import QRCode from 'qrcode';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { invokeWechatPay } from '@dukang/weixin-sdk';
import ChinaRegionPicker from '../components/ChinaRegionPicker';
import { getToken, request } from '../lib/api';
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
import {
clearProxyOrderDraft,
loadProxyOrderDraft,
saveProxyOrderDraft,
type ProxyOrderDraft,
} from '../lib/proxyOrderDraft';
import { toastError, toastSuccess } from '../lib/toast';
import { isWechatEnv } from '../lib/weixin';
import { fetchPartnerProfile, partnerHasWechatBinding } from '../lib/wechat-auth';
import type {
PartnerProxyDeliveryMode,
PartnerProxyOrderCreateRequest,
PartnerProxyOrderOptions,
PartnerProxyOrderPreviewResult,
ProxyOrderCreateResponse,
ProxyOrderPayResponse,
ProxyPayMethod,
} from '@dukang/shared-types';
import { usePartnerPageView } from '../lib/usePageView';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function initialDraft(): ProxyOrderDraft {
return (
loadProxyOrderDraft() ?? {
phone: '',
receiverName: '',
regionCodes: [],
addressDetail: '',
productId: '',
quantity: 2,
promoCodeId: '',
deliveryMode: 'ADDRESS',
autoReceive: false,
}
);
}
export default function ProxyOrderPage() {
usePartnerPageView('partner_proxy_order_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const draft0 = useMemo(() => initialDraft(), []);
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(true);
const [phone, setPhone] = useState(draft0.phone);
const [receiverName, setReceiverName] = useState(draft0.receiverName);
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
const [productId, setProductId] = useState(draft0.productId);
const [quantity, setQuantity] = useState(draft0.quantity);
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
const [autoReceive, setAutoReceive] = useState(draft0.autoReceive);
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState('');
const [step, setStep] = useState<'form' | 'pay'>('form');
const [created, setCreated] = useState<ProxyOrderCreateResponse | null>(null);
const [payMethod, setPayMethod] = useState<ProxyPayMethod>('NATIVE');
const [codeUrl, setCodeUrl] = useState<string | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [paying, setPaying] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [lockedPayMethod, setLockedPayMethod] = useState<ProxyPayMethod | null>(null);
const pollRef = useRef<number | null>(null);
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
const regionLabel = region ? formatRegionLabel(region) : '';
function stopPoll() {
if (pollRef.current != null) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
}
useEffect(() => () => stopPoll(), []);
useEffect(() => {
if (!codeUrl) {
setQrDataUrl(null);
return;
}
void QRCode.toDataURL(codeUrl, { width: 220, margin: 1 }).then(setQrDataUrl).catch(() => setQrDataUrl(null));
}, [codeUrl]);
function persistDraft(overrides?: Partial<ProxyOrderDraft>) {
saveProxyOrderDraft({
phone,
receiverName,
regionCodes,
addressDetail,
productId,
quantity,
promoCodeId,
deliveryMode,
autoReceive,
...overrides,
});
}
useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
.then((data) => {
setOptions({
...data,
stores: Array.isArray(data.stores) ? data.stores : [],
});
const fromQuery = searchParams.get('productId');
if (fromQuery && data.products.some((p) => p.id === fromQuery)) {
setProductId(fromQuery);
persistDraft({ productId: fromQuery });
} else if (data.products[0] && !productId) {
setProductId(data.products[0].id);
}
})
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoadingOptions(false));
// eslint-disable-next-line react-hooks/exhaustive-deps -- hydrate once; productId from query
}, [searchParams]);
useEffect(() => {
const fromQuery = searchParams.get('productId');
if (fromQuery) {
setProductId(fromQuery);
persistDraft({ productId: fromQuery });
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync productId from URL
}, [searchParams]);
useEffect(() => {
persistDraft();
// eslint-disable-next-line react-hooks/exhaustive-deps -- keep draft in sync for product-picker roundtrip
}, [
phone,
receiverName,
regionCodes,
addressDetail,
productId,
quantity,
promoCodeId,
deliveryMode,
autoReceive,
]);
const selectedProduct = options?.products.find((p) => p.id === productId);
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
useEffect(() => {
if (!selectedProduct) return;
if (!allowOnline && allowOnSite && deliveryMode !== 'ON_SITE_PICKUP') {
setDeliveryMode('ON_SITE_PICKUP');
return;
}
if (allowOnline && !allowOnSite && deliveryMode !== 'ADDRESS') {
setDeliveryMode('ADDRESS');
}
}, [selectedProduct, allowOnline, allowOnSite, deliveryMode]);
useEffect(() => {
if (!productId || quantity < 1) {
setPreview(null);
return;
}
const timer = setTimeout(() => {
setPreviewLoading(true);
request<PartnerProxyOrderPreviewResult>('PARTNER_H5', '/partner/proxy-orders/preview', {
method: 'POST',
body: JSON.stringify({
productId,
quantity,
deliveryMode,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}),
silent: true,
})
.then((data) => {
setPreview(data);
setMsg('');
})
.catch((e) => {
setPreview(null);
setMsg(e instanceof Error ? e.message : '费用预览失败');
})
.finally(() => setPreviewLoading(false));
}, 300);
return () => clearTimeout(timer);
}, [productId, quantity, deliveryMode, region?.city, region?.district]);
function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!productId) return '请选择商品';
if (deliveryMode === 'ADDRESS') {
if (!allowOnline) return '该商品不支持线上购买';
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
if (!addressDetail.trim()) return '请填写详细地址';
if (!autoReceive) return '配送到址须勾选同意自动收货';
} else if (!allowOnSite) {
return '该商品不支持现场提货';
}
if (!preview) return msg || '请等待费用计算完成';
return null;
}
function startPoll(orderId: string) {
stopPoll();
pollRef.current = window.setInterval(() => {
void request<{ payStatus: string; orderNo: string }>(
'PARTNER_H5',
`/partner/proxy-orders/${orderId}/pay-status`,
{ silent: true },
)
.then((st) => {
if (st.payStatus === 'PAID') {
stopPoll();
toastSuccess(`支付成功:${st.orderNo}`);
clearProxyOrderDraft();
navigate(`/center/proxy-orders/${orderId}`, { replace: true });
}
})
.catch(() => undefined);
}, 2000);
}
async function startPay(orderId: string, method: ProxyPayMethod) {
if (lockedPayMethod && lockedPayMethod !== method) {
setMsg(
lockedPayMethod === 'JSAPI'
? '该订单已发起微信代付,请先取消支付后重新下单'
: '该订单已生成收款码,请先取消支付后重新下单',
);
return;
}
setPaying(true);
setMsg('');
setPayMethod(method);
try {
if (method === 'JSAPI') {
if (!isWechatEnv()) {
throw new Error('请在微信内打开合伙人端以使用微信代付');
}
const profile = await fetchPartnerProfile();
if (!partnerHasWechatBinding(profile)) {
throw new Error('请先绑定微信后再代付');
}
}
const pay = await request<ProxyOrderPayResponse>(
'PARTNER_H5',
`/partner/proxy-orders/${orderId}/pay`,
{
method: 'POST',
body: JSON.stringify({ payMethod: method }),
silent: true,
},
);
if (pay.mode === 'mock') {
toastSuccess('支付成功');
clearProxyOrderDraft();
navigate(`/center/proxy-orders/${orderId}`, { replace: true });
return;
}
if (pay.mode === 'native' && pay.codeUrl) {
setCodeUrl(pay.codeUrl);
setLockedPayMethod(method);
startPoll(orderId);
return;
}
if (pay.mode === 'jsapi' && pay.prepay) {
setCodeUrl(null);
setLockedPayMethod(method);
await invokeWechatPay(pay.prepay, {
apiBase: '/api/v1',
clientApp: 'PARTNER_H5',
getAccessToken: getToken,
platform: 'wechat-h5',
});
startPoll(orderId);
return;
}
throw new Error('支付发起失败');
} catch (e) {
setMsg(e instanceof Error ? e.message : '支付失败');
} finally {
setPaying(false);
}
}
async function cancelPay(orderId: string) {
setCancelling(true);
setMsg('');
stopPoll();
try {
await request('PARTNER_H5', `/partner/proxy-orders/${orderId}/cancel-pay`, {
method: 'POST',
body: '{}',
});
toastSuccess('已取消支付,可重新下单并选择其他支付方式');
setStep('form');
setCreated(null);
setCodeUrl(null);
setLockedPayMethod(null);
} catch (e) {
setMsg(e instanceof Error ? e.message : '取消失败');
} finally {
setCancelling(false);
}
}
function switchPayMethod(method: ProxyPayMethod) {
if (lockedPayMethod && lockedPayMethod !== method) {
setMsg(
lockedPayMethod === 'JSAPI'
? '该订单已发起微信代付,请先取消支付后重新下单'
: '该订单已生成收款码,请先取消支付后重新下单',
);
return;
}
setPayMethod(method);
setMsg('');
}
async function submit() {
setMsg('');
const err = validateForm();
if (err) {
setMsg(err);
return;
}
const payload: PartnerProxyOrderCreateRequest = {
phone: phone.trim(),
deliveryMode,
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
receiverName: receiverName.trim() || undefined,
province: deliveryMode === 'ADDRESS' ? region?.province : undefined,
city: deliveryMode === 'ADDRESS' ? region?.city : undefined,
district: deliveryMode === 'ADDRESS' ? region?.district : undefined,
addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
productId,
quantity,
promoCodeId: promoCodeId || undefined,
};
setSubmitting(true);
try {
const order = await request<ProxyOrderCreateResponse>('PARTNER_H5', '/partner/proxy-orders', {
method: 'POST',
body: JSON.stringify(payload),
silent: true,
});
clearProxyOrderDraft();
setCreated(order);
setStep('pay');
setCodeUrl(null);
setLockedPayMethod(null);
await startPay(order.id, payMethod);
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setSubmitting(false);
}
}
const deliveryLabel =
preview?.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
: preview?.deliveryType === 'CROSS_CITY'
? '跨城配送'
: '同城配送';
return (
<div className="page partner-proxy-order-page">
<PageHeader title={step === 'pay' ? '代下单支付' : '代下单'} onBack={() => navigate(-1)} />
<main className="partner-form-card" style={{ margin: '0 16px 24px' }}>
{step === 'pay' && created ? (
<>
<p className="body-md">
{created.orderNo} · ¥{fmtMoney(created.payAmount)}
</p>
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{created.deliveryType === 'ON_SITE_PICKUP'
? '支付成功后订单即为已完成,并发放好客权益'
: '支付成功后进入待发货,由总部履约'}
</p>
<section className="partner-form-section" style={{ marginTop: 16 }}>
<label className="partner-form-label"></label>
<div className="partner-proxy-mode-tabs">
<button
type="button"
className={`partner-proxy-mode-tab${payMethod === 'NATIVE' ? ' is-active' : ''}`}
disabled={!!lockedPayMethod && lockedPayMethod !== 'NATIVE'}
onClick={() => switchPayMethod('NATIVE')}
>
</button>
<button
type="button"
className={`partner-proxy-mode-tab${payMethod === 'JSAPI' ? ' is-active' : ''}`}
disabled={!!lockedPayMethod && lockedPayMethod !== 'JSAPI'}
onClick={() => switchPayMethod('JSAPI')}
>
</button>
</div>
{lockedPayMethod ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{lockedPayMethod === 'NATIVE' ? '收款码' : '微信代付'}
</p>
) : null}
</section>
{payMethod === 'NATIVE' ? (
<div style={{ textAlign: 'center', marginTop: 16 }}>
{qrDataUrl ? (
<img src={qrDataUrl} alt="收款码" width={220} height={220} style={{ margin: '0 auto' }} />
) : (
<p className="label-md text-muted">{paying ? '生成收款码中…' : '暂无收款码'}</p>
)}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
使
</p>
<button
type="button"
className="btn btn-block"
style={{ marginTop: 16 }}
disabled={paying}
onClick={() => void startPay(created.id, 'NATIVE')}
>
{paying ? '生成中…' : '重新生成收款码'}
</button>
</div>
) : (
<div style={{ marginTop: 16 }}>
<p className="label-md text-muted"></p>
<button
type="button"
className="btn btn-primary btn-block"
style={{ marginTop: 12 }}
disabled={paying}
onClick={() => void startPay(created.id, 'JSAPI')}
>
{paying ? '支付中…' : '重新调起微信代付'}
</button>
</div>
)}
{msg ? (
<p className="partner-form-error" role="alert" style={{ marginTop: 12 }}>
{msg}
</p>
) : null}
<button
type="button"
className="btn btn-block"
style={{ marginTop: 16 }}
disabled={cancelling || paying}
onClick={() => void cancelPay(created.id)}
>
{cancelling ? '取消中…' : '取消支付'}
</button>
</>
) : loadingOptions ? (
<p className="label-md text-muted"></p>
) : (
<>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
placeholder="11 位手机号"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<button
type="button"
className="partner-proxy-product-picker"
onClick={() => {
persistDraft();
navigate(
`/proxy-order/products${productId ? `?selected=${encodeURIComponent(productId)}` : ''}`,
);
}}
>
{selectedProduct ? (
<>
<span className="partner-proxy-product-name">{selectedProduct.name}</span>
<span className="label-md text-muted">
{selectedProduct.spec} · ¥{fmtMoney(selectedProduct.price)}
</span>
</>
) : (
<span className="label-md text-muted"></span>
)}
<span className="partner-proxy-product-picker-arrow"></span>
</button>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
type="number"
min={1}
value={quantity}
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
/>
</div>
</section>
{(options?.promoCodes.length ?? 0) > 0 && (
<section className="partner-form-section">
<label className="partner-form-label">广</label>
<select
className="partner-input"
value={promoCodeId}
onChange={(e) => setPromoCodeId(e.target.value)}
style={{
width: '100%',
padding: '12px 14px',
borderRadius: 12,
border: '1px solid var(--color-border)',
}}
>
<option value=""></option>
{options!.promoCodes.map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.code}
</option>
))}
</select>
</section>
)}
<section className="partner-form-section">
<label className="partner-form-label"></label>
{allowOnline && allowOnSite ? (
<div className="partner-proxy-mode-tabs">
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ADDRESS' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ADDRESS')}
>
</button>
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ON_SITE_PICKUP' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ON_SITE_PICKUP')}
>
</button>
</div>
) : (
<p className="label-md text-muted">
{!allowOnline && allowOnSite
? '该商品仅支持现场提货'
: allowOnline && !allowOnSite
? allowCrossCity
? '该商品仅支持配送到址(含跨城)'
: '该商品仅支持配送到址(不可跨城)'
: '该商品暂无可选履约方式'}
</p>
)}
</section>
{deliveryMode === 'ADDRESS' && allowOnline ? (
<>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
placeholder="默认:用户+手机尾号"
value={receiverName}
onChange={(e) => setReceiverName(e.target.value)}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
{regionLabel ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{regionLabel}
</p>
) : null}
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-field-input partner-field-input--block">
<textarea
rows={3}
placeholder="街道、门牌号等"
value={addressDetail}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</div>
</section>
<label className="partner-proxy-auto-receive">
<input
type="checkbox"
checked={autoReceive}
onChange={(e) => setAutoReceive(e.target.checked)}
/>
<span></span>
</label>
</>
) : null}
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-proxy-mode-tabs">
<button
type="button"
className={`partner-proxy-mode-tab${payMethod === 'NATIVE' ? ' is-active' : ''}`}
onClick={() => setPayMethod('NATIVE')}
>
</button>
<button
type="button"
className={`partner-proxy-mode-tab${payMethod === 'JSAPI' ? ' is-active' : ''}`}
onClick={() => setPayMethod('JSAPI')}
>
</button>
</div>
<p className="label-md text-muted" style={{ marginTop: 8, lineHeight: 1.5 }}>
{payMethod === 'NATIVE'
? '提交后展示商家收款码,客户或现场扫码支付'
: '提交后在微信内由您代客户完成支付(需已绑定微信)'}
</p>
</section>
<section className="partner-proxy-fee-card">
<h3 className="headline-md"></h3>
{previewLoading ? (
<p className="label-md text-muted"></p>
) : preview ? (
<>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
<span className="body-md">¥{fmtMoney(preview.unitPrice)}</span>
</div>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
<span className="body-md">×{quantity}</span>
</div>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
<span className="body-md">{deliveryLabel}</span>
</div>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
<span className="body-md">¥{fmtMoney(preview.benefitAmount)}</span>
</div>
<div className="partner-proxy-fee-row partner-proxy-fee-row--total">
<span className="headline-md"></span>
<span className="amount-lg text-primary">¥{fmtMoney(preview.payAmount)}</span>
</div>
</>
) : (
<p className="label-md text-muted">
{selectedProduct ? '请确认数量与履约信息后查看费用' : '请选择商品'}
</p>
)}
</section>
{msg ? (
<p className="partner-form-error" role="alert">
{msg}
</p>
) : null}
<button
type="button"
className="btn btn-primary btn-block"
disabled={submitting || paying || !preview}
onClick={() => void submit()}
style={{ marginTop: 16 }}
>
{submitting || paying ? '处理中…' : '提交并支付'}
</button>
<p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}>
线
</p>
</>
)}
</main>
</div>
);
}
@@ -0,0 +1,121 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import { toastError } from '../lib/toast';
import type { PartnerProxyOrderOptions, PartnerProxyOrderProductOption } from '@dukang/shared-types';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
type FulfillmentFilter = 'ALL' | 'ONLINE' | 'CROSS_CITY' | 'ON_SITE';
const FILTERS: Array<{ key: FulfillmentFilter; label: string }> = [
{ key: 'ALL', label: '全部' },
{ key: 'ONLINE', label: '线上' },
{ key: 'CROSS_CITY', label: '跨城' },
{ key: 'ON_SITE', label: '现场' },
];
function matchFilter(p: PartnerProxyOrderProductOption, filter: FulfillmentFilter): boolean {
if (filter === 'ALL') return true;
if (filter === 'ONLINE') return p.allowOnlinePurchase !== false;
if (filter === 'CROSS_CITY') return p.allowCrossCityDelivery !== false;
return !!p.allowOnSitePickup;
}
export default function ProxyOrderProductsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const selectedId = searchParams.get('selected') || '';
const [products, setProducts] = useState<PartnerProxyOrderProductOption[]>([]);
const [loading, setLoading] = useState(true);
const [keyword, setKeyword] = useState('');
const [filter, setFilter] = useState<FulfillmentFilter>('ALL');
useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
.then((data) => setProducts(Array.isArray(data.products) ? data.products : []))
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, []);
const filtered = useMemo(() => {
const q = keyword.trim().toLowerCase();
return products.filter((p) => {
if (!matchFilter(p, filter)) return false;
if (!q) return true;
return `${p.name} ${p.spec}`.toLowerCase().includes(q);
});
}, [products, keyword, filter]);
function pick(product: PartnerProxyOrderProductOption) {
navigate(`/proxy-order?productId=${encodeURIComponent(product.id)}`, { replace: true });
}
return (
<div className="page partner-proxy-order-page">
<PageHeader title="选择酒品" onBack={() => navigate(-1)} />
<div className="partner-proxy-product-toolbar">
<input
className="partner-input"
placeholder="搜索酒品名称 / 规格"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<div className="partner-proxy-product-filters">
{FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={`partner-proxy-product-filter${filter === f.key ? ' is-active' : ''}`}
onClick={() => setFilter(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
<main className="partner-proxy-product-list">
{loading ? <p className="label-md text-muted"></p> : null}
{!loading && filtered.length === 0 ? (
<p className="label-md text-muted"></p>
) : null}
{filtered.map((p) => {
const benefit = p.benefitAmount != null ? Number(p.benefitAmount) : Number(p.price);
const active = p.id === selectedId;
return (
<button
key={p.id}
type="button"
className={`partner-proxy-product-card${active ? ' partner-proxy-product-card--active' : ''}`}
onClick={() => pick(p)}
>
<div className="partner-proxy-product-thumb">
{p.coverUrl ? (
<img src={p.coverUrl} alt="" />
) : (
<span className="partner-proxy-product-thumb-empty" />
)}
</div>
<div className="partner-proxy-product-main">
<div className="partner-proxy-product-row">
<span className="partner-proxy-product-name">{p.name}</span>
<span className="partner-proxy-product-price">¥{fmtMoney(p.price)}</span>
</div>
<p className="partner-proxy-product-spec">{p.spec}</p>
<p className="partner-proxy-product-benefit"> ¥{fmtMoney(benefit)}</p>
<div className="partner-proxy-product-tags">
{p.allowOnlinePurchase !== false ? <span className="partner-proxy-tag">线</span> : null}
{p.allowCrossCityDelivery !== false ? <span className="partner-proxy-tag"></span> : null}
{p.allowOnSitePickup ? <span className="partner-proxy-tag"></span> : null}
</div>
</div>
</button>
);
})}
</main>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
type ReshipStatus = 'ALL' | 'PENDING' | 'SHIPPING' | 'DONE';
const MOCK_ITEMS = [
{ id: '1', orderNo: 'DK20260301001', status: 'PENDING' as const, productName: '杜康窖藏', originalOrderNo: 'DK20260228088', address: '郑州市金水区花园路 88 号' },
{ id: '2', orderNo: 'DK20260302002', status: 'SHIPPING' as const, productName: '杜康特曲', originalOrderNo: 'DK20260215012', address: '郑州市二七区大学路 12 号' },
];
const STATUS_LABEL: Record<string, string> = {
PENDING: '待配送',
SHIPPING: '配送中',
DONE: '已完成',
};
const FILTERS: { key: ReshipStatus; label: string }[] = [
{ key: 'ALL', label: '全部' },
{ key: 'PENDING', label: '待配送' },
{ key: 'SHIPPING', label: '配送中' },
{ key: 'DONE', label: '已完成' },
];
/** BUWR-23:补发处理 */
export default function ReshipPage() {
const navigate = useNavigate();
const [filter, setFilter] = useState<ReshipStatus>('ALL');
const list = MOCK_ITEMS.filter((item) => filter === 'ALL' || item.status === filter);
return (
<div className="page-no-tab partner-orders-page">
<PageHeader title="补发处理" onBack={() => navigate('/')} />
<p className="label-md text-muted" style={{ padding: '0 20px 8px' }}>
</p>
<div className="partner-filter-row" style={{ padding: '0 20px 12px' }}>
{FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={`partner-chip${filter === f.key ? ' active' : ''}`}
onClick={() => setFilter(f.key)}
>
{f.label}
</button>
))}
</div>
{list.length === 0 && <div className="empty"></div>}
{list.map((item) => (
<div key={item.id} className="partner-order-card" style={{ margin: '0 20px 12px' }}>
<div className="partner-order-card-top">
<span className="label-md text-muted"> {item.originalOrderNo}</span>
<span className="label-md text-primary" style={{ fontWeight: 600 }}>{STATUS_LABEL[item.status]}</span>
</div>
<h3 className="headline-md" style={{ marginTop: 8 }}>{item.productName}</h3>
<p className="label-md text-muted" style={{ marginTop: 4 }}> {item.orderNo}</p>
<div className="partner-order-address" style={{ marginTop: 12 }}>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
<p className="line-2-clamp">{item.address}</p>
</div>
{item.status === 'PENDING' && (
<button type="button" className="partner-btn-primary" style={{ marginTop: 16, width: '100%' }}>
</button>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,221 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api';
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing' | 'rejected';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function billStatusLabel(status: string) {
switch (status) {
case 'AWAITING_CONFIRM': return '待确认';
case 'UNPAID': return '未打款';
case 'PAID': return '已打款';
case 'REJECTED': return '已驳回';
default: return status;
}
}
function billStatusClass(status: string) {
switch (status) {
case 'AWAITING_CONFIRM': return 'partner-settlement-status--pending';
case 'UNPAID': return 'partner-settlement-status--reviewing';
case 'PAID': return 'partner-settlement-status--settled';
case 'REJECTED': return 'partner-settlement-status--rejected';
default: return '';
}
}
function billPeriodLabel(periodStart: string) {
const d = new Date(periodStart);
if (Number.isNaN(d.getTime())) return '—';
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function billMonthLabel(year: number, month: number) {
return `${year}${month}`;
}
export default function SettlementPage() {
const navigate = useNavigate();
const [bills, setBills] = useState<PartnerBillDto[]>([]);
const now = new Date();
const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 });
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const loadBills = useCallback(() => {
return request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
}, []);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
void loadBills();
}, [navigate, loadBills]);
const summary = useMemo(() => {
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
const settled = bills.filter((b) => b.status === 'PAID');
const currentMonth = bills.filter((b) => {
const d = new Date(b.periodStart);
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
});
return {
pendingTotal: pending.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
monthEstimate: currentMonth.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
};
}, [bills, now]);
const filteredBills = useMemo(() => {
return bills.filter((b) => {
const d = new Date(b.periodStart);
const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
if (!matchMonth) return false;
if (statusFilter === 'all') return true;
if (statusFilter === 'pending') return b.status === 'AWAITING_CONFIRM';
if (statusFilter === 'settled') return b.status === 'PAID';
if (statusFilter === 'reviewing') return b.status === 'UNPAID';
if (statusFilter === 'rejected') return b.status === 'REJECTED';
return true;
});
}, [bills, month, statusFilter]);
const monthOptions = useMemo(() => {
const opts: Array<{ year: number; month: number }> = [];
for (let i = 0; i < 6; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
opts.push({ year: d.getFullYear(), month: d.getMonth() + 1 });
}
return opts;
}, [now]);
function openBill(bill: PartnerBillDto) {
navigate(bill.id ? `/center/bills?id=${bill.id}` : '/center/bills');
}
const statusTabs: Array<{ key: StatusFilter; label: string }> = [
{ key: 'all', label: '全部' },
{ key: 'pending', label: '待确认' },
{ key: 'reviewing', label: '未打款' },
{ key: 'rejected', label: '已驳回' },
{ key: 'settled', label: '已打款' },
];
return (
<PullToRefresh onRefresh={loadBills} className="page-no-tab partner-settlement-page">
<PageHeader title="财务对账中心" onBack={() => navigate('/')} />
<main className="partner-settlement-body">
<section className="partner-settlement-summary">
<div className="partner-settlement-summary-deco" aria-hidden>
<span className="material-symbols-outlined">account_balance_wallet</span>
</div>
<p className="partner-settlement-summary-label"></p>
<div className="partner-settlement-summary-amount">
<span className="partner-settlement-currency">¥</span>
{fmtMoney(summary.pendingTotal)}
</div>
<div className="partner-settlement-summary-grid">
<div>
<p className="partner-settlement-summary-sub"></p>
<p className="partner-settlement-summary-value">¥{fmtMoney(summary.settledTotal)}</p>
</div>
<div className="partner-settlement-summary-divider" />
<div>
<p className="partner-settlement-summary-sub"></p>
<p className="partner-settlement-summary-value partner-settlement-summary-value--accent">
¥{fmtMoney(summary.monthEstimate)}
</p>
</div>
</div>
</section>
<section className="partner-settlement-filters">
<select
className="partner-settlement-month"
value={`${month.year}-${month.month}`}
onChange={(e) => {
const [y, m] = e.target.value.split('-').map(Number);
setMonth({ year: y, month: m });
}}
>
{monthOptions.map((opt) => (
<option key={`${opt.year}-${opt.month}`} value={`${opt.year}-${opt.month}`}>
{billMonthLabel(opt.year, opt.month)}
</option>
))}
</select>
<button type="button" className="partner-settlement-status-btn">
<span className="material-symbols-outlined">filter_list</span>
</button>
</section>
<div className="partner-settlement-chips">
{statusTabs.map((tab) => (
<button
key={tab.key}
type="button"
className={`partner-settlement-chip${statusFilter === tab.key ? ' partner-settlement-chip--active' : ''}`}
onClick={() => setStatusFilter(tab.key)}
>
{tab.label}
</button>
))}
</div>
<section className="partner-settlement-list-section">
<h3 className="partner-settlement-list-title"></h3>
{filteredBills.length === 0 && (
<div className="empty" style={{ padding: '32px 0' }}></div>
)}
<div className="partner-settlement-list">
{filteredBills.map((bill) => (
<button
key={bill.id}
type="button"
className="partner-settlement-item"
onClick={() => openBill(bill)}
>
<div className="partner-settlement-item-main">
<div className="partner-settlement-item-head">
<span className="partner-settlement-item-no">{bill.billNo}</span>
<span className={`partner-settlement-status ${billStatusClass(bill.status)}`}>
{billStatusLabel(bill.status)}
</span>
</div>
<p className="partner-settlement-item-meta">
<span className="material-symbols-outlined">event</span>
{billPeriodLabel(bill.periodStart)}
<span className="partner-settlement-dot" />
¥{fmtMoney(Number(bill.orderCommission || 0))}
</p>
{bill.status === 'REJECTED' && bill.rejectReason ? (
<p className="label-md text-primary" style={{ marginTop: 6 }}>
{bill.rejectReason}
</p>
) : null}
<p className={`partner-settlement-item-amount${bill.status === 'AWAITING_CONFIRM' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
¥{fmtMoney(Number(bill.totalAmount || 0))}
</p>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</button>
))}
</div>
{filteredBills.length > 0 && (
<p className="partner-settlement-footer-note"></p>
)}
</section>
</main>
</PullToRefresh>
);
}
@@ -0,0 +1,225 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { createPartnerStaff, sendPartnerStaffPhoneSms } from '../lib/staff';
import { toastError, toastSuccess } from '../lib/toast';
type FieldErrors = {
name?: string;
phone?: string;
smsCode?: string;
};
export default function StaffCreatePage() {
const navigate = useNavigate();
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [smsCode, setSmsCode] = useState('');
const [smsCooldown, setSmsCooldown] = useState(0);
const [smsHint, setSmsHint] = useState('');
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
function patchName(value: string) {
setName(value);
setSubmitError('');
setFieldErrors((prev) => ({ ...prev, name: undefined }));
}
function patchPhone(value: string) {
setPhone(value);
setSmsHint('');
setSubmitError('');
setFieldErrors((prev) => ({ ...prev, phone: undefined, smsCode: undefined }));
if (smsCode) setSmsCode('');
}
function patchSmsCode(value: string) {
setSmsCode(value.replace(/\D/g, ''));
setSubmitError('');
setFieldErrors((prev) => ({ ...prev, smsCode: undefined }));
}
async function sendPhoneCode() {
const trimmedPhone = phone.trim();
if (!/^1[3-9]\d{9}$/.test(trimmedPhone)) {
setFieldErrors({ phone: '请先填写正确的11位手机号' });
return;
}
setSmsHint('');
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
try {
const res = await sendPartnerStaffPhoneSms(trimmedPhone);
setSmsHint(`验证码已发送至 ${res.maskedPhone}`);
setSmsCooldown(60);
const timer = setInterval(() => {
setSmsCooldown((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
} catch (e) {
const msg = e instanceof Error ? e.message : '验证码发送失败';
setFieldErrors({ phone: msg });
}
}
async function submit() {
const trimmedName = name.trim();
const trimmedPhone = phone.trim();
const trimmedSmsCode = smsCode.trim();
const nextFieldErrors: FieldErrors = {};
if (!trimmedName) nextFieldErrors.name = '请填写真实姓名';
if (!trimmedPhone) {
nextFieldErrors.phone = '请填写手机号码';
} else if (!/^1[3-9]\d{9}$/.test(trimmedPhone)) {
nextFieldErrors.phone = '请输入正确的11位手机号';
}
if (!trimmedSmsCode) {
nextFieldErrors.smsCode = '请输入手机号验证码';
} else if (!/^\d{4,6}$/.test(trimmedSmsCode)) {
nextFieldErrors.smsCode = '验证码格式不正确';
}
if (Object.keys(nextFieldErrors).length) {
setFieldErrors(nextFieldErrors);
return;
}
setSubmitting(true);
setSubmitError('');
setFieldErrors({});
try {
await createPartnerStaff({
name: trimmedName,
phone: trimmedPhone,
smsCode: trimmedSmsCode,
});
toastSuccess('子账号创建成功');
navigate('/center/staff');
} catch (e) {
const message = e instanceof Error ? e.message : '创建失败';
if (message.includes('验证码')) {
setFieldErrors({ smsCode: message });
return;
}
if (message.includes('手机号')) {
setFieldErrors({ phone: message });
return;
}
setSubmitError(message);
toastError(message);
} finally {
setSubmitting(false);
}
}
return (
<div className="partner-page-sticky">
<PageHeader title="添加子账号" onBack={() => navigate('/center/staff')} />
{submitError && (
<p className="partner-form-error" role="alert">{submitError}</p>
)}
<section className="partner-form-card">
<div className="partner-section-title">
<div className="partner-section-bar" />
<h2 className="headline-md"></h2>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">person</span>
<input
placeholder="请输入员工姓名"
value={name}
onChange={(e) => patchName(e.target.value)}
/>
</div>
{fieldErrors.name && (
<p className="partner-field-error" role="alert">{fieldErrors.name}</p>
)}
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">call</span>
<input
type="tel"
placeholder="请输入11位手机号"
value={phone}
onChange={(e) => patchPhone(e.target.value)}
/>
</div>
{fieldErrors.phone && (
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
)}
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-input-row">
<div className="partner-input-wrap" style={{ flex: 1 }}>
<span className="material-symbols-outlined partner-input-icon">shield</span>
<input
className="partner-input"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="请输入短信验证码"
value={smsCode}
onChange={(e) => patchSmsCode(e.target.value)}
/>
</div>
<button
type="button"
className="partner-code-btn"
disabled={smsCooldown > 0 || submitting}
onClick={() => void sendPhoneCode()}
>
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
</button>
</div>
{smsHint && (
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
)}
{fieldErrors.smsCode && (
<p className="partner-field-error" role="alert">{fieldErrors.smsCode}</p>
)}
</div>
</section>
<div className="partner-info-banner">
<div className="partner-bills-icon">
<span className="material-symbols-outlined">info</span>
</div>
<p className="label-md text-variant" style={{ marginTop: 0, lineHeight: 1.5 }}>
</p>
</div>
<footer className="partner-sticky-footer">
<button
type="button"
className="partner-btn-primary"
disabled={submitting}
onClick={() => void submit()}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>person_add</span>
{submitting ? '创建中…' : '确认创建'}
</button>
</footer>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import {
AccountStatus,
PARTNER_STAFF_ROLE_LABELS,
PartnerStaffRole,
type PartnerStaffItem,
} from '@dukang/shared-types';
import { deletePartnerStaff, listPartnerStaff, updatePartnerStaff } from '../lib/staff';
import { usePartnerPageView } from '../lib/usePageView';
export default function StaffListPage() {
usePartnerPageView('partner_staff_list_view');
const navigate = useNavigate();
const [staff, setStaff] = useState<PartnerStaffItem[]>([]);
const [q, setQ] = useState('');
const [error, setError] = useState('');
const [busyId, setBusyId] = useState<string | null>(null);
const loadStaff = useCallback(() => {
return listPartnerStaff().then(setStaff).catch((e) => {
setError(e instanceof Error ? e.message : '加载失败');
});
}, []);
useEffect(() => {
void loadStaff();
}, [loadStaff]);
const filtered = useMemo(() => staff.filter((item) => {
if (!q.trim()) return true;
const keyword = q.trim();
return item.name.includes(keyword) || item.phone.includes(keyword);
}), [staff, q]);
async function toggleStatus(item: PartnerStaffItem) {
const next = item.status === AccountStatus.ACTIVE ? AccountStatus.DISABLED : AccountStatus.ACTIVE;
setBusyId(item.id);
setError('');
try {
await updatePartnerStaff(item.id, { status: next });
await loadStaff();
} catch (e) {
setError(e instanceof Error ? e.message : '操作失败');
} finally {
setBusyId(null);
}
}
async function removeStaff(item: PartnerStaffItem) {
const ok = window.confirm(`确认删除子账号「${item.name}」?`);
if (!ok) return;
setBusyId(item.id);
setError('');
try {
await deletePartnerStaff(item.id);
await loadStaff();
} catch (e) {
setError(e instanceof Error ? e.message : '删除失败');
} finally {
setBusyId(null);
}
}
async function editStaff(item: PartnerStaffItem) {
const name = window.prompt('修改姓名', item.name);
if (name == null || !name.trim()) return;
const roleOptions = Object.values(PartnerStaffRole);
const roleLabels = roleOptions.map((r) => PARTNER_STAFF_ROLE_LABELS[r]).join(' / ');
const roleInput = window.prompt(`分配角色(${roleLabels}`, item.staffRole);
if (roleInput == null) return;
const staffRole = roleOptions.find((r) => r === roleInput || PARTNER_STAFF_ROLE_LABELS[r] === roleInput);
if (!staffRole) {
setError('无效的角色');
return;
}
setBusyId(item.id);
setError('');
try {
await updatePartnerStaff(item.id, { name: name.trim(), staffRole });
await loadStaff();
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败');
} finally {
setBusyId(null);
}
}
return (
<PullToRefresh onRefresh={loadStaff} className="page-no-tab partner-staff-page">
<PageHeader title="子账号管理" onBack={() => navigate('/center')} />
<div style={{ padding: '0 20px 16px' }}>
<div className="partner-search">
<span className="material-symbols-outlined">search</span>
<input placeholder="搜索姓名或手机号" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
</div>
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
<div style={{ padding: '0 20px 100px' }}>
{filtered.length === 0 && (
<div className="empty">
<p></p>
<p className="label-md text-muted"></p>
</div>
)}
{filtered.map((item) => {
const active = item.status === AccountStatus.ACTIVE;
const busy = busyId === item.id;
return (
<div key={item.id} className="partner-staff-card">
<div className="partner-staff-avatar">{item.name.slice(0, 1)}</div>
<div className="partner-staff-info">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span className="headline-md">{item.name}</span>
<span className="partner-role-badge partner-role-badge--subtle">
{PARTNER_STAFF_ROLE_LABELS[item.staffRole]}
</span>
</div>
<p className="label-md text-muted">{item.phone}</p>
</div>
<div className="partner-staff-actions">
<button
type="button"
className={`partner-toggle${active ? ' partner-toggle--on' : ''}`}
disabled={busy}
aria-label={active ? '禁用' : '启用'}
onClick={() => void toggleStatus(item)}
>
<span className="partner-toggle-thumb" />
</button>
<div style={{ display: 'flex', gap: 12 }}>
<button type="button" className="partner-icon-btn" disabled={busy} onClick={() => void editStaff(item)}>
<span className="material-symbols-outlined">edit</span>
</button>
<button type="button" className="partner-icon-btn partner-icon-btn--danger" disabled={busy} onClick={() => void removeStaff(item)}>
<span className="material-symbols-outlined">delete</span>
</button>
</div>
</div>
</div>
);
})}
</div>
<div className="partner-sticky-footer">
<Link to="/center/staff/new" className="partner-btn-primary" style={{ display: 'flex', textDecoration: 'none' }}>
<span className="material-symbols-outlined">person_add</span>
</Link>
</div>
</PullToRefresh>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,517 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canManagePartnerStore } from '../lib/partnerAccess';
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray, patchEnvPhotoAt, addEnvPhotoSlot } from '../lib/storeDraft';
import OssUploadField from '../components/OssUploadField';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import {
canPartnerOpenStore,
storeAuditLabel,
storeAuditPillClass,
storeStatusLabel,
storeStatusPillClass,
type StoreStatusValue,
} from '../lib/storeStatus';
import { usePartnerPageView } from '../lib/usePageView';
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
function uniqueEnvUrls(urls: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of urls) {
const url = raw.trim();
if (!url || seen.has(url)) continue;
seen.add(url);
out.push(url);
}
return out;
}
export default function StoreDetailPage() {
const { id } = useParams();
usePartnerPageView('partner_store_detail_view', id ? { storeId: id } : undefined);
const navigate = useNavigate();
const { account } = usePartnerSession();
const canMutate = canManagePartnerStore(account);
const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({
name: '',
phone: '',
address: '',
intro: '',
benefitUsageRule: '',
latitude: '',
longitude: '',
});
const [coverUrl, setCoverUrl] = useState('');
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
const [statusSaving, setStatusSaving] = useState(false);
const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false);
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [actionError, setActionError] = useState('');
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
function applyStore(data: Record<string, unknown>) {
setStore(data);
setForm({
name: String(data.name || ''),
phone: String(data.phone || ''),
address: String(data.address || ''),
intro: String(data.intro || ''),
benefitUsageRule: (() => {
const raw = String(data.benefitUsageRule || '').trim();
return raw && !/^null$/i.test(raw) ? raw : '';
})(),
latitude: data.latitude != null && data.latitude !== '' ? String(data.latitude) : '',
longitude: data.longitude != null && data.longitude !== '' ? String(data.longitude) : '',
});
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
setCoverUrl(String(data.coverUrl || ''));
const envFromMedia = Array.isArray(data.media)
? uniqueEnvUrls(
(data.media as Array<{ url?: string; bizType?: string }>)
.filter((m) => m.bizType === 'ENV')
.map((m) => String(m.url || '')),
)
: [];
setEnvPhotoUrls(normalizeStringArray(envFromMedia, MIN_ENV_PHOTO_COUNT));
}
useEffect(() => {
if (!id) return;
setLoadError('');
request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}`)
.then(applyStore)
.catch((e) => {
setStore(null);
setLoadError(e instanceof Error ? e.message : '加载失败');
});
}, [id]);
async function changeStatus(next: StoreStatusValue) {
if (!id || next === status || statusSaving) return;
if (status === 'CLOSED') return;
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
setActionError(
auditStatus === 'REJECTED'
? `门店审核未通过${store?.rejectReason ? `${String(store.rejectReason)}` : ''}`
: '门店尚在总部审核中,通过后方可开门',
);
return;
}
if (next === 'CLOSED') {
setCloseConfirmOpen(true);
return;
}
setStatusSaving(true);
setActionError('');
try {
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
method: 'PUT',
body: JSON.stringify({ status: next }),
});
setStatus(next);
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
toastSuccess(next === 'OPEN' ? '开店成功' : '状态已更新');
} catch (e) {
setActionError(e instanceof Error ? e.message : '状态更新失败');
} finally {
setStatusSaving(false);
}
}
async function confirmCloseStore() {
if (!id || statusSaving) return;
setCloseConfirmOpen(false);
setStatusSaving(true);
setActionError('');
try {
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
method: 'PUT',
body: JSON.stringify({ status: 'CLOSED' }),
});
setStatus('CLOSED');
setStore((prev) => (prev ? { ...prev, ...updated, status: 'CLOSED' } : prev));
toastSuccess('门店已关闭');
} catch (e) {
setActionError(e instanceof Error ? e.message : '状态更新失败');
} finally {
setStatusSaving(false);
}
}
async function saveBasic() {
if (!id || saving || status === 'CLOSED') return;
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
if (auditStatus === 'PENDING') {
setActionError('门店审核中,暂不可修改资料');
return;
}
setSaving(true);
setActionError('');
try {
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, {
method: 'PUT',
body: JSON.stringify({
name: form.name.trim(),
phone: form.phone.trim(),
address: form.address.trim(),
intro: form.intro.trim(),
benefitUsageRule: form.benefitUsageRule.trim() || null,
...(form.latitude.trim() && form.longitude.trim()
? {
latitude: Number(form.latitude),
longitude: Number(form.longitude),
}
: {}),
}),
});
applyStore(data);
toastSuccess(auditStatus === 'REJECTED' ? '已保存并重新提交审核' : '已保存');
} catch (e) {
setActionError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function saveMedia() {
if (!id || mediaSaving || status === 'CLOSED') return;
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
if (auditStatus === 'PENDING') {
setActionError('门店审核中,暂不可修改资料');
return;
}
const nextCover = coverUrl.trim();
const nextEnv = uniqueEnvUrls(envPhotoUrls);
if (!nextCover) {
setActionError('请上传门头照');
return;
}
if (nextEnv.length < MIN_ENV_PHOTO_COUNT) {
setActionError(`请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`);
return;
}
setMediaSaving(true);
setActionError('');
try {
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
method: 'PUT',
body: JSON.stringify({
coverUrl: nextCover,
envPhotoUrls: nextEnv,
}),
});
applyStore(data);
toastSuccess(auditStatus === 'REJECTED' ? '照片已更新并重新提交审核' : '照片已更新');
} catch (e) {
setActionError(e instanceof Error ? e.message : '照片更新失败');
} finally {
setMediaSaving(false);
}
}
if (loadError) {
return (
<div className="partner-detail-page partner-home--flush-top">
<div className="empty">{loadError}</div>
</div>
);
}
if (!store) return <div className="empty">...</div>;
const envPhotos = uniqueEnvUrls(
Array.isArray(store.media)
? (store.media as Array<{ url?: string; bizType?: string }>)
.filter((m) => m.bizType === 'ENV')
.map((m) => String(m.url || ''))
: [],
);
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
const auditPending = auditStatus === 'PENDING';
const auditRejected = auditStatus === 'REJECTED';
const readOnly = !canMutate || status === 'CLOSED' || auditPending;
const canOpen = canPartnerOpenStore(auditStatus);
return (
<div className="partner-detail-page partner-home--flush-top">
<main style={{ padding: '12px 20px 16px' }}>
{actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>}
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}></h3>
<span className={`partner-status-pill ${storeAuditPillClass(auditStatus)}`}>
{storeAuditLabel(auditStatus)}
</span>
</div>
{auditPending && (
<p className="body-md" style={{ color: 'var(--color-secondary)' }}>
</p>
)}
{auditRejected && (
<div>
<p className="body-md" style={{ color: 'var(--color-heritage-red)', fontWeight: 600, marginBottom: 8 }}>
</p>
<p className="body-md" style={{ background: 'rgba(180,35,24,0.06)', padding: 12, borderRadius: 8 }}>
{String(store.rejectReason || '未填写驳回原因')}
</p>
<p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
</div>
)}
{auditStatus === 'APPROVED' && (
<p className="label-md text-muted"></p>
)}
</section>
{canMutate && !auditPending && status !== 'CLOSED' && (
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}></h3>
<button type="button" className="partner-btn-outline" onClick={() => navigate(`/stores/${id}/packages`)}>
</button>
</div>
<p className="label-md text-muted" style={{ marginTop: 8 }}></p>
</section>
)}
{canMutate && (
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}></h3>
<span className={`partner-status-pill ${storeStatusPillClass(status)}`}>
{status === 'OPEN' && <span className="partner-dot partner-dot--green" style={{ width: 8, height: 8, display: 'inline-block', marginRight: 4 }} />}
{storeStatusLabel(status)}
</span>
</div>
<div className="partner-status-toggle">
{STATUS_OPTIONS.map((s) => (
<button
key={s}
type="button"
className={status === s ? 'active' : ''}
disabled={readOnly || statusSaving || (s === 'OPEN' && !canOpen) || status === 'CLOSED'}
onClick={() => void changeStatus(s)}
>
{s === 'OPEN' && !canOpen ? '待审核' : storeStatusLabel(s)}
</button>
))}
</div>
{status === 'CLOSED' && (
<p className="label-md text-muted" style={{ marginTop: 12 }}></p>
)}
</section>
)}
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}></h3>
{!canMutate || readOnly ? (
<div className="partner-cover">
<AppImage
src={coverUrl || null}
alt={form.name}
wrapperClassName="app-image--fill"
/>
</div>
) : (
<div style={{ marginBottom: 16 }}>
<p className="label-md text-muted" style={{ marginBottom: 8 }}></p>
<OssUploadField
wide
bizType="STORE_TITLE"
mediaType="IMAGE"
value={coverUrl}
onChange={setCoverUrl}
label="拍照 / 从相册选择"
/>
</div>
)}
<div className="partner-field">
<label></label>
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
</div>
<div className="partner-field">
<label></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">call</span>
<input disabled={readOnly} type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
</div>
</div>
<div className="partner-field">
<label></label>
<textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
{!readOnly ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
onClick={() => setMapPickerOpen(true)}
>
</button>
<span className="label-md text-muted">
{form.latitude.trim() && form.longitude.trim() ? '已选点' : '未选点'}
</span>
</div>
) : null}
<input type="hidden" name="latitude" value={form.latitude} readOnly />
<input type="hidden" name="longitude" value={form.longitude} readOnly />
</div>
<div className="partner-field">
<label></label>
<textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(2-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
<div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.intro.length} / 500</span>
</div>
</div>
<div className="partner-field">
<label>使</label>
<textarea
disabled={readOnly}
rows={4}
placeholder="选填,展示在用户端门店详情,最多1000字"
maxLength={1000}
value={form.benefitUsageRule}
onChange={(e) => setForm({ ...form, benefitUsageRule: e.target.value })}
/>
<div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.benefitUsageRule.length} / 1000</span>
</div>
</div>
</section>
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}></h3>
<span className="label-md text-muted">
{canMutate && !readOnly
? `至少 ${MIN_ENV_PHOTO_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length}`
: envPhotos.length
? `已上传 ${envPhotos.length}`
: '暂无照片'}
</span>
</div>
{canMutate && !readOnly ? (
<>
<div className="partner-upload-grid">
{envPhotoUrls.map((url, index) => (
<OssUploadField
key={index}
compact
bizType="STORE_ENV"
mediaType="IMAGE"
value={url}
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
/>
))}
</div>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 12 }}
onClick={() => setEnvPhotoUrls((prev) => addEnvPhotoSlot(prev))}
>
</button>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 16 }}
disabled={mediaSaving}
onClick={() => void saveMedia()}
>
<span className="material-symbols-outlined" style={{ fontSize: 18, verticalAlign: 'middle', marginRight: 4 }}>
upload
</span>
{mediaSaving ? '上传中…' : '重新上传照片'}
</button>
</>
) : envPhotos.length > 0 ? (
<div className="partner-photo-grid">
{envPhotos.map((url, index) => (
<div key={`${url}-${index}`} className="partner-cover" style={{ aspectRatio: '1', marginBottom: 0 }}>
<AppImage src={url || null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
</div>
))}
</div>
) : (
<p className="label-md text-muted"></p>
)}
</section>
<section className="partner-form-card" style={{ margin: 0, background: 'var(--color-surface-container)' }}>
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-subtle-gray)', paddingLeft: 12, marginBottom: 16 }}></h3>
<div style={{ marginBottom: 12 }}>
<label className="label-md text-muted"></label>
<p className="body-md" style={{ fontWeight: 500 }}>{storeStatusLabel(status)}</p>
</div>
<div>
<label className="label-md text-muted"></label>
<p className="body-md" style={{ fontWeight: 500 }}>{storeAuditLabel(auditStatus)}</p>
</div>
</section>
</main>
<footer className="partner-save-footer">
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button>
{canMutate && (
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
<span className="material-symbols-outlined">save</span>
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
</button>
)}
</footer>
{closeConfirmOpen && (
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseConfirmOpen(false)}>
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
</p>
<div className="partner-ship-actions">
<button type="button" className="partner-btn-secondary" onClick={() => setCloseConfirmOpen(false)}>
</button>
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
</button>
</div>
</div>
</div>
)}
<TencentLocPickerOverlay
open={mapPickerOpen}
onClose={() => setMapPickerOpen(false)}
latitude={form.latitude ? Number(form.latitude) : undefined}
longitude={form.longitude ? Number(form.longitude) : undefined}
onPick={(loc) => {
setForm((prev) => ({
...prev,
latitude: String(loc.latitude),
longitude: String(loc.longitude),
...(loc.address && !prev.address.trim() ? { address: loc.address } : {}),
}));
toastSuccess(loc.name ? `已选定:${loc.name}` : '位置已选定');
}}
/>
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isLoggedIn, request } from '../lib/api';
import { toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
import {
canPartnerOpenStore,
storeAuditLabel,
storeAuditPillClass,
storeStatusLabel,
storeStatusPillClass,
type StoreStatusValue,
} from '../lib/storeStatus';
import { usePartnerPageView } from '../lib/usePageView';
type StatusFilter = 'ALL' | StoreStatusValue | 'PENDING_AUDIT' | 'REJECTED';
const FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'ALL', label: '全部' },
{ key: 'OPEN', label: '营业中' },
{ key: 'PAUSED', label: '暂时闭店' },
{ key: 'PENDING_AUDIT', label: '待审核' },
{ key: 'REJECTED', label: '已驳回' },
{ key: 'CLOSED', label: '关闭' },
];
export default function StoreListPage() {
usePartnerPageView('partner_store_list_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { account } = usePartnerSession();
const canMutate = canManagePartnerStore(account);
const canCreate = canCreatePartnerStore(account);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [q, setQ] = useState('');
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('');
const [closeTarget, setCloseTarget] = useState<string | null>(null);
const loadStores = useCallback(() => {
return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
}, []);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
void loadStores();
}, [navigate, loadStores]);
useEffect(() => {
document.title = canMutate ? '门店管理' : '我的门店';
}, [canMutate]);
const filtered = useMemo(() => stores.filter((s) => {
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
const status = String(s.status).toUpperCase();
let matchStatus = true;
if (filter === 'PENDING_AUDIT') matchStatus = audit === 'PENDING';
else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED';
else if (filter !== 'ALL') matchStatus = status === filter;
return matchQ && matchStatus;
}), [stores, q, filter]);
async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) {
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
setError(auditStatus === 'REJECTED' ? '门店审核未通过,请查看驳回原因并修改后重新提交' : '门店尚在总部审核中,通过后方可开门');
return;
}
if (next === 'CLOSED') {
setCloseTarget(storeId);
return;
}
setUpdatingId(storeId);
setError('');
try {
await request('PARTNER_H5', `/partner/stores/${storeId}/status`, {
method: 'PUT',
body: JSON.stringify({ status: next }),
});
await loadStores();
if (next === 'OPEN') toastSuccess('开店成功');
} catch {
/* request 已 toast */
} finally {
setUpdatingId(null);
}
}
async function confirmCloseStore() {
if (!closeTarget) return;
const storeId = closeTarget;
setCloseTarget(null);
setUpdatingId(storeId);
setError('');
try {
await request('PARTNER_H5', `/partner/stores/${storeId}/status`, {
method: 'PUT',
body: JSON.stringify({ status: 'CLOSED' }),
});
await loadStores();
} catch {
/* request 已 toast */
} finally {
setUpdatingId(null);
}
}
return (
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
<div className="partner-sticky-filter">
<div className="partner-search">
<span className="material-symbols-outlined">search</span>
<input placeholder="搜索门店名称/地址" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="partner-chips">
{FILTERS.map((f) => (
<button key={f.key} type="button" className={`partner-chip${filter === f.key ? ' active' : ''}`} onClick={() => setFilter(f.key)}>
{f.label}
</button>
))}
</div>
</div>
{canCreate && (
<Link to="/stores/new" className="partner-fab-link">
<button type="button" className="partner-btn-primary">
<span className="material-symbols-outlined">add_business</span>
</button>
</Link>
)}
{filtered.length === 0 && <div className="empty"></div>}
{filtered.map((s) => {
const storeId = String(s.id);
const currentStatus = String(s.status).toUpperCase() as StoreStatusValue;
const auditStatus = String(s.auditStatus || 'APPROVED').toUpperCase();
const dim = currentStatus === 'CLOSED';
const storeName = String(s.name || '未命名门店');
const busy = updatingId === storeId;
const canOpen = canPartnerOpenStore(auditStatus);
return (
<div key={storeId} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
<Link to={`/stores/${storeId}`} className="partner-store-card-hit" style={{ color: 'inherit', textDecoration: 'none' }}>
<div className="partner-store-card-header">
<div>
<p className="label-md text-muted" style={{ marginBottom: 2 }}></p>
<h3 className="headline-md">{storeName}</h3>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p>
{auditStatus !== 'APPROVED' && (
<p className="label-md" style={{ marginTop: 8, color: auditStatus === 'REJECTED' ? 'var(--color-heritage-red)' : 'var(--color-secondary)' }}>
{storeAuditLabel(auditStatus)}
{auditStatus === 'REJECTED' && s.rejectReason ? `${String(s.rejectReason)}` : ''}
</p>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
{auditStatus !== 'APPROVED' ? (
<span className={`partner-status-pill ${storeAuditPillClass(auditStatus)}`}>
{storeAuditLabel(auditStatus)}
</span>
) : (
<span className={`partner-status-pill ${storeStatusPillClass(currentStatus)}`}>
{storeStatusLabel(currentStatus)}
</span>
)}
</div>
</div>
</Link>
{canMutate && (
<div className="partner-store-card-actions">
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px' }}
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED' || auditStatus === 'PENDING'}
onClick={() => void updateStatus(storeId, 'PAUSED', auditStatus)}
>
</button>
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px', borderColor: 'var(--color-subtle-gray)', color: 'var(--color-subtle-gray)' }}
disabled={busy || currentStatus === 'CLOSED'}
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
>
</button>
{currentStatus === 'PAUSED' && (
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px' }}
disabled={busy || !canOpen}
onClick={() => void updateStatus(storeId, 'OPEN', auditStatus)}
>
{canOpen ? '开门营业' : '待审核通过'}
</button>
)}
<Link to={`/stores/${storeId}`} className="partner-menu-icon" style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
</Link>
</div>
)}
</div>
);
})}
{closeTarget && (
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseTarget(null)}>
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
</p>
<div className="partner-ship-actions">
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
</button>
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
</button>
</div>
</div>
</div>
)}
</PullToRefresh>
);
}
@@ -0,0 +1,143 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import type { StorePackagesResponse } from '@dukang/shared-types';
import StorePackagesForm from '../components/StorePackagesForm';
import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
import {
emptyPackage,
normalizePackageFormItems,
validatePackageFormItems,
type PackageFormItem,
} from '../lib/storePackages';
export default function StorePackagesPage() {
const { id } = useParams();
const navigate = useNavigate();
const [items, setItems] = useState<PackageFormItem[]>([emptyPackage()]);
const [pending, setPending] = useState<StorePackagesResponse['pendingRequest']>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!id) return;
setLoading(true);
request<StorePackagesResponse>('PARTNER_H5', `/partner/stores/${id}/packages`)
.then((data) => {
const base = data.pendingRequest?.packages?.length
? data.pendingRequest.packages
: data.live?.length
? data.live
: [emptyPackage()];
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
setPending(data.pendingRequest ?? null);
})
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, [id]);
async function submitAudit() {
if (!id) return;
const msg = validatePackageFormItems(items);
if (msg) {
toastError(msg);
return;
}
if (pending?.status === 'PENDING') {
toastError('已有套餐变更审核中,请等待总部处理');
return;
}
setSubmitting(true);
try {
const packages = normalizePackageFormItems(items).map((p) => ({
...p,
price: Number(p.price).toFixed(2),
}));
await request('PARTNER_H5', `/partner/stores/${id}/packages`, {
method: 'PUT',
body: JSON.stringify({ packages }),
});
toastSuccess('已提交审核');
navigate(`/stores/${id}`);
} catch (e) {
toastError(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false);
}
}
if (loading) {
return (
<div className="partner-detail-page partner-home--flush-top">
<main style={{ padding: '24px 20px' }}>
<p className="label-md text-muted"></p>
</main>
</div>
);
}
return (
<div className="partner-page-sticky partner-home--flush-top">
<header className="partner-packages-page-header">
<button type="button" className="partner-packages-back" onClick={() => navigate(`/stores/${id}`)}>
<span className="material-symbols-outlined">arrow_back</span>
</button>
<h1 className="headline-md"></h1>
</header>
<main className="partner-packages-page-main">
{error ? <p className="partner-form-error" role="alert">{error}</p> : null}
{pending?.status === 'PENDING' ? (
<div className="partner-info-banner">
<span className="material-symbols-outlined" style={{ color: 'var(--color-heritage-red)' }}>hourglass_top</span>
<p className="body-md">C </p>
</div>
) : null}
{pending?.status === 'REJECTED' && pending.rejectReason ? (
<div className="partner-info-banner" style={{ background: 'rgba(180,35,24,0.06)', borderColor: 'rgba(180,35,24,0.15)' }}>
<span className="material-symbols-outlined" style={{ color: 'var(--color-heritage-red)' }}>error</span>
<p className="body-md">{pending.rejectReason}</p>
</div>
) : null}
<section className="partner-form-card">
<div className="partner-section-title">
<div className="partner-section-bar" />
<h2 className="headline-md"></h2>
</div>
<p className="label-md text-muted" style={{ marginBottom: 16 }}>
C
</p>
<StorePackagesForm
items={items}
onChange={setItems}
disabled={pending?.status === 'PENDING' || submitting}
embedded
/>
</section>
</main>
<footer className="partner-sticky-footer">
<button
type="button"
className="partner-btn-outline"
onClick={() => navigate(`/stores/${id}`)}
disabled={submitting}
>
</button>
<button
type="button"
className="partner-btn-primary"
disabled={submitting || pending?.status === 'PENDING'}
onClick={() => void submitAudit()}
>
{submitting ? '提交中…' : '提交审核'}
</button>
</footer>
</div>
);
}
@@ -0,0 +1,223 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
import { usePartnerPageView } from '../lib/usePageView';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
function fmtGrowth(n: number) {
const sign = n > 0 ? '+' : '';
return `${sign}${n}%`;
}
export default function WeeklyReportPage() {
usePartnerPageView('partner_weekly_report_view');
const navigate = useNavigate();
const [selectedStart, setSelectedStart] = useState<string | undefined>();
const [data, setData] = useState<PartnerWeeklyReportResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const loadWeeklyReport = useCallback(() => {
setLoading(true);
setError('');
return fetchPartnerWeeklyReport(selectedStart)
.then(setData)
.catch((e) => {
setData(null);
setError(e instanceof Error ? e.message : '加载失败');
})
.finally(() => setLoading(false));
}, [selectedStart]);
useEffect(() => {
void loadWeeklyReport();
}, [loadWeeklyReport]);
const maxDailyGmv = useMemo(() => {
if (!data?.dailyGmv.length) return 1;
return Math.max(1, ...data.dailyGmv.map((item) => item.amount));
}, [data]);
const topStores = data?.storeRanking.slice(0, 3) ?? [];
const summary = data?.summary;
const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0;
return (
<PullToRefresh onRefresh={loadWeeklyReport} className="page-no-tab">
<PageHeader title="数据周报" onBack={() => navigate('/')} />
<main className="partner-weekly-page">
<section className="partner-weekly-title-section">
<h2></h2>
{data && (
<div className="partner-weekly-tabs">
{data.availablePeriods.map((period) => (
<button
key={period.startDate}
type="button"
className={`partner-weekly-tab${
period.startDate === data.period.startDate ? ' active' : ''
}`}
onClick={() => setSelectedStart(period.startDate)}
>
{period.label}
</button>
))}
</div>
)}
</section>
{loading && <p className="label-md text-muted partner-weekly-state"></p>}
{!loading && error && (
<p className="label-md text-error partner-weekly-state">{error}</p>
)}
{!loading && !error && data && summary && (
<>
<section className="partner-weekly-hero">
<div className="partner-weekly-hero-top">
<div>
<p className="partner-weekly-hero-label"> (GMV)</p>
<p className="partner-weekly-hero-gmv">¥{fmtMoney(summary.gmv)}</p>
</div>
<div
className={`partner-weekly-growth-badge${
growthPositive ? ' partner-weekly-growth-badge--up' : ' partner-weekly-growth-badge--down'
}`}
>
<span className="material-symbols-outlined">
{growthPositive ? 'trending_up' : 'trending_down'}
</span>
<span>{fmtGrowth(summary.gmvGrowthPercent)}</span>
</div>
</div>
<div className="partner-weekly-hero-grid">
<div>
<p className="label-md text-muted"></p>
<p className="partner-weekly-stat-value">
{summary.activeStoreCount}
<span className="partner-weekly-stat-sub">/ {summary.totalStoreCount}</span>
</p>
</div>
<div>
<p className="label-md text-muted"></p>
<p className="partner-weekly-stat-value">{summary.orderCount}</p>
</div>
</div>
</section>
<section className="partner-weekly-card">
<div className="partner-weekly-card-header">
<div className="partner-weekly-card-title">
<span className="material-symbols-outlined partner-weekly-icon-amber">storefront</span>
<h3 className="headline-md"></h3>
</div>
<p className="partner-weekly-target">
{summary.newStoreCount}
<span className="partner-weekly-target-sub">/ {summary.newStoreTarget}</span>
</p>
</div>
<div className="partner-weekly-progress-track">
<div
className="partner-weekly-progress-bar"
style={{ width: `${summary.newStoreProgressPercent}%` }}
/>
</div>
<p className="label-md text-muted partner-weekly-progress-hint">
{summary.newStoreProgressPercent}%
{summary.newStoreCount < summary.newStoreTarget
? `还差 ${summary.newStoreTarget - summary.newStoreCount} 家!`
: '目标已达成!'}
</p>
</section>
<section className="partner-weekly-card">
<div className="partner-weekly-card-header">
<h3 className="headline-md"></h3>
<span className="label-md text-muted"> 7 </span>
</div>
<div className="partner-weekly-chart">
<div className="partner-weekly-chart-bars">
{data.dailyGmv.map((item) => {
const height = Math.max(8, Math.round((item.amount / maxDailyGmv) * 100));
const isMax = item.amount === maxDailyGmv && item.amount > 0;
return (
<div
key={item.date}
className={`partner-weekly-chart-bar${isMax ? ' partner-weekly-chart-bar--peak' : ''}`}
style={{ height: `${height}%` }}
title={`${item.weekdayLabel} ¥${fmtMoney(item.amount)}`}
/>
);
})}
</div>
<div className="partner-weekly-chart-labels">
{data.dailyGmv.map((item) => (
<span key={item.date}>{item.weekdayLabel}</span>
))}
</div>
</div>
</section>
<section className="partner-weekly-rank-section">
<div className="partner-weekly-card-header">
<h3 className="headline-md"></h3>
<button
type="button"
className="partner-weekly-link"
onClick={() => navigate('/stores')}
>
<span className="material-symbols-outlined">arrow_forward</span>
</button>
</div>
{topStores.length === 0 ? (
<p className="label-md text-muted partner-weekly-empty"></p>
) : (
<div className="partner-weekly-rank-list">
{topStores.map((store) => (
<div
key={store.storeId}
className={`partner-weekly-rank-row${
store.rank === 1 ? ' partner-weekly-rank-row--first' : ''
}`}
>
<div className="partner-weekly-rank-badge">
{store.rank === 1 ? (
<span className="material-symbols-outlined">workspace_premium</span>
) : (
<span>{store.rank}</span>
)}
</div>
<div className="partner-weekly-rank-info">
<p className="partner-weekly-rank-name">{store.name}</p>
{store.subtitle && (
<p className="label-md text-muted">{store.subtitle}</p>
)}
</div>
<p className="partner-weekly-rank-amount">¥{fmtMoney(store.redeemAmount)}</p>
</div>
))}
</div>
)}
</section>
<section className="partner-weekly-insight">
<span className="material-symbols-outlined">tips_and_updates</span>
<div>
<p className="headline-md"></p>
<p className="body-md text-muted">{data.insight}</p>
</div>
</section>
</>
)}
</main>
</PullToRefresh>
);
}