@@ -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="/" element={<HomePage />} />
|
||||||
|
{isWarehouse ? (
|
||||||
|
<Route path="/orders" element={<OrderListPage tabRoot />} />
|
||||||
|
) : (
|
||||||
<Route path="/stores" element={<StoreListPage />} />
|
<Route path="/stores" element={<StoreListPage />} />
|
||||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
)}
|
||||||
<Route path="/me" element={<PartnerMePage />} />
|
<Route path="/me" element={<PartnerMePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
{/* 与主账号一致:录入门店全屏,避免 sticky 底栏被 TabBar 挡住 */}
|
{!isWarehouse && (
|
||||||
|
<>
|
||||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||||
<Route path="*" element={<Navigate to="/stores/new?step=1" replace />} />
|
<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}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? '');
|
||||||
|
}
|
||||||
@@ -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}/`)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,140 +1,125 @@
|
|||||||
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">
|
|
||||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">description</span></div>
|
|
||||||
<span className="body-md" style={{ fontSize: 16 }}>财务对账</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
{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>
|
|
||||||
</div>
|
|
||||||
</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">
|
<Link to="/center/staff" className="partner-menu-item">
|
||||||
<div className="partner-menu-item-left">
|
<div className="partner-menu-item-left">
|
||||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">group</span></div>
|
<div className="partner-menu-icon"><span className="material-symbols-outlined">group</span></div>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
|
||||||
|
.catch(() => setOrders([]));
|
||||||
|
}
|
||||||
|
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')
|
fetchPartnerLeaderboard('month')
|
||||||
.then((data) => setLeaderboardPreview(data.list.slice(0, 2)))
|
.then((data) => {
|
||||||
.catch(() => setLeaderboardPreview([]));
|
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
|
||||||
}, [navigate]);
|
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,55 +209,16 @@ 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">
|
||||||
|
{isPrimary && (
|
||||||
|
<>
|
||||||
<section className="partner-revenue-card">
|
<section className="partner-revenue-card">
|
||||||
<p className="partner-revenue-label">
|
<p className="partner-revenue-label">
|
||||||
实时营业额 (CNY)
|
实时营业额 (CNY)
|
||||||
@@ -110,9 +232,61 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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="/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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="partner-bento">
|
{isWarehouse && (
|
||||||
<section className="partner-bento-card">
|
<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">
|
<div className="partner-bento-header">
|
||||||
<h2 className="headline-md">门店总数</h2>
|
<h2 className="headline-md">门店总数</h2>
|
||||||
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
|
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
|
||||||
@@ -135,66 +309,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="partner-home-store-section">
|
||||||
<div className="partner-home-store-header">
|
<div className="partner-home-store-header">
|
||||||
<h2 className="headline-md">辖区门店</h2>
|
<h2 className="headline-md">辖区门店</h2>
|
||||||
@@ -203,7 +317,7 @@ export default function HomePage() {
|
|||||||
{previewStores.length === 0 ? (
|
{previewStores.length === 0 ? (
|
||||||
<div className="partner-home-store-empty">
|
<div className="partner-home-store-empty">
|
||||||
<p className="label-md text-muted">暂无门店</p>
|
<p className="label-md text-muted">暂无门店</p>
|
||||||
<Link to="/stores/new" className="label-md text-primary">录入新店</Link>
|
<Link to={isPrimary ? '/stores/new' : '/stores/new?step=1'} className="label-md text-primary">录入新店</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
previewStores.map((s) => {
|
previewStores.map((s) => {
|
||||||
@@ -230,50 +344,22 @@ export default function HomePage() {
|
|||||||
<div className="partner-expansion-split">
|
<div className="partner-expansion-split">
|
||||||
<div>
|
<div>
|
||||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>本月新增签约</p>
|
<p className="label-md text-muted" style={{ marginBottom: 4 }}>本月新增签约</p>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
|
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待审核门店</p>
|
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待审核门店</p>
|
||||||
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
|
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="partner-leaderboard-section">
|
<LeaderboardPreview entries={leaderboardEntries} self={leaderboardSelf} />
|
||||||
<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 className="partner-leaderboard-stat">
|
|
||||||
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
|
||||||
<p className="label-md text-muted">本月新增</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
|
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
|
||||||
{String(dash?.companyName || '郑州合伙人')} · 辖区管理
|
{String(dash?.companyName || '郑州合伙人')} · 辖区管理
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if (!id) return;
|
||||||
|
setShipping(true);
|
||||||
|
try {
|
||||||
await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, {
|
await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ targetStatus: status }),
|
body: JSON.stringify({ targetStatus: status }),
|
||||||
});
|
});
|
||||||
if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
|
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
|
||||||
</div>
|
type="button"
|
||||||
<button type="button" className="btn btn-outline" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}>
|
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>
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
|
||||||
联系配送员
|
联系配送员
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{!tabRoot && (
|
||||||
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}>权益记录</button>
|
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}>权益记录</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'orders' && (
|
{tab === 'orders' && (
|
||||||
@@ -82,8 +115,10 @@ 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">
|
||||||
|
<Link to={`/orders/${orderId}`} className="partner-order-card">
|
||||||
<div className="partner-order-card-top">
|
<div className="partner-order-card-top">
|
||||||
<span className="label-md text-muted">NO. {String(o.orderNo)}</span>
|
<span className="label-md text-muted">NO. {String(o.orderNo)}</span>
|
||||||
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
|
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
|
||||||
@@ -95,37 +130,30 @@ export default function OrderListPage() {
|
|||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
|
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
|
||||||
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
|
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
|
||||||
<span className="partner-benefit-tag">
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>confirmation_number</span>
|
|
||||||
¥{Math.round(payAmount * 0.2)}权益
|
|
||||||
</span>
|
|
||||||
</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>
|
||||||
<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>
|
|
||||||
<p className="label-md text-muted">核销佣金</p>
|
|
||||||
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥0.00</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="label-md text-muted">获赠权益</p>
|
|
||||||
<p className="headline-md" style={{ marginTop: 4, color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
{canShip(String(o.status)) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-order-ship-btn"
|
||||||
|
disabled={shippingId === orderId}
|
||||||
|
onClick={(e) => void handleShip(orderId, e)}
|
||||||
|
>
|
||||||
|
{shippingId === orderId ? '发货中…' : '确认发货'}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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([
|
|
||||||
this.prisma.store.count({ where: { partnerAccountId: primaryId } }),
|
|
||||||
this.prisma.order.count({
|
|
||||||
where: await this.partnerCityService.buildPartnerOrderWhere(primaryId),
|
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) {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user