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-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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+26
View File
@@ -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>
);
}
+34
View File
@@ -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>
</>
);
}
+27
View File
@@ -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');
}
+9
View File
@@ -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>,
);
+109
View File
@@ -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>
);
}
+239
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+181
View File
@@ -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>
);
}
+133
View File
@@ -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>
);
}
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"]
}
+16
View File
@@ -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' },
},
});