feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import AuthGate from './components/AuthGate';
|
||||
import TabLayout from './layouts/TabLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import LegalPage from './pages/LegalPage';
|
||||
import SelectStorePage from './pages/SelectStorePage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import RedeemConfirmPage from './pages/RedeemConfirmPage';
|
||||
import PhoneRedeemPage from './pages/PhoneRedeemPage';
|
||||
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||
import RecordsPage from './pages/RecordsPage';
|
||||
import StatusPage from './pages/StatusPage';
|
||||
import MinePage from './pages/MinePage';
|
||||
import StaffPage from './pages/StaffPage';
|
||||
import WithdrawPage from './pages/WithdrawPage';
|
||||
import PackagesPage from './pages/PackagesPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthGate>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/legal/user-agreement" element={<LegalPage docId="user-agreement" />} />
|
||||
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
|
||||
<Route path="/select-store" element={<SelectStorePage />} />
|
||||
<Route path="/staff" element={<StaffPage />} />
|
||||
<Route path="/withdraw" element={<WithdrawPage />} />
|
||||
<Route path="/packages" element={<PackagesPage />} />
|
||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||
<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>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { getStoreProfile, hasShopWxSession } from '../lib/api';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
|
||||
const SELECT_STORE_PATH = '/select-store';
|
||||
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const { ready, authenticated, needsSelectStore } = useStoreSession();
|
||||
const location = useLocation();
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="session-boot">
|
||||
<p className="session-boot-text">加载中…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (authenticated && location.pathname === '/login') {
|
||||
return <Navigate to={needsSelectStore ? SELECT_STORE_PATH : '/'} replace />;
|
||||
}
|
||||
|
||||
if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) {
|
||||
return <Navigate to={SELECT_STORE_PATH} replace />;
|
||||
}
|
||||
|
||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||
const profile = getStoreProfile();
|
||||
if (profile && hasShopWxSession() && location.pathname !== '/login') {
|
||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
||||
}
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from 'react';
|
||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
import { emptyPackage, type PackageFormItem } from '../lib/storePackages';
|
||||
|
||||
type Props = {
|
||||
items: PackageFormItem[];
|
||||
onChange: (items: PackageFormItem[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
||||
|
||||
function updateAt(index: number, patch: Partial<PackageFormItem>) {
|
||||
onChange(items.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||
onChange([...items, emptyPackage(items.length)]);
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
onChange(items.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
|
||||
setCollapsed((prev) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
Object.entries(prev).forEach(([k, v]) => {
|
||||
const i = Number(k);
|
||||
if (i < index) next[i] = v;
|
||||
else if (i > index) next[i - 1] = v;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCollapse(index: number) {
|
||||
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||
}
|
||||
|
||||
const list = items.length ? items : [emptyPackage(0)];
|
||||
|
||||
return (
|
||||
<div className="shop-packages-form">
|
||||
{list.map((item, index) => {
|
||||
const isCollapsed = !!collapsed[index];
|
||||
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
||||
return (
|
||||
<section key={index} className={`shop-packages-card${isCollapsed ? ' shop-packages-card--collapsed' : ''}`}>
|
||||
<div className="shop-packages-card-head">
|
||||
<button
|
||||
type="button"
|
||||
className="shop-packages-toggle"
|
||||
onClick={() => toggleCollapse(index)}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={isCollapsed ? '展开套餐' : '收起套餐'}
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{isCollapsed ? 'expand_more' : 'expand_less'}
|
||||
</span>
|
||||
<span className="shop-packages-card-title">{displayName}</span>
|
||||
</button>
|
||||
{!disabled && list.length > 1 ? (
|
||||
<button type="button" className="shop-packages-remove" onClick={() => removeAt(index)}>
|
||||
删除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
<label className="shop-packages-field">
|
||||
<span className="shop-packages-label">套餐名称 *</span>
|
||||
<input
|
||||
className="shop-packages-input"
|
||||
placeholder="如:套餐A"
|
||||
value={item.name}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="shop-packages-field">
|
||||
<span className="shop-packages-label">价格(元) *</span>
|
||||
<input
|
||||
className="shop-packages-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="198"
|
||||
value={item.price}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { price: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="shop-packages-field">
|
||||
<span className="shop-packages-label">菜品 *</span>
|
||||
<textarea
|
||||
className="shop-packages-textarea"
|
||||
rows={3}
|
||||
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||
value={item.dishes}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="shop-packages-field">
|
||||
<span className="shop-packages-label">使用时间</span>
|
||||
<input
|
||||
className="shop-packages-input"
|
||||
placeholder="节假日除外"
|
||||
value={item.usableTime || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="shop-packages-field">
|
||||
<span className="shop-packages-label">其他说明</span>
|
||||
<input
|
||||
className="shop-packages-input"
|
||||
placeholder="不可叠加"
|
||||
value={item.otherNotes || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{!disabled && list.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||
<button type="button" className="shop-packages-add" onClick={addItem}>
|
||||
<span className="material-symbols-outlined">add_circle</span>
|
||||
添加套餐
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
import { uploadRedeemPendingPhoto } from '../lib/upload';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import type { RedeemPendingSubmitResult } from '@dukang/shared-types';
|
||||
|
||||
type Props = {
|
||||
redeemToken: string;
|
||||
failCount: number;
|
||||
};
|
||||
|
||||
export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const [photoResourceId, setPhotoResourceId] = useState('');
|
||||
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
|
||||
|
||||
async function handleFile(file: File) {
|
||||
setUploading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const registered = await uploadRedeemPendingPhoto(file);
|
||||
setPhotoResourceId(registered.id);
|
||||
setPreviewUrl(registered.url);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickPhoto() {
|
||||
setMsg('');
|
||||
if (isWechatEnv()) {
|
||||
try {
|
||||
setUploading(true);
|
||||
await weixinSdk.init();
|
||||
const files = await weixinSdk.chooseImages({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
});
|
||||
if (files?.[0]) {
|
||||
await handleFile(files[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
const text = e instanceof Error ? e.message : '选图失败';
|
||||
if (!/cancel/i.test(text)) {
|
||||
setMsg(`${text},可改从系统相册选择`);
|
||||
setShowAlbumFallback(true);
|
||||
inputRef.current?.click();
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
inputRef.current?.click();
|
||||
}
|
||||
|
||||
async function submitPending() {
|
||||
if (!photoResourceId) {
|
||||
setMsg('请先拍摄或上传核销码照片');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const res = await request<RedeemPendingSubmitResult>('SHOP_H5', '/shop/redeem/pending', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
token: redeemToken,
|
||||
photoResourceId,
|
||||
failCount,
|
||||
}),
|
||||
});
|
||||
setResult(res);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
void navigator.clipboard?.writeText(text).then(
|
||||
() => setMsg('已复制'),
|
||||
() => setMsg('复制失败,请手动长按复制'),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="shop-weaknet-panel" role="alert">
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>弱网核销兜底</h3>
|
||||
<p className="body-md" style={{ marginBottom: 12, lineHeight: 1.5 }}>
|
||||
扫码核销网络失败已达 {failCount} 次。请记录用户手机号尝试「手机号核销」,
|
||||
或拍下用户核销码照片提交客服人工处理。
|
||||
</p>
|
||||
|
||||
<div className="shop-weaknet-token">
|
||||
<span className="label-md text-muted">核销码 ID(追查用)</span>
|
||||
<button type="button" className="shop-weaknet-copy" onClick={() => copyText(redeemToken)}>
|
||||
{redeemToken}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="shop-weaknet-success">
|
||||
<p className="body-md">已提交客服,待处理单号:</p>
|
||||
<button type="button" className="shop-weaknet-copy" onClick={() => copyText(result.pendingNo)}>
|
||||
{result.pendingNo}
|
||||
</button>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
请告知用户保留核销码编号,客服将尽快人工补核销。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setShowAlbumFallback(false);
|
||||
void handleFile(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{previewUrl && (
|
||||
<img src={previewUrl} alt="核销码照片预览" className="shop-weaknet-preview" />
|
||||
)}
|
||||
<div className="shop-weaknet-actions">
|
||||
<button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}>
|
||||
{uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'}
|
||||
</button>
|
||||
{showAlbumFallback && isWechatEnv() && (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-btn-outline"
|
||||
disabled={uploading}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
从系统相册选择
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={submitting || !photoResourceId}
|
||||
onClick={() => void submitPending()}
|
||||
>
|
||||
{submitting ? '提交中…' : '提交客服人工核销'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-btn-outline"
|
||||
onClick={() => navigate('/redeem/phone')}
|
||||
>
|
||||
去手机号核销
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{msg && <p className="shop-redeem-error" style={{ marginTop: 12 }}>{msg}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
type WechatScanAuthModalProps = {
|
||||
open: boolean;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
onAuthorize: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export default function WechatScanAuthModal({
|
||||
open,
|
||||
loading,
|
||||
error,
|
||||
onAuthorize,
|
||||
onCancel,
|
||||
}: WechatScanAuthModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title">
|
||||
<div className="shop-scan-auth-card">
|
||||
<div className="shop-scan-auth-icon">
|
||||
<span className="material-symbols-outlined shop-fill-icon">qr_code_scanner</span>
|
||||
</div>
|
||||
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title">微信授权</h2>
|
||||
<p className="shop-scan-auth-desc">
|
||||
扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。
|
||||
</p>
|
||||
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
|
||||
<div className="shop-scan-auth-actions">
|
||||
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}>
|
||||
{loading ? '跳转授权中…' : '微信授权'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import {
|
||||
clearAuth,
|
||||
ensureSession,
|
||||
saveAuth,
|
||||
type ShopSessionPayload,
|
||||
type StoreSessionStore,
|
||||
} from '../lib/api';
|
||||
import {
|
||||
fetchClientConfig,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatLoginResult,
|
||||
isShopWechatUnboundError,
|
||||
SHOP_WX_NEED_PHONE_LOGIN_MSG,
|
||||
stashShopWechatLoginHint,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
|
||||
type StoreSessionContextValue = {
|
||||
ready: boolean;
|
||||
authenticated: boolean;
|
||||
needsSelectStore: boolean;
|
||||
store: StoreSessionStore | null;
|
||||
applySession: (session: ShopSessionPayload) => void;
|
||||
resetSession: () => void;
|
||||
};
|
||||
|
||||
const StoreSessionContext = createContext<StoreSessionContextValue | null>(null);
|
||||
|
||||
function deriveSelectStore(session: ShopSessionPayload, nextStore: StoreSessionStore | null) {
|
||||
const storeId = nextStore?.storeId || session.selectedStoreId || '';
|
||||
const stores = session.stores ?? nextStore?.stores ?? [];
|
||||
return stores.length > 1 && !storeId;
|
||||
}
|
||||
|
||||
export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [needsSelectStore, setNeedsSelectStore] = useState(false);
|
||||
const [store, setStore] = useState<StoreSessionStore | null>(null);
|
||||
|
||||
const applySession = useCallback((session: ShopSessionPayload) => {
|
||||
saveAuth(session);
|
||||
setAuthenticated(true);
|
||||
const nextStore = session.store
|
||||
? {
|
||||
...session.store,
|
||||
stores: session.stores ?? session.store.stores,
|
||||
isPrimary: session.account?.isPrimary ?? session.store.isPrimary,
|
||||
}
|
||||
: null;
|
||||
setStore(nextStore);
|
||||
setNeedsSelectStore(deriveSelectStore(session, nextStore));
|
||||
}, []);
|
||||
|
||||
const resetSession = useCallback(() => {
|
||||
clearAuth();
|
||||
setAuthenticated(false);
|
||||
setNeedsSelectStore(false);
|
||||
setStore(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (isWechatEnv() && params.get('code')) {
|
||||
try {
|
||||
const config = await fetchClientConfig();
|
||||
if (isWxAuthorizeEnabled(config)) {
|
||||
const result = await handleShopWechatCallback();
|
||||
if (result && !cancelled) {
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
if (session) applySession(session);
|
||||
}
|
||||
stripOAuthParamsFromLocation();
|
||||
}
|
||||
} catch (e) {
|
||||
const raw = e instanceof Error ? e.message : '';
|
||||
if (isShopWechatUnboundError(raw)) {
|
||||
stashShopWechatLoginHint(SHOP_WX_NEED_PHONE_LOGIN_MSG);
|
||||
}
|
||||
stripOAuthParamsFromLocation();
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ensureSession();
|
||||
if (cancelled) return;
|
||||
setAuthenticated(result.authenticated);
|
||||
setStore(result.store);
|
||||
setNeedsSelectStore(result.needsSelectStore);
|
||||
} catch {
|
||||
if (!cancelled) resetSession();
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applySession, resetSession]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ ready, authenticated, needsSelectStore, store, applySession, resetSession }),
|
||||
[ready, authenticated, needsSelectStore, store, applySession, resetSession],
|
||||
);
|
||||
|
||||
return <StoreSessionContext.Provider value={value}>{children}</StoreSessionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useStoreSession() {
|
||||
const ctx = useContext(StoreSessionContext);
|
||||
if (!ctx) throw new Error('useStoreSession must be used within StoreSessionProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
|
||||
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() {
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<nav className="app-tabbar">
|
||||
{TABS.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
end={tab.end}
|
||||
className={({ isActive }) => `app-tabbar-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<span className="material-symbols-outlined app-tabbar-icon">{tab.icon}</span>
|
||||
<span className="app-tabbar-label">{tab.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createStoreTracker } from '@dukang/client-logging';
|
||||
import { apiBase } from './api';
|
||||
|
||||
const tracker = createStoreTracker({
|
||||
apiBase,
|
||||
clientApp: 'SHOP_H5',
|
||||
getToken: () => localStorage.getItem('shopAccessToken'),
|
||||
getStoreId: () => {
|
||||
try {
|
||||
const raw = localStorage.getItem('shopSession');
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as { storeId?: string };
|
||||
return parsed.storeId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export function trackStore(eventName: string, params?: Record<string, unknown>) {
|
||||
tracker.track(eventName, params);
|
||||
}
|
||||
|
||||
export function trackStorePageView(eventName: string, params?: Record<string, unknown>) {
|
||||
tracker.trackPageView(eventName, params);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
export const apiBase = '/api/v1';
|
||||
const CLIENT_APP = 'SHOP_H5';
|
||||
|
||||
export type ShopStoreOption = {
|
||||
storeId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
district?: string;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
export type StoreSessionStore = {
|
||||
id: string;
|
||||
storeId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
storeName: string;
|
||||
isPrimary?: boolean;
|
||||
stores?: ShopStoreOption[];
|
||||
};
|
||||
|
||||
export type StoreProfile = {
|
||||
id: string;
|
||||
storeId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
status?: string;
|
||||
isPrimary?: boolean;
|
||||
account?: {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
isPrimary: boolean;
|
||||
hasWechat?: boolean;
|
||||
};
|
||||
store?: ShopStoreOption | { id: string; name: string } | null;
|
||||
stores?: ShopStoreOption[];
|
||||
};
|
||||
|
||||
export type ShopSessionPayload = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
store?: StoreSessionStore;
|
||||
stores?: ShopStoreOption[];
|
||||
account?: StoreProfile['account'];
|
||||
selectedStoreId?: string;
|
||||
};
|
||||
|
||||
const ACCESS_TOKEN = 'accessToken';
|
||||
const REFRESH_TOKEN = 'refreshToken';
|
||||
const LAST_PHONE = 'shopLastPhone';
|
||||
const STORE_PROFILE = 'shopStoreProfile';
|
||||
const SESSION_EXPIRES_AT = 'shopSessionExpiresAt';
|
||||
export const SHOP_WX_BOUND = 'shopWxBound';
|
||||
|
||||
/** 手机号或微信验证通过后的免登录时长 */
|
||||
export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
||||
'/shop/auth/token/refresh',
|
||||
'/shop/auth/sms/send',
|
||||
'/shop/auth/login/sms',
|
||||
'/shop/auth/login/wechat',
|
||||
];
|
||||
|
||||
export function getLastPhone() {
|
||||
return localStorage.getItem(LAST_PHONE) ?? '';
|
||||
}
|
||||
|
||||
export function getStoreProfile(): StoreSessionStore | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORE_PROFILE);
|
||||
return raw ? (JSON.parse(raw) as StoreSessionStore) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasShopWxSession() {
|
||||
return localStorage.getItem(SHOP_WX_BOUND) === '1';
|
||||
}
|
||||
|
||||
export function isShopSessionExpired() {
|
||||
const raw = localStorage.getItem(SESSION_EXPIRES_AT);
|
||||
if (!raw) return false;
|
||||
return Date.now() > Number(raw);
|
||||
}
|
||||
|
||||
export function touchShopSession() {
|
||||
if (!localStorage.getItem(REFRESH_TOKEN)) return;
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
export function saveAuth(data: ShopSessionPayload) {
|
||||
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||
if (data.store) {
|
||||
const profile: StoreSessionStore = {
|
||||
...data.store,
|
||||
stores: data.stores ?? data.store.stores,
|
||||
isPrimary: data.account?.isPrimary ?? data.store.isPrimary,
|
||||
};
|
||||
localStorage.setItem(STORE_PROFILE, JSON.stringify(profile));
|
||||
if (data.store.phone) localStorage.setItem(LAST_PHONE, data.store.phone);
|
||||
} else if (data.account) {
|
||||
localStorage.setItem(
|
||||
STORE_PROFILE,
|
||||
JSON.stringify({
|
||||
id: data.account.id,
|
||||
storeId: '',
|
||||
name: data.account.name,
|
||||
phone: data.account.phone,
|
||||
storeName: '',
|
||||
isPrimary: data.account.isPrimary,
|
||||
stores: data.stores ?? [],
|
||||
} satisfies StoreSessionStore),
|
||||
);
|
||||
localStorage.setItem(LAST_PHONE, data.account.phone);
|
||||
}
|
||||
}
|
||||
|
||||
/** 手机号验证成功后写入 7 天免验证码会话 */
|
||||
export function saveRememberedSession(data: ShopSessionPayload) {
|
||||
saveAuth(data);
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
||||
export function saveWechatSession(data: ShopSessionPayload) {
|
||||
saveRememberedSession(data);
|
||||
localStorage.setItem(SHOP_WX_BOUND, '1');
|
||||
}
|
||||
|
||||
export function clearAuth(options?: { keepProfile?: boolean }) {
|
||||
localStorage.removeItem(ACCESS_TOKEN);
|
||||
localStorage.removeItem(REFRESH_TOKEN);
|
||||
localStorage.removeItem(SESSION_EXPIRES_AT);
|
||||
localStorage.removeItem(SHOP_WX_BOUND);
|
||||
if (!options?.keepProfile) {
|
||||
localStorage.removeItem(STORE_PROFILE);
|
||||
localStorage.removeItem(LAST_PHONE);
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
function profileFromMe(me: StoreProfile): StoreSessionStore {
|
||||
const selected =
|
||||
me.store && 'storeId' in me.store
|
||||
? me.store
|
||||
: me.store && 'id' in me.store
|
||||
? { storeId: String((me.store as { id: string }).id), name: me.store.name }
|
||||
: null;
|
||||
return {
|
||||
id: me.account?.id ?? me.id,
|
||||
storeId: selected?.storeId ?? me.storeId ?? '',
|
||||
name: me.account?.name ?? me.name,
|
||||
phone: me.account?.phone ?? me.phone,
|
||||
storeName: selected?.name ?? '',
|
||||
isPrimary: me.account?.isPrimary ?? me.isPrimary,
|
||||
stores: me.stores ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function needsStoreSelection(session: {
|
||||
store?: StoreSessionStore | null;
|
||||
stores?: ShopStoreOption[];
|
||||
selectedStoreId?: string;
|
||||
}): boolean {
|
||||
const storeId = session.store?.storeId || session.selectedStoreId || '';
|
||||
const stores = session.stores ?? session.store?.stores ?? [];
|
||||
if (stores.length > 1 && !storeId) return true;
|
||||
if (!storeId && stores.length !== 1) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function selectStore(storeId: string): Promise<ShopSessionPayload> {
|
||||
const data = await requestWithAuthRetry<ShopSessionPayload>('/shop/auth/select-store', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ storeId }),
|
||||
});
|
||||
saveAuth(data);
|
||||
touchShopSession();
|
||||
return data;
|
||||
}
|
||||
|
||||
async function rawRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
token?: string | null,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': CLIENT_APP,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
const authToken = token ?? localStorage.getItem(ACCESS_TOKEN);
|
||||
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||
|
||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||
let json: { code: number; message?: string; data?: T };
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
const err = new Error(res.ok ? '接口返回异常' : `网络异常(HTTP ${res.status})`) as Error & {
|
||||
status?: number;
|
||||
};
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
if (json.code !== 0) {
|
||||
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||
err.status = res.status >= 500 ? res.status : json.code;
|
||||
throw err;
|
||||
}
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
async function refreshSession(): Promise<ShopSessionPayload | null> {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const data = await rawRequest<ShopSessionPayload>(
|
||||
'/shop/auth/token/refresh',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
},
|
||||
null,
|
||||
);
|
||||
saveAuth(data);
|
||||
touchShopSession();
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestWithAuthRetry<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
retried = false,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await rawRequest<T>(path, options);
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
const canRecover =
|
||||
err.status === 401 &&
|
||||
!retried &&
|
||||
!AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p));
|
||||
if (!canRecover) throw e;
|
||||
const refreshed = await refreshSession();
|
||||
if (!refreshed) {
|
||||
clearAuth();
|
||||
throw e;
|
||||
}
|
||||
return requestWithAuthRetry<T>(path, options, true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(clientApp: string, path: string, options: RequestInit = {}): Promise<T> {
|
||||
void clientApp;
|
||||
return requestWithAuthRetry<T>(path, options);
|
||||
}
|
||||
|
||||
export async function ensureSession(): Promise<{
|
||||
authenticated: boolean;
|
||||
store: StoreSessionStore | null;
|
||||
needsSelectStore: boolean;
|
||||
}> {
|
||||
if (!isLoggedIn()) {
|
||||
return { authenticated: false, store: null, needsSelectStore: false };
|
||||
}
|
||||
if (isShopSessionExpired()) {
|
||||
clearAuth({ keepProfile: true });
|
||||
return { authenticated: false, store: getStoreProfile(), needsSelectStore: false };
|
||||
}
|
||||
try {
|
||||
const me = await rawRequest<StoreProfile>('/shop/auth/me');
|
||||
const store = profileFromMe(me);
|
||||
saveAuth({
|
||||
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
||||
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||||
store,
|
||||
stores: me.stores,
|
||||
account: me.account,
|
||||
});
|
||||
touchShopSession();
|
||||
return {
|
||||
authenticated: true,
|
||||
store,
|
||||
needsSelectStore: needsStoreSelection({ store, stores: me.stores }),
|
||||
};
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
if (err.status === 401) {
|
||||
const refreshed = await refreshSession();
|
||||
if (refreshed) {
|
||||
return {
|
||||
authenticated: true,
|
||||
store: refreshed.store ?? getStoreProfile(),
|
||||
needsSelectStore: needsStoreSelection(refreshed),
|
||||
};
|
||||
}
|
||||
clearAuth({ keepProfile: true });
|
||||
return { authenticated: false, store: getStoreProfile(), needsSelectStore: false };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { apiBase } from './api';
|
||||
|
||||
const CLIENT_APP = 'SHOP_H5';
|
||||
const ACCESS_TOKEN = 'accessToken';
|
||||
|
||||
export type ClientErrorLevel = 'fatal' | 'error' | 'warn';
|
||||
export type ClientErrorCategory =
|
||||
| 'js_error'
|
||||
| 'unhandled_rejection'
|
||||
| 'api_error'
|
||||
| 'network'
|
||||
| 'render'
|
||||
| 'bridge'
|
||||
| 'other';
|
||||
|
||||
export type ClientErrorPayload = {
|
||||
level: ClientErrorLevel;
|
||||
category: ClientErrorCategory;
|
||||
message: string;
|
||||
stack?: string;
|
||||
pagePath?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function currentPagePath(): string | undefined {
|
||||
try {
|
||||
return typeof window !== 'undefined' ? window.location.pathname : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(): string {
|
||||
try {
|
||||
return localStorage.getItem(ACCESS_TOKEN) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 上报客户端错误(失败静默,避免递归) */
|
||||
export function reportClientError(payload: ClientErrorPayload): void {
|
||||
const body = {
|
||||
level: payload.level,
|
||||
category: payload.category,
|
||||
message: String(payload.message || 'unknown').slice(0, 1000),
|
||||
stack: payload.stack ? String(payload.stack).slice(0, 4000) : undefined,
|
||||
pagePath: (payload.pagePath || currentPagePath() || '').slice(0, 128) || undefined,
|
||||
clientApp: CLIENT_APP,
|
||||
extra: payload.extra,
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': CLIENT_APP,
|
||||
};
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
void fetch(`${apiBase}/common/client-errors`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
/** 安装 H5 全局未捕获错误钩子(幂等) */
|
||||
export function installClientErrorReporting(): void {
|
||||
if (installed || typeof window === 'undefined') return;
|
||||
installed = true;
|
||||
|
||||
window.addEventListener('error', (ev) => {
|
||||
reportClientError({
|
||||
level: 'fatal',
|
||||
category: 'js_error',
|
||||
message: ev.message || 'window.error',
|
||||
stack: ev.error instanceof Error ? ev.error.stack : undefined,
|
||||
extra: { filename: ev.filename, lineno: ev.lineno, colno: ev.colno },
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (ev) => {
|
||||
const reason = ev.reason;
|
||||
reportClientError({
|
||||
level: 'error',
|
||||
category: 'unhandled_rejection',
|
||||
message:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: typeof reason === 'string'
|
||||
? reason
|
||||
: 'unhandledrejection',
|
||||
stack: reason instanceof Error ? reason.stack : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { REDEEM_WEAKNET_FAIL_THRESHOLD } from '@dukang/shared-types';
|
||||
import type { RedeemErrorClass, RedeemFailureReportResult } from '@dukang/shared-types';
|
||||
import { request } from './api';
|
||||
|
||||
export function isNetworkError(e: unknown): boolean {
|
||||
const err = e as Error & { status?: number };
|
||||
const status = err.status;
|
||||
const message = (err.message ?? String(e)).toLowerCase();
|
||||
if (status != null && status >= 500) return true;
|
||||
if (status === 0 || status === 408 || status === 429 || status === 502 || status === 503 || status === 504) {
|
||||
return true;
|
||||
}
|
||||
if (/failed to fetch|network|timeout|timed out|abort|offline|连接|网络|超时/.test(message)) {
|
||||
return true;
|
||||
}
|
||||
// 无 status 的 fetch 失败通常是网络类
|
||||
if (status == null && e instanceof TypeError) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function reportRedeemFailure(
|
||||
token: string,
|
||||
step: 'preview' | 'confirm',
|
||||
error: unknown,
|
||||
): Promise<RedeemFailureReportResult | null> {
|
||||
const err = error as Error & { status?: number };
|
||||
const errorClass: RedeemErrorClass = isNetworkError(error) ? 'NETWORK' : 'BUSINESS';
|
||||
try {
|
||||
return await request<RedeemFailureReportResult>('SHOP_H5', '/shop/redeem/failures', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
errorClass,
|
||||
message: err.message?.slice(0, 200),
|
||||
step,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
if (errorClass === 'NETWORK') {
|
||||
return {
|
||||
failCount: REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||
thresholdReached: true,
|
||||
threshold: REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { REDEEM_WEAKNET_FAIL_THRESHOLD };
|
||||
@@ -0,0 +1,22 @@
|
||||
/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */
|
||||
export function parseRedeemTokenFromScan(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^[a-f0-9]{32}$/i.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
try {
|
||||
const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid');
|
||||
const fromQuery = url.searchParams.get('token');
|
||||
if (fromQuery && /^[a-f0-9]{32}$/i.test(fromQuery)) {
|
||||
return fromQuery.toLowerCase();
|
||||
}
|
||||
} catch {
|
||||
/* not a URL */
|
||||
}
|
||||
|
||||
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
|
||||
return hexMatch ? hexMatch[0].toLowerCase() : null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
|
||||
export type PackageFormItem = StorePackageItemDto;
|
||||
|
||||
export function emptyPackage(index = 0): PackageFormItem {
|
||||
return {
|
||||
name: '',
|
||||
price: '',
|
||||
dishes: '',
|
||||
usableTime: '',
|
||||
otherNotes: '',
|
||||
sortOrder: index,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
||||
return raw
|
||||
.map((item, index) => ({
|
||||
name: item.name.trim(),
|
||||
price: item.price.trim(),
|
||||
dishes: item.dishes.trim(),
|
||||
usableTime: item.usableTime?.trim() || '',
|
||||
otherNotes: item.otherNotes?.trim() || '',
|
||||
sortOrder: index,
|
||||
}))
|
||||
.filter((item) => item.name || item.price || item.dishes || item.usableTime || item.otherNotes);
|
||||
}
|
||||
|
||||
export function validatePackageFormItems(items: PackageFormItem[]): string | null {
|
||||
const filled = normalizePackageFormItems(items);
|
||||
if (filled.length > STORE_PACKAGE_MAX_COUNT) {
|
||||
return `套餐最多 ${STORE_PACKAGE_MAX_COUNT} 条`;
|
||||
}
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
const item = filled[i];
|
||||
if (!item.name) return `第 ${i + 1} 条套餐名称不能为空`;
|
||||
const price = Number(item.price);
|
||||
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
||||
if (!item.dishes) return `第 ${i + 1} 条套餐菜品不能为空`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { apiBase } from './api';
|
||||
import { getStoreProfile } from './api';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
export type UploadFileResult = {
|
||||
url: string;
|
||||
ossKey: string;
|
||||
bucket: string;
|
||||
mock: boolean;
|
||||
};
|
||||
|
||||
export type RegisteredResource = {
|
||||
id: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('bizType', bizType);
|
||||
formData.append('mediaType', 'IMAGE');
|
||||
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = { 'X-Client-App': 'SHOP_H5' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code === 401) {
|
||||
localStorage.removeItem('accessToken');
|
||||
throw new Error('未登录');
|
||||
}
|
||||
if (json.code !== 0) {
|
||||
throw new Error(json.message || '上传失败');
|
||||
}
|
||||
return json.data as UploadFileResult;
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
throw new Error('上传超时,请检查网络后重试');
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function registerResource(upload: UploadFileResult, fileName: string): Promise<RegisteredResource> {
|
||||
const profile = getStoreProfile();
|
||||
if (!profile?.storeId) throw new Error('门店信息缺失,请重新登录');
|
||||
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': 'SHOP_H5',
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}/common/resources`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
ownerType: 'STORE',
|
||||
ownerId: profile.storeId,
|
||||
bizType: 'REDEEM_PENDING_PHOTO',
|
||||
mediaType: 'IMAGE',
|
||||
ossKey: upload.ossKey,
|
||||
url: upload.url,
|
||||
ossBucket: upload.bucket,
|
||||
fileName,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '登记资源失败');
|
||||
return { id: String(json.data.id), url: String(json.data.url) };
|
||||
}
|
||||
|
||||
/** 上传核销码照片并登记为 CommonResource,返回 resourceId */
|
||||
export async function uploadRedeemPendingPhoto(file: File): Promise<RegisteredResource> {
|
||||
const uploaded = await uploadFileToOss(file, 'REDEEM_PENDING_PHOTO');
|
||||
return registerResource(uploaded, file.name || 'redeem-pending.jpg');
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { trackStorePageView } from './analytics';
|
||||
|
||||
export function useStorePageView(eventName: string, params?: Record<string, unknown>) {
|
||||
const fired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (fired.current) return;
|
||||
fired.current = true;
|
||||
trackStorePageView(eventName, params);
|
||||
}, [eventName, params]);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
import { request, saveWechatSession, type ShopSessionPayload } from './api';
|
||||
|
||||
export type ShopAccountProfile = {
|
||||
id: string;
|
||||
storeId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
/** 是否已绑定微信(来自 /shop/auth/me account.hasWechat) */
|
||||
hasWechat?: boolean;
|
||||
wxOpenId?: string | null;
|
||||
store?: { name: string };
|
||||
};
|
||||
|
||||
/** 微信未绑定门店账号时的登录提示 */
|
||||
export const SHOP_WX_NEED_PHONE_LOGIN_MSG = '请先用手机号登录';
|
||||
|
||||
const SHOP_WX_LOGIN_HINT_KEY = 'shop_wx_login_hint';
|
||||
|
||||
export function isShopWechatUnboundError(message: string): boolean {
|
||||
return message.includes('首次登录') || message.includes('手机验证码') || message.includes('请先用手机号');
|
||||
}
|
||||
|
||||
export function formatShopWechatError(e: unknown): string {
|
||||
const text = e instanceof Error ? e.message : '微信登录失败';
|
||||
if (isShopWechatUnboundError(text)) return SHOP_WX_NEED_PHONE_LOGIN_MSG;
|
||||
return text;
|
||||
}
|
||||
|
||||
/** OAuth 在启动层失败时暂存提示,供登录页展示 */
|
||||
export function stashShopWechatLoginHint(message: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(SHOP_WX_LOGIN_HINT_KEY, message);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeShopWechatLoginHint(): string | null {
|
||||
try {
|
||||
const hint = sessionStorage.getItem(SHOP_WX_LOGIN_HINT_KEY);
|
||||
if (hint) sessionStorage.removeItem(SHOP_WX_LOGIN_HINT_KEY);
|
||||
return hint;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||
return request<ClientRuntimeConfig>('SHOP_H5', '/common/client-config');
|
||||
}
|
||||
|
||||
export async function fetchShopAccount(): Promise<ShopAccountProfile> {
|
||||
const me = await request<{
|
||||
id?: string;
|
||||
storeId?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
account?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
hasWechat?: boolean;
|
||||
};
|
||||
store?: { name?: string } | null;
|
||||
}>('SHOP_H5', '/shop/auth/me');
|
||||
return {
|
||||
id: String(me.account?.id ?? me.id ?? ''),
|
||||
storeId: String(me.storeId ?? ''),
|
||||
name: String(me.account?.name ?? me.name ?? ''),
|
||||
phone: String(me.account?.phone ?? me.phone ?? ''),
|
||||
hasWechat: !!me.account?.hasWechat,
|
||||
store: me.store?.name ? { name: String(me.store.name) } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function needsWechatAuth(
|
||||
profile: ShopAccountProfile | null,
|
||||
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
|
||||
): boolean {
|
||||
if (config && !isWxAuthorizeEnabled(config)) return false;
|
||||
const unbound = !!profile && !(profile.hasWechat || profile.wxOpenId);
|
||||
return isWechatEnv() && unbound;
|
||||
}
|
||||
|
||||
export async function checkNeedsWechatAuth(profile: ShopAccountProfile | null): Promise<boolean> {
|
||||
const config = await fetchClientConfig();
|
||||
return needsWechatAuth(profile, config);
|
||||
}
|
||||
|
||||
export function sessionFromWechatLogin(result: WechatLoginResult): ShopSessionPayload | null {
|
||||
if (!result.accessToken || !result.refreshToken) return null;
|
||||
const store = result.store as Record<string, unknown> | undefined;
|
||||
const stores = Array.isArray((result as { stores?: unknown }).stores)
|
||||
? ((result as { stores: ShopSessionPayload['stores'] }).stores)
|
||||
: undefined;
|
||||
const account = (result as { account?: ShopSessionPayload['account'] }).account;
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
stores,
|
||||
account,
|
||||
selectedStoreId: (result as { selectedStoreId?: string }).selectedStoreId,
|
||||
store: store
|
||||
? {
|
||||
id: String(store.id ?? account?.id ?? ''),
|
||||
storeId: String(store.storeId ?? ''),
|
||||
name: String(store.name ?? account?.name ?? ''),
|
||||
phone: String(store.phone ?? account?.phone ?? ''),
|
||||
storeName: String(store.storeName ?? store.name ?? ''),
|
||||
isPrimary: account?.isPrimary,
|
||||
stores,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 处理微信登录/绑定结果,写入 7 天免登录 session */
|
||||
export function handleShopWechatLoginResult(result: WechatLoginResult): ShopSessionPayload | null {
|
||||
const session = sessionFromWechatLogin(result);
|
||||
if (!session) return null;
|
||||
saveWechatSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** @deprecated 使用 handleShopWechatLoginResult */
|
||||
export function saveShopWechatAuth(result: WechatLoginResult): boolean {
|
||||
return !!handleShopWechatLoginResult(result);
|
||||
}
|
||||
|
||||
export async function handleShopWechatCallback(): Promise<WechatLoginResult | null> {
|
||||
if (!isWechatEnv()) return null;
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return null;
|
||||
return weixinSdk.handleOAuthCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信一键登录(已绑定微信的门店账号免验证码)。
|
||||
* 返回 session = 已登录;void = 已跳转授权页等待回调。
|
||||
*/
|
||||
export async function loginShopWithWechat(): Promise<ShopSessionPayload | null | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return null;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
const result = await weixinSdk.login();
|
||||
if (result) return handleShopWechatLoginResult(result);
|
||||
}
|
||||
|
||||
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
|
||||
export async function bindShopWechatAfterSmsLogin(): Promise<void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) return;
|
||||
await weixinSdk.login();
|
||||
}
|
||||
|
||||
export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
return weixinSdk.login();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
|
||||
|
||||
export const weixinSdk = createWeixinSdk({
|
||||
apiBase: '/api/v1',
|
||||
clientApp: 'SHOP_H5',
|
||||
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||
wechatLoginPath: '/shop/auth/login/wechat',
|
||||
});
|
||||
|
||||
export { isWechatEnv };
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { getRouterBasename } from '@dukang/weixin-sdk';
|
||||
import { StoreSessionProvider } from './contexts/StoreSessionContext';
|
||||
import { installClientErrorReporting } from '@dukang/client-logging';
|
||||
import App from './App';
|
||||
import { apiBase } from './lib/api';
|
||||
import './styles.css';
|
||||
import './styles/legal.css';
|
||||
|
||||
installClientErrorReporting({
|
||||
apiBase,
|
||||
clientApp: 'SHOP_H5',
|
||||
getToken: () => localStorage.getItem('shopAccessToken'),
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename={getRouterBasename()}>
|
||||
<StoreSessionProvider>
|
||||
<App />
|
||||
</StoreSessionProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { request } from '../lib/api';
|
||||
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
||||
import {
|
||||
authorizeShopWechat,
|
||||
checkNeedsWechatAuth,
|
||||
fetchShopAccount,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatLoginResult,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
import { trackStore } from '../lib/analytics';
|
||||
|
||||
const PENDING_SCAN_KEY = 'shop_pending_scan';
|
||||
|
||||
function formatMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatScanError(e: unknown): string {
|
||||
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
||||
if (/invalid signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
||||
}
|
||||
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
|
||||
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
useStorePageView('store_home_view');
|
||||
const navigate = useNavigate();
|
||||
const { applySession } = useStoreSession();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
const [scanMsg, setScanMsg] = useState('');
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
const [authError, setAuthError] = useState('');
|
||||
|
||||
const loadDashboard = useCallback(() => {
|
||||
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
|
||||
.then((d) => {
|
||||
setDash(d);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, [loadDashboard]);
|
||||
|
||||
useEffect(() => {
|
||||
function onResume() {
|
||||
setScanning(false);
|
||||
void loadDashboard();
|
||||
}
|
||||
function onVisibility() {
|
||||
if (document.visibilityState === 'visible') onResume();
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
window.addEventListener('pageshow', onResume);
|
||||
window.addEventListener('focus', onResume);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
window.removeEventListener('pageshow', onResume);
|
||||
window.removeEventListener('focus', onResume);
|
||||
};
|
||||
}, [loadDashboard]);
|
||||
|
||||
async function runScan(opts?: { postAuthWarmup?: boolean }) {
|
||||
trackStore('store_redeem_scan_start');
|
||||
if (!isWechatEnv()) {
|
||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||
return;
|
||||
}
|
||||
setScanning(true);
|
||||
setScanMsg('');
|
||||
try {
|
||||
// 授权回跳后强制重签,避免沿用带 code 的旧签名态
|
||||
if (opts?.postAuthWarmup) {
|
||||
weixinSdk.reset();
|
||||
}
|
||||
await weixinSdk.init();
|
||||
const raw = await weixinSdk.scanQrCode(
|
||||
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
|
||||
);
|
||||
if (!raw) {
|
||||
void loadDashboard();
|
||||
return;
|
||||
}
|
||||
const token = parseRedeemTokenFromScan(raw);
|
||||
if (!token) {
|
||||
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
|
||||
return;
|
||||
}
|
||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
||||
} catch (e) {
|
||||
setScanMsg(formatScanError(e));
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !searchParams.get('code')) return;
|
||||
void handleShopWechatCallback()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
if (session) {
|
||||
applySession(session);
|
||||
}
|
||||
setAuthModalOpen(false);
|
||||
setAuthError('');
|
||||
// 先清 OAuth 参数再扫,保证 JSSDK 签名 URL 与当前页一致
|
||||
stripOAuthParamsFromLocation();
|
||||
setSearchParams({}, { replace: true });
|
||||
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
|
||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||
if (shouldScan) {
|
||||
// OAuth 回跳后微信权限离线校验未完成;稍候再扫,失败可再点一次
|
||||
window.setTimeout(() => void runScan({ postAuthWarmup: true }), 400);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
||||
});
|
||||
}, [searchParams, applySession, setSearchParams]);
|
||||
|
||||
async function handleScan() {
|
||||
setScanMsg('');
|
||||
if (!isWechatEnv()) {
|
||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const profile = await fetchShopAccount();
|
||||
if (await checkNeedsWechatAuth(profile)) {
|
||||
setAuthModalOpen(true);
|
||||
return;
|
||||
}
|
||||
await runScan();
|
||||
} catch (e) {
|
||||
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
|
||||
}
|
||||
}
|
||||
|
||||
async function startWechatAuth() {
|
||||
setAuthLoading(true);
|
||||
setAuthError('');
|
||||
try {
|
||||
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
|
||||
await authorizeShopWechat();
|
||||
} catch (e) {
|
||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
||||
setAuthLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const store = dash?.store as Record<string, unknown> | undefined;
|
||||
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
||||
const status = String(store?.status || '');
|
||||
const open = status === 'OPEN';
|
||||
const hoursParts: string[] = [];
|
||||
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
|
||||
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
|
||||
const hoursText = hoursParts.length ? hoursParts.join(',') : '10:00 - 22:00';
|
||||
const statusText =
|
||||
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadDashboard} 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>
|
||||
<p className="shop-home-stat-sub">
|
||||
扫码 {Number(dash?.todayScanCount || 0)} · 手机号 {Number(dash?.todayPhoneCount || 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"
|
||||
disabled={scanning}
|
||||
onClick={() => void handleScan()}
|
||||
>
|
||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
||||
</button>
|
||||
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
|
||||
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
|
||||
<Link to="/redeem/phone" className="shop-home-phone-link">
|
||||
<span className="material-symbols-outlined">smartphone</span>
|
||||
手机号核销
|
||||
</Link>
|
||||
</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">{statusText}</p>
|
||||
<p className="shop-home-status-sub">营业时间: {hoursText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="shop-home-switch" onClick={() => navigate('/status')}>
|
||||
<input type="checkbox" checked={open && status !== 'CLOSED'} 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>
|
||||
|
||||
<WechatScanAuthModal
|
||||
open={authModalOpen}
|
||||
loading={authLoading}
|
||||
error={authError}
|
||||
onAuthorize={() => void startWechatAuth()}
|
||||
onCancel={() => {
|
||||
setAuthModalOpen(false);
|
||||
setAuthError('');
|
||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||
}}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getLegalDocument, type LegalDocument } from '@dukang/shared-types';
|
||||
|
||||
type LegalPageProps = {
|
||||
docId: LegalDocument['id'];
|
||||
/** 返回登录页的路径,如 /login */
|
||||
backTo?: string;
|
||||
};
|
||||
|
||||
/** H5 各端共用的协议/隐私正文页 */
|
||||
export default function LegalPage({ docId, backTo = '/login' }: LegalPageProps) {
|
||||
const doc = getLegalDocument(docId);
|
||||
|
||||
return (
|
||||
<div className="legal-h5-page">
|
||||
<header className="legal-h5-header">
|
||||
<Link to={backTo} className="legal-h5-back" aria-label="返回">
|
||||
‹
|
||||
</Link>
|
||||
<h1 className="legal-h5-title">{doc.title}</h1>
|
||||
</header>
|
||||
<main className="legal-h5-body">
|
||||
<p className="legal-h5-updated">更新日期:{doc.updatedAt}</p>
|
||||
<p className="legal-h5-intro">{doc.intro}</p>
|
||||
{doc.sections.map((section) => (
|
||||
<section key={section.heading} className="legal-h5-section">
|
||||
<h2>{section.heading}</h2>
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<p key={`${section.heading}-${i}`}>{p}</p>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import {
|
||||
getLastPhone,
|
||||
getStoreProfile,
|
||||
hasShopWxSession,
|
||||
request,
|
||||
saveRememberedSession,
|
||||
type ShopSessionPayload,
|
||||
} from '../lib/api';
|
||||
import { routeAfterShopLogin } from './SelectStorePage';
|
||||
import {
|
||||
bindShopWechatAfterSmsLogin,
|
||||
consumeShopWechatLoginHint,
|
||||
fetchClientConfig,
|
||||
formatShopWechatError,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatLoginResult,
|
||||
loginShopWithWechat,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
if (phone.length < 7) return phone;
|
||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
function ShopAgreementCheckbox({
|
||||
agreed,
|
||||
onChange,
|
||||
labelRef,
|
||||
}: {
|
||||
agreed: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
labelRef?: RefObject<HTMLLabelElement>;
|
||||
}) {
|
||||
return (
|
||||
<label className="shop-login-agreement" ref={labelRef}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
我已阅读并同意
|
||||
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
|
||||
《用户协议》
|
||||
</Link>
|
||||
与
|
||||
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
|
||||
《隐私政策》
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { applySession } = useStoreSession();
|
||||
const [params, setSearchParams] = useSearchParams();
|
||||
const quick = params.get('quick') === '1';
|
||||
const savedProfile = getStoreProfile();
|
||||
const [phone, setPhone] = useState(getLastPhone());
|
||||
const [code, setCode] = useState('');
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wxLoading, setWxLoading] = useState(false);
|
||||
const [msg, setMsg] = useState(() => consumeShopWechatLoginHint() ?? '');
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClientConfig()
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const hint = consumeShopWechatLoginHint();
|
||||
if (hint) setMsg(hint);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
|
||||
void handleShopWechatCallback()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
if (session) {
|
||||
applySession(session);
|
||||
stripOAuthParamsFromLocation();
|
||||
setSearchParams({}, { replace: true });
|
||||
routeAfterShopLogin(session, navigate);
|
||||
}
|
||||
})
|
||||
.catch((e) => setMsg(formatShopWechatError(e)));
|
||||
}, [applySession, navigate, params, setSearchParams, wxAuthorize]);
|
||||
|
||||
const quickStoreName = savedProfile?.storeName ?? '门店管理中心';
|
||||
const quickPhone = savedProfile?.phone || phone;
|
||||
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
setMsg('请先阅读并同意用户协议');
|
||||
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
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('验证码已发送');
|
||||
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 {
|
||||
const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
saveRememberedSession(data);
|
||||
applySession(data);
|
||||
if (isWechatEnv() && wxAuthorize) {
|
||||
setMsg('登录成功,正在关联微信…');
|
||||
await bindShopWechatAfterSmsLogin();
|
||||
return;
|
||||
}
|
||||
routeAfterShopLogin(data, navigate);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function wechatLogin() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('');
|
||||
if (!isWechatEnv()) {
|
||||
setMsg('请在微信内打开以使用微信一键登录');
|
||||
return;
|
||||
}
|
||||
setWxLoading(true);
|
||||
try {
|
||||
const session = await loginShopWithWechat();
|
||||
if (session) {
|
||||
applySession(session);
|
||||
routeAfterShopLogin(session, navigate);
|
||||
}
|
||||
} catch (e) {
|
||||
setMsg(formatShopWechatError(e));
|
||||
} finally {
|
||||
setWxLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (quick) {
|
||||
const canWechatQuick = wxAuthorize && isWechatEnv() && hasShopWxSession() && !!savedProfile;
|
||||
|
||||
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">{quickStoreName}</h2>
|
||||
<p className="shop-quick-store-phone">{maskPhone(quickPhone)}</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>}
|
||||
<ShopAgreementCheckbox
|
||||
agreed={agreed}
|
||||
onChange={setAgreed}
|
||||
labelRef={agreementRef}
|
||||
/>
|
||||
{canWechatQuick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-quick-login-btn shop-quick-login-btn--wechat"
|
||||
disabled={wxLoading}
|
||||
onClick={() => void wechatLogin()}
|
||||
>
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
||||
</button>
|
||||
) : (
|
||||
<p className="shop-login-msg" style={{ textAlign: 'center' }}>
|
||||
{wxAuthorize && !isWechatEnv()
|
||||
? '请在微信内打开以使用一键登录'
|
||||
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
||||
</p>
|
||||
)}
|
||||
{!canWechatQuick && (
|
||||
<Link to="/login" className="shop-quick-login-btn" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
||||
验证码登录
|
||||
</Link>
|
||||
)}
|
||||
<div className="shop-quick-secure">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>lock</span>
|
||||
<span>{canWechatQuick ? '微信验证 · 7 天内免登录' : '加密环境安全登录中'}</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>}
|
||||
|
||||
<ShopAgreementCheckbox
|
||||
agreed={agreed}
|
||||
onChange={setAgreed}
|
||||
labelRef={agreementRef}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shop-login-submit"
|
||||
disabled={loading}
|
||||
onClick={() => void login()}
|
||||
>
|
||||
<span>{loading ? '登录中...' : '登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
|
||||
{wxAuthorize && (
|
||||
<>
|
||||
<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"
|
||||
disabled={wxLoading}
|
||||
onClick={() => void wechatLogin()}
|
||||
>
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="shop-login-msg" style={{ textAlign: 'center', marginTop: 12 }}>
|
||||
手机号验证成功后,7 天内无需再次输入验证码;微信内登录将自动关联微信
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
{hasShopWxSession() && savedProfile && (
|
||||
<p style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Link to="/login?quick=1" className="text-primary body-md">微信快捷登录</Link>
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { getStoreProfile, request } from '../lib/api';
|
||||
import {
|
||||
authorizeShopWechat,
|
||||
fetchClientConfig,
|
||||
fetchShopAccount,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatLoginResult,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
export default function MinePage() {
|
||||
useStorePageView('store_mine_view');
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { resetSession, store: sessionStore, applySession } = useStoreSession();
|
||||
const profile = sessionStore ?? getStoreProfile();
|
||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||
const [hasWechat, setHasWechat] = useState<boolean | null>(null);
|
||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||
const [binding, setBinding] = useState(false);
|
||||
const [bindMsg, setBindMsg] = useState('');
|
||||
|
||||
const loadMine = useCallback(() => {
|
||||
return Promise.all([
|
||||
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null)),
|
||||
fetchClientConfig()
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(false)),
|
||||
fetchShopAccount()
|
||||
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
|
||||
.catch(() => setHasWechat(null)),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadMine();
|
||||
}, [loadMine]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
|
||||
void handleShopWechatCallback()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
if (session) {
|
||||
applySession(session);
|
||||
setHasWechat(true);
|
||||
setBindMsg('微信绑定成功');
|
||||
}
|
||||
stripOAuthParamsFromLocation();
|
||||
setSearchParams({}, { replace: true });
|
||||
})
|
||||
.catch((e) => setBindMsg(e instanceof Error ? e.message : '微信绑定失败'));
|
||||
}, [applySession, searchParams, setSearchParams, wxAuthorize]);
|
||||
|
||||
const hoursParts: string[] = [];
|
||||
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
|
||||
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
|
||||
const hoursText = hoursParts.length ? hoursParts.join(',') : '09:30 - 22:00';
|
||||
const multiStore = (profile?.stores?.length ?? 0) > 1;
|
||||
const showBindWechat = wxAuthorize && hasWechat === false;
|
||||
|
||||
async function bindWechat() {
|
||||
if (binding) return;
|
||||
setBindMsg('');
|
||||
if (!isWechatEnv()) {
|
||||
setBindMsg('请在微信内打开门店端以绑定微信');
|
||||
return;
|
||||
}
|
||||
setBinding(true);
|
||||
try {
|
||||
const result = await authorizeShopWechat();
|
||||
if (!result) {
|
||||
// 跳转公众号 OAuth,回跳后由上面 useEffect 处理
|
||||
return;
|
||||
}
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
if (session) {
|
||||
applySession(session);
|
||||
setHasWechat(true);
|
||||
setBindMsg('微信绑定成功');
|
||||
} else {
|
||||
setBindMsg('绑定未完成,请重试');
|
||||
}
|
||||
} catch (e) {
|
||||
setBindMsg(e instanceof Error ? e.message : '微信绑定失败');
|
||||
} finally {
|
||||
setBinding(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadMine} 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 || profile?.storeName || '—')}</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 || profile?.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">{hoursText}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shop-mine-actions">
|
||||
{multiStore ? (
|
||||
<button type="button" className="shop-mine-action" onClick={() => navigate('/select-store')}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>swap_horiz</span>
|
||||
切换门店
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="shop-mine-action" onClick={() => navigate('/withdraw')}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>account_balance_wallet</span>
|
||||
结算提现
|
||||
</button>
|
||||
{profile?.isPrimary ? (
|
||||
<button type="button" className="shop-mine-action" onClick={() => navigate('/packages')}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>restaurant_menu</span>
|
||||
门店套餐
|
||||
</button>
|
||||
) : null}
|
||||
{profile?.isPrimary ? (
|
||||
<button type="button" className="shop-mine-action" onClick={() => navigate('/staff')}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>group</span>
|
||||
子账号管理
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="shop-mine-help">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>help</span>
|
||||
<p>如需修改信息请联系城市合伙人</p>
|
||||
</div>
|
||||
|
||||
{bindMsg ? <p className="shop-mine-bind-msg">{bindMsg}</p> : null}
|
||||
|
||||
{showBindWechat ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-mine-bind-wechat"
|
||||
disabled={binding}
|
||||
onClick={() => void bindWechat()}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>chat</span>
|
||||
{binding ? '绑定中…' : '绑定微信'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shop-mine-logout"
|
||||
onClick={() => { resetSession(); navigate('/login'); }}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>logout</span>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { StorePackagesResponse } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import ShopPackagesForm from '../components/ShopPackagesForm';
|
||||
import { request } from '../lib/api';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
import {
|
||||
emptyPackage,
|
||||
normalizePackageFormItems,
|
||||
validatePackageFormItems,
|
||||
type PackageFormItem,
|
||||
} from '../lib/storePackages';
|
||||
|
||||
export default function PackagesPage() {
|
||||
useStorePageView('store_packages_view');
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<PackageFormItem[]>([emptyPackage()]);
|
||||
const [pending, setPending] = useState<StorePackagesResponse['pendingRequest']>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request<StorePackagesResponse>('SHOP_H5', '/shop/store/packages');
|
||||
const base = data.pendingRequest?.packages?.length
|
||||
? data.pendingRequest.packages
|
||||
: data.live?.length
|
||||
? data.live
|
||||
: [emptyPackage()];
|
||||
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
|
||||
setPending(data.pendingRequest ?? null);
|
||||
setMsg('');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
async function submitAudit() {
|
||||
const validationMsg = validatePackageFormItems(items);
|
||||
if (validationMsg) {
|
||||
setMsg(validationMsg);
|
||||
return;
|
||||
}
|
||||
if (pending?.status === 'PENDING') {
|
||||
setMsg('已有套餐变更审核中,请等待总部处理');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const packages = normalizePackageFormItems(items).map((p) => ({
|
||||
...p,
|
||||
price: Number(p.price).toFixed(2),
|
||||
}));
|
||||
await request('SHOP_H5', '/shop/store/packages', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ packages }),
|
||||
});
|
||||
setMsg('已提交审核,请等待总部处理');
|
||||
await load();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const disabled = pending?.status === 'PENDING' || submitting;
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={load} className="shop-records-page shop-packages-page">
|
||||
<header className="shop-records-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-withdraw-back"
|
||||
onClick={() => navigate('/mine')}
|
||||
aria-label="返回"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
||||
arrow_back
|
||||
</span>
|
||||
</button>
|
||||
<h1 className="app-page-title" style={{ margin: 0 }}>
|
||||
门店套餐
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="shop-records-main">
|
||||
{loading ? <p className="shop-records-empty">加载中…</p> : null}
|
||||
|
||||
{!loading && pending?.status === 'PENDING' ? (
|
||||
<section className="shop-packages-notice">
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 18 }}>
|
||||
hourglass_top
|
||||
</span>
|
||||
<p>套餐变更审核中,用户端仍展示上一版生效套餐。</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!loading && pending?.status === 'REJECTED' && pending.rejectReason ? (
|
||||
<section className="shop-packages-notice shop-packages-notice--warn">
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 18 }}>
|
||||
error
|
||||
</span>
|
||||
<p>上次驳回:{pending.rejectReason}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!loading ? (
|
||||
<>
|
||||
<p className="shop-packages-intro">
|
||||
维护门店可核销套餐信息,提交后由总部审核通过后在用户端展示。
|
||||
</p>
|
||||
<ShopPackagesForm items={items} onChange={setItems} disabled={disabled} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{msg ? <p className="shop-withdraw-msg">{msg}</p> : null}
|
||||
|
||||
{!loading ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-withdraw-btn"
|
||||
style={{ marginTop: 16 }}
|
||||
disabled={disabled}
|
||||
onClick={() => void submitAudit()}
|
||||
>
|
||||
{submitting ? '提交中…' : '提交审核'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function PhoneRedeemPage() {
|
||||
useStorePageView('store_phone_redeem_view');
|
||||
const navigate = useNavigate();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [confirmCode, setConfirmCode] = useState('');
|
||||
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [storeClosed, setStoreClosed] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => {
|
||||
setStoreName(String(s.name || '当前门店'));
|
||||
if (s.status && s.status !== 'OPEN') setStoreClosed(true);
|
||||
})
|
||||
.catch(() => setStoreName('当前门店'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirmCooldown <= 0) return;
|
||||
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [confirmCooldown]);
|
||||
|
||||
async function sendConfirmSms() {
|
||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
if (storeClosed) {
|
||||
setMsg('门店未营业,无法核销');
|
||||
return;
|
||||
}
|
||||
const value = Number(amount);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
setMsg('请输入有效核销金额');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: phone.trim(), amount: value }),
|
||||
});
|
||||
setPrepared(result);
|
||||
setConfirmCode('');
|
||||
setConfirmCooldown(60);
|
||||
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
|
||||
} catch (e) {
|
||||
setPrepared(null);
|
||||
setMsg(e instanceof Error ? e.message : '发送验证码失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRedeem() {
|
||||
if (!prepared) {
|
||||
setMsg('请先发送核销验证码');
|
||||
return;
|
||||
}
|
||||
if (!confirmCode.trim()) {
|
||||
setMsg('请输入确认验证码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: prepared.sessionId,
|
||||
code: confirmCode.trim(),
|
||||
}),
|
||||
});
|
||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
||||
navigate('/redeem/success', {
|
||||
state: { result, storeName, user: prepared.user },
|
||||
});
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const amountValue = Number(amount);
|
||||
const canSendCode =
|
||||
/^1\d{10}$/.test(phone.trim()) && Number.isFinite(amountValue) && amountValue > 0;
|
||||
|
||||
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">
|
||||
{storeClosed && (
|
||||
<p className="shop-redeem-error" style={{ marginBottom: 12 }}>门店当前未营业,无法核销</p>
|
||||
)}
|
||||
|
||||
<section className="shop-redeem-card">
|
||||
<div className="shop-redeem-banner">
|
||||
<div className="shop-redeem-banner-icon">
|
||||
<span className="material-symbols-outlined shop-fill-icon">smartphone</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-phone-field">
|
||||
<label className="shop-phone-label">用户手机号</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
placeholder="请输入用户手机号"
|
||||
value={phone}
|
||||
disabled={loading}
|
||||
onChange={(e) => {
|
||||
setPhone(e.target.value.replace(/\D/g, ''));
|
||||
setPrepared(null);
|
||||
setConfirmCode('');
|
||||
setConfirmCooldown(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">待核销金额</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
placeholder="请输入待核销金额"
|
||||
value={amount}
|
||||
disabled={loading}
|
||||
onChange={(e) => {
|
||||
setAmount(e.target.value);
|
||||
setPrepared(null);
|
||||
setConfirmCode('');
|
||||
setConfirmCooldown(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">核销验证码</label>
|
||||
<div className="shop-phone-code-row">
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="输入用户收到的验证码"
|
||||
value={confirmCode}
|
||||
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-phone-code-btn"
|
||||
disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode}
|
||||
onClick={() => void sendConfirmSms()}
|
||||
>
|
||||
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
|
||||
验证码将发送到用户手机号,验证成功后直接完成核销。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={loading || storeClosed || !prepared || !confirmCode.trim()}
|
||||
onClick={() => void confirmRedeem()}
|
||||
>
|
||||
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
|
||||
</button>
|
||||
|
||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel, type RedeemStatsDto } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function channelLabel(channel: unknown): string {
|
||||
const key = channel === 'PHONE' ? 'PHONE' : 'SCAN';
|
||||
return REDEEM_CHANNEL_LABELS[key];
|
||||
}
|
||||
|
||||
export default function RecordsPage() {
|
||||
useStorePageView('store_records_view');
|
||||
const [records, setRecords] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [range, setRange] = useState<RangeKey>('today');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [statsOpen, setStatsOpen] = useState(false);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [stats, setStats] = useState<RedeemStatsDto | null>(null);
|
||||
|
||||
const loadRecords = useCallback(() => {
|
||||
return Promise.all([
|
||||
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records?pageSize=200').then(
|
||||
(d) => {
|
||||
setRecords(d.list || []);
|
||||
},
|
||||
),
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => setStoreName(String(s.name || '')))
|
||||
.catch(() => {}),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const loadStats = useCallback(async (r: RangeKey) => {
|
||||
setStatsLoading(true);
|
||||
try {
|
||||
const data = await request<RedeemStatsDto>('SHOP_H5', `/shop/redeem/stats?range=${r}`);
|
||||
setStats(data);
|
||||
} catch {
|
||||
setStats(null);
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRecords();
|
||||
}, [loadRecords]);
|
||||
|
||||
useEffect(() => {
|
||||
if (statsOpen) void loadStats(range);
|
||||
}, [statsOpen, range, loadStats]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return records.filter((r) => {
|
||||
if (!inRange(String(r.createdAt), range)) return false;
|
||||
if (statusFilter === 'all') return true;
|
||||
const payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
|
||||
const isPaid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || 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 (
|
||||
<PullToRefresh onRefresh={loadRecords} 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-row">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-records-stats-btn"
|
||||
onClick={() => setStatsOpen(true)}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>
|
||||
bar_chart
|
||||
</span>
|
||||
统计
|
||||
</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 payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
|
||||
const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
||||
const paidAt = payout?.paidAt || (typeof r.paidAt === 'string' ? r.paidAt : null);
|
||||
const channel = (r.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel;
|
||||
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>{r.redeemNo ? String(r.redeemNo) : '—'}</span>
|
||||
</div>
|
||||
<p className="shop-record-time">
|
||||
核销时间:{' '}
|
||||
{new Date(String(r.createdAt))
|
||||
.toLocaleString('zh-CN', { hour12: false })
|
||||
.slice(0, 16)}
|
||||
</p>
|
||||
<p className="shop-record-channel">
|
||||
<span
|
||||
className={`shop-record-channel-tag${channel === 'PHONE' ? ' phone' : ''}`}
|
||||
>
|
||||
{channelLabel(channel)}
|
||||
</span>
|
||||
</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
|
||||
? `打款时间: ${paidAt ? new Date(String(paidAt)).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>
|
||||
|
||||
{statsOpen && (
|
||||
<div className="shop-stats-overlay" role="dialog" aria-modal="true" aria-label="核销方式统计">
|
||||
<button
|
||||
type="button"
|
||||
className="shop-stats-backdrop"
|
||||
aria-label="关闭"
|
||||
onClick={() => setStatsOpen(false)}
|
||||
/>
|
||||
<div className="shop-stats-sheet">
|
||||
<div className="shop-stats-sheet-head">
|
||||
<h2>核销方式统计</h2>
|
||||
<button type="button" className="shop-stats-close" onClick={() => setStatsOpen(false)}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="shop-stats-range-hint">
|
||||
统计区间:{range === 'today' ? '今日' : range === '7d' ? '近7日' : '近30日'}(与上方筛选同步)
|
||||
</p>
|
||||
{statsLoading ? (
|
||||
<p className="shop-records-empty">加载中…</p>
|
||||
) : !stats ? (
|
||||
<p className="shop-records-empty">统计加载失败</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="shop-stats-total">
|
||||
<div>
|
||||
<p className="shop-records-summary-label">合计笔数</p>
|
||||
<p className="shop-stats-total-value">{stats.totalCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-records-summary-label">核销总额</p>
|
||||
<p className="shop-stats-total-value">¥{formatMoney(stats.totalAmount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-records-summary-label">到账总额</p>
|
||||
<p className="shop-stats-total-value">¥{formatMoney(stats.totalSettleAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shop-stats-channel-list">
|
||||
{stats.byChannel.map((b) => (
|
||||
<div key={b.channel} className="shop-stats-channel-card">
|
||||
<div className="shop-stats-channel-title">
|
||||
<span
|
||||
className={`shop-record-channel-tag${b.channel === 'PHONE' ? ' phone' : ''}`}
|
||||
>
|
||||
{REDEEM_CHANNEL_LABELS[b.channel]}
|
||||
</span>
|
||||
<strong>{b.count} 笔</strong>
|
||||
</div>
|
||||
<div className="shop-stats-channel-row">
|
||||
<span>核销额</span>
|
||||
<span>¥{formatMoney(b.amount)}</span>
|
||||
</div>
|
||||
<div className="shop-stats-channel-row">
|
||||
<span>到账额</span>
|
||||
<span>¥{formatMoney(b.settleAmount)}</span>
|
||||
</div>
|
||||
<div className="shop-stats-bar">
|
||||
<div
|
||||
className={`shop-stats-bar-fill${b.channel === 'PHONE' ? ' phone' : ''}`}
|
||||
style={{
|
||||
width: `${stats.totalCount > 0 ? Math.round((b.count / stats.totalCount) * 100) : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
||||
import { request } from '../lib/api';
|
||||
import { reportRedeemFailure } from '../lib/redeem-failure';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
type Preview = {
|
||||
amount: number;
|
||||
expireInSeconds: number;
|
||||
user?: { userNo?: string; phone?: string; nickname?: string };
|
||||
redeemType?: string;
|
||||
boundStoreId?: string | null;
|
||||
};
|
||||
|
||||
export default function RedeemConfirmPage() {
|
||||
useStorePageView('store_redeem_confirm_view');
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [token, setToken] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [storeClosed, setStoreClosed] = useState(false);
|
||||
const [failCount, setFailCount] = useState(0);
|
||||
const [showWeakNet, setShowWeakNet] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => {
|
||||
setStoreName(String(s.name || '当前门店'));
|
||||
if (s.status && s.status !== 'OPEN') setStoreClosed(true);
|
||||
})
|
||||
.catch(() => setStoreName('当前门店'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const scanned = searchParams.get('token')?.trim();
|
||||
if (!scanned) {
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
setToken(scanned);
|
||||
}, [searchParams, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token.trim()) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
request<Preview>('SHOP_H5', '/shop/redeem/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch(async (e) => {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '无法预览核销码');
|
||||
const report = await reportRedeemFailure(token, 'preview', e);
|
||||
if (report?.thresholdReached) {
|
||||
setFailCount(report.failCount);
|
||||
setShowWeakNet(true);
|
||||
} else if (report) {
|
||||
setFailCount(report.failCount);
|
||||
}
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
async function confirm() {
|
||||
if (storeClosed) {
|
||||
setMsg('门店未营业,无法核销');
|
||||
return;
|
||||
}
|
||||
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, user: preview?.user } });
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||
const report = await reportRedeemFailure(token, 'confirm', e);
|
||||
if (report?.thresholdReached) {
|
||||
setFailCount(report.failCount);
|
||||
setShowWeakNet(true);
|
||||
} else if (report) {
|
||||
setFailCount(report.failCount);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const previewAmount = preview?.amount ?? 0;
|
||||
const userLabel = preview?.user?.nickname || preview?.user?.phone || '—';
|
||||
|
||||
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">
|
||||
{storeClosed && (
|
||||
<p className="shop-redeem-error" style={{ marginBottom: 12 }}>门店当前未营业,无法核销</p>
|
||||
)}
|
||||
<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 }}>
|
||||
{userLabel}
|
||||
</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>
|
||||
好客权益 · {preview?.redeemType === 'COUPON' ? '单据核销' : '直接核销'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="shop-redeem-details">
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>核销码 ID</span>
|
||||
<span style={{ wordBreak: 'break-all', maxWidth: '60%', textAlign: 'right' }}>
|
||||
{token || '扫码后显示'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>有效期</span>
|
||||
<span>{preview ? `${preview.expireInSeconds} 秒` : '—'}</span>
|
||||
</div>
|
||||
{preview?.boundStoreId && (
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>绑定门店</span>
|
||||
<span>仅限指定门店</span>
|
||||
</div>
|
||||
)}
|
||||
{failCount > 0 && !showWeakNet && (
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>网络失败</span>
|
||||
<span>{failCount} / 5 次</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||
|
||||
{!showWeakNet && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
|
||||
disabled={loading || storeClosed || !preview}
|
||||
onClick={() => void confirm()}
|
||||
>
|
||||
<span className="material-symbols-outlined shop-fill-icon">
|
||||
{loading ? 'sync' : 'check_circle'}
|
||||
</span>
|
||||
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '加载中…'}</span>
|
||||
</button>
|
||||
<p className="shop-redeem-hint">请核对金额后点击确认</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{showWeakNet && token && (
|
||||
<WeakNetFallbackPanel redeemToken={token} failCount={failCount} />
|
||||
)}
|
||||
|
||||
<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,100 @@
|
||||
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 user = (location.state as { user?: { nickname?: string; phone?: string; userNo?: string } })?.user;
|
||||
const userLabel = user?.nickname || user?.phone || '—';
|
||||
const amount = Number(result?.amount ?? 0);
|
||||
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">{userLabel}</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('/')}>
|
||||
<span>继续核销</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span>
|
||||
</button>
|
||||
<button type="button" className="shop-success-outline-btn" onClick={() => navigate('/')}>
|
||||
<span>返回首页</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>home</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shop-success-brand">
|
||||
<div className="shop-success-brand-icon">
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 12 }}>verified</span>
|
||||
</div>
|
||||
<p className="shop-success-brand-text">山西领势酒业有限责任公司</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import {
|
||||
needsStoreSelection,
|
||||
request,
|
||||
selectStore,
|
||||
type ShopSessionPayload,
|
||||
type ShopStoreOption,
|
||||
} from '../lib/api';
|
||||
|
||||
export default function SelectStorePage() {
|
||||
const navigate = useNavigate();
|
||||
const { applySession, store, authenticated } = useStoreSession();
|
||||
const [stores, setStores] = useState<ShopStoreOption[]>(store?.stores ?? []);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const currentStoreId = store?.storeId || '';
|
||||
const canGoBack = Boolean(currentStoreId);
|
||||
|
||||
const loadStores = useCallback(() => {
|
||||
return request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
|
||||
.then((list) => setStores(list))
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) {
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
void loadStores();
|
||||
}, [authenticated, navigate, loadStores]);
|
||||
|
||||
async function onSelect(storeId: string) {
|
||||
if (loadingId) return;
|
||||
if (storeId === currentStoreId) {
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
setLoadingId(storeId);
|
||||
setMsg('');
|
||||
try {
|
||||
const session = await selectStore(storeId);
|
||||
applySession(session);
|
||||
navigate('/', { replace: true });
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '选店失败');
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
// 仅一家店时自动选
|
||||
useEffect(() => {
|
||||
if (stores.length === 1 && needsStoreSelection({ store, stores })) {
|
||||
void onSelect(stores[0].storeId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stores.length]);
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'OPEN') return '营业中';
|
||||
if (status === 'PAUSED') return '临时闭店';
|
||||
if (status === 'CLOSED') return '永久关闭';
|
||||
return status || '门店';
|
||||
}
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadStores} className="shop-select-store-page">
|
||||
<header className="shop-subpage-header">
|
||||
{canGoBack ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-subpage-back"
|
||||
onClick={() => navigate('/mine')}
|
||||
aria-label="返回"
|
||||
>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="shop-subpage-header-spacer" />
|
||||
)}
|
||||
<h1 className="app-page-title">切换门店</h1>
|
||||
<span className="shop-subpage-header-spacer" />
|
||||
</header>
|
||||
|
||||
<div className="shop-subpage-content">
|
||||
<section className="shop-subpage-hero">
|
||||
<div className="shop-subpage-hero-icon">
|
||||
<span className="material-symbols-outlined shop-fill-icon">store</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="shop-subpage-hero-title">选择要进入的门店</h2>
|
||||
<p className="shop-subpage-hero-desc">
|
||||
该账号绑定了 {stores.length || '多'} 家门店,进入后可直接核销与查看营业数据
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{msg ? (
|
||||
<p className="shop-subpage-msg" role="alert">
|
||||
{msg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<h3 className="shop-subpage-section-title">我的门店</h3>
|
||||
<ul className="shop-select-store-list">
|
||||
{stores.map((item) => {
|
||||
const active = item.storeId === currentStoreId;
|
||||
const busy = loadingId === item.storeId;
|
||||
return (
|
||||
<li key={item.storeId}>
|
||||
<button
|
||||
type="button"
|
||||
className={`shop-select-store-item${active ? ' is-active' : ''}`}
|
||||
disabled={Boolean(loadingId)}
|
||||
onClick={() => void onSelect(item.storeId)}
|
||||
>
|
||||
<div className="shop-select-store-item-icon">
|
||||
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
|
||||
</div>
|
||||
<div className="shop-select-store-item-body">
|
||||
<div className="shop-select-store-item-top">
|
||||
<span className="shop-select-store-name">{item.name}</span>
|
||||
{active ? (
|
||||
<span className="shop-select-store-badge">当前</span>
|
||||
) : (
|
||||
<span className="shop-select-store-status">{statusLabel(item.status)}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="shop-select-store-meta">
|
||||
{[item.district, item.address].filter(Boolean).join(' · ') ||
|
||||
statusLabel(item.status)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shop-select-store-item-action">
|
||||
{busy ? (
|
||||
'进入中…'
|
||||
) : (
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{!stores.length && !msg ? (
|
||||
<div className="shop-subpage-empty">
|
||||
<span className="material-symbols-outlined">store</span>
|
||||
<p>暂无绑定门店</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
/** After login/wechat: route to select-store or home */
|
||||
export function routeAfterShopLogin(
|
||||
session: ShopSessionPayload,
|
||||
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
||||
) {
|
||||
if (needsStoreSelection(session)) {
|
||||
navigate('/select-store', { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { STORE_STAFF_ROLE_LABELS, type StoreStaffRole } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { getStoreProfile, request } from '../lib/api';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
type StaffItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
staffRole: StoreStaffRole;
|
||||
status: string;
|
||||
storeIds: string[];
|
||||
stores: Array<{ storeId: string; name: string }>;
|
||||
};
|
||||
|
||||
type StoreOption = { storeId: string; name: string };
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
if (!phone || phone.length < 7) return phone;
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
}
|
||||
|
||||
export default function StaffPage() {
|
||||
useStorePageView('store_staff_view');
|
||||
const navigate = useNavigate();
|
||||
const { store } = useStoreSession();
|
||||
const profile = store ?? getStoreProfile();
|
||||
const [list, setList] = useState<StaffItem[]>([]);
|
||||
const [ownedStores, setOwnedStores] = useState<StoreOption[]>([]);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form, setForm] = useState({ phone: '', name: '', storeIds: [] as string[] });
|
||||
|
||||
async function reload() {
|
||||
const [staff, stores] = await Promise.all([
|
||||
request<StaffItem[]>('SHOP_H5', '/shop/staff'),
|
||||
request<StoreOption[]>('SHOP_H5', '/shop/auth/stores'),
|
||||
]);
|
||||
setList(staff);
|
||||
setOwnedStores(stores);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile?.isPrimary) {
|
||||
navigate('/mine', { replace: true });
|
||||
return;
|
||||
}
|
||||
void reload().catch((e) => setMsg(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [navigate, profile?.isPrimary]);
|
||||
|
||||
async function createStaff() {
|
||||
const phone = form.phone.trim();
|
||||
const name = form.name.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
if (!name) {
|
||||
setMsg('请填写姓名');
|
||||
return;
|
||||
}
|
||||
setMsg('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/staff', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
phone,
|
||||
name,
|
||||
storeIds: form.storeIds.length ? form.storeIds : ownedStores.map((s) => s.storeId),
|
||||
}),
|
||||
});
|
||||
setShowForm(false);
|
||||
setForm({ phone: '', name: '', storeIds: [] });
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '创建失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(item: StaffItem) {
|
||||
const next = item.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE';
|
||||
try {
|
||||
await request('SHOP_H5', `/shop/staff/${item.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStoreId(storeId: string) {
|
||||
setForm((prev) => {
|
||||
const allIds = ownedStores.map((s) => s.storeId);
|
||||
// 空数组表示「全部」;取消某一家时转为「除该店外的全部」
|
||||
if (prev.storeIds.length === 0) {
|
||||
return { ...prev, storeIds: allIds.filter((id) => id !== storeId) };
|
||||
}
|
||||
const next = prev.storeIds.includes(storeId)
|
||||
? prev.storeIds.filter((id) => id !== storeId)
|
||||
: [...prev.storeIds, storeId];
|
||||
// 又选回全部时,恢复默认空数组语义
|
||||
if (next.length === allIds.length) {
|
||||
return { ...prev, storeIds: [] };
|
||||
}
|
||||
return { ...prev, storeIds: next };
|
||||
});
|
||||
}
|
||||
|
||||
const selectedCount =
|
||||
form.storeIds.length === 0 ? ownedStores.length : form.storeIds.length;
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={reload} className="shop-staff-page">
|
||||
<header className="shop-subpage-header">
|
||||
<button
|
||||
type="button"
|
||||
className="shop-subpage-back"
|
||||
onClick={() => {
|
||||
if (showForm) {
|
||||
setShowForm(false);
|
||||
setMsg('');
|
||||
return;
|
||||
}
|
||||
navigate('/mine');
|
||||
}}
|
||||
aria-label="返回"
|
||||
>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className="app-page-title">{showForm ? '添加子账号' : '子账号管理'}</h1>
|
||||
{!showForm ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-subpage-header-action"
|
||||
onClick={() => {
|
||||
setShowForm(true);
|
||||
setMsg('');
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">person_add</span>
|
||||
添加
|
||||
</button>
|
||||
) : (
|
||||
<span className="shop-subpage-header-spacer" />
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="shop-subpage-content">
|
||||
{!showForm ? (
|
||||
<section className="shop-subpage-hero shop-subpage-hero--compact">
|
||||
<div className="shop-subpage-hero-icon">
|
||||
<span className="material-symbols-outlined shop-fill-icon">group</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="shop-subpage-hero-title">门店员工子账号</h2>
|
||||
<p className="shop-subpage-hero-desc">
|
||||
共 {list.length} 人 · 可授权员工用手机号登录门店端进行核销
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{msg ? (
|
||||
<p className="shop-subpage-msg" role="alert">
|
||||
{msg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{showForm ? (
|
||||
<section className="shop-staff-form-card">
|
||||
<div className="shop-staff-form-field">
|
||||
<label className="shop-staff-form-label" htmlFor="staff-phone">
|
||||
手机号
|
||||
</label>
|
||||
<div className="shop-staff-form-input-wrap">
|
||||
<span className="material-symbols-outlined">smartphone</span>
|
||||
<input
|
||||
id="staff-phone"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={11}
|
||||
placeholder="员工登录手机号"
|
||||
value={form.phone}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, phone: e.target.value.replace(/\D/g, '').slice(0, 11) }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shop-staff-form-field">
|
||||
<label className="shop-staff-form-label" htmlFor="staff-name">
|
||||
姓名
|
||||
</label>
|
||||
<div className="shop-staff-form-input-wrap">
|
||||
<span className="material-symbols-outlined">badge</span>
|
||||
<input
|
||||
id="staff-name"
|
||||
type="text"
|
||||
placeholder="员工姓名"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shop-staff-form-field">
|
||||
<p className="shop-staff-form-label">
|
||||
绑定门店
|
||||
<span className="shop-staff-form-hint">
|
||||
{form.storeIds.length === 0
|
||||
? `(默认全部 ${ownedStores.length} 家)`
|
||||
: `(已选 ${selectedCount} 家)`}
|
||||
</span>
|
||||
</p>
|
||||
<div className="shop-staff-store-picks">
|
||||
{ownedStores.map((s) => {
|
||||
const checked =
|
||||
form.storeIds.length === 0 || form.storeIds.includes(s.storeId);
|
||||
return (
|
||||
<label
|
||||
key={s.storeId}
|
||||
className={`shop-staff-store-pick${checked ? ' is-checked' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleStoreId(s.storeId)}
|
||||
/>
|
||||
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
|
||||
<span className="shop-staff-store-pick-name">{s.name}</span>
|
||||
{checked ? (
|
||||
<span className="material-symbols-outlined shop-staff-store-pick-check">
|
||||
check_circle
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shop-staff-submit"
|
||||
disabled={submitting}
|
||||
onClick={() => void createStaff()}
|
||||
>
|
||||
{submitting ? '创建中…' : '创建子账号'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-staff-cancel"
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setMsg('');
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</section>
|
||||
) : (
|
||||
<section>
|
||||
<div className="shop-home-records-head">
|
||||
<h3 className="shop-home-records-title">账号列表</h3>
|
||||
</div>
|
||||
<ul className="shop-staff-list">
|
||||
{list.map((item) => {
|
||||
const active = item.status === 'ACTIVE';
|
||||
const initial = (item.name || item.phone || '?').slice(0, 1);
|
||||
return (
|
||||
<li key={item.id} className="shop-staff-item">
|
||||
<div className="shop-staff-avatar">{initial}</div>
|
||||
<div className="shop-staff-item-body">
|
||||
<div className="shop-staff-item-top">
|
||||
<strong>{item.name || '未命名'}</strong>
|
||||
<span
|
||||
className={`shop-staff-status-badge${active ? ' is-active' : ' is-disabled'}`}
|
||||
>
|
||||
{active ? '启用' : '停用'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="shop-staff-item-meta">{maskPhone(item.phone)}</p>
|
||||
<p className="shop-staff-item-meta">
|
||||
{STORE_STAFF_ROLE_LABELS[item.staffRole] ?? item.staffRole}
|
||||
{item.stores.length
|
||||
? ` · ${item.stores.map((s) => s.name).join('、')}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`shop-staff-toggle${active ? '' : ' is-enable'}`}
|
||||
onClick={() => void toggleStatus(item)}
|
||||
>
|
||||
{active ? '停用' : '启用'}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{!list.length ? (
|
||||
<div className="shop-subpage-empty">
|
||||
<span className="material-symbols-outlined">person_off</span>
|
||||
<p>暂无子账号</p>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-staff-empty-add"
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
添加第一个子账号
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
function formatShopHours(store: Record<string, unknown> | null) {
|
||||
const parts: string[] = [];
|
||||
const openTime = store?.openTime ? String(store.openTime) : '';
|
||||
const closeTime = store?.closeTime ? String(store.closeTime) : '';
|
||||
const openTime2 = store?.openTime2 ? String(store.openTime2) : '';
|
||||
const closeTime2 = store?.closeTime2 ? String(store.closeTime2) : '';
|
||||
if (openTime && closeTime) parts.push(`${openTime} - ${closeTime}`);
|
||||
if (openTime2 && closeTime2) parts.push(`${openTime2} - ${closeTime2}`);
|
||||
return parts.length ? parts.join(',') : '09:30 - 22:00';
|
||||
}
|
||||
|
||||
export default function StatusPage() {
|
||||
const navigate = useNavigate();
|
||||
const { resetSession } = useStoreSession();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [permanentlyClosed, setPermanentlyClosed] = useState(false);
|
||||
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);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request('SHOP_H5', '/shop/dashboard').then((d) => {
|
||||
const s = d.store as Record<string, unknown>;
|
||||
setStore(s);
|
||||
const status = String(s?.status || '');
|
||||
setPermanentlyClosed(status === 'CLOSED');
|
||||
setOpen(status === 'OPEN');
|
||||
if (s?.updatedAt) {
|
||||
setLastUpdate(new Date(String(s.updatedAt)).toLocaleString('zh-CN'));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
function requestToggle(next: boolean) {
|
||||
if (permanentlyClosed) return;
|
||||
if (next === open) return;
|
||||
setPendingOpen(next);
|
||||
setShowModal(true);
|
||||
}
|
||||
|
||||
async function confirmToggle() {
|
||||
if (pendingOpen === null || permanentlyClosed) return;
|
||||
const next = pendingOpen ? 'OPEN' : 'PAUSED';
|
||||
try {
|
||||
setError('');
|
||||
await request('SHOP_H5', '/shop/store/status', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
setOpen(pendingOpen);
|
||||
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '状态切换失败');
|
||||
} finally {
|
||||
setShowModal(false);
|
||||
setPendingOpen(null);
|
||||
}
|
||||
}
|
||||
|
||||
const hoursText = formatShopHours(store);
|
||||
const statusLabel = permanentlyClosed ? '永久关闭' : open ? '营业中' : '临时闭店';
|
||||
|
||||
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={() => { resetSession(); 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 && !permanentlyClosed ? ' open' : ' closed'}`}>
|
||||
<div className={`shop-status-icon-inner${open && !permanentlyClosed ? ' open' : ' closed'}`}>
|
||||
<span className="material-symbols-outlined">storefront</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shop-status-check">
|
||||
<span
|
||||
className="material-symbols-outlined shop-fill-icon"
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: open && !permanentlyClosed ? 'var(--color-success-green)' : 'var(--color-subtle-gray)',
|
||||
}}
|
||||
>
|
||||
{open && !permanentlyClosed ? 'check_circle' : 'cancel'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className={`shop-status-label${open && !permanentlyClosed ? ' open' : ' closed'}`}>
|
||||
{statusLabel}
|
||||
</h2>
|
||||
|
||||
{permanentlyClosed ? (
|
||||
<p className="shop-status-switch-caption" style={{ marginTop: 12 }}>
|
||||
总部已永久关闭本店,门店端无法自行恢复营业
|
||||
</p>
|
||||
) : (
|
||||
<label className={`shop-status-switch${open ? ' open' : ' closed'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={open}
|
||||
onChange={(e) => requestToggle(e.target.checked)}
|
||||
aria-label={open ? '切换为临时闭店' : '切换为营业中'}
|
||||
/>
|
||||
<span className="shop-status-switch-track" />
|
||||
<span className="shop-status-switch-caption">
|
||||
{open ? '点击可临时闭店' : '点击恢复营业'}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<p className="shop-status-hours-label">营业时间</p>
|
||||
<p className="shop-status-hours">{hoursText}</p>
|
||||
{lastUpdate && <p className="shop-status-updated">最后修改于 {lastUpdate}</p>}
|
||||
{error ? <p className="shop-status-updated" style={{ color: 'var(--color-error, #c62828)' }}>{error}</p> : null}
|
||||
</section>
|
||||
|
||||
<div className={`shop-status-hint${open && !permanentlyClosed ? ' open' : ' closed'}`}>
|
||||
<span className="material-symbols-outlined">info</span>
|
||||
<p>
|
||||
{permanentlyClosed
|
||||
? '门店已永久关闭。如需重新营业,请联系总部或合伙人处理。'
|
||||
: open
|
||||
? '当前处于营业状态,用户可在您的门店核销餐券。'
|
||||
: '当前处于临时闭店状态,用户将无法看到您的门店或进行核销。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="shop-status-modal" role="dialog" aria-modal="true">
|
||||
<div className="shop-status-modal-card">
|
||||
<h4 className="shop-status-modal-title">确认切换状态?</h4>
|
||||
<p className="shop-status-modal-desc">
|
||||
{pendingOpen
|
||||
? '切换至“营业中”后,用户可正常选择本店核销餐券。'
|
||||
: '切换至“临时闭店”后,用户将无法选择本店核销餐券。'}
|
||||
</p>
|
||||
<div className="shop-status-modal-actions">
|
||||
<button type="button" className="shop-status-modal-cancel" onClick={() => { setShowModal(false); setPendingOpen(null); }}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="shop-status-modal-confirm" onClick={confirmToggle}>
|
||||
确认切换
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
STORE_WITHDRAW_STATUS_LABELS,
|
||||
type StoreWithdrawRequestDto,
|
||||
type StoreWithdrawStatus,
|
||||
type StoreWithdrawSummaryDto,
|
||||
} from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { request } from '../lib/api';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
type StatusFilter = 'all' | StoreWithdrawStatus;
|
||||
|
||||
function formatMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function WithdrawPage() {
|
||||
useStorePageView('store_withdraw_view');
|
||||
const navigate = useNavigate();
|
||||
const [summary, setSummary] = useState<StoreWithdrawSummaryDto | null>(null);
|
||||
const [items, setItems] = useState<StoreWithdrawRequestDto[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [summaryRes, listRes] = await Promise.all([
|
||||
request<StoreWithdrawSummaryDto>('SHOP_H5', '/shop/withdraw/summary'),
|
||||
request<{ items: StoreWithdrawRequestDto[] }>('SHOP_H5', '/shop/withdraw/requests?pageSize=50'),
|
||||
]);
|
||||
setSummary(summaryRes);
|
||||
setItems(listRes.items || []);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '加载失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (statusFilter === 'all') return items;
|
||||
return items.filter((r) => r.status === statusFilter);
|
||||
}, [items, statusFilter]);
|
||||
|
||||
async function applyWithdraw() {
|
||||
if (!summary || submitting) return;
|
||||
if (!summary.isPrimary) {
|
||||
setMsg('仅主账号可申请提现');
|
||||
return;
|
||||
}
|
||||
if (!(summary.availableAmount > 0)) {
|
||||
setMsg('暂无可提未出账余额');
|
||||
return;
|
||||
}
|
||||
const ok = window.confirm(
|
||||
`确认申请提现 ¥${formatMoney(summary.availableAmount)}?\n审核通过后将打款至入驻收款账户。`,
|
||||
);
|
||||
if (!ok) return;
|
||||
setSubmitting(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/withdraw', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
setMsg('提现申请已提交,请等待总部审核');
|
||||
await load();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '提现申请失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canApply =
|
||||
!!summary?.isPrimary &&
|
||||
summary.availableAmount > 0 &&
|
||||
!summary.hasPendingRequest &&
|
||||
summary.hasBankAccount &&
|
||||
!submitting;
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={load} className="shop-records-page shop-withdraw-page">
|
||||
<header className="shop-records-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-withdraw-back"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label="返回"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
||||
arrow_back
|
||||
</span>
|
||||
</button>
|
||||
<h1 className="app-page-title" style={{ margin: 0 }}>
|
||||
结算提现
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="shop-records-main">
|
||||
<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?.availableAmount ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-records-summary-label">今日剩余额度</p>
|
||||
<p className="shop-records-summary-value">
|
||||
¥ {formatMoney(summary?.remainingDailyLimit ?? 0)}
|
||||
</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)' }}
|
||||
>
|
||||
info
|
||||
</span>
|
||||
单日上限 ¥{formatMoney(summary?.dailyLimit ?? 5000)}
|
||||
{summary?.hasPendingRequest ? ' · 已有待审核申请' : ''}
|
||||
{!summary?.hasBankAccount ? ' · 请先完善收款账户' : ''}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{summary && !summary.isPrimary ? (
|
||||
<p className="shop-records-empty">仅主账号可申请提现,店员可查看记录</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-withdraw-btn"
|
||||
disabled={!canApply}
|
||||
onClick={() => void applyWithdraw()}
|
||||
>
|
||||
{submitting ? '提交中…' : '申请提现'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{msg ? <p className="shop-withdraw-msg">{msg}</p> : null}
|
||||
|
||||
<nav className="shop-records-filters" style={{ marginTop: 16 }}>
|
||||
<div className="shop-records-status-chips">
|
||||
{(
|
||||
[
|
||||
['all', '全部'],
|
||||
['PENDING_REVIEW', '待审核'],
|
||||
['PAID', '已结算'],
|
||||
['REJECTED', '已驳回'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`shop-records-chip${statusFilter === key ? ' active' : ''}`}
|
||||
onClick={() => setStatusFilter(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<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 status = r.status as StoreWithdrawStatus;
|
||||
const badgeClass =
|
||||
status === 'PAID' ? 'paid' : status === 'REJECTED' ? 'rejected' : 'pending';
|
||||
return (
|
||||
<article key={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>{r.withdrawNo}</span>
|
||||
</div>
|
||||
<p className="shop-record-time">
|
||||
申请时间:{' '}
|
||||
{new Date(r.appliedAt)
|
||||
.toLocaleString('zh-CN', { hour12: false })
|
||||
.slice(0, 16)}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`shop-record-badge ${badgeClass}`}>
|
||||
{STORE_WITHDRAW_STATUS_LABELS[status] ?? status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shop-record-amounts">
|
||||
<div>
|
||||
<p className="shop-record-amount-label">提现金额</p>
|
||||
<p className="shop-record-amount-value red">¥{formatMoney(Number(r.amount))}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-record-amount-label">明细笔数</p>
|
||||
<p className="shop-record-amount-value">{r.payoutCount} 笔</p>
|
||||
</div>
|
||||
</div>
|
||||
{status === 'REJECTED' && r.rejectReason ? (
|
||||
<div className="shop-record-footer">
|
||||
<p>驳回原因: {r.rejectReason}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{status === 'PAID' && r.paidAt ? (
|
||||
<div className="shop-record-footer">
|
||||
<p>
|
||||
结算时间:{' '}
|
||||
{new Date(r.paidAt).toLocaleString('zh-CN', { hour12: false }).slice(0, 16)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
.legal-h5-page {
|
||||
min-height: 100vh;
|
||||
background: #faf9f7;
|
||||
color: #1f1a17;
|
||||
}
|
||||
|
||||
.legal-h5-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 52px;
|
||||
padding: 0 16px;
|
||||
background: #faf9f7;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.legal-h5-back {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
color: #1f1a17;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.legal-h5-title {
|
||||
margin: 0;
|
||||
/* 与微信系统标题重复,仅保留顶栏高度与返回 */
|
||||
visibility: hidden;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.legal-h5-body {
|
||||
padding: 16px 20px 40px;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.legal-h5-updated {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
color: #8d706e;
|
||||
}
|
||||
|
||||
.legal-h5-intro {
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.legal-h5-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.legal-h5-section h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.legal-h5-section p {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #5c504c;
|
||||
}
|
||||
Reference in New Issue
Block a user