fix:需求完善

This commit is contained in:
2026-07-01 16:24:18 +08:00
parent f3ae9e6520
commit d6855c9f5c
9 changed files with 405 additions and 43 deletions
+6
View File
@@ -9,6 +9,9 @@ import OrderListPage from './pages/OrderListPage';
import OrderDetailPage from './pages/OrderDetailPage';
import CenterPage from './pages/CenterPage';
import BillsPage from './pages/BillsPage';
import ReshipPage from './pages/ReshipPage';
import WeeklyReportPage from './pages/WeeklyReportPage';
import LeaderboardPage from './pages/LeaderboardPage';
export default function App() {
return (
@@ -22,6 +25,9 @@ export default function App() {
</Route>
<Route path="/stores/new" element={<StoreCreatePage />} />
<Route path="/center/bills" element={<BillsPage />} />
<Route path="/reshipments" element={<ReshipPage />} />
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
<Route path="/orders/:id" element={<OrderDetailPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
+15
View File
@@ -0,0 +1,15 @@
export type LeaderboardEntry = {
rank: number;
name: string;
role: string;
totalStores: number;
monthStores: number;
};
/** preV1:贡献榜 Mock,待 `/partner/dashboard/leaderboard` 接入后替换 */
export const LEADERBOARD_MOCK: LeaderboardEntry[] = [
{ rank: 1, name: '陈经理', role: '内部员工', totalStores: 45, monthStores: 8 },
{ rank: 2, name: '李华', role: '城市合伙人', totalStores: 32, monthStores: 5 },
{ rank: 3, name: '王拓', role: '推广员', totalStores: 18, monthStores: 3 },
{ rank: 4, name: '赵敏', role: '内部员工', totalStores: 12, monthStores: 2 },
];
+65
View File
@@ -0,0 +1,65 @@
export type StoreDraftForm = {
name: string;
phone: string;
district: string;
address: string;
intro: string;
bankAccountName: string;
bankAccountNo: string;
bankBranch: string;
};
export const STORE_DRAFT_KEY = 'partner_store_draft_v1';
export const defaultStoreForm = (): StoreDraftForm => ({
name: '',
phone: '',
district: '金水区',
address: '',
intro: '',
bankAccountName: '',
bankAccountNo: '',
bankBranch: '',
});
export function loadStoreDraft(): StoreDraftForm | null {
try {
const raw = localStorage.getItem(STORE_DRAFT_KEY);
if (!raw) return null;
return { ...defaultStoreForm(), ...JSON.parse(raw) };
} catch {
return null;
}
}
export function saveStoreDraft(form: StoreDraftForm) {
localStorage.setItem(STORE_DRAFT_KEY, JSON.stringify(form));
}
export function clearStoreDraft() {
localStorage.removeItem(STORE_DRAFT_KEY);
}
const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/;
export function validateStoreStep1(form: StoreDraftForm): string | null {
if (!form.name.trim()) return '请填写门店名称';
if (!form.phone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
if (!form.district.trim()) return '请填写区县';
if (!form.address.trim()) return '请填写详细地址';
if (form.intro.trim()) {
const len = form.intro.trim().length;
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
}
return null;
}
export function validateStoreStep3(form: StoreDraftForm): string | null {
if (!form.bankAccountName.trim()) return '请填写户主姓名';
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch.trim()) return '请填写开户支行';
return null;
}
+68 -12
View File
@@ -1,29 +1,48 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
import { LEADERBOARD_MOCK } from '../lib/leaderboard';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function auditStatus(store: Record<string, unknown>) {
const audits = store.audits as Array<{ status?: string }> | undefined;
return String(audits?.[0]?.status ?? '').toUpperCase();
}
export default function HomePage() {
const navigate = useNavigate();
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
request('PARTNER_H5', '/partner/dashboard').then(setDash);
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash);
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
}, [navigate]);
const storeCount = Number(dash?.storeCount || 0);
const storeCount = Number(dash?.storeCount || stores.length || 0);
const orderCount = Number(dash?.orderCount || 0);
const activeStores = Math.max(0, storeCount - Math.ceil(storeCount * 0.05));
const abnormalStores = storeCount - activeStores;
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
const abnormalStores = Math.max(0, storeCount - activeStores);
const pendingStores = useMemo(
() => stores.filter((s) => auditStatus(s) === 'PENDING'),
[stores],
);
const revenue = orderCount * 128.45;
const profit = revenue * 0.25;
const pendingShip = Math.ceil(orderCount * 0.04);
const shipping = Math.ceil(orderCount * 0.12);
const completed = Math.max(0, orderCount - pendingShip - shipping);
const monthNew = stores.filter((s) => {
const created = new Date(String(s.createdAt || ''));
const now = new Date();
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
}).length;
const previewLeaderboard = LEADERBOARD_MOCK.slice(0, 2);
return (
<div className="page partner-home">
@@ -87,7 +106,7 @@ export default function HomePage() {
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/orders" className="partner-quick-action">
<Link to="/reshipments" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--amber">
<span className="material-symbols-outlined">assignment_return</span>
{pendingShip > 0 && <span className="partner-quick-badge-count">{pendingShip}</span>}
@@ -100,7 +119,7 @@ export default function HomePage() {
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/orders" className="partner-quick-action">
<Link to="/reports/weekly" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
<span className="material-symbols-outlined">monitoring</span>
</div>
@@ -139,17 +158,54 @@ export default function HomePage() {
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="headline-lg" style={{ fontSize: 24 }}>{Math.ceil(storeCount * 0.08)}</span>
<span className="label-md text-success" style={{ display: 'flex', alignItems: 'center', fontSize: 10 }}>
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>trending_up</span> 18%
</span>
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
</div>
</div>
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24 }}>{Math.ceil(storeCount * 0.03)}</p>
<p className="headline-lg" style={{ fontSize: 24 }}>{pendingStores.length}</p>
</div>
</div>
{stores.length > 0 && (
<ul className="partner-store-name-list">
{stores.slice(0, 5).map((s) => (
<li key={String(s.id)}>
<span className="headline-md">{String(s.name)}</span>
<span className="label-md text-muted">
{auditStatus(s) === 'PENDING' ? '待审核' : String(s.status) === 'OPEN' ? '营业中' : String(s.status)}
</span>
</li>
))}
</ul>
)}
<div className="partner-leaderboard-section">
<div className="partner-leaderboard-header">
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 18 }}>military_tech</span>
</h3>
<Link to="/leaderboard" className="label-md text-primary"></Link>
</div>
{previewLeaderboard.map((entry) => (
<div key={entry.rank} className="partner-leaderboard-row partner-leaderboard-row--compact">
<div className="partner-leaderboard-rank">{entry.rank}</div>
<div className="partner-leaderboard-info">
<p className="body-md" style={{ fontWeight: 600 }}>
{entry.name}
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.role})</span>
</p>
<p className="label-md text-muted"> {entry.totalStores} </p>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.monthStores} </p>
<p className="label-md text-muted"></p>
</div>
</div>
))}
</div>
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
{String(dash?.companyName || '郑州合伙人')} ·
</p>
@@ -0,0 +1,36 @@
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { LEADERBOARD_MOCK } from '../lib/leaderboard';
export default function LeaderboardPage() {
const navigate = useNavigate();
return (
<div className="page-no-tab">
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} />
<p className="label-md text-muted" style={{ padding: '0 20px 12px' }}>
</p>
<div style={{ padding: '0 20px 24px' }}>
{LEADERBOARD_MOCK.map((entry) => (
<div key={entry.rank} className="partner-leaderboard-row">
<div className="partner-leaderboard-rank">{entry.rank}</div>
<div className="partner-leaderboard-info">
<p className="headline-md">
{entry.name}
<span className="label-md text-muted" style={{ marginLeft: 6, fontWeight: 400 }}>({entry.role})</span>
</p>
<p className="label-md text-muted"> {entry.totalStores} </p>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.monthStores} </p>
<p className="label-md text-muted"></p>
</div>
</div>
))}
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
/** BUWR-23:补发处理(preV1 占位,待 `/partner/after-sales/reshipments` 接入) */
const MOCK_ITEMS = [
{ id: '1', orderNo: 'DK20260301001', status: '待配送', productName: '杜康窖藏', originalOrderNo: 'DK20260228088' },
];
export default function ReshipPage() {
const navigate = useNavigate();
return (
<div className="page-no-tab partner-orders-page">
<PageHeader title="补发处理" onBack={() => navigate('/')} />
<p className="label-md text-muted" style={{ padding: '0 20px 12px' }}>
</p>
{MOCK_ITEMS.length === 0 && <div className="empty"></div>}
{MOCK_ITEMS.map((item) => (
<div key={item.id} className="partner-order-card" style={{ margin: '0 20px 12px' }}>
<div className="partner-order-card-top">
<span className="label-md text-muted"> {item.originalOrderNo}</span>
<span className="label-md text-primary" style={{ fontWeight: 600 }}>{item.status}</span>
</div>
<h3 className="headline-md" style={{ marginTop: 8 }}>{item.productName}</h3>
<p className="label-md text-muted" style={{ marginTop: 4 }}> {item.orderNo}</p>
<button type="button" className="partner-btn-primary" style={{ marginTop: 16, width: '100%' }}>
</button>
</div>
))}
</div>
);
}
+59 -31
View File
@@ -1,7 +1,16 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import {
clearStoreDraft,
defaultStoreForm,
loadStoreDraft,
saveStoreDraft,
type StoreDraftForm,
validateStoreStep1,
validateStoreStep3,
} from '../lib/storeDraft';
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
@@ -9,28 +18,51 @@ export default function StoreCreatePage() {
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const step = Number(params.get('step') || 1);
const [form, setForm] = useState({
name: '',
phone: '',
district: '金水区',
address: '',
intro: '',
accountPhone: '',
accountName: '',
bankAccountName: '',
bankAccountNo: '',
bankBranch: '',
});
const [form, setForm] = useState<StoreDraftForm>(() => loadStoreDraft() ?? defaultStoreForm());
const [error, setError] = useState('');
useEffect(() => {
saveStoreDraft(form);
}, [form]);
function patchForm(patch: Partial<StoreDraftForm>) {
setForm((prev) => ({ ...prev, ...patch }));
setError('');
}
function goStep(n: number) {
setParams({ step: String(n) });
setError('');
}
function handleNext() {
if (step === 1) {
const msg = validateStoreStep1(form);
if (msg) { setError(msg); return; }
}
goStep(step + 1);
}
async function submit() {
const msg = validateStoreStep3(form);
if (msg) { setError(msg); return; }
const step1Msg = validateStoreStep1(form);
if (step1Msg) { setError(step1Msg); return; }
await request('PARTNER_H5', '/partner/stores', {
method: 'POST',
body: JSON.stringify(form),
body: JSON.stringify({
name: form.name.trim(),
phone: form.phone.trim(),
district: form.district.trim(),
address: form.address.trim(),
intro: form.intro.trim() || undefined,
bankAccountName: form.bankAccountName.trim(),
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
bankBranch: form.bankBranch.trim(),
}),
});
clearStoreDraft();
navigate('/stores');
}
@@ -61,6 +93,10 @@ export default function StoreCreatePage() {
</div>
</nav>
{error && (
<p className="partner-form-error" role="alert">{error}</p>
)}
{step === 1 && (
<>
<section className="partner-form-card">
@@ -72,30 +108,30 @@ export default function StoreCreatePage() {
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">store</span>
<input placeholder="请输入门店名称" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<input placeholder="请输入门店名称" value={form.name} onChange={(e) => patchForm({ name: e.target.value })} />
</div>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">call</span>
<input type="tel" placeholder="请输入联系电话" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<input type="tel" placeholder="请输入联系电话" value={form.phone} onChange={(e) => patchForm({ phone: e.target.value })} />
</div>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">location_on</span>
<input placeholder="请选择区县" value={form.district} onChange={(e) => setForm({ ...form, district: e.target.value })} />
<input placeholder="请选择区县" value={form.district} onChange={(e) => patchForm({ district: e.target.value })} />
</div>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
</div>
<div className="partner-field">
<label></label>
<textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
<textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} />
<div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.intro.length} / 500</span>
</div>
@@ -184,23 +220,15 @@ export default function StoreCreatePage() {
<section className="partner-form-card">
<div className="partner-field">
<label> *</label>
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => setForm({ ...form, bankAccountName: e.target.value })} />
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
</div>
<div className="partner-field">
<label> *</label>
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => setForm({ ...form, bankAccountNo: e.target.value })} />
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => patchForm({ bankAccountNo: e.target.value })} />
</div>
<div className="partner-field">
<label> *</label>
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => setForm({ ...form, bankBranch: e.target.value })} />
</div>
<div className="partner-field">
<label></label>
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} value={form.accountPhone} onChange={(e) => setForm({ ...form, accountPhone: e.target.value })} />
</div>
<div className="partner-field">
<label></label>
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} value={form.accountName} onChange={(e) => setForm({ ...form, accountName: e.target.value })} />
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
</div>
</section>
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
@@ -217,7 +245,7 @@ export default function StoreCreatePage() {
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)}></button>
)}
{step < 3 ? (
<button type="button" className="partner-btn-primary" onClick={() => goStep(step + 1)}>
<button type="button" className="partner-btn-primary" onClick={handleNext}>
<span></span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
</button>
@@ -0,0 +1,43 @@
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** BUWR-25:经营周报(preV1 占位,待 `/partner/reports/weekly` 接入) */
export default function WeeklyReportPage() {
const navigate = useNavigate();
return (
<div className="page-no-tab">
<PageHeader title="数据周报" onBack={() => navigate('/')} />
<main style={{ padding: '0 20px 24px' }}>
<section className="partner-revenue-card" style={{ marginBottom: 16 }}>
<p className="partner-revenue-label"> 7 GMV (CNY)</p>
<div className="partner-revenue-amount">{fmtMoney(128450)}</div>
</section>
<section className="partner-bento-card" style={{ marginBottom: 12 }}>
<div className="partner-expansion-split">
<div>
<p className="label-md text-muted"></p>
<p className="headline-lg" style={{ fontSize: 24, marginTop: 4 }}>86</p>
</div>
<div>
<p className="label-md text-muted"></p>
<p className="headline-lg" style={{ fontSize: 24, marginTop: 4 }}>12</p>
</div>
</div>
</section>
<section className="partner-bento-card">
<h2 className="headline-md" style={{ marginBottom: 12 }}></h2>
<p className="headline-lg text-primary">3 </p>
<p className="label-md text-muted" style={{ marginTop: 8 }}> API </p>
</section>
</main>
</div>
);
}
+76
View File
@@ -581,6 +581,82 @@ html, body, #root {
border-left: 1px solid rgba(226, 190, 188, 0.2);
}
.partner-form-error {
margin: 0 20px 12px;
padding: 10px 12px;
border-radius: 8px;
background: var(--color-error-container);
color: var(--color-error);
font-size: 14px;
}
.partner-store-name-list {
list-style: none;
margin: 0 0 16px;
padding: 0;
border-bottom: 1px solid rgba(226, 190, 188, 0.15);
padding-bottom: 12px;
}
.partner-store-name-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
gap: 12px;
}
.partner-leaderboard-section {
padding-top: 12px;
}
.partner-leaderboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.partner-leaderboard-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid rgba(226, 190, 188, 0.1);
}
.partner-leaderboard-row--compact {
padding: 8px 0;
}
.partner-leaderboard-rank {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color-surface-container-high);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
.partner-leaderboard-row:first-of-type .partner-leaderboard-rank {
background: var(--color-aged-amber);
color: #fff;
}
.partner-leaderboard-info {
flex: 1;
min-width: 0;
}
.partner-leaderboard-stat {
text-align: right;
flex-shrink: 0;
}
/* ── Partner bottom tabbar ── */
.app-tabbar {
display: grid;