V4.0.9版本更新合伙人H5端调整

This commit is contained in:
2026-09-02 09:46:41 +08:00
parent af3c4b6f08
commit c905b3f930
33 changed files with 893 additions and 176 deletions
+27 -23
View File
@@ -103,6 +103,11 @@ function partnerDashLabel(p?: {
return company || person || (p.phone || '').trim() || '—';
}
function weekMonday(d: Dayjs) {
const wd = d.day();
return d.subtract(wd === 0 ? 6 : wd - 1, 'day').startOf('day');
}
export default function PartnerBillsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
@@ -113,8 +118,7 @@ export default function PartnerBillsPage() {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
if (filters.weekStartYmd) qs.set('weekStartYmd', filters.weekStartYmd);
return qs;
},
[filters],
@@ -136,19 +140,18 @@ export default function PartnerBillsPage() {
.catch(() => {});
}, []);
async function generateBill(values: { partnerId?: string; month: Dayjs }) {
const year = values.month.year();
const month = values.month.month() + 1;
async function generateBill(values: { partnerId?: string; week: Dayjs }) {
const weekStartYmd = weekMonday(values.week).format('YYYY-MM-DD');
if (!values.partnerId) {
const result = await request<{ success: number; failed: number; total: number }>(
'/admin/partner-bills/generate-all',
{ method: 'POST', body: JSON.stringify({ year, month }) },
{ method: 'POST', body: JSON.stringify({ weekStartYmd }) },
);
message.success(`已生成 ${result.success}/${result.total} 份账单${result.failed ? `,失败 ${result.failed}` : ''}`);
} else {
await request('/admin/partner-bills/generate', {
method: 'POST',
body: JSON.stringify({ partnerId: values.partnerId, year, month }),
body: JSON.stringify({ partnerId: values.partnerId, weekStartYmd }),
});
message.success('账单已生成(待审核)');
}
@@ -259,10 +262,9 @@ export default function PartnerBillsPage() {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
if (filters.weekStartYmd) qs.set('weekStartYmd', filters.weekStartYmd);
const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
const suffix = filters.weekStartYmd || 'all';
downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
@@ -274,7 +276,9 @@ export default function PartnerBillsPage() {
const partnerName = (r: Row) => partnerDashLabel(r.partnerAccount ?? r.partner);
const partnerBank = (r: Row) => r.partnerAccount ?? r.partner;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
const canSend = selectedRows.filter(
(r) => (r.status === 'PENDING_REVIEW' || r.status === 'REJECTED') && Number(r.totalAmount) > 0,
);
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
@@ -313,8 +317,9 @@ export default function PartnerBillsPage() {
{
title: '合计应付',
dataIndex: 'totalAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
width: 130,
render: (v) =>
Number(v) <= 0 ? '¥0.00(不同步)' : `¥${Number(v).toFixed(2)}`,
},
{
title: '收款户名',
@@ -350,7 +355,7 @@ export default function PartnerBillsPage() {
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && Number(row.totalAmount) > 0 && (
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
</Button>
@@ -383,7 +388,7 @@ export default function PartnerBillsPage() {
<AdminListHeader
title="合伙人账单"
settings={settingsButton}
description="每月 1 日 8:00 自动生成上账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款"
description="每周一 8:00 自动生成上账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款。零元账单不同步给合伙人。"
/>
{summary && (
@@ -401,12 +406,11 @@ export default function PartnerBillsPage() {
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: { partnerId?: string; status?: string; month?: Dayjs }) => {
onFinish={(v: { partnerId?: string; status?: string; week?: Dayjs }) => {
setFilters({
partnerId: v.partnerId || '',
status: v.status || '',
year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '',
weekStartYmd: v.week ? weekMonday(v.week).format('YYYY-MM-DD') : '',
});
setPage(1);
}}
@@ -424,8 +428,8 @@ export default function PartnerBillsPage() {
}))}
/>
</Form.Item>
<Form.Item name="month" label="账期">
<DatePicker picker="month" />
<Form.Item name="week" label="账期">
<DatePicker picker="week" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
@@ -503,11 +507,11 @@ export default function PartnerBillsPage() {
<Form
form={genForm}
layout="vertical"
initialValues={{ month: dayjs().subtract(1, 'month') }}
initialValues={{ week: weekMonday(dayjs().subtract(1, 'week')) }}
onFinish={generateBill}
>
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}>
<DatePicker picker="month" style={{ width: '100%' }} />
<Form.Item name="week" label="账单周(选任意一天,按周一起算)" rules={[{ required: true }]}>
<DatePicker picker="week" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="partnerId" label="指定合伙人(留空则全部主合伙人)">
<Select
+4
View File
@@ -26,6 +26,7 @@ import UsersManagePage from './pages/UsersManagePage';
import AssocOrdersPage from './pages/AssocOrdersPage';
import CommissionOrdersPage from './pages/CommissionOrdersPage';
import ActivityPostersPage from './pages/ActivityPostersPage';
import BankAccountPage from './pages/BankAccountPage';
function PrimaryRoutes() {
return (
@@ -42,6 +43,7 @@ function PrimaryRoutes() {
<Route path="/center/settlement" element={<SettlementPage />} />
<Route path="/center/staff" element={<StaffListPage />} />
<Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/center/bank" element={<BankAccountPage />} />
<Route path="/center/activity-posters" element={<ActivityPostersPage />} />
<Route path="/center/assoc" element={<Navigate to="/users" replace />} />
<Route path="/center/assoc/users" element={<Navigate to="/users" replace />} />
@@ -71,6 +73,7 @@ function SubAccountRoutes() {
<Routes>
<Route element={<SubAccountLayout navKind={navKind} />}>
<Route path="/" element={<HomePage />} />
<Route path="/users" element={<UsersManagePage />} />
{isWarehouse ? (
<Route path="/orders" element={<OrderListPage tabRoot />} />
) : (
@@ -86,6 +89,7 @@ function SubAccountRoutes() {
</>
)}
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/users/:userId/orders" element={<AssocOrdersPage />} />
{isWarehouse && <Route path="/orders/:id" element={<OrderDetailPage />} />}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -3,12 +3,14 @@ import type { PartnerNavKind } from '../lib/partnerAccess';
const STORE_STAFF_TABS = [
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
{ to: '/users', icon: 'group', label: '用户管理' },
{ to: '/stores', icon: 'store', label: '门店管理' },
{ to: '/me', icon: 'person', label: '个人中心' },
] as const;
const WAREHOUSE_STAFF_TABS = [
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
{ to: '/users', icon: 'group', label: '用户管理' },
{ to: '/orders', icon: 'receipt_long', label: '订单管理' },
{ to: '/me', icon: 'person', label: '个人中心' },
] as const;
+2 -2
View File
@@ -94,8 +94,8 @@ export function partnerHomePath(account: PartnerMe | null | undefined): string {
return '/';
}
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/leaderboard', '/login'];
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/leaderboard', '/login'];
const STORE_STAFF_PREFIXES = ['/', '/stores', '/users', '/me', '/leaderboard', '/login'];
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/users', '/me', '/leaderboard', '/login'];
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
if (pathname === '/login') return true;
+16
View File
@@ -0,0 +1,16 @@
import type { PartnerSettlementPreviewDto } from '@dukang/shared-types';
export function formatBillPeriod(start?: string | null, end?: string | null) {
const s = String(start || '').slice(0, 10);
const e = String(end || '').slice(0, 10);
if (!s) return '—';
if (!e || e === s) return s;
return `${s} ~ ${e}`;
}
export function formatIssueLabel(preview: Pick<PartnerSettlementPreviewDto, 'nextIssueAt' | 'cycleLabel'> | null) {
if (!preview?.nextIssueAt) return preview?.cycleLabel || '每周一 08:00 出账';
const ymd = new Date(preview.nextIssueAt).toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
if (!ymd || ymd === 'Invalid Date') return preview.cycleLabel || '每周一 08:00 出账';
return `${ymd} 08:00 出账`;
}
@@ -0,0 +1,141 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import type { PartnerMe, UpdatePartnerBankRequest } from '@dukang/shared-types';
import { request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { toastError, toastSuccess } from '../lib/toast';
type FieldErrors = {
bankAccountName?: string;
bankAccountNo?: string;
bankBranch?: string;
};
export default function BankAccountPage() {
const navigate = useNavigate();
const { account, refresh } = usePartnerSession();
const [bankAccountName, setBankAccountName] = useState(account?.bankAccount?.bankAccountName ?? '');
const [bankAccountNo, setBankAccountNo] = useState(account?.bankAccount?.bankAccountNo ?? '');
const [bankBranch, setBankBranch] = useState(account?.bankAccount?.bankBranch ?? '');
const [submitting, setSubmitting] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
useEffect(() => {
document.title = '收款账户';
}, []);
useEffect(() => {
setBankAccountName(account?.bankAccount?.bankAccountName ?? '');
setBankAccountNo(account?.bankAccount?.bankAccountNo ?? '');
setBankBranch(account?.bankAccount?.bankBranch ?? '');
}, [account?.bankAccount]);
async function submit() {
const name = bankAccountName.trim();
const no = bankAccountNo.replace(/\s+/g, '');
const branch = bankBranch.trim();
const next: FieldErrors = {};
if (!name) next.bankAccountName = '请填写收款人';
if (!/^\d{8,32}$/.test(no)) next.bankAccountNo = '请填写 832 位数字账号';
if (!branch) next.bankBranch = '请填写开户行名称';
setFieldErrors(next);
if (Object.keys(next).length) return;
setSubmitting(true);
try {
const body: UpdatePartnerBankRequest = {
bankAccountName: name,
bankAccountNo: no,
bankBranch: branch,
};
await request<PartnerMe>('PARTNER_H5', '/partner/me/bank', {
method: 'PUT',
body: JSON.stringify(body),
});
await refresh();
toastSuccess('收款账户已保存');
navigate('/center');
} catch (e) {
toastError(e instanceof Error ? e.message : '保存失败');
} finally {
setSubmitting(false);
}
}
return (
<div className="partner-page-sticky">
<PageHeader title="收款账户" onBack={() => navigate('/center')} />
<section className="partner-form-card">
<div className="partner-section-title">
<div className="partner-section-bar" />
<h2 className="headline-md"></h2>
</div>
<p className="label-md text-muted" style={{ marginBottom: 16, lineHeight: 1.5 }}>
</p>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">person</span>
<input
placeholder="户名 / 收款人姓名"
value={bankAccountName}
maxLength={64}
onChange={(e) => setBankAccountName(e.target.value)}
/>
</div>
{fieldErrors.bankAccountName && (
<p className="partner-field-error" role="alert">{fieldErrors.bankAccountName}</p>
)}
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">credit_card</span>
<input
inputMode="numeric"
placeholder="请输入银行账号"
value={bankAccountNo}
maxLength={32}
onChange={(e) => setBankAccountNo(e.target.value.replace(/[^\d]/g, ''))}
/>
</div>
{fieldErrors.bankAccountNo && (
<p className="partner-field-error" role="alert">{fieldErrors.bankAccountNo}</p>
)}
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">account_balance</span>
<input
placeholder="如 中国工商银行洛阳分行"
value={bankBranch}
maxLength={128}
onChange={(e) => setBankBranch(e.target.value)}
/>
</div>
{fieldErrors.bankBranch && (
<p className="partner-field-error" role="alert">{fieldErrors.bankBranch}</p>
)}
</div>
</section>
<footer className="partner-sticky-footer">
<button
type="button"
className="partner-btn-primary"
disabled={submitting}
onClick={() => void submit()}
>
{submitting ? '保存中…' : '保存'}
</button>
</footer>
</div>
);
}
+4 -2
View File
@@ -4,6 +4,7 @@ import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerBillDetailDto, PartnerBillDto, PartnerBillItemDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api';
import { formatBillPeriod } from '../lib/settlement';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerPageView } from '../lib/usePageView';
@@ -188,7 +189,8 @@ export default function BillsPage() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '结算账单')}</h2>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{formatBillPeriod(bill.periodStart, bill.periodEnd)}</p>
</div>
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isUnpaid ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
{billStatusLabel(status)}
@@ -271,7 +273,7 @@ export default function BillsPage() {
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div>
<p className="body-md">{b.billNo}</p>
<p className="label-md text-muted">{billStatusLabel(b.status)}</p>
<p className="label-md text-muted">{formatBillPeriod(b.periodStart, b.periodEnd)} · {billStatusLabel(b.status)}</p>
{b.status === 'REJECTED' && b.rejectReason ? (
<p className="label-md text-primary" style={{ marginTop: 4 }}>{b.rejectReason}</p>
) : null}
+31 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import type { PartnerBillDto } from '@dukang/shared-types';
import type { PartnerBillDto, PartnerSettlementPreviewDto } from '@dukang/shared-types';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api';
@@ -8,6 +8,7 @@ import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { contactSupport } from '../lib/contact';
import { isPrimaryAccount } from '../lib/partnerAccess';
import { listPartnerStaff } from '../lib/staff';
import { formatBillPeriod, formatIssueLabel } from '../lib/settlement';
import { toastError, toastSuccess } from '../lib/toast';
import {
authorizePartnerWechat,
@@ -31,6 +32,7 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
const isPrimary = variant === 'primary' && isPrimaryAccount(account);
const [authorizing, setAuthorizing] = useState(false);
const [bills, setBills] = useState<PartnerBillDto[]>([]);
const [preview, setPreview] = useState<PartnerSettlementPreviewDto | null>(null);
const [staffCount, setStaffCount] = useState(0);
useEffect(() => {
@@ -44,6 +46,9 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
.then(setBills)
.catch(() => setBills([])),
request<PartnerSettlementPreviewDto>('PARTNER_H5', '/partner/settlement/preview')
.then(setPreview)
.catch(() => setPreview(null)),
listPartnerStaff()
.then((list) => setStaffCount(list.length))
.catch(() => setStaffCount(0)),
@@ -188,6 +193,15 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
<p className="partner-finance-hero-label"> ()</p>
<p className="partner-finance-hero-amount">{fmtMoney(finance.balance)}</p>
</div>
<div className="partner-finance-card">
<p className="label-md text-muted"></p>
<p className="partner-finance-value text-primary">¥ {fmtMoney(preview?.prepaymentAmount ?? 0)}</p>
<p className="label-md text-muted" style={{ marginTop: 6, lineHeight: 1.4 }}>
{formatBillPeriod(preview?.periodStart, preview?.periodEnd)}
<br />
{formatIssueLabel(preview)}
</p>
</div>
<Link to="/center/settlement" className="partner-finance-card">
<p className="label-md text-muted"></p>
<p className="partner-finance-value text-primary">¥ {fmtMoney(finance.pendingTotal)}</p>
@@ -219,6 +233,22 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<Link to="/center/bank" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">account_balance</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<div className="partner-menu-item-right">
{account?.bankAccount?.bankAccountNo ? (
<span className="label-md text-muted">{account.bankAccount.bankAccountNo.slice(-4)}</span>
) : (
<span className="partner-menu-status"></span>
)}
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<Link to="/center/activity-posters" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
+14 -15
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { PartnerAssocStats, PartnerLeaderboardEntry } from '@dukang/shared-types';
import type { PartnerAssocStats, PartnerLeaderboardEntry, PartnerSettlementPreviewDto } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { usePartnerPageView } from '../lib/usePageView';
import { isLoggedIn, request } from '../lib/api';
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import { formatBillPeriod, formatIssueLabel } from '../lib/settlement';
import {
canAccessPartnerDashboard,
canAccessPartnerOrders,
@@ -154,6 +155,7 @@ export default function HomePage() {
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
const [assocStats, setAssocStats] = useState<PartnerAssocStats | null>(null);
const [preview, setPreview] = useState<PartnerSettlementPreviewDto | null>(null);
useEffect(() => {
document.title = '工作台';
@@ -203,9 +205,13 @@ export default function HomePage() {
request<PartnerAssocStats>('PARTNER_H5', '/partner/assoc/stats', { silent: true })
.then(setAssocStats)
.catch(() => setAssocStats(null)),
request<PartnerSettlementPreviewDto>('PARTNER_H5', '/partner/settlement/preview', { silent: true })
.then(setPreview)
.catch(() => setPreview(null)),
);
} else {
setAssocStats(null);
setPreview(null);
}
tasks.push(
@@ -245,27 +251,20 @@ export default function HomePage() {
? Math.round(((monthNew - lastMonthNew) / lastMonthNew) * 100)
: (monthNew > 0 ? 100 : 0);
const orderCount = Number(dash?.orderCount || orderStats.todayCount || 0);
const revenue = orderCount * 128.45;
const profit = revenue * 0.25;
return (
<PullToRefresh onRefresh={loadHome} className="page partner-home partner-home--flush-top">
<main className="partner-home-body">
{isPrimary && (
<>
<section className="partner-revenue-card">
<p className="partner-revenue-label">
(CNY)
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
<p className="partner-revenue-label"> (CNY)</p>
<div className="partner-revenue-amount">{fmtMoney(preview?.prepaymentAmount ?? 0)}</div>
<p className="partner-revenue-label" style={{ marginTop: 8, opacity: 0.85, lineHeight: 1.5 }}>
{formatBillPeriod(preview?.periodStart, preview?.periodEnd)} · {formatIssueLabel(preview)}
</p>
<p className="partner-revenue-label" style={{ marginTop: 6, opacity: 0.75 }}>
</p>
<div className="partner-revenue-amount">{fmtMoney(revenue)}</div>
<div className="partner-revenue-grid">
<div>
<p className="partner-revenue-label"></p>
<p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
</div>
</div>
</section>
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
<div className="partner-quick-actions">
+19 -52
View File
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, 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 { PartnerBillDto } from '@dukang/shared-types';
import type { PartnerBillDto, PartnerSettlementPreviewDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api';
import { formatBillPeriod, formatIssueLabel } from '../lib/settlement';
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing' | 'rejected';
@@ -31,25 +32,23 @@ function billStatusClass(status: string) {
}
}
function billPeriodLabel(periodStart: string) {
const d = new Date(periodStart);
if (Number.isNaN(d.getTime())) return '—';
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function billMonthLabel(year: number, month: number) {
return `${year}${month}`;
function billPeriodLabel(bill: PartnerBillDto) {
return formatBillPeriod(bill.periodStart, bill.periodEnd);
}
export default function SettlementPage() {
const navigate = useNavigate();
const [bills, setBills] = useState<PartnerBillDto[]>([]);
const now = new Date();
const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 });
const [preview, setPreview] = useState<PartnerSettlementPreviewDto | null>(null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const loadBills = useCallback(() => {
return request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
return Promise.all([
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills),
request<PartnerSettlementPreviewDto>('PARTNER_H5', '/partner/settlement/preview')
.then(setPreview)
.catch(() => setPreview(null)),
]).then(() => undefined);
}, []);
useEffect(() => {
@@ -60,22 +59,14 @@ export default function SettlementPage() {
const summary = useMemo(() => {
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
const settled = bills.filter((b) => b.status === 'PAID');
const currentMonth = bills.filter((b) => {
const d = new Date(b.periodStart);
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
});
return {
pendingTotal: pending.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
monthEstimate: currentMonth.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
};
}, [bills, now]);
}, [bills]);
const filteredBills = useMemo(() => {
return bills.filter((b) => {
const d = new Date(b.periodStart);
const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
if (!matchMonth) return false;
if (statusFilter === 'all') return true;
if (statusFilter === 'pending') return b.status === 'AWAITING_CONFIRM';
if (statusFilter === 'settled') return b.status === 'PAID';
@@ -83,16 +74,7 @@ export default function SettlementPage() {
if (statusFilter === 'rejected') return b.status === 'REJECTED';
return true;
});
}, [bills, month, statusFilter]);
const monthOptions = useMemo(() => {
const opts: Array<{ year: number; month: number }> = [];
for (let i = 0; i < 6; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
opts.push({ year: d.getFullYear(), month: d.getMonth() + 1 });
}
return opts;
}, [now]);
}, [bills, statusFilter]);
function openBill(bill: PartnerBillDto) {
navigate(bill.id ? `/center/bills?id=${bill.id}` : '/center/bills');
@@ -127,33 +109,18 @@ export default function SettlementPage() {
</div>
<div className="partner-settlement-summary-divider" />
<div>
<p className="partner-settlement-summary-sub"></p>
<p className="partner-settlement-summary-sub"></p>
<p className="partner-settlement-summary-value partner-settlement-summary-value--accent">
¥{fmtMoney(summary.monthEstimate)}
¥{fmtMoney(preview?.prepaymentAmount ?? 0)}
</p>
</div>
</div>
</section>
<section className="partner-settlement-filters">
<select
className="partner-settlement-month"
value={`${month.year}-${month.month}`}
onChange={(e) => {
const [y, m] = e.target.value.split('-').map(Number);
setMonth({ year: y, month: m });
}}
>
{monthOptions.map((opt) => (
<option key={`${opt.year}-${opt.month}`} value={`${opt.year}-${opt.month}`}>
{billMonthLabel(opt.year, opt.month)}
</option>
))}
</select>
<button type="button" className="partner-settlement-status-btn">
<span className="material-symbols-outlined">filter_list</span>
</button>
<p className="label-md text-muted" style={{ margin: 0, lineHeight: 1.5 }}>
{formatBillPeriod(preview?.periodStart, preview?.periodEnd)} · {formatIssueLabel(preview)}
</p>
</section>
<div className="partner-settlement-chips">
@@ -193,7 +160,7 @@ export default function SettlementPage() {
</div>
<p className="partner-settlement-item-meta">
<span className="material-symbols-outlined">event</span>
{billPeriodLabel(bill.periodStart)}
{billPeriodLabel(bill)}
<span className="partner-settlement-dot" />
¥{fmtMoney(Number(bill.orderCommission || 0))}
</p>
@@ -205,7 +205,7 @@ export default function StaffCreatePage() {
<span className="material-symbols-outlined">info</span>
</div>
<p className="label-md text-variant" style={{ marginTop: 0, lineHeight: 1.5 }}>
</p>
</div>
+47 -26
View File
@@ -16,6 +16,8 @@ import { toastError, toastSuccess } from '../lib/toast';
import { blobToDataUrl, isHttpImageUrl, previewSaveableImage } from '../lib/wechat-save-image';
import { isWechatEnv } from '../lib/weixin';
import { usePartnerPageView } from '../lib/usePageView';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess';
type ListRes = { items: PartnerAssocUserItem[]; total: number };
@@ -38,6 +40,8 @@ function fmtTime(iso?: string | null) {
export default function UsersManagePage() {
usePartnerPageView('partner_users_manage_view');
const { account } = usePartnerSession();
const hidePoster = isSubAccount(account);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [summary, setSummary] = useState<PartnerAssocSummary | null>(null);
@@ -63,7 +67,7 @@ export default function UsersManagePage() {
}, [previewUrl]);
useEffect(() => {
const posterId = summary?.activityPosterId;
const posterId = hidePoster ? null : summary?.activityPosterId;
if (!posterId) {
setHeroUrl(null);
setHeroLoading(false);
@@ -88,7 +92,7 @@ export default function UsersManagePage() {
cancelled = true;
if (acc.url) URL.revokeObjectURL(acc.url);
};
}, [summary?.activityPosterId]);
}, [hidePoster, summary?.activityPosterId]);
const loadSummary = useCallback(async () => {
const data = await request<PartnerAssocSummary>('PARTNER_H5', '/partner/assoc');
@@ -130,7 +134,7 @@ export default function UsersManagePage() {
}, [searchParams, navigate]);
async function downloadMainImage() {
const posterId = summary?.activityPosterId;
const posterId = hidePoster ? null : summary?.activityPosterId;
try {
if (isWechatEnv() && !posterId && isHttpImageUrl(summary?.qrcodeUrl)) {
await previewSaveableImage(summary.qrcodeUrl);
@@ -190,14 +194,14 @@ export default function UsersManagePage() {
<p className="body-md text-muted" style={{ marginBottom: 12 }}>
</p>
{summary?.activityPosterId && (previewUrl || heroUrl) ? (
{summary?.activityPosterId && !hidePoster && (previewUrl || heroUrl) ? (
<img
className="partner-longpress-img"
src={previewUrl || heroUrl || ''}
alt="活动图"
style={{ width: '100%', maxWidth: 360, background: '#f5f5f5' }}
/>
) : summary?.activityPosterId && heroLoading ? (
) : summary?.activityPosterId && !hidePoster && heroLoading ? (
<p className="body-md text-muted"></p>
) : previewUrl || summary?.qrcodeUrl ? (
<img
@@ -209,19 +213,27 @@ export default function UsersManagePage() {
) : (
<p className="body-md text-muted">{loading ? '加载中…' : '关联码尚未生成'}</p>
)}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{summary?.userCount ?? total}
</p>
{(() => {
const scanCount = summary?.scanCount ?? 0;
const userCount = summary?.userCount ?? total;
if (scanCount <= 0 && userCount <= 0) return null;
return (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{scanCount} · {userCount}
</p>
);
})()}
<button type="button" className="partner-btn-primary" style={{ marginTop: 16 }} onClick={() => void downloadMainImage()}>
{summary?.activityPosterId ? '下载活动图' : '下载二维码'}
{summary?.activityPosterId && !hidePoster ? '下载活动图' : '下载二维码'}
</button>
{isWechatEnv() && (previewUrl || summary?.qrcodeUrl) ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{summary?.activityPosterId ? '下载活动图' : '下载二维码'}
{summary?.activityPosterId && !hidePoster ? '下载活动图' : '下载二维码'}
</p>
) : null}
</section>
{!hidePoster && (
<Link to="/center/activity-posters" className="partner-menu-card" style={{ display: 'block', marginBottom: 20, textDecoration: 'none', color: 'inherit' }}>
<div className="partner-menu-item">
<div className="partner-menu-item-left">
@@ -233,6 +245,7 @@ export default function UsersManagePage() {
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
)}
<h2 className="headline-md" style={{ marginBottom: 12 }}></h2>
<div className="partner-search">
@@ -264,32 +277,40 @@ export default function UsersManagePage() {
{!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>
<div className="partner-store-card-header" style={{ marginBottom: 4 }}>
<p className="body-md">{formatUserLabel(u)}</p>
<button
type="button"
className="label-md text-primary"
style={{ background: 'none', border: 0, padding: 0 }}
style={{ background: 'none', border: 0, padding: 0, flexShrink: 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 ?? '');
<p className="label-md text-muted">{u.phone || '未绑定手机'}</p>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
marginTop: 4,
}}
>
{u.partnerRemark ? '改备注' : '添加备注'}
</button>
<p className="label-md text-muted"> {fmtTime(u.createdAt)}</p>
<button
type="button"
className="label-md text-primary"
style={{ background: 'none', border: 0, padding: 0, flexShrink: 0 }}
onClick={() => {
setEditing(u);
setRemarkDraft(u.partnerRemark ?? '');
}}
>
{u.partnerRemark ? '改备注' : '添加备注'}
</button>
</div>
</div>
))}
</div>
+19
View File
@@ -162,6 +162,11 @@ export async function capturePromoSceneAndTouchScan(): Promise<void> {
const assocScene = extractAssocSceneFromEnterOptions(opts);
if (assocScene) {
setStoredAssocScene(assocScene);
const touchKey = `assoc|${assocScene}|${opts?.path || ''}|${JSON.stringify(opts?.query || {})}|${String(opts?.scene ?? '')}`;
if (touchKey !== lastScanTouchKey) {
lastScanTouchKey = touchKey;
await touchAssoc({ scene: assocScene, countScan: true });
}
await bindStoredAssocIfLoggedIn();
return;
}
@@ -179,6 +184,20 @@ export async function capturePromoSceneAndTouchScan(): Promise<void> {
// 无新 scene 时不重复扫码计数
}
async function touchAssoc(input: { scene: string; countScan: boolean }): Promise<void> {
try {
await request('/user/partner-assoc/touch', {
method: 'POST',
data: {
scene: input.scene,
countScan: input.countScan,
},
});
} catch {
/* 静默失败,不阻断浏览 */
}
}
/** 登录成功后:用已缓存的活动 ID 做归因(不重复加扫码次数) */
export async function touchStoredPromoAfterLogin(): Promise<void> {
await bindStoredAssocIfLoggedIn();