This commit is contained in:
2026-06-30 10:33:56 +08:00
commit 6e047dc0a5
607 changed files with 65966 additions and 0 deletions
+15
View File
@@ -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>
+25
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

+47
View File
@@ -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>
);
}
+48
View File
@@ -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>
</>
);
}
+42
View File
@@ -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');
}
+58
View File
@@ -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');
}
+3
View File
@@ -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';
+44
View File
@@ -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 [];
}
+16
View File
@@ -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];
}
+13
View File
@@ -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>,
);
+273
View File
@@ -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>
);
}
+192
View File
@@ -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>
);
}
+204
View File
@@ -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>
);
}
+88
View File
@@ -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>
);
}
+150
View File
@@ -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>
);
}
+213
View File
@@ -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>
);
}
+295
View File
@@ -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>
);
}
+261
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+237
View File
@@ -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>
);
}
+221
View File
@@ -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>
);
}
+158
View File
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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"]
}
+17
View File
@@ -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',
},
},
});