feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代
This commit is contained in:
@@ -1,37 +0,0 @@
|
||||
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}</>;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user