合伙人 H5 改造
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-07-16 15:04:56 +08:00
parent fd5427c7ea
commit f1dc23c54c
21 changed files with 817 additions and 614 deletions
+22 -8
View File
@@ -1,6 +1,6 @@
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import { usePartnerSession } from './contexts/PartnerSessionContext'; import { usePartnerSession } from './contexts/PartnerSessionContext';
import { isSubAccount } from './lib/partnerAccess'; import { getPartnerNavKind, isSubAccount } from './lib/partnerAccess';
import TabLayout from './layouts/TabLayout'; import TabLayout from './layouts/TabLayout';
import SubAccountLayout from './layouts/SubAccountLayout'; import SubAccountLayout from './layouts/SubAccountLayout';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
@@ -26,7 +26,6 @@ function PrimaryRoutes() {
<Route element={<TabLayout />}> <Route element={<TabLayout />}>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/stores" element={<StoreListPage />} /> <Route path="/stores" element={<StoreListPage />} />
<Route path="/orders" element={<OrderListPage />} />
<Route path="/center" element={<CenterPage />} /> <Route path="/center" element={<CenterPage />} />
</Route> </Route>
<Route path="/stores/new" element={<StoreCreatePage />} /> <Route path="/stores/new" element={<StoreCreatePage />} />
@@ -39,6 +38,7 @@ function PrimaryRoutes() {
<Route path="/reports/weekly" element={<WeeklyReportPage />} /> <Route path="/reports/weekly" element={<WeeklyReportPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} /> <Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} /> <Route path="/stores/:id" element={<StoreDetailPage />} />
<Route path="/orders" element={<OrderListPage />} />
<Route path="/orders/:id" element={<OrderDetailPage />} /> <Route path="/orders/:id" element={<OrderDetailPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
@@ -46,16 +46,30 @@ function PrimaryRoutes() {
} }
function SubAccountRoutes() { function SubAccountRoutes() {
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
const isWarehouse = navKind === 'warehouse_staff';
return ( return (
<Routes> <Routes>
<Route element={<SubAccountLayout />}> <Route element={<SubAccountLayout navKind={navKind} />}>
<Route path="/stores" element={<StoreListPage />} /> <Route path="/" element={<HomePage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} /> {isWarehouse ? (
<Route path="/orders" element={<OrderListPage tabRoot />} />
) : (
<Route path="/stores" element={<StoreListPage />} />
)}
<Route path="/me" element={<PartnerMePage />} /> <Route path="/me" element={<PartnerMePage />} />
</Route> </Route>
{/* 与主账号一致:录入门店全屏,避免 sticky 底栏被 TabBar 挡住 */} {!isWarehouse && (
<Route path="/stores/new" element={<StoreCreatePage />} /> <>
<Route path="*" element={<Navigate to="/stores/new?step=1" replace />} /> <Route path="/stores/new" element={<StoreCreatePage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
</>
)}
{isWarehouse && <Route path="/orders/:id" element={<OrderDetailPage />} />}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
); );
} }
@@ -50,6 +50,8 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
staffRole: profile.staffRole, staffRole: profile.staffRole,
permissions: profile.permissions, permissions: profile.permissions,
primaryAccountId: profile.primaryAccountId, primaryAccountId: profile.primaryAccountId,
primaryPhone: profile.primaryPhone,
primaryName: profile.primaryName,
hasWechat: profile.hasWechat, hasWechat: profile.hasWechat,
}; };
} }
@@ -1,17 +1,30 @@
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet } from 'react-router-dom';
import type { PartnerNavKind } from '../lib/partnerAccess';
const TABS = [ const STORE_STAFF_TABS = [
{ to: '/stores', end: true, icon: 'store', label: '门店管理' }, { to: '/', end: true, icon: 'dashboard', label: '首页' },
{ to: '/stores/new', icon: 'add_business', label: '录入门店' }, { to: '/stores', icon: 'store', label: '门店管理' },
{ to: '/me', icon: 'person', label: '我的' }, { to: '/me', icon: 'person', label: '个人中心' },
] as const; ] as const;
export default function SubAccountLayout() { const WAREHOUSE_STAFF_TABS = [
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
{ to: '/orders', icon: 'receipt_long', label: '订单管理' },
{ to: '/me', icon: 'person', label: '个人中心' },
] as const;
type SubAccountLayoutProps = {
navKind: PartnerNavKind;
};
export default function SubAccountLayout({ navKind }: SubAccountLayoutProps) {
const tabs = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_TABS : STORE_STAFF_TABS;
return ( return (
<> <>
<Outlet /> <Outlet />
<nav className="app-tabbar"> <nav className="app-tabbar">
{TABS.map((tab) => ( {tabs.map((tab) => (
<NavLink <NavLink
key={tab.to} key={tab.to}
to={tab.to} to={tab.to}
+3 -1
View File
@@ -24,7 +24,7 @@ const AUTH_RECOVERY_EXEMPT_PATHS = [
export type PartnerSessionProfile = Pick< export type PartnerSessionProfile = Pick<
PartnerMe, PartnerMe,
'id' | 'name' | 'phone' | 'companyName' | 'isPrimary' | 'permissions' | 'primaryAccountId' | 'hasWechat' 'id' | 'name' | 'phone' | 'companyName' | 'isPrimary' | 'permissions' | 'primaryAccountId' | 'primaryPhone' | 'primaryName' | 'hasWechat'
> & { > & {
staffRole?: PartnerStaffRole; staffRole?: PartnerStaffRole;
}; };
@@ -109,6 +109,8 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile {
staffRole: me.staffRole ?? undefined, staffRole: me.staffRole ?? undefined,
permissions: me.permissions, permissions: me.permissions,
primaryAccountId: me.primaryAccountId, primaryAccountId: me.primaryAccountId,
primaryPhone: me.primaryPhone,
primaryName: me.primaryName,
hasWechat: me.hasWechat, hasWechat: me.hasWechat,
}; };
} }
+17
View File
@@ -0,0 +1,17 @@
/** 合伙人端客服热线(可后续改为环境变量) */
export const PARTNER_SUPPORT_PHONE = '400-000-0000';
export function dialPhone(phone: string) {
const normalized = phone.replace(/\s+/g, '');
if (!normalized) return false;
window.location.href = `tel:${normalized}`;
return true;
}
export function contactSupport() {
return dialPhone(PARTNER_SUPPORT_PHONE);
}
export function contactPartnerPhone(phone?: string) {
return dialPhone(phone ?? '');
}
+25 -5
View File
@@ -1,5 +1,7 @@
import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types'; import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types';
export type PartnerNavKind = 'primary' | 'store_staff' | 'warehouse_staff';
export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean { export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean {
return account?.isPrimary !== false; return account?.isPrimary !== false;
} }
@@ -17,15 +19,33 @@ export function hasPartnerPermission(
return account.permissions?.includes(permission) ?? false; return account.permissions?.includes(permission) ?? false;
} }
export function partnerHomePath(account: PartnerMe | null | undefined): string { /** 仓库管理员:warehouse:manage,或仅有 order:view(无门店权限) */
return isSubAccount(account) ? '/stores/new?step=1' : '/'; export function isWarehouseStaff(account: PartnerMe | null | undefined): boolean {
if (!account || isPrimaryAccount(account)) return false;
if (hasPartnerPermission(account, 'warehouse:manage')) return true;
const hasStore =
hasPartnerPermission(account, 'store:create') || hasPartnerPermission(account, 'store:manage');
if (hasPartnerPermission(account, 'order:view') && !hasStore) return true;
return false;
} }
export const SUB_ACCOUNT_ALLOWED_PREFIXES = ['/stores', '/me', '/login']; export function getPartnerNavKind(account: PartnerMe | null | undefined): PartnerNavKind {
if (!account || isPrimaryAccount(account)) return 'primary';
if (isWarehouseStaff(account)) return 'warehouse_staff';
return 'store_staff';
}
export function isSubAccountPath(pathname: string): boolean { export function partnerHomePath(account: PartnerMe | null | undefined): string {
return '/';
}
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/login'];
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/login'];
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
if (pathname === '/login') return true; if (pathname === '/login') return true;
return SUB_ACCOUNT_ALLOWED_PREFIXES.some( const prefixes = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_PREFIXES : STORE_STAFF_PREFIXES;
return prefixes.some(
(prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)), (prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)),
); );
} }
+98 -113
View File
@@ -1,147 +1,132 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api'; import { request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { contactSupport } from '../lib/contact';
import { isPrimaryAccount } from '../lib/partnerAccess'; import { isPrimaryAccount } from '../lib/partnerAccess';
import { toastError } from '../lib/toast';
export default function CenterPage() { type CenterPageProps = {
const navigate = useNavigate(); /** 子账号个人中心复用 */
const { account, logout } = usePartnerSession(); variant?: 'primary' | 'sub';
const showPrimaryMenus = isPrimaryAccount(account); roleLabel?: string;
const me = account as unknown as Record<string, unknown> | null; };
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) {
const { account, logout, refresh } = usePartnerSession();
const isPrimary = variant === 'primary' && isPrimaryAccount(account);
const [name, setName] = useState(account?.name ?? '');
const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } document.title = isPrimary ? '合伙人中心' : '个人中心';
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills); }, [isPrimary]);
}, [navigate]);
const pendingBills = bills.filter((b) => String(b.status).includes('PENDING') || String(b.status).includes('CONFIRM')); useEffect(() => {
setName(account?.name ?? '');
}, [account?.name]);
async function handleSave() {
const trimmed = name.trim();
if (!trimmed) {
toastError('请输入姓名');
return;
}
if (trimmed === account?.name) return;
setSaving(true);
try {
await request('PARTNER_H5', '/partner/me', {
method: 'PUT',
body: JSON.stringify({ name: trimmed }),
});
await refresh();
} finally {
setSaving(false);
}
}
function handleContactPartner() {
if (isPrimary) {
if (!contactSupport()) toastError('暂无客服电话');
return;
}
if (!account?.primaryPhone) {
toastError('暂无合伙人联系方式');
return;
}
window.location.href = `tel:${account.primaryPhone}`;
}
return ( return (
<div className="page partner-center-page"> <div className="page partner-center-page partner-home--flush-top">
<header className="header app-page-header">
<h1 className="app-page-title"></h1>
</header>
<section className="partner-profile-card"> <section className="partner-profile-card">
<div className="partner-profile-avatar"> <div className="partner-profile-avatar">
<span className="material-symbols-outlined">person</span> <span className="material-symbols-outlined">person</span>
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(me?.name || '合伙人')}</h2> <h2 className="headline-lg" style={{ fontSize: 20 }}>{account?.name || '合伙人'}</h2>
<span className="partner-role-badge"></span> <span className="partner-role-badge">{roleLabel || (isPrimary ? '城市合伙人' : '拓店员')}</span>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, color: 'var(--color-subtle-gray)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, color: 'var(--color-subtle-gray)' }}>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span> <span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span>
<span className="body-md">{String(me?.companyName || '郑州')}</span> <span className="body-md">{account?.companyName || '—'}</span>
</div>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(me?.phone || '')}</p>
</div>
</section>
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 20px 8px' }}>
<h3 className="headline-md"></h3>
<Link to="/center/bills" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center' }}>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
</Link>
</div>
<Link to="/center/bills" className="partner-bills-banner">
<div className="partner-bills-banner-left">
<div className="partner-bills-icon">
<span className="material-symbols-outlined">pending_actions</span>
</div>
<div>
<p className="body-md" style={{ fontWeight: 500 }}></p>
<p className="label-md text-primary"> {pendingBills.length || bills.length} </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="label-md" style={{ opacity: 0.8, marginBottom: 4 }}> ()</p>
<span className="amount-xl" style={{ color: '#fff', fontSize: 32 }}>
{bills.length > 0 ? Number(bills[0].totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) : '0.00'}
</span>
</div>
<div className="partner-finance-card">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-md">
<span className="text-primary">¥</span>
{pendingBills.reduce((s, b) => s + Number(b.totalAmount || 0), 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</p>
</div>
<div className="partner-finance-card">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span className="headline-md">{bills.length} </span>
<span className="material-symbols-outlined text-muted">history</span>
</div>
</div> </div>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{account?.phone || ''}</p>
</div>
</section>
<section className="partner-menu-section">
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<div className="partner-menu-card" style={{ padding: '16px' }}>
<label className="partner-form-label" htmlFor="center-name"></label>
<input
id="center-name"
className="partner-form-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="请输入姓名"
maxLength={32}
/>
<label className="partner-form-label" style={{ marginTop: 16 }}></label>
<input className="partner-form-input" value={account?.phone ?? ''} readOnly disabled />
<p className="label-md text-muted" style={{ marginTop: 4 }}></p>
<button
type="button"
className="partner-btn-primary"
style={{ width: '100%', marginTop: 16 }}
disabled={saving}
onClick={() => void handleSave()}
>
{saving ? '保存中…' : '保存资料'}
</button>
</div> </div>
</section> </section>
<section className="partner-menu-section"> <section className="partner-menu-section">
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<div className="partner-menu-card"> <div className="partner-menu-card">
<Link to="/stores" className="partner-menu-item"> <button type="button" className="partner-menu-item" onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}>
<div className="partner-menu-item-left"> <div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">store</span></div> <div className="partner-menu-icon"><span className="material-symbols-outlined">support_agent</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span> <span className="body-md" style={{ fontSize: 16 }}></span>
</div> </div>
<span className="material-symbols-outlined text-muted">chevron_right</span> <span className="material-symbols-outlined text-muted">chevron_right</span>
</Link> </button>
<Link to="/orders" className="partner-menu-item"> <button type="button" className="partner-menu-item" onClick={handleContactPartner}>
<div className="partner-menu-item-left"> <div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">receipt_long</span></div> <div className="partner-menu-icon"><span className="material-symbols-outlined">call</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span> <span className="body-md" style={{ fontSize: 16 }}>{isPrimary ? '联系总部' : '联系合伙人'}</span>
</div> </div>
<span className="material-symbols-outlined text-muted">chevron_right</span> <span className="material-symbols-outlined text-muted">chevron_right</span>
</Link> </button>
<Link to="/center/settlement" className="partner-menu-item"> {isPrimary && (
<div className="partner-menu-item-left"> <Link to="/center/staff" className="partner-menu-item">
<div className="partner-menu-icon"><span className="material-symbols-outlined">description</span></div> <div className="partner-menu-item-left">
<span className="body-md" style={{ fontSize: 16 }}></span> <div className="partner-menu-icon"><span className="material-symbols-outlined">group</span></div>
</div> <span className="body-md" style={{ fontSize: 16 }}></span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> </div>
{pendingBills.length > 0 && <span className="label-md text-primary" style={{ background: 'rgba(166,29,36,0.1)', padding: '2px 8px', borderRadius: 999 }}>{pendingBills.length}</span>}
<span className="material-symbols-outlined text-muted">chevron_right</span> <span className="material-symbols-outlined text-muted">chevron_right</span>
</div> </Link>
</Link>
<Link to="/reports/weekly" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">monitoring</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/reshipments" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">assignment_return</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/leaderboard" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">military_tech</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
{showPrimaryMenus && (
<Link to="/center/staff" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">group</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
)} )}
</div> </div>
</section> </section>
+320 -234
View File
@@ -1,46 +1,207 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import type { PartnerLeaderboardEntry } from '@dukang/shared-types'; import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { fetchPartnerLeaderboard } from '../lib/leaderboard'; import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import { getPartnerNavKind } from '../lib/partnerAccess';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { storeStatusLabel, storeStatusPillClass } from '../lib/storeStatus'; import { storeStatusLabel, storeStatusPillClass } from '../lib/storeStatus';
function fmtMoney(n: number) { function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
const HOME_STORE_PREVIEW_LIMIT = 6; const HOME_STORE_PREVIEW_LIMIT = 6;
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,
self,
}: {
entries: PartnerLeaderboardEntry[];
self?: PartnerLeaderboardEntry;
}) {
const selfInList = self ? entries.some((e) => e.accountId === self.accountId) : false;
return (
<div className="partner-leaderboard-section">
<div className="partner-leaderboard-header">
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 18 }}>military_tech</span>
</h3>
<Link to="/leaderboard" className="label-md text-primary"></Link>
</div>
{entries.length === 0 && !self ? (
<p className="label-md text-muted"></p>
) : (
<>
{entries.map((entry) => (
<div
key={entry.accountId}
className={`partner-leaderboard-row partner-leaderboard-row--compact${entry.isSelf ? ' partner-leaderboard-row--self' : ''}`}
>
<div className="partner-leaderboard-rank">{entry.rank}</div>
<div className="partner-leaderboard-info">
<p className="body-md" style={{ fontWeight: 600 }}>
{entry.name}
{entry.isSelf ? <span className="partner-leaderboard-self-tag"></span> : null}
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
</p>
<p className="label-md text-muted"> {entry.totalStores} </p>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted"></p>
</div>
</div>
))}
{self && !selfInList ? (
<div className="partner-leaderboard-row partner-leaderboard-row--compact partner-leaderboard-row--self">
<div className="partner-leaderboard-rank">{self.rank}</div>
<div className="partner-leaderboard-info">
<p className="body-md" style={{ fontWeight: 600 }}>
{self.name}
<span className="partner-leaderboard-self-tag"></span>
</p>
<p className="label-md text-muted"> {self.totalStores} </p>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{self.periodStores} </p>
<p className="label-md text-muted"></p>
</div>
</div>
) : null}
</>
)}
</div>
);
}
export default function HomePage() { export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
const isPrimary = navKind === 'primary';
const isWarehouse = navKind === 'warehouse_staff';
const [dash, setDash] = useState<Record<string, unknown> | null>(null); const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]); const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [leaderboardPreview, setLeaderboardPreview] = useState<PartnerLeaderboardEntry[]>([]); const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
const [notifOpen, setNotifOpen] = useState(false); const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
const [leaderboardSelf, setLeaderboardSelf] = useState<PartnerLeaderboardEntry | undefined>();
useEffect(() => {
document.title = '工作台';
}, []);
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } if (!isLoggedIn()) { navigate('/login'); return; }
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash); if (isWarehouse || isPrimary) {
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores); request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders')
fetchPartnerLeaderboard('month') .then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
.then((data) => setLeaderboardPreview(data.list.slice(0, 2))) .catch(() => setOrders([]));
.catch(() => setLeaderboardPreview([])); }
}, [navigate]); if (!isWarehouse) {
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {});
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores).catch(() => []);
fetchPartnerLeaderboard('month')
.then((data) => {
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
setLeaderboardSelf(data.self);
})
.catch(() => {
setLeaderboardEntries([]);
setLeaderboardSelf(undefined);
});
} else {
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {});
}
}, [navigate, isWarehouse, isPrimary]);
const storeCount = Number(dash?.storeCount || stores.length || 0); const storeCount = Number(dash?.storeCount || stores.length || 0);
const orderCount = Number(dash?.orderCount || 0); const orderStats = useMemo(() => summarizeOrders(orders), [orders]);
const pendingAuditCount = Number(dash?.pendingAuditCount || 0); const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
const notifications = Array.isArray(dash?.notifications)
? (dash.notifications as Array<Record<string, unknown>>)
: [];
const hasUnread = notifications.length > 0;
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length; const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
const abnormalStores = Math.max(0, storeCount - activeStores); const abnormalStores = Math.max(0, storeCount - activeStores);
const revenue = orderCount * 128.45;
const profit = revenue * 0.25;
const pendingShip = Math.ceil(orderCount * 0.04);
const shipping = Math.ceil(orderCount * 0.12);
const completed = Math.max(0, orderCount - pendingShip - shipping);
const monthNew = stores.filter((s) => { const monthNew = stores.filter((s) => {
const created = new Date(String(s.createdAt || '')); const created = new Date(String(s.createdAt || ''));
const now = new Date(); const now = new Date();
@@ -48,232 +209,157 @@ export default function HomePage() {
}).length; }).length;
const previewStores = stores.slice(0, HOME_STORE_PREVIEW_LIMIT); const previewStores = stores.slice(0, HOME_STORE_PREVIEW_LIMIT);
const orderCount = Number(dash?.orderCount || orderStats.todayCount || 0);
const revenue = orderCount * 128.45;
const profit = revenue * 0.25;
const pendingShipBadge = orderStats.pendingShip;
return ( return (
<div className="page partner-home"> <div className="page partner-home partner-home--flush-top">
<header className="partner-home-header">
<h1 className="app-page-title"></h1>
<div className="partner-home-header-actions">
<button
type="button"
className="partner-notif-btn"
aria-label="通知"
onClick={() => setNotifOpen((v) => !v)}
>
<span className="material-symbols-outlined">notifications</span>
{hasUnread && <span className="partner-notif-dot" />}
</button>
<div className="partner-profile-avatar" style={{ width: 40, height: 40 }}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>person</span>
</div>
</div>
</header>
{notifOpen && (
<section className="partner-form-card" style={{ margin: '0 16px 12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h2 className="headline-md"></h2>
<button type="button" className="label-md text-muted" onClick={() => setNotifOpen(false)}></button>
</div>
{notifications.length === 0 ? (
<p className="label-md text-muted"></p>
) : (
notifications.slice(0, 8).map((n) => (
<Link
key={String(n.id)}
to={`/stores/${n.storeId}`}
className="partner-home-store-row"
style={{ marginBottom: 8 }}
onClick={() => setNotifOpen(false)}
>
<div className="partner-home-store-info">
<p className="body-md" style={{ fontWeight: 600 }}>{String(n.title || '门店审核')}</p>
<p className="label-md text-muted">{String(n.content || '')}</p>
</div>
</Link>
))
)}
</section>
)}
<main className="partner-home-body"> <main className="partner-home-body">
<section className="partner-revenue-card"> {isPrimary && (
<p className="partner-revenue-label"> <>
(CNY) <section className="partner-revenue-card">
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span> <p className="partner-revenue-label">
</p> (CNY)
<div className="partner-revenue-amount">{fmtMoney(revenue)}</div> <span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
<div className="partner-revenue-grid"> </p>
<div> <div className="partner-revenue-amount">{fmtMoney(revenue)}</div>
<p className="partner-revenue-label"></p> <div className="partner-revenue-grid">
<p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
</div>
</div>
</section>
<div className="partner-bento">
<section className="partner-bento-card">
<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> <div>
<p className="label-md text-muted"></p> <p className="partner-revenue-label"></p>
<p className="headline-md">{activeStores}</p> <p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
</div> </div>
</div> </div>
<div className="partner-store-stat"> </section>
<span className="partner-dot partner-dot--red" /> <section className="partner-bento-card" style={{ marginBottom: 16 }}>
<div> <div className="partner-quick-actions">
<p className="label-md text-muted">/</p> <Link to="/proxy-order" className="partner-quick-action">
<p className="headline-md">{abnormalStores}</p> <div className="partner-quick-action-icon partner-quick-action-icon--red">
</div> <span className="material-symbols-outlined">shopping_cart_checkout</span>
</div>
</div>
</section>
<section className="partner-bento-card">
<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="/reshipments" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--amber">
<span className="material-symbols-outlined">assignment_return</span>
{pendingShip > 0 && <span className="partner-quick-badge-count">{pendingShip}</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>
</div>
<section>
<div className="partner-order-summary-header">
<h2 className="headline-md"> {orderCount}</h2>
<Link to="/orders" 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>
<section className="partner-home-store-section">
<div className="partner-home-store-header">
<h2 className="headline-md"></h2>
<Link to="/stores" className="label-md text-primary"></Link>
</div>
{previewStores.length === 0 ? (
<div className="partner-home-store-empty">
<p className="label-md text-muted"></p>
<Link to="/stores/new" className="label-md text-primary"></Link>
</div>
) : (
previewStores.map((s) => {
const storeId = String(s.id);
const status = String(s.status || 'PAUSED').toUpperCase();
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
return (
<Link key={storeId} to={`/stores/${storeId}`} className="partner-home-store-row">
<div className="partner-home-store-info">
<p className="headline-md">{String(s.name || '未命名门店')}</p>
<p className="label-md text-muted">{String(s.address || s.district || '')}</p>
</div> </div>
<span className={`partner-status-pill ${audit !== 'APPROVED' ? storeStatusPillClass(audit === 'REJECTED' ? 'CLOSED' : 'PAUSED') : storeStatusPillClass(status)}`}> <span className="partner-quick-action-label"></span>
{audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)}
</span>
</Link> </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>
</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 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</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>
<div className="partner-leaderboard-section">
<div className="partner-leaderboard-header">
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 18 }}>military_tech</span>
</h3>
<Link to="/leaderboard" className="label-md text-primary"></Link>
</div>
{leaderboardPreview.length === 0 ? (
<p className="label-md text-muted"></p>
) : (
leaderboardPreview.map((entry) => (
<div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
<div className="partner-leaderboard-rank">{entry.rank}</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>
<div className="partner-leaderboard-stat"> <span className="partner-quick-action-label"></span>
<p className="headline-md text-primary">{entry.periodStores} </p> </Link>
<p className="label-md text-muted"></p> <Link to="/reshipments" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--amber">
<span className="material-symbols-outlined">assignment_return</span>
{pendingShipBadge > 0 && <span className="partner-quick-badge-count">{pendingShipBadge}</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>
)}
{(isPrimary || isWarehouse) && (
<OrderSummarySection
title={isWarehouse ? '今日订单' : undefined}
todayCount={orderStats.todayCount}
pendingShip={orderStats.pendingShip}
shipping={orderStats.shipping}
completed={orderStats.completed}
/>
)}
{!isWarehouse && (
<>
<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> </div>
)) <div className="partner-store-stat">
)} <span className="partner-dot partner-dot--red" />
</div> <div>
<p className="label-md text-muted">/</p>
<p className="headline-md">{abnormalStores}</p>
</div>
</div>
</div>
</section>
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}> <section className="partner-home-store-section">
{String(dash?.companyName || '郑州合伙人')} · <div className="partner-home-store-header">
</p> <h2 className="headline-md"></h2>
</section> <Link to="/stores" className="label-md text-primary"></Link>
</div>
{previewStores.length === 0 ? (
<div className="partner-home-store-empty">
<p className="label-md text-muted"></p>
<Link to={isPrimary ? '/stores/new' : '/stores/new?step=1'} className="label-md text-primary"></Link>
</div>
) : (
previewStores.map((s) => {
const storeId = String(s.id);
const status = String(s.status || 'PAUSED').toUpperCase();
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
return (
<Link key={storeId} to={`/stores/${storeId}`} className="partner-home-store-row">
<div className="partner-home-store-info">
<p className="headline-md">{String(s.name || '未命名门店')}</p>
<p className="label-md text-muted">{String(s.address || s.district || '')}</p>
</div>
<span className={`partner-status-pill ${audit !== 'APPROVED' ? storeStatusPillClass(audit === 'REJECTED' ? 'CLOSED' : 'PAUSED') : storeStatusPillClass(status)}`}>
{audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)}
</span>
</Link>
);
})
)}
</section>
<section className="partner-expansion-card">
<h2 className="headline-md" style={{ marginBottom: 16 }}></h2>
<div className="partner-expansion-split">
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
</div>
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
</div>
</div>
<LeaderboardPreview entries={leaderboardEntries} self={leaderboardSelf} />
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
{String(dash?.companyName || '郑州合伙人')} ·
</p>
</section>
</>
)}
</main> </main>
</div> </div>
); );
+63 -50
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
const TIMELINE = [ const TIMELINE = [
{ key: 'confirm', label: '待确认' }, { key: 'confirm', label: '待确认' },
@@ -28,21 +29,55 @@ function statusBanner(status: string) {
return { title: status, desc: '', icon: 'receipt_long' }; 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() { export default function OrderDetailPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [order, setOrder] = useState<Record<string, unknown> | null>(null); const [order, setOrder] = useState<Record<string, unknown> | null>(null);
const [shipping, setShipping] = useState(false);
useEffect(() => { useEffect(() => {
if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder); document.title = '订单详情';
}, []);
useEffect(() => {
if (id) void request<Record<string, unknown>>('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
}, [id]); }, [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) { async function advance(status: string) {
await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, { if (!id) return;
method: 'POST', setShipping(true);
body: JSON.stringify({ targetStatus: status }), try {
}); await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, {
if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder); 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>; if (!order) return <div className="empty">...</div>;
@@ -50,6 +85,7 @@ export default function OrderDetailPage() {
const banner = statusBanner(String(order.status)); const banner = statusBanner(String(order.status));
const currentIdx = statusIndex(String(order.status)); const currentIdx = statusIndex(String(order.status));
const payAmount = Number(order.payAmount || 0); const payAmount = Number(order.payAmount || 0);
const showShip = canShip(String(order.status));
return ( return (
<div className="partner-order-detail"> <div className="partner-order-detail">
@@ -91,9 +127,6 @@ export default function OrderDetailPage() {
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-surface-container)' }}> <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> <span className="material-symbols-outlined text-muted" style={{ fontSize: 32 }}>liquor</span>
</div> </div>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(166,29,36,0.8)', textAlign: 'center', padding: '2px 0' }}>
<span className="label-md" style={{ color: '#fff', fontSize: 10 }}></span>
</div>
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<h3 className="headline-md">{String(order.productName || '杜康好酒')}</h3> <h3 className="headline-md">{String(order.productName || '杜康好酒')}</h3>
@@ -106,39 +139,6 @@ export default function OrderDetailPage() {
</div> </div>
</section> </section>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, margin: '0 20px' }}>
<div className="partner-detail-section" style={{ margin: 0, borderTop: '2px solid var(--color-heritage-red)' }}>
<p className="label-md text-muted" style={{ marginBottom: 8 }}></p>
<div className="partner-info-row">
<span className="label-md text-muted"></span>
<span className="text-primary" style={{ fontWeight: 700, fontSize: 12 }}>¥{(payAmount * 0.04).toFixed(2)}</span>
</div>
<div className="partner-info-row">
<span className="label-md text-muted"></span>
<span className="text-primary" style={{ fontWeight: 700, fontSize: 12 }}>¥{(payAmount * 0.04).toFixed(2)}</span>
</div>
</div>
<div className="partner-detail-section" style={{ margin: 0, borderTop: '2px solid var(--color-aged-amber)' }}>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<span className="amount-lg" style={{ color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</span>
</div>
</div>
<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 className="partner-info-row">
<span className="text-muted body-md"></span>
<span className="body-md">{String(order.orderNo)}</span>
</div>
<div className="partner-info-row">
<span className="text-muted body-md"></span>
<span className="body-md">{String(order.status)}</span>
</div>
</section>
<section className="partner-detail-section"> <section className="partner-detail-section">
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}> <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 }} /> <span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
@@ -156,21 +156,34 @@ export default function OrderDetailPage() {
</section> </section>
<footer className="partner-order-footer"> <footer className="partner-order-footer">
<div> <div className="partner-order-footer-actions">
<span className="label-md text-muted"></span> {showShip && (
<p className="headline-md text-primary" style={{ fontWeight: 700 }}>¥{(payAmount * 0.08).toFixed(2)}</p> <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> </div>
<button type="button" className="btn btn-outline" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
</button>
</footer> </footer>
{import.meta.env.DEV && ( {import.meta.env.DEV && (
<details className="partner-dev-tools"> <details className="partner-dev-tools">
<summary>Dev: Mock </summary> <summary>Dev: Mock </summary>
{['OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE', 'COMPLETED'].map((s) => ( {['OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE', 'COMPLETED'].map((s) => (
<button key={s} type="button" onClick={() => advance(s)}>{s}</button> <button key={s} type="button" onClick={() => void advance(s)}>{s}</button>
))} ))}
</details> </details>
)} )}
+68 -40
View File
@@ -29,16 +29,26 @@ const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'ABNORMAL', label: '异常' }, { key: 'ABNORMAL', label: '异常' },
]; ];
export default function OrderListPage() { type OrderListPageProps = {
/** Tab 根页:无返回顶栏 */
tabRoot?: boolean;
};
export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [data, setData] = useState<{ list: Array<Record<string, unknown>> }>({ list: [] }); const [data, setData] = useState<{ list: Array<Record<string, unknown>> }>({ list: [] });
const [tab, setTab] = useState<'orders' | 'coupons'>('orders'); const [tab, setTab] = useState<'orders' | 'coupons'>('orders');
const [dateFilter, setDateFilter] = useState<DateFilter>('today'); const [dateFilter, setDateFilter] = useState<DateFilter>('today');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL'); const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
const [shippingId, setShippingId] = useState<string | null>(null);
useEffect(() => {
document.title = '订单管理';
}, []);
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } if (!isLoggedIn()) { navigate('/login'); return; }
request('PARTNER_H5', '/partner/orders').then(setData); request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders').then(setData);
}, [navigate]); }, [navigate]);
const filtered = useMemo(() => data.list.filter((o) => { const filtered = useMemo(() => data.list.filter((o) => {
@@ -51,13 +61,36 @@ export default function OrderListPage() {
return true; return true;
}), [data.list, statusFilter]); }), [data.list, statusFilter]);
async function handleShip(orderId: string, e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
setShippingId(orderId);
try {
await request('PARTNER_H5', `/partner/orders/${orderId}/mock-advance-delivery`, {
method: 'POST',
body: JSON.stringify({ targetStatus: 'OUT_WAREHOUSE' }),
});
const next = await request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders');
setData(next);
} finally {
setShippingId(null);
}
}
function canShip(status: string) {
const s = status.toUpperCase();
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
}
return ( return (
<div className="page-no-tab partner-orders-page"> <div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
<PageHeader title="订单中心" onBack={() => navigate('/')} /> {!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
<div className="partner-segment"> <div className="partner-segment">
<button type="button" className={tab === 'orders' ? 'active' : ''} onClick={() => setTab('orders')}></button> <button type="button" className={tab === 'orders' ? 'active' : ''} onClick={() => setTab('orders')}></button>
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}></button> {!tabRoot && (
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}></button>
)}
</div> </div>
{tab === 'orders' && ( {tab === 'orders' && (
@@ -82,50 +115,45 @@ export default function OrderListPage() {
{filtered.map((o) => { {filtered.map((o) => {
const st = orderStatusLabel(String(o.status)); const st = orderStatusLabel(String(o.status));
const payAmount = Number(o.payAmount || 0); const payAmount = Number(o.payAmount || 0);
const orderId = String(o.id);
return ( return (
<Link key={String(o.id)} to={`/orders/${o.id}`} className="partner-order-card"> <div key={orderId} className="partner-order-card-wrap">
<div className="partner-order-card-top"> <Link to={`/orders/${orderId}`} className="partner-order-card">
<span className="label-md text-muted">NO. {String(o.orderNo)}</span> <div className="partner-order-card-top">
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span> <span className="label-md text-muted">NO. {String(o.orderNo)}</span>
</div> <span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
<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>
<div style={{ flex: 1, minWidth: 0 }}> <div className="partner-order-product">
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3> <div className="partner-order-product-img" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p> <span className="material-symbols-outlined text-muted">liquor</span>
<span className="partner-benefit-tag"> </div>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>confirmation_number</span> <div style={{ flex: 1, minWidth: 0 }}>
¥{Math.round(payAmount * 0.2)} <h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
</span> <p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
</div>
</div> </div>
</div> <div className="partner-order-address">
<div className="partner-order-address"> <span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span> <p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
<p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
</div>
<div className="partner-order-commission">
<div>
<p className="label-md text-muted"></p>
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥{(payAmount * 0.04).toFixed(2)}</p>
</div> </div>
<div> </Link>
<p className="label-md text-muted"></p> {canShip(String(o.status)) && (
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥0.00</p> <button
</div> type="button"
<div> className="partner-order-ship-btn"
<p className="label-md text-muted"></p> disabled={shippingId === orderId}
<p className="headline-md" style={{ marginTop: 4, color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</p> onClick={(e) => void handleShip(orderId, e)}
</div> >
</div> {shippingId === orderId ? '发货中…' : '确认发货'}
</Link> </button>
)}
</div>
); );
})} })}
</> </>
)} )}
{tab === 'coupons' && ( {tab === 'coupons' && !tabRoot && (
<div style={{ padding: '0 20px' }}> <div style={{ padding: '0 20px' }}>
<div className="partner-revenue-card" style={{ marginBottom: 16 }}> <div className="partner-revenue-card" style={{ marginBottom: 16 }}>
<p className="partner-revenue-label"></p> <p className="partner-revenue-label"></p>
+7 -130
View File
@@ -1,135 +1,12 @@
import { useEffect, useState } from 'react'; import { getPartnerNavKind } from '../lib/partnerAccess';
import { Link } from 'react-router-dom';
import { PARTNER_STAFF_ROLE_LABELS, type PartnerStaffRole } from '@dukang/shared-types';
import { request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { toastError, toastSuccess } from '../lib/toast'; import CenterPage from './CenterPage';
/** 子账号个人中心:复用精简版 Center 布局 */
export default function PartnerMePage() { export default function PartnerMePage() {
const { account, refresh, logout } = usePartnerSession(); const { account } = usePartnerSession();
const [name, setName] = useState(account?.name ?? ''); const navKind = getPartnerNavKind(account);
const [saving, setSaving] = useState(false); const isWarehouse = navKind === 'warehouse_staff';
useEffect(() => { return <CenterPage variant="sub" roleLabel={isWarehouse ? '仓库管理员' : '拓店员'} />;
setName(account?.name ?? '');
}, [account?.name]);
const roleLabel = account?.staffRole
? PARTNER_STAFF_ROLE_LABELS[account.staffRole as PartnerStaffRole] || account.staffRole
: '拓店账号';
async function handleSave() {
const trimmed = name.trim();
if (!trimmed) {
toastError('请输入姓名');
return;
}
if (trimmed === account?.name) {
toastSuccess('已保存');
return;
}
setSaving(true);
try {
await request('PARTNER_H5', '/partner/me', {
method: 'PUT',
body: JSON.stringify({ name: trimmed }),
});
await refresh();
toastSuccess('已保存');
} catch (e) {
toastError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
return (
<div className="page partner-center-page partner-me-page">
<header className="header app-page-header">
<h1 className="app-page-title"></h1>
</header>
<section className="partner-profile-card">
<div className="partner-profile-avatar">
<span className="material-symbols-outlined">person</span>
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{account?.name || '合伙人'}</h2>
<span className="partner-role-badge">{roleLabel}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, color: 'var(--color-subtle-gray)' }}>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span>
<span className="body-md">{account?.companyName || '—'}</span>
</div>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{account?.phone || ''}</p>
</div>
</section>
<section className="partner-menu-section">
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<div className="partner-menu-card" style={{ padding: '16px' }}>
<label className="partner-form-label" htmlFor="me-name"></label>
<input
id="me-name"
className="partner-form-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="请输入姓名"
maxLength={32}
/>
<label className="partner-form-label" style={{ marginTop: 16 }}></label>
<input
className="partner-form-input"
value={account?.phone ?? ''}
readOnly
disabled
/>
<p className="label-md text-muted" style={{ marginTop: 4 }}>
</p>
<button
type="button"
className="partner-btn-primary"
style={{ width: '100%', marginTop: 16 }}
disabled={saving}
onClick={() => void handleSave()}
>
{saving ? '保存中…' : '保存资料'}
</button>
</div>
</section>
<section className="partner-menu-section">
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<div className="partner-menu-card">
<Link to="/stores" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">store</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/new?step=1" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">add_business</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
</div>
</section>
<button type="button" className="partner-logout-btn" onClick={logout}>
<span className="material-symbols-outlined">logout</span>
退
</button>
<div style={{ textAlign: 'center', opacity: 0.3, padding: '32px 0' }}>
<p className="label-md text-muted"> · </p>
</div>
</div>
);
} }
+5 -10
View File
@@ -42,6 +42,10 @@ export default function StoreListPage() {
void loadStores(); void loadStores();
}, [navigate, loadStores]); }, [navigate, loadStores]);
useEffect(() => {
document.title = readonly ? '我的门店' : '门店管理';
}, [readonly]);
const filtered = useMemo(() => stores.filter((s) => { const filtered = useMemo(() => stores.filter((s) => {
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q); const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
const audit = String(s.auditStatus || 'APPROVED').toUpperCase(); const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
@@ -78,16 +82,7 @@ export default function StoreListPage() {
} }
return ( return (
<div className="page partner-store-page"> <div className="page partner-store-page partner-home--flush-top">
<header className="header app-page-header">
<h1 className="app-page-title"></h1>
</header>
<div className="partner-page-title-block">
<h2>{readonly ? '我的门店' : '门店管理'}</h2>
<p className="text-muted body-md">{readonly ? '查看您录入的合作门店' : '管理您的合作门店及其运营状态'}</p>
</div>
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>} {error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
<div className="partner-sticky-filter"> <div className="partner-sticky-filter">
+62
View File
@@ -3067,6 +3067,68 @@ body {
border-top: 1px dashed rgba(166, 29, 36, 0.15); border-top: 1px dashed rgba(166, 29, 36, 0.15);
} }
.partner-home--flush-top .partner-home-body,
.partner-home--flush-top.partner-store-page,
.partner-home--flush-top.partner-center-page {
padding-top: 0;
}
.partner-home--flush-top .partner-sticky-filter {
padding-top: 12px;
}
.partner-leaderboard-row--self {
background: rgba(166, 29, 36, 0.08);
border: 1px solid rgba(166, 29, 36, 0.18);
border-radius: 10px;
}
.partner-leaderboard-self-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
font-size: 10px;
font-weight: 700;
color: var(--color-heritage-red);
background: rgba(166, 29, 36, 0.12);
border-radius: 4px;
vertical-align: middle;
}
.partner-order-card-wrap {
margin: 0 var(--space-page) 12px;
}
.partner-order-ship-btn {
display: block;
width: 100%;
margin-top: -4px;
padding: 10px 16px;
border: none;
border-radius: 0 0 var(--radius-md) var(--radius-md);
background: var(--color-heritage-red);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.partner-order-ship-btn:disabled {
opacity: 0.6;
}
.partner-order-footer-actions {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.partner-order-ship-btn--footer {
width: 100%;
justify-content: center;
}
/* 微信 H5 系统标题已展示:隐藏页内重复标题,保留返回键与操作区 */ /* 微信 H5 系统标题已展示:隐藏页内重复标题,保留返回键与操作区 */
.app-page-title { .app-page-title {
display: none !important; display: none !important;
+3
View File
@@ -10,6 +10,9 @@ export interface PartnerMe {
staffRole?: PartnerStaffRole; staffRole?: PartnerStaffRole;
permissions?: string[]; permissions?: string[];
primaryAccountId?: string; primaryAccountId?: string;
/** 子账号联系主账号用 */
primaryPhone?: string;
primaryName?: string;
} }
export interface UpdatePartnerMeRequest { export interface UpdatePartnerMeRequest {
@@ -0,0 +1,7 @@
import { SetMetadata } from '@nestjs/common';
import type { PartnerPermissionKey } from '@dukang/shared-types';
export const PARTNER_PERMISSIONS_KEY = 'partner_permissions';
export const RequirePartnerPermissions = (...permissions: PartnerPermissionKey[]) =>
SetMetadata(PARTNER_PERMISSIONS_KEY, permissions);
@@ -0,0 +1,45 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { PartnerPermissionKey } from '@dukang/shared-types';
import { PARTNER_PERMISSIONS_KEY } from '../decorators/partner-permission.decorator';
import { PrismaService } from '../prisma/prisma.module';
import { AuthUser, JwtAuthGuard } from './jwt-auth.guard';
@Injectable()
export class PartnerPermissionGuard implements CanActivate {
constructor(
private readonly jwtAuthGuard: JwtAuthGuard,
private readonly prisma: PrismaService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
await this.jwtAuthGuard.canActivate(context);
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser;
if (user.actorType !== 'PARTNER') {
throw new ForbiddenException('仅合伙人可操作');
}
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: user.actorId },
});
if (account.isPrimary === 1) return true;
const required = this.reflector.getAllAndOverride<PartnerPermissionKey[]>(
PARTNER_PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!required?.length) return true;
const perms = Array.isArray(account.permissions)
? (account.permissions as string[])
: [];
if (required.some((p) => perms.includes(p))) return true;
throw new ForbiddenException('当前子账号无此操作权限');
}
}
@@ -21,6 +21,7 @@ import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard'; import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
import { ShopStoreGuard } from '../../common/guards/shop-store.guard'; import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
import { StoreMembershipService } from '../../common/guards/store-membership.service'; import { StoreMembershipService } from '../../common/guards/store-membership.service';
@@ -54,6 +55,7 @@ import { StoreMembershipService } from '../../common/guards/store-membership.ser
OptionalJwtAuthGuard, OptionalJwtAuthGuard,
HqAuthGuard, HqAuthGuard,
PartnerPrimaryGuard, PartnerPrimaryGuard,
PartnerPermissionGuard,
ShopStoreGuard, ShopStoreGuard,
], ],
exports: [ exports: [
@@ -68,6 +70,7 @@ import { StoreMembershipService } from '../../common/guards/store-membership.ser
OptionalJwtAuthGuard, OptionalJwtAuthGuard,
HqAuthGuard, HqAuthGuard,
PartnerPrimaryGuard, PartnerPrimaryGuard,
PartnerPermissionGuard,
ShopStoreGuard, ShopStoreGuard,
], ],
}) })
@@ -263,6 +263,8 @@ export class PartnerMeController {
staffRole: account.staffRole ?? undefined, staffRole: account.staffRole ?? undefined,
permissions: Array.isArray(account.permissions) ? account.permissions : undefined, permissions: Array.isArray(account.permissions) ? account.permissions : undefined,
primaryAccountId: primary.id.toString(), primaryAccountId: primary.id.toString(),
primaryPhone: primary.phone,
primaryName: primary.name,
companyName: primary.companyName ?? undefined, companyName: primary.companyName ?? undefined,
hasWechat: !!account.wxOpenId, hasWechat: !!account.wxOpenId,
}; };
@@ -3,6 +3,8 @@ import { StoreService } from './store.service';
import { RedeemService } from '../redeem/redeem.service'; import { RedeemService } from '../redeem/redeem.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { RequirePartnerPermissions } from '../../common/decorators/partner-permission.decorator';
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
import { ShopStoreGuard } from '../../common/guards/shop-store.guard'; import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
@@ -76,7 +78,8 @@ export class PartnerStoreController {
} }
@Controller('partner/dashboard') @Controller('partner/dashboard')
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard) @UseGuards(JwtAuthGuard, PartnerPermissionGuard)
@RequirePartnerPermissions('store:create', 'store:manage', 'order:view', 'warehouse:manage')
export class PartnerDashboardController { export class PartnerDashboardController {
constructor(private readonly storeService: StoreService) {} constructor(private readonly storeService: StoreService) {}
@@ -468,33 +468,56 @@ export class StoreService {
async partnerDashboard(partnerAccountId: bigint) { async partnerDashboard(partnerAccountId: bigint) {
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId); const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account); const primaryAccount = await this.prisma.partnerAccount.findUniqueOrThrow({
const partnerStoreIds = await this.prisma.store.findMany({ where: { id: primaryId },
where: { partnerAccountId: primaryId },
select: { id: true },
}); });
const storeIds = partnerStoreIds.map((s) => s.id); const orderCount = await this.prisma.order.count({
const [storeCount, orderCount, recentStores, pendingAuditCount, recentNotices] = await Promise.all([ where: await this.partnerCityService.buildPartnerOrderWhere(primaryId),
this.prisma.store.count({ where: { partnerAccountId: primaryId } }), });
this.prisma.order.count({
where: await this.partnerCityService.buildPartnerOrderWhere(primaryId), let storeWhere: { partnerAccountId: bigint; id?: { in: bigint[] } } = {
}), partnerAccountId: primaryId,
};
if (this.isSubAccount(account)) {
const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId);
if (storeIds.length === 0) {
return {
storeCount: 0,
orderCount,
companyName: primaryAccount.companyName ?? '',
recentStores: [],
pendingAuditCount: 0,
notifications: [],
};
}
storeWhere = { partnerAccountId: primaryId, id: { in: storeIds } };
}
const storeIdsForNotices = (
await this.prisma.store.findMany({
where: storeWhere,
select: { id: true },
})
).map((s) => s.id);
const [storeCount, recentStores, pendingAuditCount, recentNotices] = await Promise.all([
this.prisma.store.count({ where: storeWhere }),
this.prisma.store.findMany({ this.prisma.store.findMany({
where: { partnerAccountId: primaryId }, where: storeWhere,
select: { id: true, name: true, status: true, auditStatus: true, createdAt: true }, select: { id: true, name: true, status: true, auditStatus: true, createdAt: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 10, take: 10,
}), }),
this.prisma.store.count({ this.prisma.store.count({
where: { partnerAccountId: primaryId, auditStatus: 'PENDING' }, where: { ...storeWhere, auditStatus: 'PENDING' },
}), }),
storeIds.length === 0 storeIdsForNotices.length === 0
? Promise.resolve([]) ? Promise.resolve([])
: this.prisma.commonEvent.findMany({ : this.prisma.commonEvent.findMany({
where: { where: {
eventType: 'STORE_AUDIT', eventType: 'STORE_AUDIT',
refType: 'STORE', refType: 'STORE',
refId: { in: storeIds }, refId: { in: storeIdsForNotices },
status: { in: ['APPROVED', 'REJECTED'] }, status: { in: ['APPROVED', 'REJECTED'] },
actorType: 'HQ', actorType: 'HQ',
}, },
@@ -515,7 +538,7 @@ export class StoreService {
return { return {
storeCount, storeCount,
orderCount, orderCount,
companyName: account.companyName ?? '', companyName: primaryAccount.companyName ?? '',
recentStores: serializeBigInt(recentStores), recentStores: serializeBigInt(recentStores),
pendingAuditCount, pendingAuditCount,
notifications: serializeBigInt( notifications: serializeBigInt(
@@ -3,6 +3,8 @@ import type { Request } from 'express';
import { TradeService } from './trade.service'; import { TradeService } from './trade.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { RequirePartnerPermissions } from '../../common/decorators/partner-permission.decorator';
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { import {
PartnerProxyOrderCreateDto, PartnerProxyOrderCreateDto,
@@ -70,7 +72,8 @@ export class TradeController {
} }
@Controller('partner/orders') @Controller('partner/orders')
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard) @UseGuards(JwtAuthGuard, PartnerPermissionGuard)
@RequirePartnerPermissions('order:view', 'warehouse:manage')
export class PartnerOrderController { export class PartnerOrderController {
constructor(private readonly tradeService: TradeService) {} constructor(private readonly tradeService: TradeService) {}