init
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>城市合伙人</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600&family=Inter:wght@500&family=Manrope:wght@600;700&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@dukang/h5-partner",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5175",
|
||||
"build": "vite build",
|
||||
"lint": "echo ok"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dukang/shared-ui": "workspace:*",
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,30 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import TabLayout from './layouts/TabLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import StoreListPage from './pages/StoreListPage';
|
||||
import StoreDetailPage from './pages/StoreDetailPage';
|
||||
import StoreCreatePage from './pages/StoreCreatePage';
|
||||
import OrderListPage from './pages/OrderListPage';
|
||||
import OrderDetailPage from './pages/OrderDetailPage';
|
||||
import CenterPage from './pages/CenterPage';
|
||||
import BillsPage from './pages/BillsPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<TabLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/stores" element={<StoreListPage />} />
|
||||
<Route path="/orders" element={<OrderListPage />} />
|
||||
<Route path="/center" element={<CenterPage />} />
|
||||
</Route>
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="/center/bills" element={<BillsPage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn } from '../lib/api';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
|
||||
{ to: '/stores', icon: 'store', label: '门店管理' },
|
||||
{ to: '/center', icon: 'account_circle', label: '合伙人中心' },
|
||||
] as const;
|
||||
|
||||
export default function TabLayout() {
|
||||
const navigate = useNavigate();
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<nav className="app-tabbar">
|
||||
{TABS.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
end={tab.end}
|
||||
className={({ isActive }) => `app-tabbar-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<span className="material-symbols-outlined app-tabbar-icon">{tab.icon}</span>
|
||||
<span className="app-tabbar-label">{tab.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export const apiBase = '/api/v1';
|
||||
|
||||
export async function request<T>(clientApp: string, path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '请求失败');
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
export function saveAuth(data: { accessToken: string }) {
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem('accessToken');
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!localStorage.getItem('accessToken');
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode><BrowserRouter><App /></BrowserRouter></React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
|
||||
export default function BillsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
||||
}, [navigate]);
|
||||
|
||||
const bill = bills[0];
|
||||
|
||||
function confirmBill() {
|
||||
if (!confirmed) return;
|
||||
setSubmitting(true);
|
||||
setTimeout(() => {
|
||||
setSubmitting(false);
|
||||
alert('申请已提交,请耐心等待打款审核。(preV1 演示)');
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="partner-bills-page">
|
||||
<PageHeader title="待确认账单" onBack={() => navigate('/center')} />
|
||||
|
||||
<div className="partner-bill-stepper">
|
||||
<div className="partner-stepper-inner">
|
||||
<div className="partner-stepper-line" aria-hidden>
|
||||
<div className="partner-stepper-line-fill" style={{ width: '50%' }} />
|
||||
</div>
|
||||
<div className="partner-step">
|
||||
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--done">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
|
||||
</div>
|
||||
<span className="partner-step-label partner-step-label--active">数据核算</span>
|
||||
</div>
|
||||
<div className="partner-step">
|
||||
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--active">2</div>
|
||||
<span className="partner-step-label partner-step-label--active">账单确认</span>
|
||||
</div>
|
||||
<div className="partner-step">
|
||||
<div className="partner-step-circle partner-step-circle--sm">3</div>
|
||||
<span className="partner-step-label">申请打款</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!bill && <div className="empty">暂无账单</div>}
|
||||
|
||||
{bill && (
|
||||
<section className="partner-bill-card">
|
||||
<div className="partner-bill-card-bar" />
|
||||
<div style={{ padding: 16 }}>
|
||||
<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>
|
||||
</div>
|
||||
<span className="partner-status-pill partner-status-pill--paused">{String(bill.status || '待确认')}</span>
|
||||
</div>
|
||||
|
||||
<div className="partner-bill-amount">
|
||||
<p className="text-muted body-md" style={{ marginBottom: 8 }}>应结总金额</p>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
|
||||
<span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span>
|
||||
<span className="amount-xl">{Number(bill.totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||
</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 }}>¥ {(Number(bill.totalAmount || 0) * 0.82).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
<div className="partner-info-row">
|
||||
<span className="text-variant body-md">核销权益分佣</span>
|
||||
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.18).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{bills.length > 1 && (
|
||||
<div style={{ padding: '0 20px' }}>
|
||||
<h3 className="headline-md" style={{ marginBottom: 12 }}>历史账单</h3>
|
||||
{bills.slice(1).map((b) => (
|
||||
<div key={String(b.id)} className="partner-store-card" style={{ margin: '0 0 12px' }}>
|
||||
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
|
||||
<div>
|
||||
<p className="body-md">{String(b.billNo)}</p>
|
||||
<p className="label-md text-muted">{String(b.status)}</p>
|
||||
</div>
|
||||
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="partner-info-banner" style={{ marginTop: 16 }}>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
|
||||
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
|
||||
账单确认后将正式进入打款流程。如有异议,请在确认前联系城市运营经理核实数据。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{bill && (
|
||||
<footer className="partner-bill-footer">
|
||||
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
|
||||
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
|
||||
<span>我已核对数据无误,同意根据此账单进行结算</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
disabled={!confirmed || submitting}
|
||||
style={{ opacity: confirmed ? 1 : 0.5 }}
|
||||
onClick={confirmBill}
|
||||
>
|
||||
{submitting ? '正在提交...' : '确认并申请打款'}
|
||||
{!submitting && <span className="material-symbols-outlined">payments</span>}
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { clearAuth, isLoggedIn, request } from '../lib/api';
|
||||
|
||||
export default function CenterPage() {
|
||||
const navigate = useNavigate();
|
||||
const [me, setMe] = useState<Record<string, unknown> | null>(null);
|
||||
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request('PARTNER_H5', '/partner/me').then(setMe);
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
||||
}, [navigate]);
|
||||
|
||||
const pendingBills = bills.filter((b) => String(b.status).includes('PENDING') || String(b.status).includes('CONFIRM'));
|
||||
|
||||
return (
|
||||
<div className="page partner-center-page">
|
||||
<header className="header app-page-header">
|
||||
<h1 className="app-page-title">杜康好客</h1>
|
||||
</header>
|
||||
|
||||
<section className="partner-profile-card">
|
||||
<div className="partner-profile-avatar">
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(me?.name || '合伙人')}</h2>
|
||||
<span className="partner-role-badge">城市合伙人</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, color: 'var(--color-subtle-gray)' }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span>
|
||||
<span className="body-md">{String(me?.companyName || '郑州')}</span>
|
||||
</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(me?.phone || '')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 20px 8px' }}>
|
||||
<h3 className="headline-md">资产概览</h3>
|
||||
<Link to="/center/bills" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
明细 <span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link to="/center/bills" className="partner-bills-banner">
|
||||
<div className="partner-bills-banner-left">
|
||||
<div className="partner-bills-icon">
|
||||
<span className="material-symbols-outlined">pending_actions</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="body-md" style={{ fontWeight: 500 }}>待确认账单</p>
|
||||
<p className="label-md text-primary">您有 {pendingBills.length || bills.length} 笔账单待确认</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-primary">chevron_right</span>
|
||||
</Link>
|
||||
|
||||
<div className="partner-finance-grid">
|
||||
<div className="partner-finance-card partner-finance-card--hero">
|
||||
<p className="label-md" style={{ opacity: 0.8, marginBottom: 4 }}>账户余额 (元)</p>
|
||||
<span className="amount-xl" style={{ color: '#fff', fontSize: 32 }}>
|
||||
{bills.length > 0 ? Number(bills[0].totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) : '0.00'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="partner-finance-card">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待结算</p>
|
||||
<p className="headline-md">
|
||||
<span className="text-primary">¥</span>
|
||||
{pendingBills.reduce((s, b) => s + Number(b.totalAmount || 0), 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="partner-finance-card">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>账单笔数</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span className="headline-md">{bills.length} 笔</span>
|
||||
<span className="material-symbols-outlined text-muted">history</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-menu-section">
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>运营管理</h3>
|
||||
<div className="partner-menu-card">
|
||||
<Link to="/stores" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">store</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>门店管理</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<Link to="/orders" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">receipt_long</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/bills" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">description</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>待确认账单</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{pendingBills.length > 0 && <span className="label-md text-primary" style={{ background: 'rgba(166,29,36,0.1)', padding: '2px 8px', borderRadius: 999 }}>{pendingBills.length}</span>}
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="button" className="partner-logout-btn" onClick={() => { clearAuth(); navigate('/login'); }}>
|
||||
<span className="material-symbols-outlined">logout</span>
|
||||
退出登录
|
||||
</button>
|
||||
|
||||
<div style={{ textAlign: 'center', opacity: 0.3, padding: '32px 0' }}>
|
||||
<p className="label-md text-muted">传承千年 · 杜康好客</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request('PARTNER_H5', '/partner/dashboard').then(setDash);
|
||||
}, [navigate]);
|
||||
|
||||
const storeCount = Number(dash?.storeCount || 0);
|
||||
const orderCount = Number(dash?.orderCount || 0);
|
||||
const activeStores = Math.max(0, storeCount - Math.ceil(storeCount * 0.05));
|
||||
const abnormalStores = storeCount - activeStores;
|
||||
const revenue = orderCount * 128.45;
|
||||
const profit = revenue * 0.25;
|
||||
const pendingShip = Math.ceil(orderCount * 0.04);
|
||||
const shipping = Math.ceil(orderCount * 0.12);
|
||||
const completed = Math.max(0, orderCount - pendingShip - shipping);
|
||||
|
||||
return (
|
||||
<div className="page partner-home">
|
||||
<header className="partner-home-header">
|
||||
<h1 className="app-page-title">工作台</h1>
|
||||
<div className="partner-home-header-actions">
|
||||
<button type="button" className="partner-notif-btn" aria-label="通知">
|
||||
<span className="material-symbols-outlined">notifications</span>
|
||||
<span className="partner-notif-dot" />
|
||||
</button>
|
||||
<div className="partner-profile-avatar" style={{ width: 40, height: 40 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>person</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="partner-home-body">
|
||||
<section className="partner-revenue-card">
|
||||
<p className="partner-revenue-label">
|
||||
实时营业额 (CNY)
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
|
||||
</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>
|
||||
|
||||
<div className="partner-bento">
|
||||
<section className="partner-bento-card">
|
||||
<div className="partner-bento-header">
|
||||
<h2 className="headline-md">门店总数</h2>
|
||||
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
|
||||
</div>
|
||||
<div className="partner-store-stats">
|
||||
<div className="partner-store-stat">
|
||||
<span className="partner-dot partner-dot--green" />
|
||||
<div>
|
||||
<p className="label-md text-muted">正常运营</p>
|
||||
<p className="headline-md">{activeStores}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-store-stat">
|
||||
<span className="partner-dot partner-dot--red" />
|
||||
<div>
|
||||
<p className="label-md text-muted">异常/闭店</p>
|
||||
<p className="headline-md">{abnormalStores}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-bento-card">
|
||||
<div className="partner-quick-actions">
|
||||
<Link to="/stores/new" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
||||
<span className="material-symbols-outlined">add_business</span>
|
||||
</div>
|
||||
<span className="partner-quick-action-label">录入新店</span>
|
||||
</Link>
|
||||
<Link to="/orders" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--amber">
|
||||
<span className="material-symbols-outlined">assignment_return</span>
|
||||
{pendingShip > 0 && <span className="partner-quick-badge-count">{pendingShip}</span>}
|
||||
</div>
|
||||
<span className="partner-quick-action-label">补发处理</span>
|
||||
</Link>
|
||||
<Link to="/center/bills" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--green">
|
||||
<span className="material-symbols-outlined">account_balance</span>
|
||||
</div>
|
||||
<span className="partner-quick-action-label">财务对账</span>
|
||||
</Link>
|
||||
<Link to="/orders" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
|
||||
<span className="material-symbols-outlined">monitoring</span>
|
||||
</div>
|
||||
<span className="partner-quick-action-label">数据周报</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className="partner-order-summary-header">
|
||||
<h2 className="headline-md">今日订单量 {orderCount}</h2>
|
||||
<Link to="/orders" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
查看订单详情 <span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="partner-order-stats">
|
||||
<div className="partner-order-stat">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待发货</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{pendingShip}</p>
|
||||
</div>
|
||||
<div className="partner-order-stat partner-order-stat--blue">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>配送中</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{shipping}</p>
|
||||
</div>
|
||||
<div className="partner-order-stat partner-order-stat--green">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>已完成</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{completed}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-expansion-card">
|
||||
<h2 className="headline-md" style={{ marginBottom: 16 }}>拓店情况</h2>
|
||||
<div className="partner-expansion-split">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
|
||||
{String(dash?.companyName || '郑州合伙人')} · 辖区管理
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { request, saveAuth } from '../lib/api';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const quick = params.get('quick') === '1';
|
||||
const [phone, setPhone] = useState('13700000001');
|
||||
const [code, setCode] = useState('123456');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
|
||||
async function login() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await request('PARTNER_H5', '/partner/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
|
||||
});
|
||||
const data = await request<{ accessToken: string }>('PARTNER_H5', '/partner/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
saveAuth(data);
|
||||
navigate('/');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function sendCode() {
|
||||
if (codeCooldown > 0) return;
|
||||
request('PARTNER_H5', '/partner/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
|
||||
}).then(() => {
|
||||
setCodeCooldown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCodeCooldown((s) => {
|
||||
if (s <= 1) { clearInterval(timer); return 0; }
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
if (quick) {
|
||||
return (
|
||||
<div className="partner-auth-page partner-auth-page--quick">
|
||||
<header className="partner-auth-brand">
|
||||
<div className="partner-quick-avatar" style={{ width: 120, height: 120, margin: '0 auto 16px' }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 48 }}>wine_bar</span>
|
||||
</div>
|
||||
<h1 className="partner-auth-title" style={{ fontSize: 20 }}>杜康好客</h1>
|
||||
<p className="partner-auth-subtitle" style={{ fontSize: 12, letterSpacing: '0.2em', textTransform: 'uppercase' }}>城市合伙人端</p>
|
||||
</header>
|
||||
|
||||
<section className="partner-glass-card">
|
||||
<div className="partner-quick-badge">已识别账号</div>
|
||||
<div className="partner-quick-avatar">
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h2 className="headline-md">
|
||||
李明 <span className="text-muted body-md" style={{ fontWeight: 400 }}>(郑州合伙人)</span>
|
||||
</h2>
|
||||
<p className="text-muted body-md" style={{ letterSpacing: '0.1em', marginTop: 4 }}>137 **** 0001</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
|
||||
<button type="button" className="partner-btn-primary" onClick={login} disabled={loading}>
|
||||
<span>{loading ? '登录中...' : '一键登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
<Link to="/login" className="partner-btn-ghost" style={{ display: 'block', marginTop: 12 }}>切换账号</Link>
|
||||
</nav>
|
||||
|
||||
<footer className="partner-auth-footer">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className="material-symbols-outlined">verified_user</span>
|
||||
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase' }}>Secured by Dukang Heritage</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="partner-auth-page">
|
||||
<div className="partner-auth-brand">
|
||||
<div className="partner-auth-logo-circle">
|
||||
<span className="material-symbols-outlined">wine_bar</span>
|
||||
</div>
|
||||
<h1 className="partner-auth-title">杜康好客</h1>
|
||||
<p className="partner-auth-subtitle">城市合伙人端</p>
|
||||
</div>
|
||||
|
||||
<main className="partner-auth-card">
|
||||
<h2 className="partner-auth-card-title">城市合伙人登录</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="partner-input-wrap">
|
||||
<span className="material-symbols-outlined partner-input-icon">smartphone</span>
|
||||
<input className="partner-input" type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
<div className="partner-input-row">
|
||||
<div className="partner-input-wrap">
|
||||
<span className="material-symbols-outlined partner-input-icon">shield</span>
|
||||
<input className="partner-input" type="text" placeholder="验证码" value={code} onChange={(e) => setCode(e.target.value)} />
|
||||
</div>
|
||||
<button type="button" className="partner-code-btn" onClick={sendCode} disabled={codeCooldown > 0}>
|
||||
{codeCooldown > 0 ? `${codeCooldown}s 后重发` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
<label className="partner-checkbox-row">
|
||||
<input type="checkbox" defaultChecked />
|
||||
<span>记住账号</span>
|
||||
</label>
|
||||
<button type="button" className="partner-btn-primary" onClick={login} disabled={loading}>
|
||||
<span>{loading ? '登录中...' : '登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
<div className="partner-auth-divider"><span>其他登录方式</span></div>
|
||||
<button type="button" className="partner-btn-outline" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="#07C160"><path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" /></svg>
|
||||
微信一键登录
|
||||
</button>
|
||||
<label className="partner-checkbox-row">
|
||||
<input type="checkbox" />
|
||||
<span>
|
||||
我已阅读并同意 <span className="text-primary" style={{ fontWeight: 600 }}>《用户协议》</span> 与 <span className="text-primary" style={{ fontWeight: 600 }}>《隐私政策》</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Link to="/login?quick=1" className="partner-link">快捷登录</Link>
|
||||
|
||||
<footer className="partner-auth-footer">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className="material-symbols-outlined">verified_user</span>
|
||||
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.15em', textTransform: 'uppercase' }}>SECURED BY DUKANG HERITAGE</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
const TIMELINE = [
|
||||
{ key: 'confirm', label: '待确认' },
|
||||
{ key: 'accepted', label: '已接单' },
|
||||
{ key: 'delivering', label: '配送中' },
|
||||
{ key: 'shipping', label: '运输中', desc: '包裹正在送往目的地' },
|
||||
{ key: 'done', label: '已送达' },
|
||||
];
|
||||
|
||||
function statusIndex(status: string) {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s === 'COMPLETED') return 4;
|
||||
if (s.includes('SHIP') || s === 'OUT_WAREHOUSE') return 3;
|
||||
if (s === 'PENDING_RECEIVE') return 3;
|
||||
if (s.includes('DELIVER')) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function statusBanner(status: string) {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s.includes('SHIP') || s === 'OUT_WAREHOUSE') return { title: '运输中', desc: '预计今日 18:00 前送达', icon: 'local_shipping' };
|
||||
if (s === 'COMPLETED') return { title: '已完成', desc: '订单已送达', icon: 'check_circle' };
|
||||
if (s.includes('PENDING')) return { title: '待发货', desc: '商家正在备货', icon: 'inventory_2' };
|
||||
return { title: status, desc: '', icon: 'receipt_long' };
|
||||
}
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [order, setOrder] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
|
||||
}, [id]);
|
||||
|
||||
async function advance(status: string) {
|
||||
await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ targetStatus: status }),
|
||||
});
|
||||
if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
|
||||
}
|
||||
|
||||
if (!order) return <div className="empty">加载中...</div>;
|
||||
|
||||
const banner = statusBanner(String(order.status));
|
||||
const currentIdx = statusIndex(String(order.status));
|
||||
const payAmount = Number(order.payAmount || 0);
|
||||
|
||||
return (
|
||||
<div className="partner-order-detail">
|
||||
<PageHeader title="订单详情" onBack={() => navigate('/orders')} />
|
||||
|
||||
<section className="partner-status-banner">
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontVariationSettings: "'FILL' 1" }}>{banner.icon}</span>
|
||||
<h2 className="headline-md" style={{ color: '#fff' }}>{banner.title}</h2>
|
||||
</div>
|
||||
{banner.desc && <p className="body-md" style={{ color: 'rgba(255,255,255,0.9)' }}>{banner.desc}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-timeline">
|
||||
<div className="partner-timeline-line" />
|
||||
{TIMELINE.map((item, i) => {
|
||||
const done = i < currentIdx;
|
||||
const current = i === currentIdx;
|
||||
const pending = i > currentIdx;
|
||||
return (
|
||||
<div key={item.key} className="partner-timeline-item">
|
||||
<div className={`partner-timeline-dot${done ? ' partner-timeline-dot--done' : ''}${current ? ' partner-timeline-dot--current' : ''}${pending ? ' partner-timeline-dot--pending' : ''}`}>
|
||||
{done && <span className="material-symbols-outlined">check</span>}
|
||||
</div>
|
||||
<div>
|
||||
<p className={current ? 'headline-md text-primary' : 'label-md'} style={{ fontWeight: current ? 700 : 500 }}>{item.label}</p>
|
||||
{item.desc && current && <p className="text-muted body-md" style={{ marginTop: 4 }}>{item.desc}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="partner-detail-section partner-detail-section--accent">
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<div className="partner-order-product-img" style={{ width: 96, height: 96, position: 'relative' }}>
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-surface-container)' }}>
|
||||
<span className="material-symbols-outlined text-muted" style={{ fontSize: 32 }}>liquor</span>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(166,29,36,0.8)', textAlign: 'center', padding: '2px 0' }}>
|
||||
<span className="label-md" style={{ color: '#fff', fontSize: 10 }}>正品保证</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 className="headline-md">{String(order.productName || '杜康好酒')}</h3>
|
||||
<p className="text-muted body-md" style={{ marginTop: 4 }}>x{Number(order.quantity || 1)}</p>
|
||||
<div style={{ marginTop: 8, display: 'flex', alignItems: 'baseline', gap: 2 }}>
|
||||
<span className="label-md text-primary">¥</span>
|
||||
<span className="amount-lg">{payAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, margin: '0 20px' }}>
|
||||
<div className="partner-detail-section" style={{ margin: 0, borderTop: '2px solid var(--color-heritage-red)' }}>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 8 }}>佣金明细</p>
|
||||
<div className="partner-info-row">
|
||||
<span className="label-md text-muted">订单佣金</span>
|
||||
<span className="text-primary" style={{ fontWeight: 700, fontSize: 12 }}>¥{(payAmount * 0.04).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="partner-info-row">
|
||||
<span className="label-md text-muted">权益核销</span>
|
||||
<span className="text-primary" style={{ fontWeight: 700, fontSize: 12 }}>¥{(payAmount * 0.04).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-detail-section" style={{ margin: 0, borderTop: '2px solid var(--color-aged-amber)' }}>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>赠送好客权益</p>
|
||||
<span className="amount-lg" style={{ color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="partner-detail-section">
|
||||
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
|
||||
订单信息
|
||||
</h4>
|
||||
<div className="partner-info-row">
|
||||
<span className="text-muted body-md">订单编号</span>
|
||||
<span className="body-md">{String(order.orderNo)}</span>
|
||||
</div>
|
||||
<div className="partner-info-row">
|
||||
<span className="text-muted body-md">订单状态</span>
|
||||
<span className="body-md">{String(order.status)}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-detail-section">
|
||||
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
|
||||
收货地址
|
||||
</h4>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<span className="material-symbols-outlined text-muted">location_on</span>
|
||||
<div>
|
||||
<p className="headline-md" style={{ fontSize: 16 }}>
|
||||
{String(order.receiverName)} <span className="body-md text-muted" style={{ fontWeight: 400 }}>{String(order.receiverPhone)}</span>
|
||||
</p>
|
||||
<p className="text-muted body-md" style={{ marginTop: 4, lineHeight: 1.5 }}>{String(order.receiverAddress)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="partner-order-footer">
|
||||
<div>
|
||||
<span className="label-md text-muted">佣金合计</span>
|
||||
<p className="headline-md text-primary" style={{ fontWeight: 700 }}>¥{(payAmount * 0.08).toFixed(2)}</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-outline" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
|
||||
联系配送员
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
{import.meta.env.DEV && (
|
||||
<details className="partner-dev-tools">
|
||||
<summary>Dev: Mock 推进配送</summary>
|
||||
{['OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE', 'COMPLETED'].map((s) => (
|
||||
<button key={s} type="button" onClick={() => advance(s)}>{s}</button>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
|
||||
type DateFilter = 'today' | '7d' | '30d';
|
||||
type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL';
|
||||
|
||||
function orderStatusLabel(status: string) {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s.includes('SHIP') || s === 'OUT_WAREHOUSE') return { label: '运输中', color: 'var(--color-status-blue)' };
|
||||
if (s === 'COMPLETED') return { label: '已完成', color: 'var(--color-success-green)' };
|
||||
if (s.includes('PENDING') || s === 'PAID') return { label: '待发货', color: 'var(--color-secondary)' };
|
||||
if (s.includes('ERROR') || s.includes('ABNORMAL')) return { label: '异常', color: 'var(--color-error)' };
|
||||
return { label: status, color: 'var(--color-subtle-gray)' };
|
||||
}
|
||||
|
||||
const DATE_FILTERS: { key: DateFilter; label: string }[] = [
|
||||
{ key: 'today', label: '今日' },
|
||||
{ key: '7d', label: '近7天' },
|
||||
{ key: '30d', label: '近30天' },
|
||||
];
|
||||
|
||||
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'PENDING_SHIP', label: '待发货' },
|
||||
{ key: 'SHIPPING', label: '运输中' },
|
||||
{ key: 'COMPLETED', label: '已完成' },
|
||||
{ key: 'ABNORMAL', label: '异常' },
|
||||
];
|
||||
|
||||
export default function OrderListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<{ list: Array<Record<string, unknown>> }>({ list: [] });
|
||||
const [tab, setTab] = useState<'orders' | 'coupons'>('orders');
|
||||
const [dateFilter, setDateFilter] = useState<DateFilter>('today');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request('PARTNER_H5', '/partner/orders').then(setData);
|
||||
}, [navigate]);
|
||||
|
||||
const filtered = useMemo(() => data.list.filter((o) => {
|
||||
if (statusFilter === 'ALL') return true;
|
||||
const s = String(o.status).toUpperCase();
|
||||
if (statusFilter === 'PENDING_SHIP') return s.includes('PENDING') || s === 'PAID';
|
||||
if (statusFilter === 'SHIPPING') return s.includes('SHIP') || s === 'OUT_WAREHOUSE';
|
||||
if (statusFilter === 'COMPLETED') return s === 'COMPLETED';
|
||||
if (statusFilter === 'ABNORMAL') return s.includes('ERROR') || s.includes('ABNORMAL');
|
||||
return true;
|
||||
}), [data.list, statusFilter]);
|
||||
|
||||
return (
|
||||
<div className="page-no-tab partner-orders-page">
|
||||
<PageHeader title="订单中心" onBack={() => navigate('/')} />
|
||||
|
||||
<div className="partner-segment">
|
||||
<button type="button" className={tab === 'orders' ? 'active' : ''} onClick={() => setTab('orders')}>订单列表</button>
|
||||
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}>权益记录</button>
|
||||
</div>
|
||||
|
||||
{tab === 'orders' && (
|
||||
<>
|
||||
<div className="partner-filter-row" style={{ marginBottom: 8 }}>
|
||||
{DATE_FILTERS.map((f) => (
|
||||
<button key={f.key} type="button" className={`partner-chip${dateFilter === f.key ? ' active' : ''}`} style={{ border: dateFilter === f.key ? 'none' : '1px solid var(--color-outline-variant)', background: dateFilter === f.key ? undefined : '#fff' }} onClick={() => setDateFilter(f.key)}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="partner-filter-row" style={{ borderBottom: '1px solid var(--color-surface-container-high)', paddingBottom: 8, marginBottom: 16 }}>
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button key={f.key} type="button" className={`partner-filter-tab${statusFilter === f.key ? ' active' : ''}`} onClick={() => setStatusFilter(f.key)}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && <div className="empty">暂无订单</div>}
|
||||
|
||||
{filtered.map((o) => {
|
||||
const st = orderStatusLabel(String(o.status));
|
||||
const payAmount = Number(o.payAmount || 0);
|
||||
return (
|
||||
<Link key={String(o.id)} to={`/orders/${o.id}`} className="partner-order-card">
|
||||
<div className="partner-order-card-top">
|
||||
<span className="label-md text-muted">NO. {String(o.orderNo)}</span>
|
||||
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
|
||||
</div>
|
||||
<div className="partner-order-product">
|
||||
<div className="partner-order-product-img" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span className="material-symbols-outlined text-muted">liquor</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
|
||||
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
|
||||
<span className="partner-benefit-tag">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>confirmation_number</span>
|
||||
¥{Math.round(payAmount * 0.2)}权益
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-order-address">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
|
||||
<p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
|
||||
</div>
|
||||
<div className="partner-order-commission">
|
||||
<div>
|
||||
<p className="label-md text-muted">下单佣金</p>
|
||||
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥{(payAmount * 0.04).toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="label-md text-muted">核销佣金</p>
|
||||
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥0.00</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="label-md text-muted">获赠权益</p>
|
||||
<p className="headline-md" style={{ marginTop: 4, color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'coupons' && (
|
||||
<div style={{ padding: '0 20px' }}>
|
||||
<div className="partner-revenue-card" style={{ marginBottom: 16 }}>
|
||||
<p className="partner-revenue-label">累计已发放权益金额</p>
|
||||
<div className="partner-revenue-amount" style={{ fontSize: 28 }}>42,800.00</div>
|
||||
</div>
|
||||
<p className="text-muted body-md text-center">权益记录 preV1 占位</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
||||
|
||||
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: '',
|
||||
});
|
||||
|
||||
function goStep(n: number) {
|
||||
setParams({ step: String(n) });
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
await request('PARTNER_H5', '/partner/stores', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
navigate('/stores');
|
||||
}
|
||||
|
||||
const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
|
||||
|
||||
return (
|
||||
<div className="partner-page-sticky">
|
||||
<PageHeader title="录入新门店" onBack={() => navigate('/stores')} />
|
||||
|
||||
<nav className="partner-stepper">
|
||||
<div className="partner-stepper-inner">
|
||||
<div className="partner-stepper-line" aria-hidden>
|
||||
<div className="partner-stepper-line-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
{STEPS.map((label, i) => {
|
||||
const n = i + 1;
|
||||
const done = step > n;
|
||||
const active = step === n;
|
||||
return (
|
||||
<div key={label} className="partner-step">
|
||||
<div className={`partner-step-circle${done ? ' partner-step-circle--done' : ''}${active ? ' partner-step-circle--active' : ''}`}>
|
||||
{done ? <span className="material-symbols-outlined" style={{ fontSize: 16, fontVariationSettings: "'FILL' 1" }}>check</span> : n}
|
||||
</div>
|
||||
<span className={`partner-step-label${active || done ? ' partner-step-label--active' : ''}`}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<section className="partner-form-card">
|
||||
<div className="partner-section-title">
|
||||
<div className="partner-section-bar" />
|
||||
<h2 className="headline-md">门店基本信息</h2>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<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 })} />
|
||||
</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 })} />
|
||||
</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 })} />
|
||||
</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 })} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店简介</label>
|
||||
<textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
|
||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||
<span className="label-md text-muted">{form.intro.length} / 500</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="partner-info-banner">
|
||||
<div className="partner-bills-icon">
|
||||
<span className="material-symbols-outlined">verified_user</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="body-md text-primary" style={{ fontWeight: 700 }}>杜康合伙人身份认证</h3>
|
||||
<p className="label-md text-variant" style={{ marginTop: 4, lineHeight: 1.4 }}>
|
||||
传承千年窖香,共筑美酒传奇。录入信息后,系统将在 1-3 个工作日内完成审核。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<div className="partner-section-title" style={{ padding: '0 20px', marginBottom: 16 }}>
|
||||
<div className="partner-bills-icon" style={{ background: '#ffdad7', borderRadius: 8, width: 40, height: 40 }}>
|
||||
<span className="material-symbols-outlined text-primary">photo_library</span>
|
||||
</div>
|
||||
<h2 className="headline-md">门店图片上传</h2>
|
||||
</div>
|
||||
|
||||
<section className="partner-form-card">
|
||||
<h3 className="headline-md">门头照 *</h3>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少1张,需包含完整招牌</p>
|
||||
<button type="button" className="partner-upload-dashed partner-upload-dashed--wide">
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 36 }}>add_a_photo</span>
|
||||
<span className="text-primary" style={{ fontWeight: 500 }}>点击或拖拽上传</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card">
|
||||
<h3 className="headline-md">环境照片 *</h3>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少3张,展示店内整洁环境</p>
|
||||
<div className="partner-upload-grid">
|
||||
<button type="button" className="partner-upload-dashed">
|
||||
<span className="material-symbols-outlined text-primary">add_a_photo</span>
|
||||
<span>添加照片</span>
|
||||
</button>
|
||||
<button type="button" className="partner-upload-dashed">
|
||||
<span className="material-symbols-outlined text-primary">add_a_photo</span>
|
||||
<span>添加照片</span>
|
||||
</button>
|
||||
<button type="button" className="partner-upload-dashed">
|
||||
<span className="material-symbols-outlined text-primary">add_a_photo</span>
|
||||
<span>添加照片</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card">
|
||||
<h3 className="headline-md">签约合同 *</h3>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>拍照上传签约协议首页与盖章页</p>
|
||||
<button type="button" className="partner-upload-dashed" style={{ flexDirection: 'row', height: 96 }}>
|
||||
<div className="partner-bills-icon" style={{ background: '#ffb3ae' }}>
|
||||
<span className="material-symbols-outlined text-primary">description</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<span className="text-primary" style={{ fontWeight: 700, display: 'block' }}>上传合同副本</span>
|
||||
<span className="label-md text-muted">支持 JPG, PNG, PDF</span>
|
||||
</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.2)', borderColor: 'rgba(254,214,91,0.5)' }}>
|
||||
<span className="material-symbols-outlined text-secondary" style={{ fontSize: 18 }}>info</span>
|
||||
<p className="label-md" style={{ lineHeight: 1.5, color: 'var(--color-on-secondary-container)' }}>
|
||||
温馨提示:请确保照片清晰无反光。preV1 占位上传,可直接下一步。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<>
|
||||
<div className="partner-section-title" style={{ padding: '0 20px', marginBottom: 16 }}>
|
||||
<span className="material-symbols-outlined text-primary">account_balance</span>
|
||||
<h2 className="headline-md">结算信息配置</h2>
|
||||
</div>
|
||||
<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 })} />
|
||||
</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 })} />
|
||||
</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 })} />
|
||||
</div>
|
||||
</section>
|
||||
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
|
||||
<span className="material-symbols-outlined text-secondary">info</span>
|
||||
<p className="body-md" style={{ color: 'var(--color-on-secondary-container)' }}>
|
||||
请确保银行卡信息准确,以免影响每月的餐费结算。结算将按合同约定账期执行。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<footer className="partner-sticky-footer">
|
||||
{step > 1 && (
|
||||
<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)}>
|
||||
<span>下一步</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="partner-btn-primary" onClick={submit}>提交审核</button>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
export default function StoreDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
|
||||
const [status, setStatus] = useState('OPEN');
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
request('PARTNER_H5', `/stores/${id}`).then((data) => {
|
||||
setStore(data);
|
||||
setForm({
|
||||
name: String(data.name || ''),
|
||||
phone: String(data.phone || ''),
|
||||
address: String(data.address || ''),
|
||||
intro: String(data.intro || ''),
|
||||
});
|
||||
setStatus(String(data.status || 'OPEN').toUpperCase());
|
||||
});
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
function save() {
|
||||
setToast('preV1:保存成功(演示)');
|
||||
setTimeout(() => setToast(''), 2000);
|
||||
}
|
||||
|
||||
if (!store) return <div className="empty">加载中...</div>;
|
||||
|
||||
const statusLabel = status === 'OPEN' ? '营业中' : status === 'PAUSED' ? '暂停服务' : '已下线';
|
||||
|
||||
return (
|
||||
<div className="partner-detail-page">
|
||||
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
|
||||
|
||||
<main style={{ padding: '16px 20px' }}>
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>运营状态</h3>
|
||||
<span className="partner-status-pill partner-status-pill--open">
|
||||
<span className="partner-dot partner-dot--green" style={{ width: 8, height: 8, display: 'inline-block', marginRight: 4 }} />
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="partner-status-toggle">
|
||||
{(['OPEN', 'PAUSED', 'CLOSED'] as const).map((s) => (
|
||||
<button key={s} type="button" className={status === s ? 'active' : ''} onClick={() => setStatus(s)}>
|
||||
{s === 'OPEN' ? '营业中' : s === 'PAUSED' ? '暂停服务' : '已下线'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}>基本信息</h3>
|
||||
<div className="partner-cover">
|
||||
<AppImage
|
||||
src={store.coverUrl ? String(store.coverUrl) : null}
|
||||
alt={form.name}
|
||||
wrapperClassName="app-image--fill"
|
||||
/>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店名称</label>
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>联系电话</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店地址</label>
|
||||
<textarea rows={2} value={form.address} onChange={(e) => setForm({ ...form, 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 })} />
|
||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||
<span className="label-md text-muted">{form.intro.length} / 500</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
|
||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}>店内环境</h3>
|
||||
<span className="label-md text-muted">最多12张</span>
|
||||
</div>
|
||||
<div className="partner-photo-grid">
|
||||
<button type="button" className="partner-photo-add">
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
<span className="label-md">添加照片</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card" style={{ margin: 0, background: 'var(--color-surface-container)' }}>
|
||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-subtle-gray)', paddingLeft: 12, marginBottom: 16 }}>管理信息</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<div>
|
||||
<label className="label-md text-muted">状态</label>
|
||||
<p className="body-md" style={{ fontWeight: 500 }}>{String(store.status)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-md text-muted">门店ID</label>
|
||||
<p className="body-md" style={{ fontWeight: 500, fontFamily: 'monospace' }}>{String(store.id)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="partner-save-footer">
|
||||
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>取消</button>
|
||||
<button type="button" className="partner-save-submit" onClick={save}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
保存修改
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
{toast && <div className="partner-toast">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
|
||||
type StatusFilter = 'ALL' | 'OPEN' | 'PAUSED' | 'CLOSED';
|
||||
|
||||
function statusPill(status: string) {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s === 'OPEN') return { cls: 'partner-status-pill--open', label: '营业中' };
|
||||
if (s === 'PAUSED') return { cls: 'partner-status-pill--paused', label: '暂时闭店' };
|
||||
return { cls: 'partner-status-pill--closed', label: '已关闭' };
|
||||
}
|
||||
|
||||
const FILTERS: { key: StatusFilter; label: string }[] = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'OPEN', label: '营业中' },
|
||||
{ key: 'PAUSED', label: '暂时闭店' },
|
||||
{ key: 'CLOSED', label: '已关闭' },
|
||||
];
|
||||
|
||||
export default function StoreListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [q, setQ] = useState('');
|
||||
const [filter, setFilter] = useState<StatusFilter>('ALL');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
||||
}, [navigate]);
|
||||
|
||||
const filtered = useMemo(() => stores.filter((s) => {
|
||||
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
||||
const matchStatus = filter === 'ALL' || String(s.status).toUpperCase() === filter;
|
||||
return matchQ && matchStatus;
|
||||
}), [stores, q, filter]);
|
||||
|
||||
return (
|
||||
<div className="page partner-store-page">
|
||||
<header className="header app-page-header">
|
||||
<h1 className="app-page-title">杜康好客</h1>
|
||||
</header>
|
||||
|
||||
<div className="partner-page-title-block">
|
||||
<h2>门店管理</h2>
|
||||
<p className="text-muted body-md">管理您的合作门店及其运营状态</p>
|
||||
</div>
|
||||
|
||||
<div className="partner-sticky-filter">
|
||||
<div className="partner-search">
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
<input placeholder="搜索门店名称/地址" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<div className="partner-chips">
|
||||
{FILTERS.map((f) => (
|
||||
<button key={f.key} type="button" className={`partner-chip${filter === f.key ? ' active' : ''}`} onClick={() => setFilter(f.key)}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link to="/stores/new" className="partner-fab-link">
|
||||
<button type="button" className="partner-btn-primary">
|
||||
<span className="material-symbols-outlined">add_business</span>
|
||||
录入新门店
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
||||
|
||||
{filtered.map((s) => {
|
||||
const pill = statusPill(String(s.status));
|
||||
const dim = String(s.status).toUpperCase() === 'CLOSED';
|
||||
return (
|
||||
<div key={String(s.id)} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
|
||||
<Link to={`/stores/${s.id}`} style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<div className="partner-store-card-header">
|
||||
<div>
|
||||
<h3 className="headline-md">{String(s.name)}</h3>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p>
|
||||
</div>
|
||||
<span className={`partner-status-pill ${pill.cls}`}>{pill.label}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="partner-store-card-actions">
|
||||
<button type="button" className="btn btn-outline" style={{ fontSize: 12, padding: '8px 12px' }}>暂时闭店</button>
|
||||
<button type="button" className="btn btn-outline" style={{ fontSize: 12, padding: '8px 12px', borderColor: 'var(--color-subtle-gray)', color: 'var(--color-subtle-gray)' }}>正式关闭</button>
|
||||
<Link to={`/stores/${s.id}`} className="partner-menu-icon" style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5175,
|
||||
proxy: { '/api': 'http://localhost:3000' },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>门店管理中心</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600&family=Inter:wght@500&family=Manrope:wght@600;700&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@dukang/h5-shop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5174",
|
||||
"build": "vite build",
|
||||
"lint": "echo ok"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dukang/shared-ui": "workspace:*",
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,26 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import TabLayout from './layouts/TabLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import RedeemConfirmPage from './pages/RedeemConfirmPage';
|
||||
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||
import RecordsPage from './pages/RecordsPage';
|
||||
import StatusPage from './pages/StatusPage';
|
||||
import MinePage from './pages/MinePage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||
<Route element={<TabLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/records" element={<RecordsPage />} />
|
||||
<Route path="/status" element={<StatusPage />} />
|
||||
<Route path="/mine" element={<MinePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn } from '../lib/api';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/', end: true, icon: 'home', label: '首页' },
|
||||
{ to: '/records', icon: 'receipt_long', label: '核销记录' },
|
||||
{ to: '/mine', icon: 'person', label: '我的' },
|
||||
] as const;
|
||||
|
||||
export default function TabLayout() {
|
||||
const navigate = useNavigate();
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<nav className="app-tabbar">
|
||||
{TABS.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
end={tab.end}
|
||||
className={({ isActive }) => `app-tabbar-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<span className="material-symbols-outlined app-tabbar-icon">{tab.icon}</span>
|
||||
<span className="app-tabbar-label">{tab.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export const apiBase = '/api/v1';
|
||||
|
||||
export async function request<T>(clientApp: string, path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '请求失败');
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
export function saveAuth(data: { accessToken: string }) {
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem('accessToken');
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!localStorage.getItem('accessToken');
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode><BrowserRouter><App /></BrowserRouter></React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
|
||||
function formatMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
request('SHOP_H5', '/shop/dashboard').then((d) => {
|
||||
setDash(d);
|
||||
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
const store = dash?.store as Record<string, unknown> | undefined;
|
||||
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
||||
const openTime = String(store?.openTime || '10:00');
|
||||
const closeTime = String(store?.closeTime || '22:00');
|
||||
|
||||
return (
|
||||
<div className="shop-home-page">
|
||||
<header className="shop-home-header">
|
||||
<h1 className="app-page-title">门店管理中心</h1>
|
||||
</header>
|
||||
|
||||
<div className="shop-home-content">
|
||||
<section className="shop-home-hero">
|
||||
<div className="shop-home-hero-store">
|
||||
<span className="material-symbols-outlined shop-fill-icon">store</span>
|
||||
<h2>{String(store?.name || '门店')}</h2>
|
||||
</div>
|
||||
<div className="shop-home-stats">
|
||||
<div className="shop-home-stat">
|
||||
<p className="shop-home-stat-label">今日核销笔数</p>
|
||||
<p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</p>
|
||||
</div>
|
||||
<div className="shop-home-stat">
|
||||
<p className="shop-home-stat-label">今日到账金额</p>
|
||||
<p className="shop-home-stat-value">
|
||||
<span style={{ fontSize: 18 }}>¥</span>
|
||||
{formatMoney(Number(dash?.todayAmount || 0))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="shop-home-scan">
|
||||
<button type="button" className="shop-home-scan-btn" onClick={() => navigate('/redeem')}>
|
||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
||||
</button>
|
||||
<p className="shop-home-scan-label">扫码核销</p>
|
||||
</section>
|
||||
|
||||
<section className="shop-home-status">
|
||||
<div className="shop-home-status-left">
|
||||
<div className={`shop-home-status-icon${open ? '' : ' closed'}`}>
|
||||
<span className="material-symbols-outlined shop-fill-icon">schedule</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-home-status-title">营业状态</p>
|
||||
<p className="shop-home-status-sub">{open ? '当前正在营业中' : '当前已停止营业'}</p>
|
||||
<p className="shop-home-status-sub">营业时间: {openTime} - {closeTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="shop-home-switch" onClick={() => navigate('/status')}>
|
||||
<input type="checkbox" checked={open} readOnly tabIndex={-1} />
|
||||
<span className="shop-home-switch-track" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="shop-home-records-head">
|
||||
<h3 className="shop-home-records-title">核销记录</h3>
|
||||
<Link to="/records" className="shop-home-records-link">
|
||||
查看全部
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="shop-home-record-list">
|
||||
{recent.length === 0 && (
|
||||
<p className="shop-home-status-sub" style={{ textAlign: 'center', padding: '16px 0' }}>暂无核销记录</p>
|
||||
)}
|
||||
{recent.map((r) => (
|
||||
<div key={String(r.id)} className="shop-home-record-item">
|
||||
<div>
|
||||
<p className="shop-home-record-time">核销时间</p>
|
||||
<p className="shop-home-record-value">
|
||||
{new Date(String(r.createdAt)).toLocaleString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request, saveAuth } from '../lib/api';
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
if (phone.length < 7) return phone;
|
||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const quick = params.get('quick') === '1';
|
||||
const [phone, setPhone] = useState('13900000001');
|
||||
const [code, setCode] = useState('123456');
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
setMsg('请先阅读并同意用户协议');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendCode() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }),
|
||||
});
|
||||
setMsg('验证码已发送(Mock: 123456)');
|
||||
setCodeCooldown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCodeCooldown((c) => {
|
||||
if (c <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '发送失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (!ensureAgreed()) return;
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }),
|
||||
});
|
||||
const data = await request<{ accessToken: string }>('SHOP_H5', '/shop/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
saveAuth(data);
|
||||
navigate('/');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function wechatLogin() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('preV1:微信一键授权暂未开放,请使用手机验证码登录');
|
||||
}
|
||||
|
||||
if (quick) {
|
||||
return (
|
||||
<div className="shop-quick-login-page">
|
||||
<header className="shop-quick-header">
|
||||
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="shop-quick-logo" fit="contain" />
|
||||
<h1 className="shop-quick-welcome">欢迎回来</h1>
|
||||
<div className="shop-quick-welcome-line" />
|
||||
</header>
|
||||
|
||||
<section className="shop-quick-store-card">
|
||||
<div className="shop-quick-store-inner">
|
||||
<div className="shop-quick-store-icon">
|
||||
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="shop-quick-store-name">门店管理中心</h2>
|
||||
<p className="shop-quick-store-phone">{maskPhone(phone)}</p>
|
||||
</div>
|
||||
<span className="shop-quick-verified">
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 14 }}>verified_user</span>
|
||||
认证门店
|
||||
</span>
|
||||
<div className="shop-quick-switch">
|
||||
<Link to="/login">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>sync</span>
|
||||
切换账号
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="shop-quick-actions">
|
||||
{msg && <p className="shop-login-msg">{msg}</p>}
|
||||
<button
|
||||
type="button"
|
||||
className="shop-quick-login-btn"
|
||||
disabled={loading}
|
||||
onClick={login}
|
||||
>
|
||||
<span>{loading ? '登录中...' : '一键登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
<div className="shop-quick-secure">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>lock</span>
|
||||
<span>加密环境安全登录中</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="shop-quick-footer">
|
||||
<p className="shop-login-footer-brand">SECURED BY DUKANG HERITAGE</p>
|
||||
<p style={{ fontSize: 10, fontFamily: 'var(--font-label)' }}>© 2024 杜康酒业门店管理系统</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shop-login-page">
|
||||
<header className="shop-login-hero">
|
||||
<div className="shop-login-logo-wrap">
|
||||
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="app-image--fill" fit="contain" />
|
||||
</div>
|
||||
<h1 className="shop-login-brand">杜康好客</h1>
|
||||
<p className="shop-login-tagline">门店管理系统</p>
|
||||
</header>
|
||||
|
||||
<main className="shop-login-main">
|
||||
<div className="shop-login-card">
|
||||
<div className="shop-login-field">
|
||||
<label htmlFor="phone">手机号码</label>
|
||||
<div className="shop-login-input-wrap">
|
||||
<span className="material-symbols-outlined">phone_iphone</span>
|
||||
<input
|
||||
id="phone"
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
placeholder="请输入您的手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shop-login-field">
|
||||
<label htmlFor="code">验证码</label>
|
||||
<div className="shop-login-code-row">
|
||||
<div className="shop-login-input-wrap">
|
||||
<span className="material-symbols-outlined">shield</span>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
maxLength={6}
|
||||
placeholder="验证码"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-login-code-btn"
|
||||
disabled={codeCooldown > 0}
|
||||
onClick={sendCode}
|
||||
>
|
||||
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && <p className="shop-login-msg">{msg}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shop-login-submit"
|
||||
disabled={loading}
|
||||
onClick={login}
|
||||
>
|
||||
<span>{loading ? '登录中...' : '登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
|
||||
<div className="shop-login-divider">
|
||||
<span className="shop-login-divider-line" />
|
||||
<span className="shop-login-divider-text">或者</span>
|
||||
<span className="shop-login-divider-line" />
|
||||
</div>
|
||||
|
||||
<button type="button" className="shop-login-wechat" onClick={wechatLogin}>
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
<span>微信一键授权</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="shop-login-agreement">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
我已阅读并同意
|
||||
<a href="#user-agreement">《用户协议》</a>
|
||||
与
|
||||
<a href="#privacy">《隐私政策》</a>
|
||||
</span>
|
||||
</label>
|
||||
</main>
|
||||
|
||||
<footer className="shop-login-footer">
|
||||
<p className="shop-login-footer-brand">Secured by DUKANG HERITAGE</p>
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}>
|
||||
security
|
||||
</span>
|
||||
<p style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Link to="/login?quick=1" className="text-primary body-md">一键登录</Link>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { clearAuth, isLoggedIn, request } from '../lib/api';
|
||||
|
||||
export default function MinePage() {
|
||||
const navigate = useNavigate();
|
||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
request('SHOP_H5', '/shop/store').then(setStore);
|
||||
}, [navigate]);
|
||||
|
||||
const openTime = String(store?.openTime || '09:30');
|
||||
const closeTime = String(store?.closeTime || '22:00');
|
||||
|
||||
return (
|
||||
<div className="shop-mine-page">
|
||||
<header className="shop-mine-header">
|
||||
<h1 className="app-page-title">我的</h1>
|
||||
</header>
|
||||
|
||||
<div className="shop-mine-content">
|
||||
<h3 className="shop-mine-section-title">门店信息</h3>
|
||||
|
||||
<div className="shop-mine-info-card">
|
||||
<div className="shop-mine-info-row">
|
||||
<div>
|
||||
<p className="shop-mine-info-label">门店名称</p>
|
||||
<p className="shop-mine-info-value name">{String(store?.name || '—')}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
||||
</div>
|
||||
<div className="shop-mine-info-row">
|
||||
<div>
|
||||
<p className="shop-mine-info-label">地理位置</p>
|
||||
<p className="shop-mine-info-value">{String(store?.address || '—')}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
||||
</div>
|
||||
<div className="shop-mine-info-row">
|
||||
<div>
|
||||
<p className="shop-mine-info-label">联系电话</p>
|
||||
<p className="shop-mine-info-value">{String(store?.phone || '—')}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
||||
</div>
|
||||
<div className="shop-mine-info-row">
|
||||
<div>
|
||||
<p className="shop-mine-info-label">营业时间</p>
|
||||
<p className="shop-mine-info-value">{openTime} - {closeTime}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shop-mine-help">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>help</span>
|
||||
<p>如需修改信息请联系城市合伙人</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shop-mine-logout"
|
||||
onClick={() => { clearAuth(); navigate('/login'); }}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>logout</span>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type RangeKey = 'today' | '7d' | '30d';
|
||||
type StatusFilter = 'all' | 'pending' | 'paid';
|
||||
|
||||
function formatMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function inRange(dateStr: string, range: RangeKey) {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
if (range === 'today') return d >= start;
|
||||
if (range === '7d') {
|
||||
start.setDate(now.getDate() - 6);
|
||||
return d >= start;
|
||||
}
|
||||
start.setDate(now.getDate() - 29);
|
||||
return d >= start;
|
||||
}
|
||||
|
||||
export default function RecordsPage() {
|
||||
const [records, setRecords] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [range, setRange] = useState<RangeKey>('today');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [storeName, setStoreName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
|
||||
setRecords(d.list || []);
|
||||
});
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => setStoreName(String(s.name || '')))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return records.filter((r) => {
|
||||
if (!inRange(String(r.createdAt), range)) return false;
|
||||
if (statusFilter === 'all') return true;
|
||||
const isPaid = Boolean(r.paidAt);
|
||||
if (statusFilter === 'paid') return isPaid;
|
||||
return !isPaid;
|
||||
});
|
||||
}, [records, range, statusFilter]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const totalAmount = filtered.reduce((s, r) => s + Number(r.amount || 0), 0);
|
||||
const totalSettle = filtered.reduce((s, r) => s + Number(r.settleAmount || 0), 0);
|
||||
const rate = totalAmount > 0 ? Math.round((totalSettle / totalAmount) * 100) : 60;
|
||||
return { totalAmount, totalSettle, rate };
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div className="shop-records-page">
|
||||
<header className="shop-records-header">
|
||||
<h1 className="app-page-title">核销记录</h1>
|
||||
</header>
|
||||
|
||||
<div className="shop-records-main">
|
||||
<nav className="shop-records-filters">
|
||||
<div className="shop-records-range-tabs">
|
||||
{([
|
||||
['today', '今日'],
|
||||
['7d', '近7日'],
|
||||
['30d', '近30日'],
|
||||
] as const).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`shop-records-range-tab${range === key ? ' active' : ''}`}
|
||||
onClick={() => setRange(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="shop-records-status-chips">
|
||||
{([
|
||||
['all', '全部'],
|
||||
['pending', '待打款'],
|
||||
['paid', '已打款'],
|
||||
] as const).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`shop-records-chip${statusFilter === key ? ' active' : ''}`}
|
||||
onClick={() => setStatusFilter(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section className="shop-records-summary">
|
||||
<div className="shop-records-summary-grid">
|
||||
<div>
|
||||
<p className="shop-records-summary-label">期间核销总额</p>
|
||||
<p className="shop-records-summary-value">¥ {formatMoney(summary.totalAmount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-records-summary-label">期间到账总额</p>
|
||||
<p className="shop-records-summary-value">¥ {formatMoney(summary.totalSettle)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="shop-records-summary-note">
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 16, color: 'var(--color-success-green)' }}>
|
||||
check_circle
|
||||
</span>
|
||||
结算比例: {summary.rate}% (按{summary.rate / 10}折结算)
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="shop-records-list-head">
|
||||
<h3 className="shop-records-list-title">交易详情</h3>
|
||||
<span className="shop-records-list-count">共 {filtered.length} 笔记录</span>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="shop-records-empty">暂无核销记录</p>
|
||||
) : (
|
||||
<div className="shop-records-list">
|
||||
{filtered.map((r) => {
|
||||
const amount = Number(r.amount || 0);
|
||||
const settle = Number(r.settleAmount || 0);
|
||||
const paid = Boolean(r.paidAt);
|
||||
return (
|
||||
<article key={String(r.id)} className="shop-record-card">
|
||||
<div className="shop-record-card-top">
|
||||
<div>
|
||||
<div className="shop-record-order">
|
||||
<span className="shop-record-time" style={{ margin: 0 }}>订单号</span>
|
||||
<span>{String(r.redeemNo || r.id)}</span>
|
||||
</div>
|
||||
<p className="shop-record-time">
|
||||
核销时间: {new Date(String(r.createdAt)).toLocaleString('zh-CN', { hour12: false }).slice(0, 16)}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`shop-record-badge ${paid ? 'paid' : 'pending'}`}>
|
||||
{paid ? '已打款' : '待打款'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shop-record-amounts">
|
||||
<div>
|
||||
<p className="shop-record-amount-label">核销金额(券面)</p>
|
||||
<p className="shop-record-amount-value">¥{formatMoney(amount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-record-amount-label">到账金额(6折)</p>
|
||||
<p className="shop-record-amount-value red">¥{formatMoney(settle)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shop-record-footer">
|
||||
<p>{paid ? `打款时间: ${new Date(String(r.createdAt)).toLocaleDateString('zh-CN')}` : '预计打款: T+1工作日'}</p>
|
||||
{storeName && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>restaurant</span>
|
||||
{storeName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 && (
|
||||
<div className="shop-records-end">
|
||||
<div className="shop-records-end-line" />
|
||||
<p className="shop-records-list-count">已显示全部核销记录</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function RedeemConfirmPage() {
|
||||
const navigate = useNavigate();
|
||||
const [token, setToken] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [previewAmount, setPreviewAmount] = useState(100);
|
||||
|
||||
useEffect(() => {
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => setStoreName(String(s.name || '当前门店')))
|
||||
.catch(() => setStoreName('当前门店'));
|
||||
}, []);
|
||||
|
||||
async function confirm() {
|
||||
if (!token.trim()) {
|
||||
setMsg('请在开发者选项中输入核销码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
||||
navigate('/redeem/success', { state: { result, storeName } });
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shop-redeem-page">
|
||||
<header className="shop-redeem-header">
|
||||
<button type="button" className="shop-redeem-back" onClick={() => navigate(-1)} aria-label="返回">
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className="app-page-title">核销确认</h1>
|
||||
</header>
|
||||
|
||||
<main className="shop-redeem-main">
|
||||
<section className="shop-redeem-card">
|
||||
<div className="shop-redeem-banner">
|
||||
<div className="shop-redeem-banner-icon">
|
||||
<span className="material-symbols-outlined shop-fill-icon">verified_user</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-redeem-banner-label">当前登录核销门店</p>
|
||||
<h2 className="shop-redeem-banner-name">{storeName}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shop-redeem-body">
|
||||
<div className="shop-redeem-user">
|
||||
<div className="shop-redeem-user-left">
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
<span>下单用户</span>
|
||||
</div>
|
||||
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
|
||||
待扫码确认
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="shop-redeem-amount-section">
|
||||
<span className="shop-redeem-notch shop-redeem-notch--left" />
|
||||
<span className="shop-redeem-notch shop-redeem-notch--right" />
|
||||
<p className="shop-redeem-amount-label">核销金额</p>
|
||||
<div className="shop-redeem-amount">
|
||||
<span className="shop-redeem-amount-symbol">¥</span>
|
||||
<span className="shop-redeem-amount-value">{formatAmount(previewAmount)}</span>
|
||||
</div>
|
||||
<span className="shop-redeem-benefit">
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 16, color: 'var(--color-aged-amber)' }}>
|
||||
confirmation_number
|
||||
</span>
|
||||
好客权益
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="shop-redeem-details">
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>券编号</span>
|
||||
<span>{token ? `…${token.slice(-8)}` : '扫码后显示'}</span>
|
||||
</div>
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>有效期</span>
|
||||
<span>永久</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
|
||||
disabled={loading}
|
||||
onClick={confirm}
|
||||
>
|
||||
<span className="material-symbols-outlined shop-fill-icon">
|
||||
{loading ? 'sync' : 'check_circle'}
|
||||
</span>
|
||||
<span>{loading ? '正在核销...' : `确认核销 ¥${formatAmount(previewAmount)}`}</span>
|
||||
</button>
|
||||
|
||||
<p className="shop-redeem-hint">请核对金额后点击确认</p>
|
||||
|
||||
<details className="shop-redeem-dev">
|
||||
<summary>开发者选项 · 手动输入核销码</summary>
|
||||
<div className="shop-redeem-dev-body">
|
||||
<input
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
if (e.target.value) setPreviewAmount(100);
|
||||
}}
|
||||
placeholder="粘贴用户核销码"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="shop-redeem-ornament">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 64, color: 'var(--color-heritage-red)' }}>
|
||||
wine_bar
|
||||
</span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function RedeemSuccessPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const result = useMemo(() => {
|
||||
const stateResult = (location.state as { result?: Record<string, unknown> })?.result;
|
||||
if (stateResult) return stateResult;
|
||||
try {
|
||||
const cached = sessionStorage.getItem('lastRedeemResult');
|
||||
return cached ? (JSON.parse(cached) as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
const storeName = (location.state as { storeName?: string })?.storeName || '当前门店';
|
||||
const amount = Number(result?.amount || 100);
|
||||
const redeemNo = String(result?.redeemNo || '—');
|
||||
const createdAt = result?.createdAt
|
||||
? new Date(String(result.createdAt)).toLocaleString('zh-CN')
|
||||
: new Date().toLocaleString('zh-CN');
|
||||
|
||||
return (
|
||||
<div className="shop-success-page">
|
||||
<header className="shop-success-header">
|
||||
<button type="button" className="shop-redeem-back" onClick={() => navigate('/')} aria-label="返回">
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className="app-page-title">杜康好客</h1>
|
||||
</header>
|
||||
|
||||
<section className="shop-success-hero">
|
||||
<div className="shop-success-icon-wrap">
|
||||
<span className="material-symbols-outlined">check_circle</span>
|
||||
</div>
|
||||
<h2 className="shop-success-title">核销成功</h2>
|
||||
<p className="shop-success-amount">¥ {formatAmount(amount)}</p>
|
||||
<p className="shop-success-sub">已入账到余额</p>
|
||||
<p className="shop-success-note">核销成功,账单通知已发送至老板手机</p>
|
||||
</section>
|
||||
|
||||
<div className="shop-success-details">
|
||||
<div className="shop-success-detail-row">
|
||||
<span className="shop-success-detail-label">核销门店</span>
|
||||
<span className="shop-success-detail-value">{storeName}</span>
|
||||
</div>
|
||||
<div className="shop-success-detail-row">
|
||||
<span className="shop-success-detail-label">核销用户</span>
|
||||
<div className="shop-success-user">
|
||||
<div className="shop-success-user-avatar">
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="shop-success-detail-value">杜康用户</div>
|
||||
<div className="shop-success-detail-label">待完善</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shop-success-detail-row">
|
||||
<span className="shop-success-detail-label">核销时间</span>
|
||||
<span className="shop-success-detail-value" style={{ fontWeight: 400, color: 'var(--color-on-surface-variant)' }}>
|
||||
{createdAt}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shop-success-detail-row">
|
||||
<span className="shop-success-detail-label">订单编号</span>
|
||||
<span className="shop-success-detail-value" style={{ fontFamily: 'monospace', fontWeight: 400 }}>
|
||||
{redeemNo}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shop-success-actions">
|
||||
<button type="button" className="shop-success-primary-btn" onClick={() => navigate('/redeem')}>
|
||||
<span>继续核销</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span>
|
||||
</button>
|
||||
<button type="button" className="shop-success-outline-btn" onClick={() => navigate('/')}>
|
||||
<span>返回首页</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>home</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shop-success-brand">
|
||||
<div className="shop-success-brand-icon">
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 12 }}>verified</span>
|
||||
</div>
|
||||
<p className="shop-success-brand-text">山西领势酒业有限责任公司</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { clearAuth, request } from '../lib/api';
|
||||
|
||||
export default function StatusPage() {
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [pendingOpen, setPendingOpen] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request('SHOP_H5', '/shop/dashboard').then((d) => {
|
||||
const s = d.store as Record<string, unknown>;
|
||||
setStore(s);
|
||||
setOpen(String(s?.status) === 'OPEN');
|
||||
if (s?.updatedAt) {
|
||||
setLastUpdate(new Date(String(s.updatedAt)).toLocaleString('zh-CN'));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
function requestToggle(next: boolean) {
|
||||
if (next === open) return;
|
||||
setPendingOpen(next);
|
||||
setShowModal(true);
|
||||
}
|
||||
|
||||
async function confirmToggle() {
|
||||
if (pendingOpen === null) return;
|
||||
const next = pendingOpen ? 'OPEN' : 'PAUSED';
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/store/status', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
setOpen(pendingOpen);
|
||||
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
||||
} catch {
|
||||
/* keep current state */
|
||||
} finally {
|
||||
setShowModal(false);
|
||||
setPendingOpen(null);
|
||||
}
|
||||
}
|
||||
|
||||
const openTime = String(store?.openTime || '09:30');
|
||||
const closeTime = String(store?.closeTime || '22:00');
|
||||
|
||||
return (
|
||||
<div className="shop-status-page">
|
||||
<header className="shop-status-header app-page-header">
|
||||
<button type="button" className="app-page-header-action shop-redeem-back" onClick={() => navigate(-1)} aria-label="返回">
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className="app-page-title">门店管理</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-status-logout app-page-header-action app-page-header-action--end"
|
||||
onClick={() => { clearAuth(); navigate('/login'); }}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>logout</span>
|
||||
退出登录
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="shop-status-content">
|
||||
<section className="shop-status-card">
|
||||
<div className="shop-status-icon-wrap">
|
||||
<div className={`shop-status-icon-outer${open ? ' open' : ' closed'}`}>
|
||||
<div className={`shop-status-icon-inner${open ? ' open' : ' closed'}`}>
|
||||
<span className="material-symbols-outlined">storefront</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shop-status-check">
|
||||
<span className={`material-symbols-outlined shop-fill-icon${open ? '' : ''}`} style={{ fontSize: 14, color: open ? 'var(--color-success-green)' : 'var(--color-subtle-gray)' }}>
|
||||
{open ? 'check_circle' : 'cancel'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className={`shop-status-label${open ? ' open' : ' closed'}`}>
|
||||
{open ? '营业中' : '临时闭店'}
|
||||
</h2>
|
||||
|
||||
<label className="shop-status-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={open}
|
||||
onChange={(e) => requestToggle(e.target.checked)}
|
||||
/>
|
||||
<span className="shop-status-switch-track" />
|
||||
</label>
|
||||
|
||||
<p className="shop-status-hours-label">营业时间</p>
|
||||
<p className="shop-status-hours">{openTime} - {closeTime}</p>
|
||||
{lastUpdate && <p className="shop-status-updated">最后修改于 {lastUpdate}</p>}
|
||||
</section>
|
||||
|
||||
<div className={`shop-status-hint${open ? ' open' : ' closed'}`}>
|
||||
<span className="material-symbols-outlined">info</span>
|
||||
<p>
|
||||
{open
|
||||
? '当前处于营业状态,用户可在您的门店核销餐券。'
|
||||
: '当前处于休息状态,用户将无法看到您的门店或进行核销。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="shop-status-modal" role="dialog" aria-modal="true">
|
||||
<div className="shop-status-modal-card">
|
||||
<h4 className="shop-status-modal-title">确认切换状态?</h4>
|
||||
<p className="shop-status-modal-desc">
|
||||
{pendingOpen
|
||||
? '切换至“营业中”后,用户可正常选择本店核销餐券。'
|
||||
: '切换至“临时闭店”后,用户将无法选择本店核销餐券。'}
|
||||
</p>
|
||||
<div className="shop-status-modal-actions">
|
||||
<button type="button" className="shop-status-modal-cancel" onClick={() => { setShowModal(false); setPendingOpen(null); }}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="shop-status-modal-confirm" onClick={confirmToggle}>
|
||||
确认切换
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: { '/api': 'http://localhost:3000' },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>杜康好客</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600&family=Inter:wght@500&family=Manrope:wght@600;700&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@dukang/h5-user",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5173",
|
||||
"build": "vite build",
|
||||
"lint": "echo ok"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dukang/shared-ui": "workspace:*",
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 356 KiB |
@@ -0,0 +1,47 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import TabLayout from './layouts/TabLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import ProductDetailPage from './pages/ProductDetailPage';
|
||||
import OrderConfirmPage from './pages/OrderConfirmPage';
|
||||
import AddressListPage from './pages/AddressListPage';
|
||||
import AddressEditPage from './pages/AddressEditPage';
|
||||
import OrderListPage from './pages/OrderListPage';
|
||||
import OrderDetailPage from './pages/OrderDetailPage';
|
||||
import StoreListPage from './pages/StoreListPage';
|
||||
import StoreDetailPage from './pages/StoreDetailPage';
|
||||
import BenefitPage from './pages/BenefitPage';
|
||||
import BenefitDetailPage from './pages/BenefitDetailPage';
|
||||
import MinePage from './pages/MinePage';
|
||||
import RedeemPage from './pages/RedeemPage';
|
||||
import RedeemCodePage from './pages/RedeemCodePage';
|
||||
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||
import PayPage from './pages/PayPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<TabLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/stores" element={<StoreListPage />} />
|
||||
<Route path="/benefit" element={<BenefitPage />} />
|
||||
<Route path="/mine" element={<MinePage />} />
|
||||
</Route>
|
||||
<Route path="/product/:id" element={<ProductDetailPage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
<Route path="/order/confirm" element={<OrderConfirmPage />} />
|
||||
<Route path="/pay" element={<PayPage />} />
|
||||
<Route path="/addresses" element={<AddressListPage />} />
|
||||
<Route path="/addresses/new" element={<AddressEditPage />} />
|
||||
<Route path="/addresses/:id/edit" element={<AddressEditPage />} />
|
||||
<Route path="/orders" element={<OrderListPage />} />
|
||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||
<Route path="/benefit/:id" element={<BenefitDetailPage />} />
|
||||
<Route path="/redeem" element={<RedeemPage />} />
|
||||
<Route path="/redeem/code" element={<RedeemCodePage />} />
|
||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
|
||||
type Props = {
|
||||
images: string[];
|
||||
alt: string;
|
||||
variant?: 'home' | 'detail' | 'store';
|
||||
};
|
||||
|
||||
export default function ProductCarousel({ images, alt, variant = 'home' }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const slides = images.length > 0 ? images : [''];
|
||||
|
||||
function onScroll() {
|
||||
const el = scrollRef.current;
|
||||
if (!el || el.offsetWidth === 0) return;
|
||||
setActiveIndex(Math.round(el.scrollLeft / el.offsetWidth));
|
||||
}
|
||||
|
||||
const wrapClass =
|
||||
variant === 'store'
|
||||
? 'store-detail-carousel-wrap'
|
||||
: variant === 'detail'
|
||||
? 'detail-carousel-wrap'
|
||||
: 'home-carousel-wrap';
|
||||
const trackClass =
|
||||
variant === 'store'
|
||||
? 'store-detail-carousel'
|
||||
: variant === 'detail'
|
||||
? 'detail-carousel'
|
||||
: 'home-carousel';
|
||||
const dotClass =
|
||||
variant === 'store'
|
||||
? 'store-detail-carousel-dot'
|
||||
: variant === 'detail'
|
||||
? 'detail-carousel-dot'
|
||||
: 'home-carousel-dot';
|
||||
const itemClass =
|
||||
variant === 'store'
|
||||
? 'store-detail-carousel-item'
|
||||
: variant === 'detail'
|
||||
? 'detail-carousel-item'
|
||||
: 'home-carousel-item';
|
||||
const placeholderClass =
|
||||
variant === 'store'
|
||||
? 'store-detail-carousel-placeholder'
|
||||
: variant === 'detail'
|
||||
? 'detail-carousel-placeholder'
|
||||
: 'home-carousel-placeholder';
|
||||
|
||||
return (
|
||||
<div className={wrapClass}>
|
||||
<div className={trackClass} ref={scrollRef} onScroll={onScroll}>
|
||||
{slides.map((src, i) => (
|
||||
<div key={i} className={itemClass}>
|
||||
{src ? (
|
||||
<AppImage src={src} alt={alt} wrapperClassName="app-image--fill" />
|
||||
) : (
|
||||
<div className={placeholderClass} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{slides.length > 1 && (
|
||||
<div className={variant === 'store' ? 'store-detail-carousel-dots' : variant === 'detail' ? 'detail-carousel-dots' : 'home-carousel-dots'}>
|
||||
{slides.map((_, i) => (
|
||||
<span key={i} className={`${dotClass}${i === activeIndex ? ' active' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
type SubPageHeaderProps = {
|
||||
title: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export default function SubPageHeader({ title, onBack }: SubPageHeaderProps) {
|
||||
return (
|
||||
<header className="sub-page-header">
|
||||
<button type="button" className="sub-page-header-back" aria-label="返回" onClick={onBack}>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className="app-page-title">{title}</h1>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type TabMainHeaderProps = {
|
||||
title: string;
|
||||
extra?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function TabMainHeader({ title, extra, className = '' }: TabMainHeaderProps) {
|
||||
return (
|
||||
<header className={`tab-main-header${className ? ` ${className}` : ''}`}>
|
||||
<h1 className="app-page-title">{title}</h1>
|
||||
{extra ? <div className="tab-main-header-extra">{extra}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn } from '../lib/api';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/', end: true, icon: 'home', label: '首页', fillActive: false },
|
||||
{ to: '/stores', icon: 'storefront', label: '门店', fillActive: true },
|
||||
{ to: '/benefit', icon: 'card_giftcard', label: '好客权益', fillActive: false },
|
||||
{ to: '/mine', icon: 'person', label: '我的', fillActive: true },
|
||||
] as const;
|
||||
|
||||
export default function TabLayout() {
|
||||
const navigate = useNavigate();
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<nav className="app-tabbar">
|
||||
{TABS.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
end={tab.end}
|
||||
className={({ isActive }) => `app-tabbar-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className="material-symbols-outlined app-tabbar-icon"
|
||||
style={
|
||||
isActive && tab.fillActive
|
||||
? { fontVariationSettings: "'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{tab.icon}
|
||||
</span>
|
||||
<span className="app-tabbar-label">{tab.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export const BRAND = {
|
||||
red: '#A02D30',
|
||||
yellow: '#FFC107',
|
||||
bg: '#f5f5f5',
|
||||
text: '#333',
|
||||
muted: '#999',
|
||||
};
|
||||
|
||||
export const apiBase = '/api/v1';
|
||||
|
||||
export async function request<T>(
|
||||
clientApp: string,
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '请求失败');
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
export function saveAuth(data: { accessToken: string; refreshToken?: string }) {
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
if (data.refreshToken) localStorage.setItem('refreshToken', data.refreshToken);
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!localStorage.getItem('accessToken');
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export type CheckoutContext = {
|
||||
productId?: string | null;
|
||||
qty?: string | number | null;
|
||||
addressId?: string | null;
|
||||
cross?: boolean | string | null;
|
||||
select?: boolean | string | null;
|
||||
};
|
||||
|
||||
export function readCheckoutContext(params: URLSearchParams): CheckoutContext {
|
||||
return {
|
||||
productId: params.get('productId'),
|
||||
qty: params.get('qty'),
|
||||
addressId: params.get('addressId'),
|
||||
cross: params.get('cross'),
|
||||
select: params.get('select'),
|
||||
};
|
||||
}
|
||||
|
||||
export function appendCheckoutContext(qs: URLSearchParams, ctx: CheckoutContext) {
|
||||
if (ctx.productId) qs.set('productId', ctx.productId);
|
||||
if (ctx.qty != null && ctx.qty !== '') qs.set('qty', String(ctx.qty));
|
||||
if (ctx.addressId) qs.set('addressId', ctx.addressId);
|
||||
if (ctx.cross === true || ctx.cross === '1') qs.set('cross', '1');
|
||||
if (ctx.select === true || ctx.select === '1') qs.set('select', '1');
|
||||
}
|
||||
|
||||
export function buildOrderConfirmUrl(search: CheckoutContext) {
|
||||
const qs = new URLSearchParams();
|
||||
if (search.productId) qs.set('productId', search.productId);
|
||||
if (search.qty != null && search.qty !== '') qs.set('qty', String(search.qty));
|
||||
if (search.addressId) qs.set('addressId', search.addressId);
|
||||
if (search.cross === true || search.cross === '1') qs.set('cross', '1');
|
||||
const query = qs.toString();
|
||||
return query ? `/order/confirm?${query}` : '/order/confirm';
|
||||
}
|
||||
|
||||
export function buildAddressListUrl(ctx: CheckoutContext = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
appendCheckoutContext(qs, ctx);
|
||||
const query = qs.toString();
|
||||
return query ? `/addresses?${query}` : '/addresses';
|
||||
}
|
||||
|
||||
export function buildAddressEditUrl(id: string | 'new', ctx: CheckoutContext = {}) {
|
||||
const path = id === 'new' ? '/addresses/new' : `/addresses/${id}/edit`;
|
||||
const qs = new URLSearchParams();
|
||||
appendCheckoutContext(qs, ctx);
|
||||
const query = qs.toString();
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
|
||||
export function buildProductDetailUrl(productId?: string | null) {
|
||||
return productId ? `/product/${productId}` : '/';
|
||||
}
|
||||
|
||||
export function hasCheckoutContext(ctx: CheckoutContext) {
|
||||
return Boolean(ctx.productId || ctx.select === true || ctx.select === '1');
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Stitch 确认订单页商品缩略图 */
|
||||
export const STITCH_ORDER_PRODUCT_IMAGE =
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuAfqy5X1jKiMBB-L5amwR3xfLYbFBc_qsPbB9mdQZxWlV3rrOARPFVhLRDlW7r8Ig03O6c_ZJKLcVEsgYCblwKg8FZ4-EWwcc5bMNc3UsmBycu3bZ5E6S_aH9UBv0_nEP0sMD8rJsC_rMYBiGDMvRbd52taX-Ir_sfRiVvQu7ImFV-YvU54iXE2x51naVuR8qxwmK7YKitPClg0Pysga859a2-yiJ_ID0QR5xM2o84QbMwNyOEoDDTKSDNqG6J9jfeTsiIYb5vdVmc';
|
||||
@@ -0,0 +1,44 @@
|
||||
/** Stitch 用户端-商品详情页 原型图(杜康·白水古酿) */
|
||||
const STITCH_CAROUSEL = [
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDiJm2VWrwCv8wnC-dBgSfvlf66izs6faELgWXlyIAUpYWOKrLwfeyB0c0XT0vmDVJnfIkzbLNm_4NYASwH_ce7BotJDLCJcd3SfnxKIe7eso-c4mzzR-4LTv4y3ELhpXHfxVyu-5LVUEwuofvUuzdJELV6CK4MIcLW_9rMaOuXSADfz0mpP-MspQvhKhxJ0wpdAiBxBq8rqHNSKjx8dU7lcVc_smZGunmtkbhmnjAn4JU8nCDnvuDU5HECng82FbFM6rzdkFHoYR0',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDzBBRn0yOqhRJ4CbTEOx1aF4fJVIhsbIZgFR9RgdB5E0xcs_RdR1khLyR0OzysGzkW_tnrZTb0avVEZ91Nd81KRItrlTjrEFvrYj0Qag45iRo5wioY8E2gK5NGhILvDpWxakuSPIGGp00nLY_5HuuLwr-0_8ZabaUFAR4C9loXIX_lgCAgRMt7An_H0AitIOBvwOfNVTMkz-P7dXQFzSUvYpFvcmvzAOIWsbipnTrgNU5H8Os37-soM-eWUCfNtJUaD_uqQ3mwJI8',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDSSmH1ygwminKXiiIqOymnukbKJfnhfHnmCJTbNN2BEN2yF3vPtoMYOBAsDHxuldT9xg_ZBZhjh6QJjabvhu_HFB3WcNU53q_AjsD0mVWXInongiXqjOh8R-B2QW9Jfs786j3TSi2gVE57Ad1WskJji-xytI3aFEuk873xGXgdkn6EgzoAMOsKRaWF27DE3GBa48qAARYR92aEyMU_hcte6L2lkaF9brXshSmujiA_3ACK21TLsT2DCJ1Djacvh25J0LZ8v7BYVkk',
|
||||
] as const;
|
||||
|
||||
const STITCH_DETAIL = [
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuA2-IVt-apnEkj9QQ4rkjN5lb0oymgiJX1XfzAH8pRzSFzMVjYDtlMWE8GwpS7I6sth7CXJiKwNm9c-hpsYKqQ6pyb48yUO6NG8vky4E6qCjwgaCsCnlvoOVLroG4bmmL16-xl4-o28ZMvtzMoCKIiUK-_dQF7lx66nwnxFOP7PcUddDK-UItoO-Gp5iqxf6kp_-t_tjoPpo_ba25DBPG1QThlI8IJYqb9bNng5mIQzdnNul24rBy_JmgS5nsaYK0Wvo7907WT3ch4',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuBe0yoN6VDKu2SVqwOy9RedE-6Rh56-a_5ygo5raDOU3Y65m1fz9hNmqbrIpvTW8RsqAwPd3zfHnw96Bb4Ct7jtmq-pil1MEvPBL4G3C8Sym_LVuEzK---hgdim1wVx-qP1v1EPex2fXpVQEY27rEVINVaXk2L1F5elWKQhMHWVXjU8B2jvtyNzmlXBpynsocnCgcwM4RhaqYdVf1JZxcfScmJ34dO3QAUIli-RPzEYynLtnW2x4lEbRrpAdBnK2fuSggLnJU1nfSw',
|
||||
] as const;
|
||||
|
||||
/** 本地商品图占位 */
|
||||
export const PRODUCT_IMAGE_INDEX = [
|
||||
'/images/1.png',
|
||||
'/images/2.png',
|
||||
'/images/3.png',
|
||||
] as const;
|
||||
|
||||
export function getProductImages(productIndex = 0): string[] {
|
||||
if (productIndex === 0) {
|
||||
return [...STITCH_CAROUSEL.slice(0, 2)];
|
||||
}
|
||||
const img = PRODUCT_IMAGE_INDEX[productIndex % PRODUCT_IMAGE_INDEX.length] ?? '/images/1.png';
|
||||
return [img];
|
||||
}
|
||||
|
||||
export function getProductMainImage(productIndex = 0): string {
|
||||
if (productIndex === 0) return STITCH_CAROUSEL[0];
|
||||
return PRODUCT_IMAGE_INDEX[productIndex % PRODUCT_IMAGE_INDEX.length] ?? '/images/1.png';
|
||||
}
|
||||
|
||||
/** 详情页轮播(首商品用 Stitch 三图,其余单图) */
|
||||
export function getProductCarouselImages(productIndex = 0): string[] {
|
||||
if (productIndex === 0) return [...STITCH_CAROUSEL];
|
||||
const img = getProductMainImage(productIndex);
|
||||
return [img];
|
||||
}
|
||||
|
||||
/** 详情页图文长图 */
|
||||
export function getProductDetailImages(productIndex = 0): string[] {
|
||||
if (productIndex === 0) return [...STITCH_DETAIL];
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Stitch user/22 门店详情页 — 图集与地图占位 */
|
||||
export const STITCH_STORE_GALLERY = [
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDqN0DeYRsWNcXyfSRec8k2fhjJsqdji3-7zrtegkiEs5lwt3Sx4l79Uzmfys2pnl_gUY_m3Dpy5cAM8HW7JcR8qPtfO2G8YNcZ3x0DGSN1DUPJPq4emVhmIuwmaLEQ944UT9hjpNQsjdqieKV8R-X-2YvSOrsEa74kyfI5UNgRQaGdinhLw6co29ji3F9BRgfgWCQ1KqjotRBC4r9lzWBdeue-xryXvN8jEp_7hjwrBNOOZoIDPnKkpQQwLLpaa7Di6kfEfwzamCg',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuAW3oxOc6XpywVJmpwYxIPBlP32ftPIOUB8JbYcqOAcLg1gbzKIgbDgBaPVUyH0gjdoWa7Hi0u1-NBYBwc5Jd3YpqufVWIou_ySFB2oLXA6T0u7DgUWKhtxbMnqMue-oasf8GlEy_e7-Rh41ZxVFkc30tQVAYz-Psm3CNgfRFqXoHDXCZAZz5ggOFOB2dScURBVN9qp_Ribo4DuE4LARgf19R8eKZR8mbDQdHesLaVl0icifdcQEb75QM6-VqCR3ch9BNKzLJ5hKMM',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDuv4URDejJ5j26kuBPG2fqmOmI90qQomZki-aHr3MmdF47Pq5HM7tiH68E77rrF0XjeaZjkQ0e39j5gY1-N_981-eguGZn8VIRZI0n6t-f8QVIhAyjL8kg-5ZD2yRsfgw5mnOYYPMyNUI54efLiU4M6mni6nJvTAXMvX0oBMXtTItj5U66d9BIvie7VfHoVYMelEW9ppZsSRzA7ZoIu6aRp_72OwAIcTFuiI2zccaAmfTk7dChjjHIHZ85B8dDc2G8Tym34SuJAZc',
|
||||
] as const;
|
||||
|
||||
export const STITCH_STORE_MAP =
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuByauC4oncButUsGa_t2ntIVz-iPk9zVUnYA6_P_URyFYzrWALFa2TKfdpEyrGs61N_sEjRYksO_HeKCZGJQXfRhXqf1iXrk8JPIfzDwb33bDacTr2J0HM-cSnNjcM1c5l6r_yuzsE0zuLBZpuAWVPwkOJUkdfk6xxNpABh-OQ0B6736YmxFQM-WJ5h0eLHpRB7RuyTFr5c_TwTysKyY6QVDZ-oJrx9Vc3FQE7Pc64o3bzP6qW3GEqdqm4WVIAMKiNKqUcXvkN8E48';
|
||||
|
||||
export function getStoreGalleryImages(coverUrl?: string | null, media?: Array<{ url: string }>) {
|
||||
const fromMedia = (media || []).map((m) => m.url).filter(Boolean);
|
||||
if (fromMedia.length > 0) return fromMedia;
|
||||
if (coverUrl) return [coverUrl, ...STITCH_STORE_GALLERY.slice(1)];
|
||||
return [...STITCH_STORE_GALLERY];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import { buildAddressListUrl, readCheckoutContext } from '../lib/navigation';
|
||||
|
||||
type AddressForm = {
|
||||
receiverName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
const REGION_OPTIONS = [
|
||||
{ province: '河南省', city: '郑州市', district: '金水区', label: '河南省 郑州市 金水区' },
|
||||
{ province: '河南省', city: '郑州市', district: '二七区', label: '河南省 郑州市 二七区' },
|
||||
{ province: '河南省', city: '洛阳市', district: '涧西区', label: '河南省 洛阳市 涧西区' },
|
||||
{ province: '河南省', city: '洛阳市', district: '洛龙区', label: '河南省 洛阳市 洛龙区' },
|
||||
{ province: '北京市', city: '北京市', district: '东城区', label: '北京市 东城区' },
|
||||
{ province: '上海市', city: '上海市', district: '黄浦区', label: '上海市 黄浦区' },
|
||||
{ province: '陕西省', city: '西安市', district: '雁塔区', label: '陕西省 西安市 雁塔区' },
|
||||
] as const;
|
||||
|
||||
function regionLabel(form: Pick<AddressForm, 'province' | 'city' | 'district'>) {
|
||||
if (!form.province || !form.city || !form.district) return '';
|
||||
return `${form.province} ${form.city} ${form.district}`;
|
||||
}
|
||||
|
||||
export default function AddressEditPage() {
|
||||
const { id } = useParams();
|
||||
const [params] = useSearchParams();
|
||||
const isEdit = Boolean(id);
|
||||
const navigate = useNavigate();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickerDraft, setPickerDraft] = useState<typeof REGION_OPTIONS[number] | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<AddressForm>({
|
||||
receiverName: '',
|
||||
phone: '13800000001',
|
||||
province: '河南省',
|
||||
city: params.get('city') || '郑州市',
|
||||
district: '金水区',
|
||||
detail: '',
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
const checkoutCtx = readCheckoutContext(params);
|
||||
|
||||
function goBackToList() {
|
||||
navigate(buildAddressListUrl(checkoutCtx));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
request<Array<Record<string, unknown>>>('USER_H5', '/user/addresses').then((list) => {
|
||||
const found = list.find((a) => String(a.id) === id);
|
||||
if (found) {
|
||||
setForm({
|
||||
receiverName: String(found.receiverName),
|
||||
phone: String(found.phone),
|
||||
province: String(found.province),
|
||||
city: String(found.city),
|
||||
district: String(found.district),
|
||||
detail: String(found.detail),
|
||||
isDefault: found.isDefault === 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const regionText = regionLabel(form);
|
||||
|
||||
function openPicker() {
|
||||
const current =
|
||||
REGION_OPTIONS.find(
|
||||
(r) => r.province === form.province && r.city === form.city && r.district === form.district,
|
||||
) ?? null;
|
||||
setPickerDraft(current);
|
||||
setPickerOpen(true);
|
||||
}
|
||||
|
||||
function confirmRegion() {
|
||||
if (pickerDraft) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
province: pickerDraft.province,
|
||||
city: pickerDraft.city,
|
||||
district: pickerDraft.district,
|
||||
}));
|
||||
}
|
||||
setPickerOpen(false);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.receiverName.trim()) return;
|
||||
if (!form.phone.trim()) return;
|
||||
if (!regionText) return;
|
||||
if (!form.detail.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await request('USER_H5', `/user/addresses/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
} else {
|
||||
await request('USER_H5', '/user/addresses', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
}
|
||||
navigate(buildAddressListUrl(checkoutCtx));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="address-edit-page">
|
||||
<SubPageHeader
|
||||
title={isEdit ? '编辑收货地址' : '添加收货地址'}
|
||||
onBack={goBackToList}
|
||||
/>
|
||||
|
||||
<main className="address-edit-main sub-page-body">
|
||||
<section className="address-edit-card">
|
||||
<div className="address-edit-field">
|
||||
<label className="address-edit-label">收货人姓名</label>
|
||||
<div className="address-edit-line">
|
||||
<input
|
||||
type="text"
|
||||
className="address-edit-input"
|
||||
placeholder="请输入姓名"
|
||||
value={form.receiverName}
|
||||
onChange={(e) => setForm({ ...form, receiverName: e.target.value })}
|
||||
/>
|
||||
<span className="material-symbols-outlined address-edit-field-icon">person</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="address-edit-field">
|
||||
<label className="address-edit-label">手机号码</label>
|
||||
<div className="address-edit-line">
|
||||
<span className="address-edit-prefix">+86</span>
|
||||
<input
|
||||
type="tel"
|
||||
className="address-edit-input"
|
||||
placeholder="请输入手机号"
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
/>
|
||||
<span className="material-symbols-outlined address-edit-field-icon">smartphone</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="address-edit-field address-edit-region" onClick={openPicker}>
|
||||
<label className="address-edit-label">所在地区</label>
|
||||
<div className="address-edit-line address-edit-line--picker">
|
||||
<span className={regionText ? 'address-edit-region-value' : 'address-edit-region-placeholder'}>
|
||||
{regionText || '请选择省/市/区'}
|
||||
</span>
|
||||
<span className="material-symbols-outlined address-edit-field-icon">chevron_right</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="address-edit-field address-edit-field--last">
|
||||
<label className="address-edit-label">详细地址</label>
|
||||
<div className="address-edit-textarea-wrap">
|
||||
<textarea
|
||||
className="address-edit-textarea"
|
||||
placeholder="街道、门牌号、小区名称等"
|
||||
rows={3}
|
||||
value={form.detail}
|
||||
onChange={(e) => setForm({ ...form, detail: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="address-edit-card address-edit-default">
|
||||
<div className="address-edit-default-info">
|
||||
<div className="address-edit-default-icon">
|
||||
<span className="material-symbols-outlined fill-icon">stars</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="address-edit-default-title">设为默认地址</h3>
|
||||
<p className="address-edit-default-desc">每次下单时将优先使用此地址</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="address-edit-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isDefault}
|
||||
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
|
||||
/>
|
||||
<span className="address-edit-toggle-track" />
|
||||
<span className="address-edit-toggle-thumb" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div className="address-edit-security">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span>已通过杜康云安全加密处理</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<nav className="address-edit-footer">
|
||||
<button type="button" className="address-edit-cancel-btn" onClick={goBackToList}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
<span>取消</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="address-edit-save-btn"
|
||||
disabled={saving}
|
||||
onClick={save}
|
||||
>
|
||||
<span className="material-symbols-outlined">publish</span>
|
||||
<span>{saving ? '保存中...' : '保存并发布'}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{pickerOpen && (
|
||||
<div
|
||||
className="address-edit-picker-overlay"
|
||||
role="presentation"
|
||||
onClick={() => setPickerOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="address-edit-picker-sheet"
|
||||
role="dialog"
|
||||
aria-label="选择地区"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="address-edit-picker-head">
|
||||
<h4>选择地区</h4>
|
||||
<button type="button" aria-label="关闭" onClick={() => setPickerOpen(false)}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="address-edit-picker-list">
|
||||
{REGION_OPTIONS.map((r) => {
|
||||
const selected =
|
||||
pickerDraft?.province === r.province &&
|
||||
pickerDraft?.city === r.city &&
|
||||
pickerDraft?.district === r.district;
|
||||
return (
|
||||
<button
|
||||
key={r.label}
|
||||
type="button"
|
||||
className={`address-edit-picker-item${selected ? ' selected' : ''}`}
|
||||
onClick={() => setPickerDraft(r)}
|
||||
>
|
||||
<span>{r.label}</span>
|
||||
{selected && (
|
||||
<span className="material-symbols-outlined">check_circle</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button type="button" className="address-edit-picker-confirm" onClick={confirmRegion}>
|
||||
确认选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import { buildAddressEditUrl, buildAddressListUrl, buildOrderConfirmUrl, hasCheckoutContext, readCheckoutContext } from '../lib/navigation';
|
||||
|
||||
type Address = {
|
||||
id: string;
|
||||
receiverName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail: string;
|
||||
isDefault?: number;
|
||||
};
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
}
|
||||
|
||||
function formatAddress(a: Address) {
|
||||
return `${a.province}${a.city}${a.district}${a.detail}`;
|
||||
}
|
||||
|
||||
export default function AddressListPage() {
|
||||
const [list, setList] = useState<Address[]>([]);
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const selectMode = params.get('select') === '1';
|
||||
const productId = params.get('productId') || '';
|
||||
const qty = params.get('qty') || '';
|
||||
const cross = params.get('cross') === '1';
|
||||
const currentAddressId = params.get('addressId') || '';
|
||||
|
||||
const loadList = useCallback(() => {
|
||||
request<Address[]>('USER_H5', '/user/addresses').then(setList);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadList();
|
||||
}, [loadList]);
|
||||
|
||||
function selectAddress(id: string) {
|
||||
if (!selectMode) return;
|
||||
const qs = new URLSearchParams();
|
||||
if (productId) qs.set('productId', productId);
|
||||
if (qty) qs.set('qty', qty);
|
||||
if (cross) qs.set('cross', '1');
|
||||
qs.set('addressId', id);
|
||||
navigate(`/order/confirm?${qs.toString()}`);
|
||||
}
|
||||
|
||||
async function setDefault(addr: Address, e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (addr.isDefault === 1) return;
|
||||
await request('USER_H5', `/user/addresses/${addr.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
receiverName: addr.receiverName,
|
||||
phone: addr.phone,
|
||||
province: addr.province,
|
||||
city: addr.city,
|
||||
district: addr.district,
|
||||
detail: addr.detail,
|
||||
isDefault: true,
|
||||
}),
|
||||
});
|
||||
loadList();
|
||||
}
|
||||
|
||||
async function removeAddress(id: string, e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm('确定删除该收货地址吗?')) return;
|
||||
await request('USER_H5', `/user/addresses/${id}`, { method: 'DELETE' });
|
||||
loadList();
|
||||
}
|
||||
|
||||
const checkoutCtx = readCheckoutContext(params);
|
||||
|
||||
function goEdit(id: string, e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
navigate(buildAddressEditUrl(id, checkoutCtx));
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (hasCheckoutContext(checkoutCtx)) {
|
||||
navigate(buildOrderConfirmUrl(checkoutCtx));
|
||||
return;
|
||||
}
|
||||
navigate('/mine');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="address-list-page">
|
||||
<SubPageHeader title="我的地址" onBack={goBack} />
|
||||
|
||||
<main className="address-list-main sub-page-body">
|
||||
{list.length === 0 && (
|
||||
<p className="address-list-empty">暂无收货地址,请新增</p>
|
||||
)}
|
||||
|
||||
<div className="address-list-cards">
|
||||
{list.map((a) => {
|
||||
const isDefault = a.isDefault === 1;
|
||||
const isSelected = selectMode && currentAddressId === String(a.id);
|
||||
return (
|
||||
<article
|
||||
key={a.id}
|
||||
className={`address-list-card${isDefault ? ' is-default' : ''}${isSelected ? ' is-selected' : ''}${selectMode ? ' is-selectable' : ''}`}
|
||||
onClick={() => selectAddress(String(a.id))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectAddress(String(a.id));
|
||||
}
|
||||
}}
|
||||
role={selectMode ? 'button' : undefined}
|
||||
tabIndex={selectMode ? 0 : undefined}
|
||||
>
|
||||
<div className="address-list-card-head">
|
||||
<div className="address-list-card-contact">
|
||||
<span className="address-list-name">{a.receiverName}</span>
|
||||
<span className="address-list-phone">{maskPhone(a.phone)}</span>
|
||||
</div>
|
||||
{isDefault && <span className="address-list-default-badge">默认</span>}
|
||||
</div>
|
||||
|
||||
<p className="address-list-detail">{formatAddress(a)}</p>
|
||||
|
||||
<div className="address-list-divider" />
|
||||
|
||||
<div className="address-list-actions">
|
||||
{isDefault ? (
|
||||
<div className="address-list-default-label">
|
||||
<span className="material-symbols-outlined fill-icon">check_circle</span>
|
||||
<span>默认地址</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="address-list-set-default"
|
||||
onClick={(e) => setDefault(a, e)}
|
||||
>
|
||||
<span className="material-symbols-outlined">radio_button_unchecked</span>
|
||||
<span>设为默认</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="address-list-action-btns">
|
||||
<button type="button" className="address-list-action-btn" onClick={(e) => goEdit(String(a.id), e)}>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="address-list-action-btn"
|
||||
onClick={(e) => removeAddress(String(a.id), e)}
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{list.length > 0 && (
|
||||
<div className="address-list-brand" aria-hidden>
|
||||
<div className="address-list-brand-icon">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
</div>
|
||||
<p>DUKANG HERITAGE SERVICE</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="address-list-footer">
|
||||
<Link
|
||||
to={buildAddressEditUrl('new', checkoutCtx)}
|
||||
className="address-list-add-btn"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
<span>新增收货地址</span>
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
export default function BenefitDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<{ coupon: Record<string, unknown>; ledgers: Array<Record<string, unknown>> } | null>(null);
|
||||
useEffect(() => {
|
||||
if (id) request('USER_H5', `/benefit/coupons/${id}`).then(setData);
|
||||
}, [id]);
|
||||
if (!data) return <div className="empty">加载中...</div>;
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PageHeader title="好客权益明细" onBack={() => navigate(-1)} />
|
||||
<div className="card">
|
||||
<p className="body-md">可用余额:<span className="amount-lg">¥{Number(data.coupon.balance)}</span></p>
|
||||
<p className="text-variant body-md">总额:¥{Number(data.coupon.totalAmount)}</p>
|
||||
</div>
|
||||
{data.ledgers.map((l) => (
|
||||
<div key={String(l.id)} className="card">
|
||||
<div className="card-row">
|
||||
<span className="body-md">{String(l.type)}</span>
|
||||
<span className={Number(l.amount) < 0 ? 'text-primary amount-lg' : 'body-md'}>{Number(l.amount) > 0 ? '+' : ''}{Number(l.amount)}</span>
|
||||
</div>
|
||||
<div className="label-md text-muted">{String(l.remark || '')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import BrandLogo from '@dukang/shared-ui/BrandLogo';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type BenefitSummary = {
|
||||
totalBalance: number;
|
||||
maxRedeemAmount: number;
|
||||
activeCouponCount: number;
|
||||
};
|
||||
|
||||
type CouponItem = {
|
||||
id: string;
|
||||
couponNo: string;
|
||||
totalAmount: number;
|
||||
usedAmount: number;
|
||||
balance: number;
|
||||
status: string;
|
||||
sourceProduct: string;
|
||||
};
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatCouponNo(no: string) {
|
||||
const tail = no.replace(/^BC/i, '').slice(-6);
|
||||
return `NO. DK${tail}`;
|
||||
}
|
||||
|
||||
function usagePercent(coupon: CouponItem) {
|
||||
const total = Number(coupon.totalAmount);
|
||||
if (total <= 0) return 0;
|
||||
return Math.min(100, Math.round((Number(coupon.usedAmount) / total) * 100));
|
||||
}
|
||||
|
||||
export default function BenefitPage() {
|
||||
const navigate = useNavigate();
|
||||
const listRef = useRef<HTMLElement>(null);
|
||||
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
||||
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
||||
const [tab, setTab] = useState<'available' | 'history'>('available');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
request<BenefitSummary>('USER_H5', '/benefit/summary'),
|
||||
request<CouponItem[]>('USER_H5', '/benefit/coupons'),
|
||||
])
|
||||
.then(([s, list]) => {
|
||||
setSummary(s);
|
||||
setCoupons(
|
||||
list.map((c) => ({
|
||||
id: String(c.id),
|
||||
couponNo: String(c.couponNo),
|
||||
totalAmount: Number(c.totalAmount),
|
||||
usedAmount: Number(c.usedAmount),
|
||||
balance: Number(c.balance),
|
||||
status: String(c.status),
|
||||
sourceProduct: String(c.sourceProduct),
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
|
||||
const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID');
|
||||
const visible = tab === 'available' ? available : history;
|
||||
|
||||
function scrollToList() {
|
||||
listRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="benefit-page">
|
||||
<header className="benefit-header">
|
||||
<button type="button" className="benefit-header-city">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span>郑州市</span>
|
||||
</button>
|
||||
<h1 className="app-page-title">好客权益</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="benefit-header-btn"
|
||||
aria-label="通知"
|
||||
onClick={() => window.alert('preV1:消息通知即将开放')}
|
||||
>
|
||||
<span className="material-symbols-outlined">notifications</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="benefit-main">
|
||||
<section className="benefit-hero">
|
||||
<div className="benefit-hero-top">
|
||||
<div>
|
||||
<p className="benefit-hero-label">当前好客权益余额</p>
|
||||
<div className="benefit-hero-amount">
|
||||
<span className="benefit-hero-symbol">¥</span>
|
||||
<span className="benefit-hero-value">
|
||||
{summary ? formatMoney(summary.totalBalance) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<BrandLogo className="benefit-hero-logo" />
|
||||
</div>
|
||||
<button type="button" className="benefit-hero-link" onClick={scrollToList}>
|
||||
<span>查看权益明细</span>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="benefit-action">
|
||||
<button type="button" className="benefit-use-btn" onClick={() => navigate('/stores')}>
|
||||
<span className="material-symbols-outlined filled">storefront</span>
|
||||
去使用
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<nav className="benefit-tabs" ref={listRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`benefit-tab${tab === 'available' ? ' active' : ''}`}
|
||||
onClick={() => setTab('available')}
|
||||
>
|
||||
待使用
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`benefit-tab${tab === 'history' ? ' active' : ''}`}
|
||||
onClick={() => setTab('history')}
|
||||
>
|
||||
已用完/已过期
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{visible.length > 0 ? (
|
||||
<div className="benefit-list">
|
||||
{visible.map((c) => (
|
||||
<article key={c.id} className="benefit-card">
|
||||
<div className="benefit-card-inner">
|
||||
<div className="benefit-card-value">
|
||||
<span className="benefit-card-value-label">好客权益</span>
|
||||
<div className="benefit-card-value-amount">
|
||||
<span>¥</span>
|
||||
<span>{Math.round(c.totalAmount)}</span>
|
||||
</div>
|
||||
<span className="benefit-card-notch" aria-hidden />
|
||||
</div>
|
||||
<div className="benefit-card-body">
|
||||
<div className="benefit-card-main">
|
||||
<div className="benefit-card-title-row">
|
||||
<h3 className="benefit-card-title">{c.sourceProduct}</h3>
|
||||
<span className="benefit-card-badge">永久有效</span>
|
||||
</div>
|
||||
<p className="benefit-card-desc">适用于合作酒店餐饮消费,到店核销使用</p>
|
||||
<div className="benefit-card-progress-wrap">
|
||||
<div className="benefit-card-progress-labels">
|
||||
<span>已使用 ¥{formatMoney(c.usedAmount)}</span>
|
||||
<span>未使用 ¥{formatMoney(c.balance)}</span>
|
||||
</div>
|
||||
<div className="benefit-card-progress">
|
||||
<div
|
||||
className="benefit-card-progress-bar"
|
||||
style={{ width: `${usagePercent(c)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="benefit-card-foot">
|
||||
<Link to={`/benefit/${c.id}`} className="benefit-card-no">
|
||||
{formatCouponNo(c.couponNo)}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="benefit-card-redeem"
|
||||
onClick={() => navigate('/redeem')}
|
||||
>
|
||||
立即核销
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="benefit-empty">
|
||||
{tab === 'available' ? (
|
||||
<>
|
||||
<span className="material-symbols-outlined">card_giftcard</span>
|
||||
<p>暂无可用权益,购酒后自动发放</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-symbols-outlined">history_edu</span>
|
||||
<p>暂无过期或已使用的权益记录</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
import ProductCarousel from '../components/ProductCarousel';
|
||||
import TabMainHeader from '../components/TabMainHeader';
|
||||
import { getProductImages } from '../lib/product-images';
|
||||
import CouponBadge from '@dukang/shared-ui/CouponBadge';
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
price: number;
|
||||
benefitDisplay: number;
|
||||
mainImageUrl: string;
|
||||
carouselUrls?: string[] | null;
|
||||
aromaType: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const AROMA_TABS = [
|
||||
{ key: 'QINGXIANG', label: '清香型', disabled: false },
|
||||
{ key: 'JIANGXIANG', label: '酱香型', disabled: true },
|
||||
{ key: 'NONGXIANG', label: '浓香型', disabled: true },
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
const [tab, setTab] = useState('QINGXIANG');
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
request<Product[]>('USER_H5', '/catalog/products').then(setProducts);
|
||||
}, []);
|
||||
|
||||
const filtered = products.filter((p) => p.aromaType === tab);
|
||||
const onSale = tab === 'QINGXIANG';
|
||||
|
||||
return (
|
||||
<div className="page home-page">
|
||||
<TabMainHeader
|
||||
title="杜康好客"
|
||||
extra={(
|
||||
<div className="tab-main-city">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span>郑州市</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<nav className="home-aroma-nav">
|
||||
{AROMA_TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
disabled={t.disabled}
|
||||
className={`home-aroma-tab${tab === t.key ? ' active' : ''}${t.disabled ? ' disabled' : ''}`}
|
||||
onClick={() => !t.disabled && setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className="home-product-list">
|
||||
{!onSale && <div className="home-empty">该香型暂未上线,敬请期待</div>}
|
||||
{onSale &&
|
||||
filtered.map((p, index) => (
|
||||
<article key={p.id} className="home-product-card">
|
||||
<ProductCarousel images={getProductImages(index)} alt={p.name} />
|
||||
<div className="home-product-body">
|
||||
<div className="home-product-row">
|
||||
<h3 className="home-product-name">{p.name}</h3>
|
||||
<span className="home-product-price">¥{p.price}</span>
|
||||
</div>
|
||||
<p className="home-product-sub">{p.subtitle}</p>
|
||||
<div className="home-product-footer">
|
||||
<CouponBadge amount={p.benefitDisplay} />
|
||||
<Link to={`/product/${p.id}`} className="home-buy-btn">
|
||||
立即购买
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request, saveAuth } from '../lib/api';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [phone, setPhone] = useState('13800000001');
|
||||
const [code, setCode] = useState('123456');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [agreed, setAgreed] = useState(true);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
setMsg('请先勾选并同意用户协议');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendCode() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('');
|
||||
await request('USER_H5', '/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'USER_LOGIN' }),
|
||||
});
|
||||
setMsg('验证码已发送(Mock: 123456)');
|
||||
setCodeCooldown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCodeCooldown((c) => {
|
||||
if (c <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (!ensureAgreed()) return;
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const data = await request<{ accessToken: string; refreshToken: string }>('USER_H5', '/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
saveAuth(data);
|
||||
navigate('/');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function wechatLogin() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('preV1:微信一键授权暂未开放,请使用手机验证码登录');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<header className="login-header">
|
||||
<div className="login-logo-wrap">
|
||||
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="login-logo" fit="contain" />
|
||||
<span className="login-logo-badge">官方</span>
|
||||
</div>
|
||||
<div className="login-welcome">
|
||||
<h1 className="login-welcome-title">欢迎来到杜康好客</h1>
|
||||
<p className="login-welcome-sub">买美酒,享好礼</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="login-main">
|
||||
<button type="button" className="login-wechat-btn" onClick={wechatLogin}>
|
||||
<span className="material-symbols-outlined login-wechat-icon">chat</span>
|
||||
<span>微信一键授权</span>
|
||||
</button>
|
||||
|
||||
<div className="login-divider">
|
||||
<span className="login-divider-line" />
|
||||
<span className="login-divider-text">或者</span>
|
||||
<span className="login-divider-line" />
|
||||
</div>
|
||||
|
||||
<div className="login-card">
|
||||
<h3 className="login-card-title">手机验证码登录</h3>
|
||||
<div className="login-field">
|
||||
<span className="login-field-prefix">+86</span>
|
||||
<input
|
||||
type="tel"
|
||||
className="login-field-input"
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="login-field">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="login-field-input"
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`login-get-code${codeCooldown > 0 ? ' disabled' : ''}`}
|
||||
disabled={codeCooldown > 0}
|
||||
onClick={sendCode}
|
||||
>
|
||||
{codeCooldown > 0 ? `${codeCooldown}s 后重新获取` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
{msg && <p className="login-msg">{msg}</p>}
|
||||
<button
|
||||
type="button"
|
||||
className="login-sms-btn"
|
||||
disabled={loading}
|
||||
onClick={login}
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="login-footer">
|
||||
<label className="login-agreement">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
我已阅读并同意
|
||||
<a href="#user-agreement">《用户协议》</a>
|
||||
和
|
||||
<a href="#privacy">《隐私政策》</a>
|
||||
</span>
|
||||
</label>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import TabMainHeader from '../components/TabMainHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { clearAuth, request } from '../lib/api';
|
||||
|
||||
const DEFAULT_AVATAR =
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuAz_9Pnpk_Md4sEU6PXkeybus8oLZO9e-3pOpLuSwBX0jm_Z0JCfX1w2oZxz1VZayTh0PKUPjwjSuxJVX410fjtWFGR_f55f-nWppXWUweHRnEC7WyIWEqx4AyVHt-k02OhyaSGQfvY5cHG5IuRe9EqdcHy47gBQ82_cxGgX-DrKV4oYcwLoNRynAV0_xv2p1GOhisnQVulHwZcQClUJcP8q4nTY0Y3DR1w4ioa0DYTHePE43mLDJptjZcQqS7V8LihJdn4ze6fvQA';
|
||||
|
||||
const ORDER_SHORTCUTS = [
|
||||
{ tab: 'pending_pay', icon: 'payments', label: '待付款' },
|
||||
{ tab: 'pending_ship', icon: 'package_2', label: '待发货' },
|
||||
{ tab: 'pending_receive', icon: 'local_shipping', label: '配送中' },
|
||||
{ tab: 'completed', icon: 'task_alt', label: '已完成' },
|
||||
] as const;
|
||||
|
||||
const SERVICES = [
|
||||
{ icon: 'location_on', label: '地址管理', to: '/addresses' },
|
||||
{ icon: 'storefront', label: '可用门店', to: '/stores' },
|
||||
{ icon: 'headset_mic', label: '联系客服', badge: '在线中', action: 'cs' as const },
|
||||
{ icon: 'info', label: '关于我们', action: 'about' as const },
|
||||
] as const;
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function MinePage() {
|
||||
const navigate = useNavigate();
|
||||
const [profile, setProfile] = useState<Record<string, unknown> | null>(null);
|
||||
const [benefitBalance, setBenefitBalance] = useState(0);
|
||||
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
request<Record<string, unknown>>('USER_H5', '/auth/me'),
|
||||
request<Array<Record<string, unknown>>>('USER_H5', '/benefit/coupons'),
|
||||
...ORDER_SHORTCUTS.map((s) =>
|
||||
request<{ total: number }>('USER_H5', `/trade/orders?tab=${s.tab}&pageSize=1`),
|
||||
),
|
||||
])
|
||||
.then(([me, coupons, ...totals]) => {
|
||||
setProfile(me);
|
||||
const balance = coupons.reduce((sum, c) => {
|
||||
if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
|
||||
return sum;
|
||||
}, 0);
|
||||
setBenefitBalance(balance);
|
||||
const counts: Record<string, number> = {};
|
||||
ORDER_SHORTCUTS.forEach((s, i) => {
|
||||
counts[s.tab] = totals[i]?.total ?? 0;
|
||||
});
|
||||
setOrderCounts(counts);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function showToast(msg: string) {
|
||||
setToast(msg);
|
||||
window.setTimeout(() => setToast(''), 2200);
|
||||
}
|
||||
|
||||
function handleService(item: (typeof SERVICES)[number]) {
|
||||
if ('to' in item && item.to) return;
|
||||
if (item.action === 'cs') showToast('preV1:在线客服即将开放');
|
||||
if (item.action === 'about') showToast('杜康好客 · 传承千年酒文化');
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearAuth();
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
const nickname = String(profile?.nickname || '用户');
|
||||
const userNo = String(profile?.userNo || '');
|
||||
const avatar = String(profile?.avatarUrl || DEFAULT_AVATAR);
|
||||
|
||||
return (
|
||||
<div className="mine-page">
|
||||
<TabMainHeader title="我的" />
|
||||
<header className="mine-header">
|
||||
<div className="mine-header-texture" aria-hidden />
|
||||
<div className="mine-profile">
|
||||
<div className="mine-avatar-wrap">
|
||||
<AppImage src={avatar} alt="" wrapperClassName="mine-avatar app-image--fill" />
|
||||
<span className="mine-avatar-badge" aria-hidden>
|
||||
<span className="material-symbols-outlined mine-fill-icon">verified</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mine-profile-info">
|
||||
<h1 className="mine-profile-name">{nickname}</h1>
|
||||
<div className="mine-profile-meta">
|
||||
{userNo && <span className="mine-profile-id">ID: {userNo}</span>}
|
||||
<span className="mine-member-tag">好客会员</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mine-settings-btn"
|
||||
aria-label="设置"
|
||||
onClick={() => showToast('preV1:账号设置即将开放')}
|
||||
>
|
||||
<span className="material-symbols-outlined">settings</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mine-header-glow" aria-hidden />
|
||||
</header>
|
||||
|
||||
<main className="mine-main">
|
||||
<section className="mine-card">
|
||||
<div className="mine-card-head">
|
||||
<h2 className="mine-card-title">
|
||||
<span className="material-symbols-outlined mine-card-title-icon">account_balance_wallet</span>
|
||||
我的资产
|
||||
</h2>
|
||||
<Link to="/benefit" className="mine-card-link">
|
||||
查看明细
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mine-asset-panel">
|
||||
<div className="mine-asset-notch" aria-hidden />
|
||||
<div className="mine-asset-notch-line" aria-hidden />
|
||||
<div>
|
||||
<p className="mine-asset-label">好客权益余额</p>
|
||||
<div className="mine-asset-amount">
|
||||
<span className="mine-asset-currency">¥</span>
|
||||
<span className="mine-asset-value">{formatMoney(benefitBalance)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/benefit" className="mine-asset-cta">
|
||||
去使用
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mine-card">
|
||||
<div className="mine-card-head mine-card-head-orders">
|
||||
<h2 className="mine-card-title">我的订单</h2>
|
||||
<Link to="/orders" className="mine-card-link">
|
||||
全部订单
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mine-order-grid">
|
||||
{ORDER_SHORTCUTS.map((item) => {
|
||||
const count = orderCounts[item.tab] ?? 0;
|
||||
return (
|
||||
<Link key={item.tab} to={`/orders?tab=${item.tab}`} className="mine-order-item">
|
||||
<div className="mine-order-icon-wrap">
|
||||
<span className="material-symbols-outlined mine-order-icon">{item.icon}</span>
|
||||
{count > 0 && (
|
||||
<span className="mine-order-badge">{count > 99 ? '99+' : count}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="mine-order-label">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mine-card mine-services">
|
||||
{SERVICES.map((item, index) => {
|
||||
const inner = (
|
||||
<>
|
||||
<div className="mine-service-left">
|
||||
<span className="mine-service-icon-wrap">
|
||||
<span className="material-symbols-outlined">{item.icon}</span>
|
||||
</span>
|
||||
<span className="mine-service-label">{item.label}</span>
|
||||
</div>
|
||||
<div className="mine-service-right">
|
||||
{'badge' in item && item.badge && (
|
||||
<span className="mine-service-badge">{item.badge}</span>
|
||||
)}
|
||||
<span className="material-symbols-outlined mine-service-chevron">chevron_right</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if ('to' in item && item.to) {
|
||||
return (
|
||||
<Link key={item.label} to={item.to} className="mine-service-item">
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className="mine-service-item"
|
||||
onClick={() => handleService(item)}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<div className="mine-footer">
|
||||
<p className="mine-version">杜康好客 V2.4.0</p>
|
||||
<button type="button" className="mine-logout" onClick={logout}>
|
||||
退出当前账号
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{toast && <div className="mine-toast">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { buildProductDetailUrl } from '../lib/navigation';
|
||||
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
|
||||
import { getProductMainImage } from '../lib/product-images';
|
||||
|
||||
type Address = {
|
||||
id: string;
|
||||
receiverName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail: string;
|
||||
isDefault?: number;
|
||||
};
|
||||
|
||||
type PreviewProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
type OrderPreview = {
|
||||
product: PreviewProduct;
|
||||
quantity: number;
|
||||
deliveryType: 'LOCAL' | 'CROSS_CITY';
|
||||
productAmount: number;
|
||||
freightPayType: 'COD' | null;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
city?: { localMinQty: number; crossMinQty: number };
|
||||
};
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
}
|
||||
|
||||
function formatAddress(a: Address) {
|
||||
return `${a.province}${a.city}${a.district}${a.detail}`;
|
||||
}
|
||||
|
||||
export default function OrderConfirmPage() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const productId = params.get('productId') || '';
|
||||
const forceCross = params.get('cross') === '1';
|
||||
const [quantity, setQuantity] = useState(Number(params.get('qty') || 2));
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [addressId, setAddressId] = useState(params.get('addressId') || '');
|
||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request<Address[]>('USER_H5', '/user/addresses').then((list) => {
|
||||
setAddresses(list);
|
||||
const fromUrl = params.get('addressId');
|
||||
if (fromUrl && list.some((a) => String(a.id) === fromUrl)) {
|
||||
setAddressId(fromUrl);
|
||||
return;
|
||||
}
|
||||
const def = list.find((a) => a.isDefault === 1) || list[0];
|
||||
if (def) setAddressId(String(def.id));
|
||||
});
|
||||
}, [params]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId || !addressId) return;
|
||||
request<OrderPreview>('USER_H5', '/trade/orders/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ productId, quantity, addressId }),
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : String(e)));
|
||||
}, [productId, quantity, addressId]);
|
||||
|
||||
function updateQuantity(next: number) {
|
||||
const delivery = forceCross || preview?.deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : 'LOCAL';
|
||||
const localMin = preview?.city?.localMinQty ?? 2;
|
||||
const crossMin = preview?.city?.crossMinQty ?? 6;
|
||||
const min = delivery === 'LOCAL' ? localMin : crossMin;
|
||||
if (next < min) {
|
||||
setMsg(
|
||||
delivery === 'LOCAL'
|
||||
? `同城配送至少购买 ${min} 瓶`
|
||||
: `跨城配送至少购买 ${min} 瓶(1箱)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setMsg('');
|
||||
setQuantity(next);
|
||||
const qs = new URLSearchParams(params);
|
||||
qs.set('qty', String(next));
|
||||
navigate({ search: qs.toString() }, { replace: true });
|
||||
}
|
||||
|
||||
const selectedAddress = useMemo(
|
||||
() => addresses.find((a) => String(a.id) === addressId),
|
||||
[addresses, addressId],
|
||||
);
|
||||
|
||||
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
|
||||
const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2);
|
||||
const productIndex = productId ? Math.max(0, Number(productId) - 1) : 0;
|
||||
const productImage =
|
||||
productIndex === 0 ? STITCH_ORDER_PRODUCT_IMAGE : getProductMainImage(productIndex);
|
||||
|
||||
async function submit() {
|
||||
if (!addressId) {
|
||||
setMsg('请选择收货地址');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const order = await request<{ id: string }>('USER_H5', '/trade/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ productId, quantity, addressId }),
|
||||
});
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('orderId', order.id);
|
||||
qs.set('productId', productId);
|
||||
qs.set('qty', String(quantity));
|
||||
qs.set('addressId', addressId);
|
||||
if (forceCross) qs.set('cross', '1');
|
||||
navigate(`/pay?${qs.toString()}`);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="order-confirm-page">
|
||||
<SubPageHeader
|
||||
title="确认订单"
|
||||
onBack={() => navigate(buildProductDetailUrl(productId))}
|
||||
/>
|
||||
|
||||
<main className="order-confirm-main sub-page-body">
|
||||
<button
|
||||
type="button"
|
||||
className="order-confirm-card order-confirm-address"
|
||||
onClick={() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (productId) qs.set('productId', productId);
|
||||
qs.set('qty', String(quantity));
|
||||
if (forceCross) qs.set('cross', '1');
|
||||
if (addressId) qs.set('addressId', addressId);
|
||||
qs.set('select', '1');
|
||||
navigate(`/addresses?${qs.toString()}`);
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined order-confirm-pin fill-icon">location_on</span>
|
||||
{selectedAddress ? (
|
||||
<div className="order-confirm-address-body">
|
||||
<div className="order-confirm-address-row">
|
||||
<span className="order-confirm-address-name">{selectedAddress.receiverName}</span>
|
||||
<span className="order-confirm-address-phone">{maskPhone(selectedAddress.phone)}</span>
|
||||
</div>
|
||||
<p className="order-confirm-address-detail">{formatAddress(selectedAddress)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<span className="order-confirm-address-placeholder">请选择收货地址</span>
|
||||
)}
|
||||
<span className="material-symbols-outlined order-confirm-chevron">chevron_right</span>
|
||||
</button>
|
||||
|
||||
{isCross && (
|
||||
<div className="order-confirm-card order-confirm-warning">
|
||||
<span className="material-symbols-outlined order-confirm-warning-icon">warning</span>
|
||||
<p className="order-confirm-warning-text">
|
||||
提示:该地址超出同城配送范围,将由总部通过物流快递发货。物流费用需由您承担(到付),请确认是否继续。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
<section className="order-confirm-card order-confirm-product">
|
||||
<div className="order-confirm-product-thumb">
|
||||
<AppImage
|
||||
src={productImage}
|
||||
alt={preview.product.name}
|
||||
wrapperClassName="app-image--fill"
|
||||
/>
|
||||
</div>
|
||||
<div className="order-confirm-product-info">
|
||||
<div>
|
||||
<h3 className="order-confirm-product-name">{preview.product.name}</h3>
|
||||
<p className="order-confirm-product-spec">
|
||||
{preview.product.spec || preview.product.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="order-confirm-product-meta">
|
||||
<span className="order-confirm-product-price">¥{preview.product.price}</span>
|
||||
<div className="order-confirm-qty-stepper">
|
||||
<button
|
||||
type="button"
|
||||
className="order-confirm-qty-btn"
|
||||
disabled={quantity <= minQty}
|
||||
aria-label="减少数量"
|
||||
onClick={() => updateQuantity(quantity - 1)}
|
||||
>
|
||||
<span className="material-symbols-outlined">remove</span>
|
||||
</button>
|
||||
<span className="order-confirm-qty-value">{quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="order-confirm-qty-btn order-confirm-qty-btn--plus"
|
||||
aria-label="增加数量"
|
||||
onClick={() => updateQuantity(quantity + 1)}
|
||||
>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="order-confirm-card order-confirm-benefit coupon-notch">
|
||||
<div className="order-confirm-benefit-inner">
|
||||
<span className="order-confirm-benefit-badge">好客权益</span>
|
||||
<span className="order-confirm-benefit-text">
|
||||
本单可享好客权益 ¥{preview.benefitAmount}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="order-confirm-card order-confirm-row-card">
|
||||
<span className="order-confirm-row-label">配送方式</span>
|
||||
<div className="order-confirm-delivery-value">
|
||||
<p className="order-confirm-row-value">
|
||||
{isCross ? '物流配送' : '小飞侠配送'}
|
||||
</p>
|
||||
{!isCross && (
|
||||
<p className="order-confirm-delivery-hint">预计24小时内送达</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="order-confirm-card order-confirm-summary">
|
||||
<div className="order-confirm-summary-line">
|
||||
<span>商品总额</span>
|
||||
<span>¥{preview.productAmount}</span>
|
||||
</div>
|
||||
<div className="order-confirm-summary-line">
|
||||
<span>运费</span>
|
||||
<span className={isCross ? 'order-confirm-freight-cod' : ''}>
|
||||
{isCross ? '到付' : '免运费'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="order-confirm-summary-total">
|
||||
<span>合计</span>
|
||||
<span className="order-confirm-total-amount">¥{preview.payAmount}</span>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!preview && productId && addressId && (
|
||||
<div className="order-confirm-loading">加载订单信息...</div>
|
||||
)}
|
||||
|
||||
{msg && <p className="order-confirm-msg">{msg}</p>}
|
||||
</main>
|
||||
|
||||
<footer className="order-confirm-footer">
|
||||
<div className="order-confirm-footer-inner">
|
||||
<div className="order-confirm-pay-label">
|
||||
<span className="order-confirm-pay-prefix">实付:</span>
|
||||
<span className="order-confirm-pay-amount">
|
||||
¥{preview?.payAmount ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="order-confirm-pay-btn"
|
||||
disabled={loading || !preview}
|
||||
onClick={submit}
|
||||
>
|
||||
{loading ? '支付中...' : '微信支付'}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
|
||||
|
||||
type OrderItem = {
|
||||
productName: string;
|
||||
productSpec: string;
|
||||
productImage: string;
|
||||
unitPrice: number;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
type StatusLog = {
|
||||
toStatus: string;
|
||||
createdAt: string;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
type Order = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
orderType?: string;
|
||||
status: string;
|
||||
originOrderId?: string | null;
|
||||
remark?: string | null;
|
||||
receiverName: string;
|
||||
receiverPhone: string;
|
||||
receiverAddress: string;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
createdAt: string;
|
||||
items?: OrderItem[];
|
||||
statusLogs?: StatusLog[];
|
||||
};
|
||||
|
||||
const RESHIP_TIMELINE = [
|
||||
{ key: 'placed', title: '补发单已下达', desc: '' },
|
||||
{ key: 'pickup', title: '包裹揽收中', desc: '包裹正由物流网点揽收处理' },
|
||||
{ key: 'transit', title: '运输中', desc: '暂无物流信息' },
|
||||
] as const;
|
||||
|
||||
const STATUS_BANNER: Record<string, { title: string; subtitle: string }> = {
|
||||
PENDING_PAY: { title: '待付款', subtitle: '请尽快完成支付' },
|
||||
PENDING_SHIP: { title: '待发货', subtitle: '商家正在备货' },
|
||||
OUT_WAREHOUSE: { title: '出库中', subtitle: '商品正在出库' },
|
||||
SHIPPING: { title: '配送中', subtitle: '包裹正在配送途中' },
|
||||
PENDING_RECEIVE: { title: '待收货', subtitle: '请注意查收' },
|
||||
COMPLETED: { title: '已完成', subtitle: '感谢您的购买' },
|
||||
RESHIP: { title: '补发中', subtitle: '包裹正在揽收,请耐心等待' },
|
||||
};
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function reshipTimelineStep(status: string, index: number): 'done' | 'active' | 'pending' {
|
||||
if (status === 'COMPLETED') return 'done';
|
||||
let activeIndex = 1;
|
||||
if (['SHIPPING', 'PENDING_RECEIVE'].includes(status)) activeIndex = 2;
|
||||
if (index < activeIndex) return 'done';
|
||||
if (index === activeIndex) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const { id } = useParams();
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [showCs, setShowCs] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) request<Order>('USER_H5', `/trade/orders/${id}`).then(setOrder);
|
||||
}, [id]);
|
||||
|
||||
const isReship = order?.orderType === 'RESHIPMENT' || params.get('type') === 'reship';
|
||||
|
||||
const banner = useMemo(() => {
|
||||
if (!order) return { title: '', subtitle: '' };
|
||||
if (isReship) return STATUS_BANNER.RESHIP;
|
||||
return STATUS_BANNER[order.status] ?? { title: order.status, subtitle: '' };
|
||||
}, [order, isReship]);
|
||||
|
||||
const item = order?.items?.[0];
|
||||
const productImage = item?.productImage || STITCH_ORDER_PRODUCT_IMAGE;
|
||||
const placedAt = order?.statusLogs?.find((l) => l.toStatus === 'PENDING_SHIP')?.createdAt
|
||||
?? order?.createdAt
|
||||
?? '';
|
||||
|
||||
if (!order) return <div className="empty">加载中...</div>;
|
||||
|
||||
return (
|
||||
<div className="order-detail-page">
|
||||
<SubPageHeader title="我的订单" onBack={() => navigate(-1)} />
|
||||
|
||||
<main className="order-detail-main sub-page-body">
|
||||
<section className="order-detail-banner">
|
||||
<div className="order-detail-banner-text">
|
||||
<h2>{banner.title}</h2>
|
||||
<p>{banner.subtitle}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined order-detail-banner-icon">local_shipping</span>
|
||||
</section>
|
||||
|
||||
<div className="order-detail-cards">
|
||||
{isReship && (
|
||||
<section className="order-detail-card">
|
||||
<h3 className="order-detail-section-title">物流动态</h3>
|
||||
<div className="order-detail-timeline">
|
||||
{RESHIP_TIMELINE.map((step, index) => {
|
||||
const state = reshipTimelineStep(order.status, index);
|
||||
return (
|
||||
<div
|
||||
key={step.key}
|
||||
className={`order-detail-step order-detail-step--${state}`}
|
||||
>
|
||||
<div className="order-detail-step-dot">
|
||||
{state === 'done' && (
|
||||
<span className="material-symbols-outlined">check</span>
|
||||
)}
|
||||
{state === 'active' && <span className="order-detail-step-pulse" />}
|
||||
{state === 'pending' && <span className="order-detail-step-idle" />}
|
||||
</div>
|
||||
<div className="order-detail-step-body">
|
||||
<p className="order-detail-step-title">{step.title}</p>
|
||||
{index === 0 && placedAt && (
|
||||
<p className="order-detail-step-time">{formatDateTime(placedAt)}</p>
|
||||
)}
|
||||
{step.desc && state !== 'pending' && (
|
||||
<p className="order-detail-step-desc">{step.desc}</p>
|
||||
)}
|
||||
{step.desc && state === 'pending' && (
|
||||
<p className="order-detail-step-desc">{step.desc}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isReship && (
|
||||
<section className="order-detail-card order-detail-reship-info">
|
||||
<div className="order-detail-info-row">
|
||||
<span>补发原因</span>
|
||||
<span>{order.remark || '商品破损'}</span>
|
||||
</div>
|
||||
<div className="order-detail-info-row">
|
||||
<span>关联原订单</span>
|
||||
{order.originOrderId ? (
|
||||
<Link to={`/orders/${order.originOrderId}`} className="order-detail-origin-link">
|
||||
查看原订单
|
||||
</Link>
|
||||
) : (
|
||||
<span className="order-detail-origin-link">DK20231005002</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{item && (
|
||||
<section className="order-detail-card">
|
||||
<div className="order-detail-product">
|
||||
<div className="order-detail-product-thumb">
|
||||
<AppImage
|
||||
src={productImage}
|
||||
alt={item.productName}
|
||||
wrapperClassName="app-image--fill"
|
||||
/>
|
||||
</div>
|
||||
<div className="order-detail-product-info">
|
||||
<div>
|
||||
<h4 className="order-detail-product-name">{item.productName}</h4>
|
||||
<p className="order-detail-product-spec">{item.productSpec}</p>
|
||||
</div>
|
||||
<div className="order-detail-product-meta">
|
||||
<span className="order-detail-product-price">
|
||||
¥{isReship ? '0.00' : Number(item.unitPrice).toFixed(2)}
|
||||
</span>
|
||||
<span className="order-detail-product-qty">x{item.quantity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{Number(order.benefitAmount) > 0 && (
|
||||
<div className="order-detail-benefit-row">
|
||||
<div className="order-detail-benefit-left">
|
||||
<span className="order-detail-benefit-badge">
|
||||
¥{order.benefitAmount}好客权益
|
||||
</span>
|
||||
<span className="order-detail-benefit-note">随单赠送</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined order-detail-benefit-info">info</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="order-detail-card">
|
||||
<div className="order-detail-address-head">
|
||||
<span className="material-symbols-outlined fill-icon">location_on</span>
|
||||
<h3>收货信息</h3>
|
||||
</div>
|
||||
<div className="order-detail-address-body">
|
||||
<div className="order-detail-address-row">
|
||||
<span className="order-detail-address-name">{order.receiverName}</span>
|
||||
<span>{maskPhone(order.receiverPhone)}</span>
|
||||
</div>
|
||||
<p>{order.receiverAddress}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!isReship && (
|
||||
<section className="order-detail-card order-detail-meta">
|
||||
<div className="order-detail-info-row">
|
||||
<span>订单号</span>
|
||||
<span className="order-detail-order-no">{order.orderNo}</span>
|
||||
</div>
|
||||
<div className="order-detail-info-row">
|
||||
<span>实付金额</span>
|
||||
<span className="order-detail-pay-amount">¥{order.payAmount}</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="order-detail-footer">
|
||||
<button type="button" className="order-detail-cs-btn" onClick={() => setShowCs(true)}>
|
||||
<span className="material-symbols-outlined">support_agent</span>
|
||||
联系客服
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
{showCs && (
|
||||
<div className="modal-overlay" onClick={() => setShowCs(false)}>
|
||||
<div className="modal-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-grabber" />
|
||||
<h3 className="headline-md" style={{ marginBottom: 12 }}>联系客服</h3>
|
||||
<p className="text-variant body-md" style={{ marginBottom: 16 }}>
|
||||
preV1 Mock:客服工作时间 9:00-18:00,请描述订单号 {order.orderNo}
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary btn-block" onClick={() => setShowCs(false)}>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import OrderStatusTabs from '@dukang/shared-ui/OrderStatusTabs';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'pending_pay', label: '待付款' },
|
||||
{ key: 'pending_ship', label: '待发货' },
|
||||
{ key: 'pending_receive', label: '待收货' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
OUT_WAREHOUSE: '出库中',
|
||||
SHIPPING: '配送中',
|
||||
PENDING_RECEIVE: '待收货',
|
||||
COMPLETED: '已完成',
|
||||
RESHIP: '补发中',
|
||||
};
|
||||
|
||||
export default function OrderListPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tab = params.get('tab') || 'all';
|
||||
const [data, setData] = useState<{ list: Array<Record<string, unknown>> }>({ list: [] });
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
request<{ list: Array<Record<string, unknown>> }>('USER_H5', `/trade/orders?tab=${tab}`).then(setData);
|
||||
}, [tab]);
|
||||
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PageHeader title="我的订单" onBack={() => navigate('/mine')} />
|
||||
<OrderStatusTabs tabs={TABS} active={tab} onChange={(key) => setParams({ tab: key })} />
|
||||
{data.list.length === 0 && <div className="empty">暂无订单</div>}
|
||||
{data.list.map((o, i) => {
|
||||
const items = (o.items as Array<Record<string, unknown>>) || [];
|
||||
const item = items[0];
|
||||
const isReshipDemo = i === 0 && tab === 'all';
|
||||
return (
|
||||
<div key={String(o.id)} className="card">
|
||||
<div className="card-row" style={{ marginBottom: 8 }}>
|
||||
<span className="label-md text-muted">订单号: {String(o.orderNo)}</span>
|
||||
<span className="status-tag">{STATUS_LABEL[String(o.status)] || String(o.status)}</span>
|
||||
</div>
|
||||
{isReshipDemo && <span className="tag-reship" style={{ marginBottom: 8, display: 'inline-block' }}>补发示例</span>}
|
||||
{item && (
|
||||
<div className="card-row">
|
||||
<AppImage
|
||||
src={String(item.productImage)}
|
||||
alt={String(item.productName || '')}
|
||||
wrapperClassName="order-list-thumb"
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="headline-md">{String(item.productName)}</div>
|
||||
<div className="text-muted body-md">{String(item.productSpec)} x{Number(item.quantity)}</div>
|
||||
</div>
|
||||
<div className="amount-lg">¥{Number(o.payAmount)}</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ textAlign: 'right', marginTop: 12 }}>
|
||||
<Link
|
||||
to={`/orders/${o.id}${isReshipDemo ? '?type=reship' : ''}`}
|
||||
className="btn btn-outline btn-pill"
|
||||
>
|
||||
{isReshipDemo ? '查看进度' : '查看详情'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import { buildOrderConfirmUrl } from '../lib/navigation';
|
||||
|
||||
export default function PayPage() {
|
||||
const [params] = useSearchParams();
|
||||
const orderId = params.get('orderId') || '';
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
function goBackConfirm() {
|
||||
navigate(
|
||||
buildOrderConfirmUrl({
|
||||
productId: params.get('productId'),
|
||||
qty: params.get('qty'),
|
||||
addressId: params.get('addressId'),
|
||||
cross: params.get('cross'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
async function pay() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await request('USER_H5', `/trade/orders/${orderId}/pay`, { method: 'POST' });
|
||||
navigate('/orders?tab=pending_ship');
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : '支付失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-no-tab pay-page">
|
||||
<SubPageHeader title="微信支付" onBack={goBackConfirm} />
|
||||
<div className="pay-body sub-page-body">
|
||||
<div className="pay-icon">
|
||||
<span className="material-symbols-outlined">account_balance_wallet</span>
|
||||
</div>
|
||||
<p className="headline-lg text-primary">Mock 微信支付</p>
|
||||
<p className="text-muted body-md" style={{ marginTop: 8 }}>preV1 环境模拟支付,点击确认即完成</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 24 }}>订单号 {orderId}</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="btn btn-primary btn-block" disabled={loading || !orderId} onClick={pay}>
|
||||
{loading ? '支付中...' : '确认支付'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import ProductCarousel from '../components/ProductCarousel';
|
||||
import { request } from '../lib/api';
|
||||
import { getProductCarouselImages, getProductDetailImages } from '../lib/product-images';
|
||||
|
||||
type Product = {
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
benefitAmount?: number;
|
||||
};
|
||||
|
||||
const FEATURES = [
|
||||
{ icon: 'water_drop', title: '泉水酿造', desc: '甘冽清甜 灵动自然' },
|
||||
{ icon: 'grain', title: '精选五谷', desc: '传统比例 匠心发酵' },
|
||||
] as const;
|
||||
|
||||
export default function ProductDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
const imageIndex = id ? Math.max(0, Number(id) - 1) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (id) request<Product>('USER_H5', `/catalog/products/${id}`).then(setProduct);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
setHeaderSolid(window.scrollY > 100);
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
if (!product) return <div className="empty">加载中...</div>;
|
||||
|
||||
const benefit = Number(product.benefitAmount ?? product.price);
|
||||
const carouselImages = getProductCarouselImages(imageIndex);
|
||||
const detailImages = getProductDetailImages(imageIndex);
|
||||
|
||||
return (
|
||||
<div className="product-detail-page">
|
||||
<header className={`product-detail-header${headerSolid ? ' solid' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="product-detail-header-btn"
|
||||
aria-label="返回"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className={`app-page-title product-detail-header-title${headerSolid ? ' visible' : ''}`}>
|
||||
{product.name}
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="product-detail-header-btn"
|
||||
aria-label="分享"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<span className="material-symbols-outlined">share</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="product-detail-main">
|
||||
<section className="product-detail-hero">
|
||||
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" />
|
||||
</section>
|
||||
|
||||
<section className="product-detail-info">
|
||||
<div className="product-detail-price">
|
||||
<span className="product-detail-price-symbol">¥</span>
|
||||
<span className="product-detail-price-value">{product.price}</span>
|
||||
</div>
|
||||
<h2 className="product-detail-name">{product.name}</h2>
|
||||
{product.subtitle && (
|
||||
<p className="product-detail-subtitle">{product.subtitle}</p>
|
||||
)}
|
||||
|
||||
<div className="product-detail-promo">
|
||||
<div className="product-detail-promo-glow" aria-hidden />
|
||||
<div className="product-detail-promo-head">
|
||||
<div className="product-detail-promo-icon">
|
||||
<span className="material-symbols-outlined fill-icon">confirmation_number</span>
|
||||
</div>
|
||||
<h3 className="product-detail-promo-title">
|
||||
买杜康美酒 · 享全城好客礼遇
|
||||
<span className="product-detail-promo-amount">¥{benefit}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<p className="product-detail-promo-desc">
|
||||
购酒即享本城专属好客权益,为您安排一场地道饭局。权益金可直接用于本地签约门店消费,到店享用,醇香美酒配佳肴。
|
||||
</p>
|
||||
<span className="material-symbols-outlined product-detail-promo-deco" aria-hidden>
|
||||
restaurant
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="product-detail-content">
|
||||
<div className="product-detail-section-head">
|
||||
<span className="product-detail-section-bar" />
|
||||
<h3>商品详情</h3>
|
||||
</div>
|
||||
|
||||
{detailImages[0] && (
|
||||
<AppImage src={detailImages[0]} alt="" wrapperClassName="product-detail-banner" />
|
||||
)}
|
||||
|
||||
<div className="product-detail-copy">
|
||||
<div className="product-detail-story">
|
||||
<h4>千年杜康 · 唯有此处</h4>
|
||||
<p>
|
||||
选自白水杜康核心产区,取山泉之灵气,集五谷之精华。古法酿造工艺,历经九九八十一道工序,方得这一口醇厚绵甜。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="product-detail-features">
|
||||
{FEATURES.map((f) => (
|
||||
<div key={f.title} className="product-detail-feature">
|
||||
<span className="material-symbols-outlined">{f.icon}</span>
|
||||
<div className="product-detail-feature-title">{f.title}</div>
|
||||
<div className="product-detail-feature-desc">{f.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailImages.length > 1 && (
|
||||
<AppImage src={detailImages[1]} alt="" wrapperClassName="product-detail-banner" />
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<nav className="product-detail-bar">
|
||||
<Link to="/" className="product-detail-bar-home">
|
||||
<span className="material-symbols-outlined">home</span>
|
||||
<span>首页</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="product-detail-buy-btn"
|
||||
onClick={() => navigate(`/order/confirm?productId=${id}&qty=2`)}
|
||||
>
|
||||
立即购买
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
|
||||
export default function RedeemCodePage() {
|
||||
const navigate = useNavigate();
|
||||
const token = sessionStorage.getItem('redeemToken') || '';
|
||||
const amount = sessionStorage.getItem('redeemAmount') || '0';
|
||||
|
||||
return (
|
||||
<div className="page-no-tab" style={{ textAlign: 'center' }}>
|
||||
<PageHeader title="核销码展示" onBack={() => navigate(-1)} />
|
||||
<p className="text-variant body-md page-actions" style={{ marginBottom: 0, paddingBottom: 0 }}>请向门店出示以下核销码</p>
|
||||
<div className="card" style={{ padding: 32 }}>
|
||||
<div className="label-md text-muted">核销金额</div>
|
||||
<div className="amount-xl" style={{ margin: '8px 0 24px' }}>¥{amount}</div>
|
||||
<div className="code-box">{token}</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 16 }}>5 分钟内有效</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="btn btn-outline btn-block" onClick={() => navigate('/redeem/success')}>模拟核销完成</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
const REDEEM_MAX_AMOUNT = 500;
|
||||
const REDEEM_TOKEN_TTL_SECONDS = 300;
|
||||
|
||||
type BenefitSummary = {
|
||||
totalBalance: number;
|
||||
maxRedeemAmount: number;
|
||||
activeCouponCount: number;
|
||||
};
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatTimer(seconds: number) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function RedeemPage() {
|
||||
const navigate = useNavigate();
|
||||
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
||||
const [amountInput, setAmountInput] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [token, setToken] = useState('');
|
||||
const [confirmAmount, setConfirmAmount] = useState(0);
|
||||
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<BenefitSummary>('USER_H5', '/benefit/summary').then(setSummary).catch(() => {});
|
||||
return () => stopTimer();
|
||||
}, []);
|
||||
|
||||
function stopTimer() {
|
||||
if (timerRef.current != null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
stopTimer();
|
||||
setModalOpen(false);
|
||||
setToken('');
|
||||
setTimerSec(REDEEM_TOKEN_TTL_SECONDS);
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
stopTimer();
|
||||
setTimerSec(REDEEM_TOKEN_TTL_SECONDS);
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setTimerSec((prev) => {
|
||||
if (prev <= 1) {
|
||||
stopTimer();
|
||||
window.setTimeout(() => {
|
||||
window.alert('核销码已失效,请重新生成');
|
||||
closeModal();
|
||||
}, 0);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function parseAmount() {
|
||||
const value = Number(amountInput);
|
||||
return Number.isFinite(value) ? Math.round(value * 100) / 100 : 0;
|
||||
}
|
||||
|
||||
function fillMaxAmount() {
|
||||
if (!summary) return;
|
||||
setAmountInput(formatMoney(summary.maxRedeemAmount));
|
||||
setMsg('');
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const amount = parseAmount();
|
||||
if (!summary) return;
|
||||
if (amount <= 0) {
|
||||
setMsg('请输入核销金额');
|
||||
return;
|
||||
}
|
||||
if (amount > summary.totalBalance) {
|
||||
setMsg('核销金额不能超过可用余额');
|
||||
return;
|
||||
}
|
||||
if (amount > REDEEM_MAX_AMOUNT) {
|
||||
setMsg(`单次核销不能超过 ¥${REDEEM_MAX_AMOUNT}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const data = await request<{ token: string; amount: number }>('USER_H5', '/redeem/tokens', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ amount }),
|
||||
});
|
||||
setToken(data.token);
|
||||
setConfirmAmount(data.amount);
|
||||
setModalOpen(true);
|
||||
startTimer();
|
||||
sessionStorage.setItem('redeemToken', data.token);
|
||||
sessionStorage.setItem('redeemAmount', String(data.amount));
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '生成失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const maxHint = summary ? Math.min(REDEEM_MAX_AMOUNT, summary.totalBalance) : REDEEM_MAX_AMOUNT;
|
||||
const qrUrl = token
|
||||
? `https://api.qrserver.com/v1/create-qr-code/?size=192x192&data=${encodeURIComponent(token)}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="redeem-page">
|
||||
<header className="redeem-header">
|
||||
<button type="button" className="redeem-header-btn" aria-label="返回" onClick={() => navigate(-1)}>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className="app-page-title">好客权益核销</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="redeem-header-btn"
|
||||
aria-label="帮助"
|
||||
onClick={() => window.alert('单次核销上限 ¥500,核销码 5 分钟内有效。')}
|
||||
>
|
||||
<span className="material-symbols-outlined">help_outline</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="redeem-main">
|
||||
<section className="redeem-balance-card">
|
||||
<div className="redeem-balance-pattern" aria-hidden />
|
||||
<div className="redeem-balance-inner">
|
||||
<span className="redeem-balance-label">当前好客权益可用余额</span>
|
||||
<div className="redeem-balance-amount">
|
||||
<span className="redeem-balance-symbol">¥</span>
|
||||
<span className="redeem-balance-value">
|
||||
{summary ? formatMoney(summary.totalBalance) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="redeem-form-card">
|
||||
<label className="redeem-form-label" htmlFor="redeem-amount">
|
||||
核销金额 (¥)
|
||||
</label>
|
||||
<div className="redeem-amount-row">
|
||||
<span className="redeem-amount-symbol">¥</span>
|
||||
<input
|
||||
id="redeem-amount"
|
||||
className="redeem-amount-input"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
value={amountInput}
|
||||
onChange={(e) => {
|
||||
setAmountInput(e.target.value);
|
||||
setMsg('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="redeem-amount-foot">
|
||||
<span>单次最高可核销 ¥{formatMoney(maxHint)}</span>
|
||||
<button type="button" className="redeem-fill-max" onClick={fillMaxAmount}>
|
||||
全部核销
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{msg && <p className="redeem-msg">{msg}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="redeem-submit-btn"
|
||||
disabled={loading || !summary || summary.totalBalance <= 0}
|
||||
onClick={submit}
|
||||
>
|
||||
<span className="material-symbols-outlined">qr_code_2</span>
|
||||
{loading ? '生成中...' : '生成核销码'}
|
||||
</button>
|
||||
|
||||
<div className="redeem-security">
|
||||
<span className="material-symbols-outlined">shield</span>
|
||||
<p>安全提示:请在核销前核实金额,核销码将在 5 分钟后自动失效。</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className={`redeem-modal${modalOpen ? ' open' : ''}`} aria-hidden={!modalOpen}>
|
||||
<div className="redeem-modal-backdrop" onClick={closeModal} aria-hidden />
|
||||
<div className={`redeem-modal-panel${modalOpen ? ' open' : ''}`}>
|
||||
<div className="redeem-modal-head">
|
||||
<h3>正在核销</h3>
|
||||
<p>请向收银员出示此码</p>
|
||||
</div>
|
||||
<div className="redeem-modal-body">
|
||||
<div className="redeem-qr-wrap">
|
||||
<div className="redeem-qr-box">
|
||||
{qrUrl ? (
|
||||
<img className="redeem-qr-img" src={qrUrl} alt="核销二维码" />
|
||||
) : (
|
||||
<div className="redeem-qr-placeholder" />
|
||||
)}
|
||||
<div className="redeem-qr-scanline" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' active' : ''}`}>
|
||||
<span className="redeem-timer-value">{formatTimer(timerSec)}</span>
|
||||
<span className="redeem-timer-label">失效倒计时</span>
|
||||
</div>
|
||||
<div className="redeem-modal-amount">
|
||||
<p>待核销金额</p>
|
||||
<p className="redeem-modal-amount-value">¥ {formatMoney(confirmAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="redeem-modal-cancel" onClick={closeModal}>
|
||||
取消核销
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
export default function RedeemSuccessPage() {
|
||||
const navigate = useNavigate();
|
||||
const [serviceScore, setServiceScore] = useState(5);
|
||||
const [envScore, setEnvScore] = useState(5);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function submit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const redeemRecordId = sessionStorage.getItem('lastRedeemRecordId');
|
||||
if (redeemRecordId) {
|
||||
await request('USER_H5', '/redeem/ratings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
redeemRecordId,
|
||||
serviceScore,
|
||||
environmentScore: envScore,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* preV1: allow navigate even if rating fails */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
navigate('/benefit');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-no-tab redeem-success-page">
|
||||
<PageHeader title="核销成功" onBack={() => navigate('/benefit')} />
|
||||
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||
<div className="success-icon">✓</div>
|
||||
<h2 className="headline-lg text-primary">核销成功</h2>
|
||||
<p className="text-variant body-md" style={{ marginTop: 8 }}>请为门店服务评分</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="form-group">
|
||||
<label>服务评分 (1-5)</label>
|
||||
<input type="number" min={1} max={5} value={serviceScore} onChange={(e) => setServiceScore(Number(e.target.value))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>环境评分 (1-5)</label>
|
||||
<input type="number" min={1} max={5} value={envScore} onChange={(e) => setEnvScore(Number(e.target.value))} />
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary btn-block" disabled={loading} onClick={submit}>
|
||||
{loading ? '提交中...' : '完成'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import ProductCarousel from '../components/ProductCarousel';
|
||||
import { request } from '../lib/api';
|
||||
import { STITCH_STORE_MAP, getStoreGalleryImages } from '../lib/store-images';
|
||||
|
||||
type StoreMedia = { url: string; mediaType?: string; sortOrder?: number };
|
||||
|
||||
type StoreDetail = {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
cityName?: string;
|
||||
city?: string;
|
||||
district: string;
|
||||
address: string;
|
||||
intro?: string | null;
|
||||
coverUrl?: string | null;
|
||||
status: string;
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
category?: { name: string } | null;
|
||||
media?: StoreMedia[];
|
||||
};
|
||||
|
||||
const SERVICES = [
|
||||
{ icon: 'wifi', label: 'WiFi' },
|
||||
{ icon: 'local_parking', label: '免费停车' },
|
||||
{ icon: 'meeting_room', label: '独立包间' },
|
||||
{ icon: 'table_restaurant', label: '宴会大厅' },
|
||||
] as const;
|
||||
|
||||
const MOCK_DISTANCES = ['800m', '1.2km', '2.4km', '3.5km'];
|
||||
|
||||
const DEFAULT_INTRO =
|
||||
'作为本地优质餐饮合作伙伴,门店融合地域饮食文化与高端社交场景,设有杜康文化体验区,让宾客在用餐之余领略中华酒祖的千年传承。主打精品地方菜与创意融合菜,氛围庄重而不失亲和力,是商务宴请、亲友小聚以及文化交流的理想场所。';
|
||||
|
||||
function formatHours(store: StoreDetail) {
|
||||
if (store.openTime && store.closeTime) return `${store.openTime} - ${store.closeTime}`;
|
||||
return '09:30 - 22:00';
|
||||
}
|
||||
|
||||
function fullAddress(store: StoreDetail) {
|
||||
const city = store.cityName || store.city || '';
|
||||
return `${store.province}${city}${store.district}${store.address}`;
|
||||
}
|
||||
|
||||
export default function StoreDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [store, setStore] = useState<StoreDetail | null>(null);
|
||||
const [benefitBalance, setBenefitBalance] = useState(0);
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) request<StoreDetail>('USER_H5', `/stores/${id}`).then(setStore);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
request<Array<{ balance: number; status: string }>>('USER_H5', '/benefit/coupons')
|
||||
.then((list) => {
|
||||
const balance = list.reduce((sum, c) => {
|
||||
if (c.status === 'ACTIVE') return sum + Number(c.balance || 0);
|
||||
return sum;
|
||||
}, 0);
|
||||
setBenefitBalance(balance);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
setHeaderSolid(window.scrollY > 80);
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const galleryImages = useMemo(() => {
|
||||
if (!store) return [];
|
||||
return getStoreGalleryImages(store.coverUrl, store.media);
|
||||
}, [store]);
|
||||
|
||||
const distance = MOCK_DISTANCES[Number(id || 0) % MOCK_DISTANCES.length];
|
||||
const isOpen = store?.status === 'OPEN';
|
||||
|
||||
if (!store) {
|
||||
return <div className="empty store-detail-page">加载中...</div>;
|
||||
}
|
||||
|
||||
function callStore() {
|
||||
if (store?.phone) window.location.href = `tel:${store.phone}`;
|
||||
}
|
||||
|
||||
function openMap() {
|
||||
window.alert('preV1:导航功能即将开放');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="store-detail-page">
|
||||
<header className={`store-detail-header${headerSolid ? ' solid' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="store-detail-header-btn"
|
||||
aria-label="返回"
|
||||
onClick={() => navigate('/stores')}
|
||||
>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className={`app-page-title store-detail-header-title${headerSolid ? ' visible' : ''}`}>门店详情</h1>
|
||||
<button type="button" className="store-detail-header-btn" aria-label="分享" onClick={() => {}}>
|
||||
<span className="material-symbols-outlined">share</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="store-detail-main">
|
||||
<section className="store-detail-hero">
|
||||
<ProductCarousel images={galleryImages} alt={store.name} variant="store" />
|
||||
</section>
|
||||
|
||||
<section className="store-detail-info-wrap">
|
||||
<div className="store-detail-info-card">
|
||||
<div className="store-detail-info-head">
|
||||
<div>
|
||||
<h2 className="store-detail-name">{store.name}</h2>
|
||||
<p className="store-detail-hours">营业时间:{formatHours(store)}</p>
|
||||
{store.category?.name && (
|
||||
<span className="store-detail-category">{store.category.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`store-detail-status${isOpen ? '' : ' closed'}`}>
|
||||
{isOpen && <span className="store-detail-status-dot" />}
|
||||
<span>{isOpen ? '营业中' : '休息中'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="store-detail-tip">
|
||||
<span className="material-symbols-outlined">info</span>
|
||||
<p>温馨提示:为保证服务品质,如用餐规模超过2桌,请提前电话联系门店确认可用性。</p>
|
||||
</section>
|
||||
|
||||
<section className="store-detail-location">
|
||||
<div className="store-detail-location-card">
|
||||
<div className="store-detail-map">
|
||||
<AppImage src={STITCH_STORE_MAP} alt="" wrapperClassName="app-image--fill" />
|
||||
<div className="store-detail-map-gradient" aria-hidden />
|
||||
</div>
|
||||
<div className="store-detail-location-body">
|
||||
<div className="store-detail-location-text">
|
||||
<p>{fullAddress(store)}</p>
|
||||
<p className="store-detail-distance">
|
||||
<span className="material-symbols-outlined">near_me</span>
|
||||
距离您 {distance}
|
||||
</p>
|
||||
</div>
|
||||
<div className="store-detail-location-actions">
|
||||
<button type="button" className="store-detail-action-btn" aria-label="电话" onClick={callStore}>
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="store-detail-action-btn store-detail-action-btn--primary"
|
||||
aria-label="导航"
|
||||
onClick={openMap}
|
||||
>
|
||||
<span className="material-symbols-outlined">directions</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="store-detail-services">
|
||||
<h3>设施服务</h3>
|
||||
<div className="store-detail-service-grid">
|
||||
{SERVICES.map((s) => (
|
||||
<div key={s.label} className="store-detail-service-item">
|
||||
<span className="material-symbols-outlined">{s.icon}</span>
|
||||
<span>{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="store-detail-intro">
|
||||
<h3>门店介绍</h3>
|
||||
<div className="store-detail-intro-card">
|
||||
<p>{store.intro || DEFAULT_INTRO}</p>
|
||||
<div className="store-detail-intro-foot">
|
||||
<div className="store-detail-intro-avatars" aria-hidden>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<span className="store-detail-intro-stat">已有 1.2w 人到店体验</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="store-detail-footer">
|
||||
<div className="store-detail-footer-inner">
|
||||
<div className="store-detail-balance">
|
||||
<span className="store-detail-balance-label">可用额度</span>
|
||||
<span className="store-detail-balance-value">
|
||||
¥{benefitBalance.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="store-detail-redeem-btn" onClick={() => navigate('/redeem')}>
|
||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
||||
去核销
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import TabMainHeader from '../components/TabMainHeader';
|
||||
|
||||
type StoreItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
district: string;
|
||||
address: string;
|
||||
coverUrl?: string | null;
|
||||
status: string;
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
category?: { name: string } | null;
|
||||
};
|
||||
|
||||
const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
|
||||
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
|
||||
|
||||
function storeCover(store: StoreItem, index: number) {
|
||||
if (store.coverUrl) return String(store.coverUrl);
|
||||
const imgs = ['/images/1.png', '/images/2.png', '/images/3.png'];
|
||||
return imgs[index % imgs.length];
|
||||
}
|
||||
|
||||
function formatHours(store: StoreItem) {
|
||||
if (store.openTime && store.closeTime) {
|
||||
return `营业时间: ${store.openTime}-${store.closeTime}`;
|
||||
}
|
||||
return '营业时间: 10:00-22:00';
|
||||
}
|
||||
|
||||
export default function StoreListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [stores, setStores] = useState<StoreItem[]>([]);
|
||||
const [categoryTab, setCategoryTab] = useState<string>('全部');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request<StoreItem[]>('USER_H5', '/stores?cityCode=410100').then(setStores);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = stores;
|
||||
if (categoryTab !== '全部') {
|
||||
list = list.filter((s) => s.category?.name === categoryTab);
|
||||
}
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.address.toLowerCase().includes(q) ||
|
||||
s.district.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [stores, categoryTab, keyword]);
|
||||
|
||||
return (
|
||||
<div className="page store-page">
|
||||
<header className="store-header">
|
||||
<TabMainHeader title="门店" />
|
||||
|
||||
<div className="store-toolbar">
|
||||
<button type="button" className="store-location">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span>郑州市 · 金水区</span>
|
||||
<span className="material-symbols-outlined store-location-arrow">expand_more</span>
|
||||
</button>
|
||||
<div className="store-search">
|
||||
<span className="material-symbols-outlined store-search-icon">search</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索门店名称或地址"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="store-category-bar">
|
||||
<div className="store-category-tabs">
|
||||
{CATEGORY_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
className={`store-category-tab${categoryTab === tab ? ' active' : ''}`}
|
||||
onClick={() => setCategoryTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="store-list">
|
||||
{filtered.map((s, index) => (
|
||||
<article
|
||||
key={s.id}
|
||||
className="store-card"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/stores/${s.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
navigate(`/stores/${s.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="store-card-cover">
|
||||
<AppImage src={storeCover(s, index)} alt={s.name} wrapperClassName="app-image--fill" />
|
||||
</div>
|
||||
<div className="store-card-body">
|
||||
<div className="store-card-top">
|
||||
<div className="store-card-title-row">
|
||||
<h3 className="store-card-name">{s.name}</h3>
|
||||
<span className="store-card-distance">
|
||||
{MOCK_DISTANCES[index % MOCK_DISTANCES.length]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="store-card-meta">
|
||||
<span className="status-open">营业中</span>
|
||||
<span className="store-card-hours">{formatHours(s)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="store-card-bottom">
|
||||
<p className="store-card-address">
|
||||
{s.district}
|
||||
{s.address}
|
||||
</p>
|
||||
<Link
|
||||
to="/redeem"
|
||||
className="store-redeem-btn"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
去核销
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="store-empty">{stores.length === 0 ? '暂无门店' : '未找到匹配门店'}</div>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 && (
|
||||
<p className="store-list-end">没有更多门店了</p>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
});
|
||||