feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理

订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 14:35:39 +08:00
parent 3b669f7e38
commit 9c8d5f2cad
125 changed files with 6355 additions and 1436 deletions
+9
View File
@@ -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>
);
}
+42 -11
View File
@@ -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>
)}
+18
View File
@@ -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>
);
}
+58 -2
View File
@@ -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>
);
}
+40 -1
View File
@@ -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;
}