89fd333702
Add client-logging SDK, expanded event taxonomy, API observability, admin domain events UI, and move SENTRY_DSN to HQ system settings with @sentry/node bootstrap after config preload. Co-authored-by: Cursor <cursoragent@cursor.com>
372 lines
15 KiB
TypeScript
372 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|