feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 deletions
+37
View File
@@ -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>
);
}