feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理
订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,12 +22,16 @@ import ProxyOrderDetailPage from './pages/ProxyOrderDetailPage';
|
||||
import StaffListPage from './pages/StaffListPage';
|
||||
import StaffCreatePage from './pages/StaffCreatePage';
|
||||
import LeaderboardPage from './pages/LeaderboardPage';
|
||||
import UsersManagePage from './pages/UsersManagePage';
|
||||
import AssocOrdersPage from './pages/AssocOrdersPage';
|
||||
import CommissionOrdersPage from './pages/CommissionOrdersPage';
|
||||
|
||||
function PrimaryRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<TabLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/users" element={<UsersManagePage />} />
|
||||
<Route path="/stores" element={<StoreListPage />} />
|
||||
<Route path="/center" element={<CenterPage />} />
|
||||
</Route>
|
||||
@@ -37,6 +41,11 @@ function PrimaryRoutes() {
|
||||
<Route path="/center/settlement" element={<SettlementPage />} />
|
||||
<Route path="/center/staff" element={<StaffListPage />} />
|
||||
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
||||
<Route path="/center/assoc" element={<Navigate to="/users" replace />} />
|
||||
<Route path="/center/assoc/users" element={<Navigate to="/users" replace />} />
|
||||
<Route path="/users/orders" element={<AssocOrdersPage />} />
|
||||
<Route path="/users/:userId/orders" element={<AssocOrdersPage />} />
|
||||
<Route path="/center/commissions" element={<CommissionOrdersPage />} />
|
||||
<Route path="/center/proxy-orders" element={<ProxyOrderListPage />} />
|
||||
<Route path="/center/proxy-orders/:id" element={<ProxyOrderDetailPage />} />
|
||||
<Route path="/proxy-order" element={<ProxyOrderPage />} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isLoggedIn } from '../lib/api';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
|
||||
{ to: '/users', icon: 'group', label: '用户管理' },
|
||||
{ to: '/stores', icon: 'store', label: '门店管理' },
|
||||
{ to: '/center', icon: 'account_circle', label: '合伙人中心' },
|
||||
] as const;
|
||||
|
||||
@@ -12,6 +12,7 @@ export type ProxyOrderDraft = {
|
||||
promoCodeId: string;
|
||||
deliveryMode: PartnerProxyDeliveryMode;
|
||||
autoReceive: boolean;
|
||||
assocEnabled: boolean;
|
||||
};
|
||||
|
||||
export function saveProxyOrderDraft(draft: ProxyOrderDraft) {
|
||||
@@ -40,6 +41,7 @@ export function loadProxyOrderDraft(): ProxyOrderDraft | null {
|
||||
promoCodeId: typeof parsed.promoCodeId === 'string' ? parsed.promoCodeId : '',
|
||||
deliveryMode: parsed.deliveryMode === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'ADDRESS',
|
||||
autoReceive: parsed.autoReceive === true,
|
||||
assocEnabled: parsed.assocEnabled !== false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import type { PartnerAssocUserOrderItem } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { toastError } from '../lib/toast';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
|
||||
type ListRes = { items: PartnerAssocUserOrderItem[]; total: number };
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function AssocOrdersPage() {
|
||||
const { userId } = useParams();
|
||||
usePartnerPageView(userId ? 'partner_assoc_user_orders_view' : 'partner_assoc_orders_view');
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<PartnerAssocUserOrderItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const path = userId
|
||||
? `/partner/assoc/users/${userId}/orders?page=1&pageSize=50`
|
||||
: '/partner/assoc/orders?page=1&pageSize=50';
|
||||
const res = await request<ListRes>('PARTNER_H5', path);
|
||||
setItems(res.items ?? []);
|
||||
setTotal(res.total ?? 0);
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '加载失败');
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={load}>
|
||||
<PageHeader title={userId ? '用户订单' : '关联用户订单'} onBack={() => navigate(-1)} />
|
||||
<div style={{ padding: '0 20px 24px' }}>
|
||||
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
|
||||
{loading ? '加载中…' : `共 ${total} 笔已付购酒单`}
|
||||
</p>
|
||||
{!loading && items.length === 0 && <div className="empty">暂无订单</div>}
|
||||
{items.map((o) => (
|
||||
<div key={o.id} className="partner-store-card" style={{ marginBottom: 12 }}>
|
||||
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
|
||||
<div>
|
||||
<p className="body-md">{o.productName}</p>
|
||||
<p className="label-md text-muted">{o.orderNo} · ×{o.quantity}</p>
|
||||
<p className="label-md text-muted">{o.paidAt ? o.paidAt.slice(0, 16).replace('T', ' ') : ''}</p>
|
||||
</div>
|
||||
<span className="amount-lg" style={{ fontSize: 18 }}>¥{fmtMoney(o.payAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import type { PartnerAssocSummary } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
|
||||
export default function AssocQrcodePage() {
|
||||
usePartnerPageView('partner_assoc_qrcode_view');
|
||||
const navigate = useNavigate();
|
||||
const [summary, setSummary] = useState<PartnerAssocSummary | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<PartnerAssocSummary>('PARTNER_H5', '/partner/assoc')
|
||||
.then(setSummary)
|
||||
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
}, [previewUrl]);
|
||||
|
||||
async function downloadQr() {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
try {
|
||||
const res = await fetch('/api/v1/partner/assoc/qrcode', {
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : '',
|
||||
'X-Client-App': 'PARTNER_H5',
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error('下载失败');
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (isWechatEnv()) {
|
||||
setPreviewUrl(url);
|
||||
toastSuccess('请长按图片保存到相册');
|
||||
return;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `partner-assoc-${summary?.partnerId || 'qr'}.png`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toastSuccess('已开始下载');
|
||||
} catch (e) {
|
||||
if (summary?.qrcodeUrl) {
|
||||
setPreviewUrl(summary.qrcodeUrl);
|
||||
toastSuccess('请长按图片保存到相册');
|
||||
return;
|
||||
}
|
||||
toastError(e instanceof Error ? e.message : '下载失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="关联码" onBack={() => navigate(-1)} />
|
||||
{loading && <div className="empty">加载中…</div>}
|
||||
{!loading && (
|
||||
<section className="partner-bill-card" style={{ margin: 16 }}>
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="body-md text-muted" style={{ marginBottom: 16 }}>
|
||||
用户扫码后首次锁定本合伙人,后续酒单按订单佣金结算
|
||||
</p>
|
||||
{summary?.qrcodeUrl ? (
|
||||
<img
|
||||
src={previewUrl || summary.qrcodeUrl}
|
||||
alt="关联码"
|
||||
style={{ width: 220, height: 220, background: '#fff' }}
|
||||
/>
|
||||
) : (
|
||||
<p className="body-md text-muted">关联码尚未生成,请稍后重试或联系总部补发</p>
|
||||
)}
|
||||
<p className="label-md text-muted" style={{ marginTop: 12 }}>
|
||||
已关联 {summary?.userCount ?? 0} 人
|
||||
</p>
|
||||
<button type="button" className="partner-btn-primary" style={{ marginTop: 20 }} onClick={() => void downloadQr()}>
|
||||
下载二维码
|
||||
</button>
|
||||
{previewUrl && isWechatEnv() && (
|
||||
<p className="label-md text-muted" style={{ marginTop: 12 }}>
|
||||
微信内请长按上方图片保存
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import type { PartnerAssocUserItem } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { toastError } from '../lib/toast';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
|
||||
type ListRes = { items: PartnerAssocUserItem[]; total: number; page: number; pageSize: number };
|
||||
|
||||
export default function AssocUsersPage() {
|
||||
usePartnerPageView('partner_assoc_users_view');
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<PartnerAssocUserItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<ListRes>('PARTNER_H5', '/partner/assoc/users?page=1&pageSize=50');
|
||||
setItems(res.items ?? []);
|
||||
setTotal(res.total ?? 0);
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '加载失败');
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={load}>
|
||||
<PageHeader title="关联用户" onBack={() => navigate(-1)} />
|
||||
<div style={{ padding: '0 20px 24px' }}>
|
||||
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
|
||||
{loading ? '加载中…' : `共 ${total} 人`}
|
||||
</p>
|
||||
{!loading && items.length === 0 && <div className="empty">暂无关联用户</div>}
|
||||
{items.map((u) => (
|
||||
<div key={u.id} className="partner-store-card" style={{ marginBottom: 12 }}>
|
||||
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
|
||||
<div>
|
||||
<p className="body-md">{u.nickname || u.userNo}</p>
|
||||
<p className="label-md text-muted">{u.phone || '未绑定手机'}</p>
|
||||
<p className="label-md text-muted">{u.boundAt ? u.boundAt.slice(0, 16).replace('T', ' ') : ''}</p>
|
||||
</div>
|
||||
<span className="label-md">{u.orderCount} 单</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||
import type { PartnerBillDetailDto, PartnerBillDto, PartnerBillItemDto } from '@dukang/shared-types';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
@@ -34,6 +34,7 @@ export default function BillsPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [detail, setDetail] = useState<PartnerBillDetailDto | null>(null);
|
||||
|
||||
async function loadBills() {
|
||||
setLoading(true);
|
||||
@@ -67,6 +68,44 @@ export default function BillsPage() {
|
||||
const isRejected = status === 'REJECTED';
|
||||
const isPaid = status === 'PAID';
|
||||
|
||||
useEffect(() => {
|
||||
if (!bill?.id) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
request<PartnerBillDetailDto>('PARTNER_H5', `/partner/settlement/bills/${bill.id}`)
|
||||
.then(setDetail)
|
||||
.catch(() => setDetail(null));
|
||||
}, [bill?.id]);
|
||||
|
||||
function renderItemList(title: string, items: PartnerBillItemDto[] | undefined, subtotal: number) {
|
||||
const list = items ?? [];
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div className="partner-info-row" style={{ marginBottom: 8 }}>
|
||||
<span className="body-md" style={{ fontWeight: 700 }}>{title}</span>
|
||||
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(subtotal)}</span>
|
||||
</div>
|
||||
{list.length === 0 ? (
|
||||
<p className="label-md text-muted">本账期无明细</p>
|
||||
) : (
|
||||
list.map((it) => (
|
||||
<div key={it.id} className="partner-info-row" style={{ alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<p className="body-md">{it.title || it.refNo}</p>
|
||||
<p className="label-md text-muted">
|
||||
{it.refNo}
|
||||
{it.extra ? ` ${it.extra}` : ''} · {((it.rate ?? 0) * 100).toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
<span className="body-md">¥ {fmtMoney(it.commission)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function askConfirm(ids: string[]) {
|
||||
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
|
||||
return;
|
||||
@@ -183,16 +222,8 @@ export default function BillsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="partner-info-row">
|
||||
<span className="text-variant body-md">订单分佣收入</span>
|
||||
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.orderCommission || 0))}</span>
|
||||
</div>
|
||||
<div className="partner-info-row">
|
||||
<span className="text-variant body-md">核销权益分佣</span>
|
||||
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.redeemCommission || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
{renderItemList('酒订单', detail?.orderItems, Number(detail?.orderCommission ?? bill.orderCommission ?? 0))}
|
||||
{renderItemList('核销订单', detail?.redeemItems, Number(detail?.redeemCommission ?? bill.redeemCommission ?? 0))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -219,6 +219,24 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Link to="/users" 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>
|
||||
<Link to="/center/commissions" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon">
|
||||
<span className="material-symbols-outlined">payments</span>
|
||||
</div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>订单佣金</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<Link to="/center/proxy-orders" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon">
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import type { PartnerCommissionOrderItem } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { toastError } from '../lib/toast';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
type ListRes = { items: PartnerCommissionOrderItem[]; total: number };
|
||||
|
||||
export default function CommissionOrdersPage() {
|
||||
usePartnerPageView('partner_commission_orders_view');
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<PartnerCommissionOrderItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<ListRes>('PARTNER_H5', '/partner/commissions/orders?page=1&pageSize=50');
|
||||
setItems(res.items ?? []);
|
||||
setTotal(res.total ?? 0);
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '加载失败');
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={load}>
|
||||
<PageHeader title="订单佣金" onBack={() => navigate(-1)} />
|
||||
<div style={{ padding: '0 20px 24px' }}>
|
||||
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
|
||||
{loading ? '加载中…' : `共 ${total} 笔关联酒单`}
|
||||
</p>
|
||||
{!loading && items.length === 0 && <div className="empty">暂无订单佣金</div>}
|
||||
{items.map((o) => (
|
||||
<div key={o.id} className="partner-store-card" style={{ marginBottom: 12 }}>
|
||||
<div className="partner-store-card-header">
|
||||
<div>
|
||||
<p className="body-md">{o.productName}</p>
|
||||
<p className="label-md text-muted">{o.orderNo} · ×{o.quantity}</p>
|
||||
<p className="label-md text-muted">{o.paidAt ? o.paidAt.slice(0, 16).replace('T', ' ') : ''}</p>
|
||||
</div>
|
||||
<span className="amount-lg" style={{ fontSize: 18 }}>¥{fmtMoney(o.commission)}</span>
|
||||
</div>
|
||||
<p className="label-md text-muted">
|
||||
实付 ¥{fmtMoney(o.payAmount)} × {((o.rate ?? 0) * 100).toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||
import type { PartnerAssocStats, PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
@@ -153,6 +153,7 @@ export default function HomePage() {
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
|
||||
const [assocStats, setAssocStats] = useState<PartnerAssocStats | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = '工作台';
|
||||
@@ -197,6 +198,16 @@ export default function HomePage() {
|
||||
setStores([]);
|
||||
}
|
||||
|
||||
if (isPrimary) {
|
||||
tasks.push(
|
||||
request<PartnerAssocStats>('PARTNER_H5', '/partner/assoc/stats', { silent: true })
|
||||
.then(setAssocStats)
|
||||
.catch(() => setAssocStats(null)),
|
||||
);
|
||||
} else {
|
||||
setAssocStats(null);
|
||||
}
|
||||
|
||||
tasks.push(
|
||||
fetchPartnerLeaderboard('month')
|
||||
.then((data) => {
|
||||
@@ -208,7 +219,7 @@ export default function HomePage() {
|
||||
);
|
||||
|
||||
return Promise.all(tasks).then(() => undefined);
|
||||
}, [navigate, account, canOrders, canDashboard, canStores]);
|
||||
}, [navigate, account, canOrders, canDashboard, canStores, isPrimary]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHome();
|
||||
@@ -287,6 +298,51 @@ export default function HomePage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPrimary && (
|
||||
<>
|
||||
<Link to="/users" className="partner-bento-card" style={{ display: 'block', marginBottom: 16, color: 'inherit', textDecoration: 'none' }}>
|
||||
<div className="partner-bento-header">
|
||||
<h2 className="headline-md">关联用户</h2>
|
||||
<span className="amount-lg" style={{ fontSize: 24 }}>{assocStats?.userTotal ?? 0}</span>
|
||||
</div>
|
||||
<div className="partner-store-stats">
|
||||
<div className="partner-store-stat">
|
||||
<div>
|
||||
<p className="label-md text-muted">本日</p>
|
||||
<p className="headline-md">{assocStats?.userToday ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-store-stat">
|
||||
<div>
|
||||
<p className="label-md text-muted">本月</p>
|
||||
<p className="headline-md">{assocStats?.userMonth ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link to="/users/orders" className="partner-bento-card" style={{ display: 'block', marginBottom: 16, color: 'inherit', textDecoration: 'none' }}>
|
||||
<div className="partner-bento-header">
|
||||
<h2 className="headline-md">关联用户订单</h2>
|
||||
<span className="amount-lg" style={{ fontSize: 24 }}>{assocStats?.orderTotal ?? 0}</span>
|
||||
</div>
|
||||
<div className="partner-store-stats">
|
||||
<div className="partner-store-stat">
|
||||
<div>
|
||||
<p className="label-md text-muted">本日</p>
|
||||
<p className="headline-md">{assocStats?.orderToday ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-store-stat">
|
||||
<div>
|
||||
<p className="label-md text-muted">本月</p>
|
||||
<p className="headline-md">{assocStats?.orderMonth ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWarehouse && (
|
||||
<p className="headline-md" style={{ marginBottom: 8 }}>订单管理</p>
|
||||
)}
|
||||
|
||||
@@ -42,6 +42,7 @@ function initialDraft(): ProxyOrderDraft {
|
||||
promoCodeId: '',
|
||||
deliveryMode: 'ADDRESS',
|
||||
autoReceive: false,
|
||||
assocEnabled: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -63,6 +64,7 @@ export default function ProxyOrderPage() {
|
||||
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
|
||||
const [autoReceive, setAutoReceive] = useState(draft0.autoReceive);
|
||||
const [assocEnabled, setAssocEnabled] = useState(draft0.assocEnabled !== false);
|
||||
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -108,6 +110,7 @@ export default function ProxyOrderPage() {
|
||||
promoCodeId,
|
||||
deliveryMode,
|
||||
autoReceive,
|
||||
assocEnabled,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
@@ -154,6 +157,7 @@ export default function ProxyOrderPage() {
|
||||
promoCodeId,
|
||||
deliveryMode,
|
||||
autoReceive,
|
||||
assocEnabled,
|
||||
]);
|
||||
|
||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||
@@ -390,6 +394,9 @@ export default function ProxyOrderPage() {
|
||||
quantity,
|
||||
promoCodeId: promoCodeId || undefined,
|
||||
skuId: skuId || undefined,
|
||||
assocPartnerAccountId: assocEnabled
|
||||
? options?.partners?.[0]?.id || undefined
|
||||
: undefined,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
@@ -595,6 +602,17 @@ export default function ProxyOrderPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={assocEnabled}
|
||||
onChange={(e) => setAssocEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>关联合伙人(本单计订单佣金;取消则本单不计)</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{(options?.promoCodes.length ?? 0) > 0 && (
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">绑定推广码(选填)</label>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import type {
|
||||
PartnerAssocSummary,
|
||||
PartnerAssocUserItem,
|
||||
PartnerAssocUserSort,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
|
||||
type ListRes = { items: PartnerAssocUserItem[]; total: number };
|
||||
|
||||
const SORTS: { key: PartnerAssocUserSort; label: string }[] = [
|
||||
{ key: 'boundAt', label: '关联时间' },
|
||||
{ key: 'createdAt', label: '注册时间' },
|
||||
{ key: 'orderCount', label: '订单数' },
|
||||
];
|
||||
|
||||
function formatUserLabel(u: PartnerAssocUserItem) {
|
||||
const name = u.nickname?.trim() || u.userNo || '—';
|
||||
const remark = u.partnerRemark?.trim();
|
||||
return remark ? `${name}(${remark})` : name;
|
||||
}
|
||||
|
||||
function fmtTime(iso?: string | null) {
|
||||
if (!iso) return '—';
|
||||
return iso.slice(0, 16).replace('T', ' ');
|
||||
}
|
||||
|
||||
export default function UsersManagePage() {
|
||||
usePartnerPageView('partner_users_manage_view');
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [summary, setSummary] = useState<PartnerAssocSummary | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [items, setItems] = useState<PartnerAssocUserItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [appliedKeyword, setAppliedKeyword] = useState('');
|
||||
const [sort, setSort] = useState<PartnerAssocUserSort>('boundAt');
|
||||
const [editing, setEditing] = useState<PartnerAssocUserItem | null>(null);
|
||||
const [remarkDraft, setRemarkDraft] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = '用户管理';
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
}, [previewUrl]);
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
const data = await request<PartnerAssocSummary>('PARTNER_H5', '/partner/assoc');
|
||||
setSummary(data);
|
||||
}, []);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
const qs = new URLSearchParams({ page: '1', pageSize: '50', sort });
|
||||
const q = appliedKeyword.trim();
|
||||
if (q) qs.set('keyword', q);
|
||||
const res = await request<ListRes>('PARTNER_H5', `/partner/assoc/users?${qs}`);
|
||||
setItems(res.items ?? []);
|
||||
setTotal(res.total ?? 0);
|
||||
}, [appliedKeyword, sort]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([loadSummary(), loadUsers()]);
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadSummary, loadUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setAppliedKeyword(keyword.trim()), 400);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
const uid = searchParams.get('userId');
|
||||
if (uid) navigate(`/users/${uid}/orders`, { replace: true });
|
||||
}, [searchParams, navigate]);
|
||||
|
||||
async function downloadQr() {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
try {
|
||||
const res = await fetch('/api/v1/partner/assoc/qrcode', {
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : '',
|
||||
'X-Client-App': 'PARTNER_H5',
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error('下载失败');
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (isWechatEnv()) {
|
||||
setPreviewUrl(url);
|
||||
toastSuccess('请长按图片保存到相册');
|
||||
return;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `partner-assoc-${summary?.partnerId || 'qr'}.png`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toastSuccess('已开始下载');
|
||||
} catch (e) {
|
||||
if (summary?.qrcodeUrl) {
|
||||
setPreviewUrl(summary.qrcodeUrl);
|
||||
toastSuccess('请长按图片保存到相册');
|
||||
return;
|
||||
}
|
||||
toastError(e instanceof Error ? e.message : '下载失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRemark() {
|
||||
if (!editing) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await request(`PARTNER_H5`, `/partner/assoc/users/${editing.id}/remark`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ remark: remarkDraft }),
|
||||
});
|
||||
toastSuccess(remarkDraft.trim() ? '备注已保存' : '已清除备注');
|
||||
setEditing(null);
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={load} className="page partner-users-page">
|
||||
<div style={{ padding: '16px 20px 24px' }}>
|
||||
<h1 className="headline-lg" style={{ marginBottom: 16 }}>用户管理</h1>
|
||||
|
||||
<section className="partner-bill-card" style={{ marginBottom: 20, textAlign: 'center', padding: 20 }}>
|
||||
<p className="body-md text-muted" style={{ marginBottom: 12 }}>
|
||||
用户扫码后首次锁定,后续购酒计入关联订单
|
||||
</p>
|
||||
{summary?.qrcodeUrl ? (
|
||||
<img
|
||||
src={previewUrl || summary.qrcodeUrl}
|
||||
alt="关联码"
|
||||
style={{ width: 200, height: 200, background: '#fff' }}
|
||||
/>
|
||||
) : (
|
||||
<p className="body-md text-muted">{loading ? '加载中…' : '关联码尚未生成'}</p>
|
||||
)}
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
已关联 {summary?.userCount ?? total} 人
|
||||
</p>
|
||||
<button type="button" className="partner-btn-primary" style={{ marginTop: 16 }} onClick={() => void downloadQr()}>
|
||||
下载二维码
|
||||
</button>
|
||||
{previewUrl && isWechatEnv() && (
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>微信内请长按上方图片保存</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<h2 className="headline-md" style={{ marginBottom: 12 }}>已关联用户</h2>
|
||||
<div className="partner-search">
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
<input
|
||||
value={keyword}
|
||||
placeholder="搜索昵称 / 手机 / 备注 / 编号"
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void loadUsers();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="partner-filter-row" style={{ marginBottom: 12, paddingLeft: 0, paddingRight: 0 }}>
|
||||
{SORTS.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
className={`partner-filter-tab${sort === s.key ? ' active' : ''}`}
|
||||
onClick={() => setSort(s.key)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 12 }}>
|
||||
{loading ? '加载中…' : `共 ${total} 人`}
|
||||
</p>
|
||||
{!loading && items.length === 0 && <div className="empty">暂无关联用户</div>}
|
||||
{items.map((u) => (
|
||||
<div key={u.id} className="partner-store-card" style={{ marginBottom: 12 }}>
|
||||
<div className="partner-store-card-header" style={{ marginBottom: 8 }}>
|
||||
<div>
|
||||
<p className="body-md">{formatUserLabel(u)}</p>
|
||||
<p className="label-md text-muted">{u.phone || '未绑定手机'}</p>
|
||||
<p className="label-md text-muted">注册 {fmtTime(u.createdAt)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="label-md text-primary"
|
||||
style={{ background: 'none', border: 0, padding: 0 }}
|
||||
onClick={() => navigate(`/users/${u.id}/orders`)}
|
||||
>
|
||||
{u.orderCount} 单
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="label-md text-primary"
|
||||
style={{ background: 'none', border: 0, padding: 0 }}
|
||||
onClick={() => {
|
||||
setEditing(u);
|
||||
setRemarkDraft(u.partnerRemark ?? '');
|
||||
}}
|
||||
>
|
||||
{u.partnerRemark ? '改备注' : '添加备注'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="partner-remark-mask" onClick={() => setEditing(null)}>
|
||||
<div className="partner-remark-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="headline-md" style={{ marginBottom: 8 }}>用户备注</p>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 12 }}>仅自己可见,总部看不到</p>
|
||||
<textarea
|
||||
value={remarkDraft}
|
||||
maxLength={128}
|
||||
rows={3}
|
||||
placeholder="最多 128 字"
|
||||
style={{ width: '100%', padding: 12, borderRadius: 8, border: '1px solid #eee' }}
|
||||
onChange={(e) => setRemarkDraft(e.target.value)}
|
||||
/>
|
||||
<div className="partner-ship-actions">
|
||||
<button type="button" className="partner-btn-secondary" onClick={() => setEditing(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
disabled={saving}
|
||||
onClick={() => void saveRemark()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -278,6 +278,22 @@ html {
|
||||
|
||||
.partner-btn-primary:active { transform: scale(0.98); }
|
||||
|
||||
.partner-btn-secondary {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-container-low);
|
||||
color: var(--color-on-surface);
|
||||
font-family: var(--font-headline);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.partner-btn-ghost {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
@@ -1344,7 +1360,7 @@ nav.app-tabbar {
|
||||
nav.app-tabbar .app-tabbar-item {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
max-width: 33.333%;
|
||||
max-width: 25%;
|
||||
height: 56px;
|
||||
padding: 6px 0 4px;
|
||||
border-radius: 0;
|
||||
@@ -3932,3 +3948,26 @@ header:has(> .app-page-title:only-child),
|
||||
margin-left: var(--space-page);
|
||||
margin-right: var(--space-page);
|
||||
}
|
||||
|
||||
.partner-users-page {
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
|
||||
.partner-remark-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.partner-remark-sheet {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--color-card, #fff);
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user