feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import AuthGate from './components/AuthGate';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import LegalPage from './pages/LegalPage';
|
||||
import PartnerAppRoutes from './PartnerAppRoutes';
|
||||
|
||||
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="/*" element={<PartnerAppRoutes />} />
|
||||
</Routes>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { usePartnerSession } from './contexts/PartnerSessionContext';
|
||||
import { getPartnerNavKind, isSubAccount } from './lib/partnerAccess';
|
||||
import TabLayout from './layouts/TabLayout';
|
||||
import SubAccountLayout from './layouts/SubAccountLayout';
|
||||
import HomePage from './pages/HomePage';
|
||||
import StoreListPage from './pages/StoreListPage';
|
||||
import StoreDetailPage from './pages/StoreDetailPage';
|
||||
import StorePackagesPage from './pages/StorePackagesPage';
|
||||
import StoreCreatePage from './pages/StoreCreatePage';
|
||||
import OrderListPage from './pages/OrderListPage';
|
||||
import OrderDetailPage from './pages/OrderDetailPage';
|
||||
import CenterPage from './pages/CenterPage';
|
||||
import PartnerMePage from './pages/PartnerMePage';
|
||||
import BillsPage from './pages/BillsPage';
|
||||
import SettlementPage from './pages/SettlementPage';
|
||||
import WeeklyReportPage from './pages/WeeklyReportPage';
|
||||
import ProxyOrderPage from './pages/ProxyOrderPage';
|
||||
import ProxyOrderProductsPage from './pages/ProxyOrderProductsPage';
|
||||
import ProxyOrderListPage from './pages/ProxyOrderListPage';
|
||||
import ProxyOrderDetailPage from './pages/ProxyOrderDetailPage';
|
||||
import StaffListPage from './pages/StaffListPage';
|
||||
import StaffCreatePage from './pages/StaffCreatePage';
|
||||
import LeaderboardPage from './pages/LeaderboardPage';
|
||||
|
||||
function PrimaryRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<TabLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/stores" element={<StoreListPage />} />
|
||||
<Route path="/center" element={<CenterPage />} />
|
||||
</Route>
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="/stores/:id/packages" element={<StorePackagesPage />} />
|
||||
<Route path="/center/bills" element={<BillsPage />} />
|
||||
<Route path="/center/settlement" element={<SettlementPage />} />
|
||||
<Route path="/center/staff" element={<StaffListPage />} />
|
||||
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
||||
<Route path="/center/proxy-orders" element={<ProxyOrderListPage />} />
|
||||
<Route path="/center/proxy-orders/:id" element={<ProxyOrderDetailPage />} />
|
||||
<Route path="/proxy-order" element={<ProxyOrderPage />} />
|
||||
<Route path="/proxy-order/products" element={<ProxyOrderProductsPage />} />
|
||||
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
<Route path="/orders" element={<OrderListPage />} />
|
||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function SubAccountRoutes() {
|
||||
const { account } = usePartnerSession();
|
||||
const navKind = getPartnerNavKind(account);
|
||||
const isWarehouse = navKind === 'warehouse_staff';
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<SubAccountLayout navKind={navKind} />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
{isWarehouse ? (
|
||||
<Route path="/orders" element={<OrderListPage tabRoot />} />
|
||||
) : (
|
||||
<Route path="/stores" element={<StoreListPage />} />
|
||||
)}
|
||||
<Route path="/me" element={<PartnerMePage />} />
|
||||
</Route>
|
||||
{!isWarehouse && (
|
||||
<>
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="/stores/:id/packages" element={<StorePackagesPage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
</>
|
||||
)}
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
{isWarehouse && <Route path="/orders/:id" element={<OrderDetailPage />} />}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PartnerAppRoutes() {
|
||||
const { account, authenticated } = usePartnerSession();
|
||||
|
||||
if (!authenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isSubAccount(account)) {
|
||||
return <SubAccountRoutes />;
|
||||
}
|
||||
return <PrimaryRoutes />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { getPartnerProfile, hasPartnerWxSession } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { partnerHomePath } from '../lib/partnerAccess';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
|
||||
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const { ready, authenticated, account } = usePartnerSession();
|
||||
const location = useLocation();
|
||||
|
||||
if (!ready) {
|
||||
return <div className="empty">加载中...</div>;
|
||||
}
|
||||
|
||||
if (authenticated && location.pathname === '/login') {
|
||||
return <Navigate to={partnerHomePath(account ?? getPartnerProfile())} replace />;
|
||||
}
|
||||
|
||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||
const profile = getPartnerProfile();
|
||||
if (profile && hasPartnerWxSession() && profile.hasWechat) {
|
||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
||||
}
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { codeToText } from 'element-china-area-data';
|
||||
import {
|
||||
CHINA_REGION_OPTIONS,
|
||||
DEFAULT_REGION_CODES,
|
||||
formatRegionLabel,
|
||||
parseRegionCodes,
|
||||
} from '../lib/china-region';
|
||||
|
||||
type RegionNode = {
|
||||
value: string;
|
||||
label: string;
|
||||
children?: RegionNode[];
|
||||
};
|
||||
|
||||
type ChinaRegionPickerProps = {
|
||||
value?: string[];
|
||||
onChange?: (codes: string[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const STEP_LABELS = ['选择省份', '选择城市', '选择区县'] as const;
|
||||
|
||||
function lockBodyScroll(lock: boolean) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const body = document.body;
|
||||
if (lock) {
|
||||
const scrollY = window.scrollY;
|
||||
body.dataset.dukangScrollY = String(scrollY);
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${scrollY}px`;
|
||||
body.style.left = '0';
|
||||
body.style.right = '0';
|
||||
body.style.overflow = 'hidden';
|
||||
} else {
|
||||
const scrollY = Number(body.dataset.dukangScrollY ?? '0');
|
||||
body.style.position = '';
|
||||
body.style.top = '';
|
||||
body.style.left = '';
|
||||
body.style.right = '';
|
||||
body.style.overflow = '';
|
||||
delete body.dataset.dukangScrollY;
|
||||
window.scrollTo(0, scrollY);
|
||||
}
|
||||
}
|
||||
|
||||
function buildInitialDraft(value: string[]): { draft: string[]; step: number } {
|
||||
if (value.length === 3) {
|
||||
return { draft: [...value], step: 2 };
|
||||
}
|
||||
if (value.length > 0) {
|
||||
const draft = [...value];
|
||||
for (let i = value.length; i < DEFAULT_REGION_CODES.length; i += 1) {
|
||||
draft.push(DEFAULT_REGION_CODES[i]);
|
||||
}
|
||||
return { draft, step: Math.min(value.length, 2) };
|
||||
}
|
||||
return { draft: [...DEFAULT_REGION_CODES], step: 1 };
|
||||
}
|
||||
|
||||
export default function ChinaRegionPicker({ value = [], onChange, disabled }: ChinaRegionPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string[]>(value);
|
||||
const [step, setStep] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const activeItemRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const options = CHINA_REGION_OPTIONS as RegionNode[];
|
||||
const parsed = parseRegionCodes(value);
|
||||
const display = parsed ? formatRegionLabel(parsed) : '请选择省 / 市 / 区县';
|
||||
|
||||
const provinces = options;
|
||||
const cities = useMemo(() => {
|
||||
const p = provinces.find((item) => item.value === draft[0]);
|
||||
return p?.children ?? [];
|
||||
}, [draft, provinces]);
|
||||
|
||||
const districts = useMemo(() => {
|
||||
const c = cities.find((item) => item.value === draft[1]);
|
||||
return c?.children ?? [];
|
||||
}, [draft, cities]);
|
||||
|
||||
const listItems = step === 0 ? provinces : step === 1 ? cities : districts;
|
||||
const activeCode = draft[step];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
lockBodyScroll(true);
|
||||
return () => lockBodyScroll(false);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
const container = listRef.current;
|
||||
const activeEl = activeItemRef.current;
|
||||
if (!container || !activeEl) return;
|
||||
const offset = activeEl.offsetTop - container.clientHeight / 2 + activeEl.clientHeight / 2;
|
||||
container.scrollTo({ top: Math.max(0, offset), behavior: 'smooth' });
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open, step, activeCode, listItems]);
|
||||
|
||||
function openPicker() {
|
||||
if (disabled) return;
|
||||
const { draft: next, step: initialStep } = buildInitialDraft(value);
|
||||
setDraft(next);
|
||||
setStep(initialStep);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function selectItem(code: string) {
|
||||
const next = [...draft.slice(0, step), code];
|
||||
setDraft(next);
|
||||
if (step < 2) {
|
||||
setStep(step + 1);
|
||||
return;
|
||||
}
|
||||
onChange?.(next);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (draft.length < 3) return;
|
||||
onChange?.(draft);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
const modal = open ? (
|
||||
<div className="partner-region-overlay" role="presentation" onClick={closePicker}>
|
||||
<div
|
||||
className="partner-region-sheet"
|
||||
role="dialog"
|
||||
aria-modal
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="partner-region-sheet-header">
|
||||
<button type="button" className="partner-region-sheet-btn" onClick={closePicker}>取消</button>
|
||||
<span className="headline-md">选择地区</span>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-region-sheet-btn partner-region-sheet-btn--primary"
|
||||
disabled={draft.length < 3}
|
||||
onClick={confirm}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="partner-region-steps">
|
||||
{STEP_LABELS.map((label, index) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={`partner-region-step${step === index ? ' partner-region-step--active' : ''}${draft[index] ? ' partner-region-step--done' : ''}`}
|
||||
disabled={index === 1 && !draft[0] || index === 2 && !draft[1]}
|
||||
onClick={() => {
|
||||
if (index === 1 && !draft[0]) return;
|
||||
if (index === 2 && !draft[1]) return;
|
||||
setStep(index);
|
||||
}}
|
||||
>
|
||||
<span className="partner-region-step-index">{index + 1}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{draft[0] && (
|
||||
<p className="partner-region-breadcrumb label-md text-muted">
|
||||
{codeToText[draft[0]]}
|
||||
{draft[1] ? ` / ${codeToText[draft[1]]}` : ''}
|
||||
{draft[2] ? ` / ${codeToText[draft[2]]}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="partner-region-list" role="listbox" ref={listRef}>
|
||||
{listItems.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={activeCode === item.value}
|
||||
ref={activeCode === item.value ? (el) => { activeItemRef.current = el; } : undefined}
|
||||
className={`partner-region-item${activeCode === item.value ? ' partner-region-item--active' : ''}`}
|
||||
onClick={() => selectItem(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="partner-field-input partner-region-trigger" onClick={openPicker} disabled={disabled}>
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span className={parsed ? '' : 'text-muted'}>{display}</span>
|
||||
<span className="material-symbols-outlined partner-region-chevron">expand_more</span>
|
||||
</button>
|
||||
{typeof document !== 'undefined' ? createPortal(modal, document.body) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||
import { enqueueUpload } from '../lib/upload-lock';
|
||||
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import { toastError } from '../lib/toast';
|
||||
|
||||
type OssUploadFieldProps = {
|
||||
value?: string;
|
||||
onChange?: (url: string) => void;
|
||||
bizType: string;
|
||||
mediaType?: OssMediaType;
|
||||
accept?: string;
|
||||
wide?: boolean;
|
||||
compact?: boolean;
|
||||
label?: string;
|
||||
/** 兼容现有调用:选图不再依赖 openId 绑定 */
|
||||
wechatReady?: boolean;
|
||||
onWechatReadyChange?: (ready: boolean) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_MB = 10;
|
||||
|
||||
function isCancelError(msg: string): boolean {
|
||||
return /cancel|取消/i.test(msg);
|
||||
}
|
||||
|
||||
function formatWechatUploadError(e: unknown): string {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
const formatted = formatChooseImageFailMessage(msg);
|
||||
if (formatted) return formatted;
|
||||
if (/invalid signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
function acceptsImages(accept: string) {
|
||||
return accept.includes('image');
|
||||
}
|
||||
|
||||
export default function OssUploadField({
|
||||
value,
|
||||
onChange,
|
||||
bizType,
|
||||
mediaType = 'IMAGE',
|
||||
accept,
|
||||
wide,
|
||||
compact,
|
||||
label,
|
||||
}: OssUploadFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const pickingRef = useRef(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
|
||||
|
||||
function showUploadError(text: string) {
|
||||
setError(text);
|
||||
toastError(text);
|
||||
}
|
||||
|
||||
const resolvedAccept =
|
||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
|
||||
const inWechat = isWechatEnv();
|
||||
const useWechatPicker =
|
||||
inWechat && (mediaType === 'IMAGE' || (mediaType === 'FILE' && acceptsImages(resolvedAccept)));
|
||||
|
||||
useEffect(() => {
|
||||
if (!useWechatPicker) return;
|
||||
// 仅预热,勿在每次点击时 reset,否则取消后再点常无法调起
|
||||
void weixinSdk.init().catch(() => {
|
||||
/* 点击上传时会再次初始化 */
|
||||
});
|
||||
}, [useWechatPicker]);
|
||||
|
||||
async function persistUpload(file: File) {
|
||||
if (!file.size) {
|
||||
showUploadError('图片读取失败,请重新选择');
|
||||
return;
|
||||
}
|
||||
if (file.size > DEFAULT_MAX_MB * 1024 * 1024) {
|
||||
showUploadError(`文件不能超过 ${DEFAULT_MAX_MB}MB`);
|
||||
return;
|
||||
}
|
||||
const result = await uploadFileToOss(file, { bizType, mediaType });
|
||||
onChange?.(result.url);
|
||||
}
|
||||
|
||||
async function uploadSelectedFile(file: File) {
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
await enqueueUpload(() => persistUpload(file));
|
||||
} catch (e) {
|
||||
showUploadError(e instanceof Error ? e.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function pickWechatImage() {
|
||||
if (pickingRef.current || uploading) return;
|
||||
pickingRef.current = true;
|
||||
setError('');
|
||||
try {
|
||||
// 不要每次 reset:会打断 JSSDK,取消后再点经常无法调起相机/相册
|
||||
await weixinSdk.init();
|
||||
const files = await weixinSdk.chooseImages({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
});
|
||||
// 取消或未选图:直接结束,允许再次点击
|
||||
if (!files?.length) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
// 仅串行化实际上传,选图本身不占上传锁,避免取消后锁态异常
|
||||
await enqueueUpload(() => persistUpload(files[0]!));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
if (isCancelError(msg)) return;
|
||||
throw e;
|
||||
} finally {
|
||||
pickingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFile() {
|
||||
if (uploading || pickingRef.current) return;
|
||||
setError('');
|
||||
|
||||
if (useWechatPicker) {
|
||||
try {
|
||||
await pickWechatImage();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
if (isCancelError(msg)) return;
|
||||
const formatted = formatWechatUploadError(e);
|
||||
setError(`${formatted},可改从系统相册选择`);
|
||||
setShowAlbumFallback(true);
|
||||
inputRef.current?.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.current?.click();
|
||||
}
|
||||
|
||||
const isImage = mediaType === 'IMAGE' && value;
|
||||
const isFile = mediaType === 'FILE' && value;
|
||||
const busy = uploading;
|
||||
const pickerLabel = label ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传');
|
||||
|
||||
const triggerProps = {
|
||||
type: 'button' as const,
|
||||
disabled: busy,
|
||||
onClick: () => void pickFile(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="partner-oss-upload">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={resolvedAccept}
|
||||
className="partner-oss-upload-input"
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setShowAlbumFallback(false);
|
||||
void uploadSelectedFile(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isImage ? (
|
||||
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
|
||||
<img src={value} alt={label ?? '已上传'} />
|
||||
<span className="partner-upload-preview-mask">
|
||||
<span className="material-symbols-outlined">{busy ? 'hourglass_top' : 'edit'}</span>
|
||||
<span>{uploading ? '上传中…' : '更换'}</span>
|
||||
</span>
|
||||
</button>
|
||||
) : isFile ? (
|
||||
<button {...triggerProps} className="partner-upload-file">
|
||||
<div className="partner-bills-icon" style={{ background: '#ffb3ae' }}>
|
||||
<span className="material-symbols-outlined text-primary">description</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<span className="text-primary" style={{ fontWeight: 700, display: 'block' }}>已上传合同</span>
|
||||
<span className="label-md text-muted">{uploading ? '上传中…' : '点击更换'}</span>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
{...triggerProps}
|
||||
className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: compact ? 28 : 36 }}>
|
||||
{busy ? 'hourglass_top' : 'add_a_photo'}
|
||||
</span>
|
||||
<span className="text-primary" style={{ fontWeight: 500 }}>
|
||||
{uploading ? '上传中…' : pickerLabel}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{showAlbumFallback && useWechatPicker && (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
style={{ marginTop: 8, width: '100%' }}
|
||||
disabled={busy}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
从系统相册选择
|
||||
</button>
|
||||
)}
|
||||
{error && <p className="partner-form-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
import type { PackageFormItem } from '../lib/storePackages';
|
||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
import { emptyPackage } from '../lib/storePackages';
|
||||
|
||||
type Props = {
|
||||
items: PackageFormItem[];
|
||||
onChange: (items: PackageFormItem[]) => void;
|
||||
disabled?: boolean;
|
||||
/** 嵌入拓店流程时不重复外层卡片边距 */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export default function StorePackagesForm({ items, onChange, disabled, embedded }: Props) {
|
||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
||||
|
||||
function updateAt(index: number, patch: Partial<PackageFormItem>) {
|
||||
const next = items.map((item, i) => (i === index ? { ...item, ...patch } : item));
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
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={`partner-packages-form${embedded ? ' partner-packages-form--embedded' : ''}`}>
|
||||
{list.map((item, index) => {
|
||||
const isCollapsed = !!collapsed[index];
|
||||
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
||||
return (
|
||||
<section
|
||||
key={index}
|
||||
className={`partner-form-card partner-packages-item${isCollapsed ? ' partner-packages-item--collapsed' : ''}`}
|
||||
style={embedded ? { marginLeft: 0, marginRight: 0 } : undefined}
|
||||
>
|
||||
<div className="partner-packages-item-head">
|
||||
<button
|
||||
type="button"
|
||||
className="partner-packages-toggle"
|
||||
onClick={() => toggleCollapse(index)}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={isCollapsed ? '展开套餐' : '收起套餐'}
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{isCollapsed ? 'expand_more' : 'expand_less'}
|
||||
</span>
|
||||
<span className="headline-md">{displayName}</span>
|
||||
</button>
|
||||
{!disabled && list.length > 1 ? (
|
||||
<button type="button" className="partner-packages-remove" onClick={() => removeAt(index)}>
|
||||
删除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
<div className="partner-field">
|
||||
<label>套餐名称 <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">restaurant</span>
|
||||
<input
|
||||
placeholder="如:套餐A"
|
||||
value={item.name}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
<label>价格(元) <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">payments</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="198"
|
||||
value={item.price}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { price: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
<label>菜品 <span className="text-primary">*</span></label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||
value={item.dishes}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
<label>使用时间</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">schedule</span>
|
||||
<input
|
||||
placeholder="节假日除外"
|
||||
value={item.usableTime || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
<label>其他说明</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">info</span>
|
||||
<input
|
||||
placeholder="不可叠加"
|
||||
value={item.otherNotes || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{!disabled && list.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||
<button type="button" className="partner-packages-add" onClick={addItem}>
|
||||
<span className="material-symbols-outlined">add_circle</span>
|
||||
添加套餐
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ClientRuntimeConfig } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import {
|
||||
buildTencentLocPickerUrl,
|
||||
parseTencentLocPickerMessage,
|
||||
type TencentPickedLocation,
|
||||
} from '../lib/tencentLocPicker';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onPick: (loc: TencentPickedLocation) => void;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
/** 保留兼容,iframe 选点不使用 */
|
||||
region?: string | null;
|
||||
};
|
||||
|
||||
let cachedKey: string | null | undefined;
|
||||
|
||||
async function loadTencentLbsKey(): Promise<string> {
|
||||
if (cachedKey) return cachedKey;
|
||||
const cfg = await request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config', {
|
||||
silent: true,
|
||||
});
|
||||
const key = (cfg.tencentLbsKey || '').trim();
|
||||
if (!key) {
|
||||
throw new Error('未配置腾讯位置服务 Key,请联系管理员在系统设置中配置');
|
||||
}
|
||||
cachedKey = key;
|
||||
return key;
|
||||
}
|
||||
|
||||
export default function TencentLocPickerOverlay({
|
||||
open,
|
||||
onClose,
|
||||
onPick,
|
||||
latitude,
|
||||
longitude,
|
||||
}: Props) {
|
||||
const [key, setKey] = useState<string | null>(cachedKey ?? null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
/** 选点结果仅存内存,界面不展示经纬度 */
|
||||
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
|
||||
const [iframeReady, setIframeReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPending(null);
|
||||
setIframeReady(false);
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
if (key) return;
|
||||
setLoading(true);
|
||||
void loadTencentLbsKey()
|
||||
.then((k) => {
|
||||
if (!cancelled) setKey(k);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : '加载地图配置失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, key]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onMessage(event: MessageEvent) {
|
||||
const picked = parseTencentLocPickerMessage(event.data);
|
||||
if (!picked) return;
|
||||
setPending(picked);
|
||||
}
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [open]);
|
||||
|
||||
const src = useMemo(() => {
|
||||
if (!key) return '';
|
||||
const lat = latitude != null ? Number(latitude) : undefined;
|
||||
const lng = longitude != null ? Number(longitude) : undefined;
|
||||
return buildTencentLocPickerUrl(key, {
|
||||
latitude: lat != null && Number.isFinite(lat) ? lat : undefined,
|
||||
longitude: lng != null && Number.isFinite(lng) ? lng : undefined,
|
||||
});
|
||||
}, [key, latitude, longitude]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function confirmPick() {
|
||||
if (!pending) return;
|
||||
onPick(pending);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="partner-locpicker-overlay"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 1000,
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100dvh',
|
||||
maxHeight: '-webkit-fill-available',
|
||||
paddingTop: 'env(safe-area-inset-top)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '12px 12px 10px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
flexShrink: 0,
|
||||
gap: 10,
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
textAlign: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: 17,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
地图选点
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
opacity: pending ? 1 : 0.45,
|
||||
width: 'auto',
|
||||
}}
|
||||
disabled={!pending}
|
||||
onClick={confirmPick}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
style={{ padding: '12px 16px', minWidth: 72, flexShrink: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="label-md text-muted"
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: '8px 14px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.04)',
|
||||
flexShrink: 0,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{pending ? '已选定位置,请点右上角「确认」' : '在地图上点选或搜索地点,选中后点右上角「确认」'}
|
||||
</p>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative', background: '#f5f5f5' }}>
|
||||
{loading ? (
|
||||
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
|
||||
正在加载地图…
|
||||
</p>
|
||||
) : error ? (
|
||||
<div style={{ padding: 24 }}>
|
||||
<p className="label-md" style={{ color: 'var(--color-heritage-red, #a61d24)' }}>
|
||||
{error}
|
||||
</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 12 }}>
|
||||
请确认系统设置中已配置腾讯位置服务 Key,并白名单 apis.map.qq.com。
|
||||
</p>
|
||||
</div>
|
||||
) : src ? (
|
||||
<>
|
||||
{!iframeReady ? (
|
||||
<p
|
||||
className="label-md text-muted"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: 0,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
地图加载中…
|
||||
</p>
|
||||
) : null}
|
||||
<iframe
|
||||
title="地图选点"
|
||||
src={src}
|
||||
allow="geolocation *"
|
||||
onLoad={() => setIframeReady(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 0,
|
||||
display: 'block',
|
||||
background: '#fff',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { stripOAuthParamsFromLocation, toAppPath } from '@dukang/weixin-sdk';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
|
||||
import {
|
||||
clearAuth,
|
||||
ensureSession,
|
||||
request,
|
||||
saveAuth,
|
||||
type PartnerSessionPayload,
|
||||
type PartnerSessionProfile,
|
||||
} from '../lib/api';
|
||||
import { fetchClientConfig, processPartnerWechatOAuthCallback } from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
|
||||
export type PartnerAccount = PartnerMe & {
|
||||
staffRole?: PartnerStaffRole;
|
||||
};
|
||||
|
||||
type PartnerSessionValue = {
|
||||
ready: boolean;
|
||||
authenticated: boolean;
|
||||
account: PartnerAccount | null;
|
||||
/** @deprecated 使用 authenticated */
|
||||
loggedIn: boolean;
|
||||
/** @deprecated 使用 ready */
|
||||
loading: boolean;
|
||||
applySession: (session: PartnerSessionPayload) => void;
|
||||
refresh: () => Promise<PartnerAccount | null>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
const PartnerSessionContext = createContext<PartnerSessionValue | null>(null);
|
||||
|
||||
function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
phone: profile.phone,
|
||||
companyName: profile.companyName ?? '',
|
||||
isPrimary: profile.isPrimary ?? true,
|
||||
staffRole: profile.staffRole,
|
||||
permissions: profile.permissions,
|
||||
primaryAccountId: profile.primaryAccountId,
|
||||
primaryPhone: profile.primaryPhone,
|
||||
primaryName: profile.primaryName,
|
||||
hasWechat: profile.hasWechat,
|
||||
wxNickname: profile.wxNickname,
|
||||
wxAvatarUrl: profile.wxAvatarUrl,
|
||||
managedWarehouseId: profile.managedWarehouseId,
|
||||
hasWarehouseAccess: profile.hasWarehouseAccess,
|
||||
};
|
||||
}
|
||||
|
||||
export function PartnerSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [account, setAccount] = useState<PartnerAccount | null>(null);
|
||||
|
||||
const applySession = useCallback((session: PartnerSessionPayload) => {
|
||||
saveAuth(session);
|
||||
setAuthenticated(true);
|
||||
if (session.partner) {
|
||||
setAccount(accountFromProfile(session.partner));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async (): Promise<PartnerAccount | null> => {
|
||||
try {
|
||||
const result = await ensureSession();
|
||||
setAuthenticated(result.authenticated);
|
||||
if (!result.authenticated || !result.partner) {
|
||||
setAccount(null);
|
||||
return null;
|
||||
}
|
||||
const me = await request<PartnerAccount>('PARTNER_H5', '/partner/me', { silent: true });
|
||||
setAccount(me);
|
||||
return me;
|
||||
} catch {
|
||||
setAccount(null);
|
||||
setAuthenticated(false);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearAuth();
|
||||
setAccount(null);
|
||||
setAuthenticated(false);
|
||||
window.location.href = toAppPath('/login');
|
||||
}, []);
|
||||
|
||||
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 session = await processPartnerWechatOAuthCallback();
|
||||
if (session && !cancelled) {
|
||||
applySession(session);
|
||||
const me = await request<PartnerAccount>('PARTNER_H5', '/partner/me', { silent: true }).catch(() => null);
|
||||
if (me && !cancelled) setAccount(me);
|
||||
}
|
||||
stripOAuthParamsFromLocation();
|
||||
}
|
||||
} catch {
|
||||
stripOAuthParamsFromLocation();
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ensureSession();
|
||||
if (cancelled) return;
|
||||
setAuthenticated(result.authenticated);
|
||||
if (result.authenticated) {
|
||||
const me = await request<PartnerAccount>('PARTNER_H5', '/partner/me', { silent: true }).catch(() => null);
|
||||
if (!cancelled) setAccount(me);
|
||||
} else {
|
||||
setAccount(null);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
clearAuth({ keepProfile: true });
|
||||
setAuthenticated(false);
|
||||
setAccount(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applySession]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
ready,
|
||||
authenticated,
|
||||
account,
|
||||
loggedIn: authenticated,
|
||||
loading: !ready,
|
||||
applySession,
|
||||
refresh,
|
||||
logout,
|
||||
}),
|
||||
[ready, authenticated, account, applySession, refresh, logout],
|
||||
);
|
||||
|
||||
return (
|
||||
<PartnerSessionContext.Provider value={value}>
|
||||
{children}
|
||||
</PartnerSessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePartnerSession(): PartnerSessionValue {
|
||||
const ctx = useContext(PartnerSessionContext);
|
||||
if (!ctx) throw new Error('usePartnerSession 必须在 PartnerSessionProvider 内使用');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export type { PartnerSessionProfile };
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { registerPartnerToastListener, type PartnerToastVariant } from '../lib/toast';
|
||||
|
||||
type PartnerToastContextValue = {
|
||||
showToast: (message: string, variant?: PartnerToastVariant) => void;
|
||||
};
|
||||
|
||||
const PartnerToastContext = createContext<PartnerToastContextValue | null>(null);
|
||||
|
||||
export function PartnerToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toast, setToast] = useState('');
|
||||
const [variant, setVariant] = useState<PartnerToastVariant>('success');
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const showToast = useCallback((message: string, nextVariant: PartnerToastVariant = 'success') => {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
setVariant(nextVariant);
|
||||
setToast(message);
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setToast('');
|
||||
timerRef.current = null;
|
||||
}, nextVariant === 'error' ? 3000 : 2000);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
registerPartnerToastListener(showToast);
|
||||
return () => {
|
||||
registerPartnerToastListener(null);
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [showToast]);
|
||||
|
||||
return (
|
||||
<PartnerToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
{toast ? (
|
||||
<div
|
||||
className={`partner-toast${variant === 'error' ? ' partner-toast--error' : ''}`}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
) : null}
|
||||
</PartnerToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePartnerToast(): PartnerToastContextValue {
|
||||
const ctx = useContext(PartnerToastContext);
|
||||
if (!ctx) throw new Error('usePartnerToast 必须在 PartnerToastProvider 内使用');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import type { PartnerNavKind } from '../lib/partnerAccess';
|
||||
|
||||
const STORE_STAFF_TABS = [
|
||||
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
|
||||
{ to: '/stores', icon: 'store', label: '门店管理' },
|
||||
{ to: '/me', icon: 'person', label: '个人中心' },
|
||||
] as const;
|
||||
|
||||
const WAREHOUSE_STAFF_TABS = [
|
||||
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
|
||||
{ to: '/orders', icon: 'receipt_long', label: '订单管理' },
|
||||
{ to: '/me', icon: 'person', label: '个人中心' },
|
||||
] as const;
|
||||
|
||||
type SubAccountLayoutProps = {
|
||||
navKind: PartnerNavKind;
|
||||
};
|
||||
|
||||
export default function SubAccountLayout({ navKind }: SubAccountLayoutProps) {
|
||||
const tabs = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_TABS : STORE_STAFF_TABS;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<nav className="app-tabbar">
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
end={'end' in tab ? tab.end : undefined}
|
||||
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,32 @@
|
||||
import { NavLink, Navigate, Outlet } from 'react-router-dom';
|
||||
import { isLoggedIn } from '../lib/api';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
|
||||
{ to: '/stores', icon: 'store', label: '门店管理' },
|
||||
{ to: '/center', icon: 'account_circle', label: '合伙人中心' },
|
||||
] as const;
|
||||
|
||||
export default function TabLayout() {
|
||||
if (!isLoggedIn()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
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,16 @@
|
||||
import { createPartnerTracker } from '@dukang/client-logging';
|
||||
import { apiBase } from './api';
|
||||
|
||||
const tracker = createPartnerTracker({
|
||||
apiBase,
|
||||
clientApp: 'PARTNER_H5',
|
||||
getToken: () => localStorage.getItem('partnerAccessToken'),
|
||||
});
|
||||
|
||||
export function trackPartner(eventName: string, params?: Record<string, unknown>) {
|
||||
tracker.track(eventName, params);
|
||||
}
|
||||
|
||||
export function trackPartnerPageView(eventName: string, params?: Record<string, unknown>) {
|
||||
tracker.trackPageView(eventName, params);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
|
||||
import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk';
|
||||
import { showPartnerToast } from './toast';
|
||||
|
||||
export const apiBase = '/api/v1';
|
||||
const CLIENT_APP = 'PARTNER_H5';
|
||||
|
||||
const ACCESS_TOKEN = 'accessToken';
|
||||
const REFRESH_TOKEN = 'refreshToken';
|
||||
const LAST_PHONE = 'partnerLastPhone';
|
||||
const PARTNER_PROFILE = 'partnerProfile';
|
||||
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
|
||||
export const PARTNER_WX_BOUND = 'partnerWxBound';
|
||||
|
||||
/** 手机号或微信验证通过后的免登录时长 */
|
||||
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
||||
'/partner/auth/token/refresh',
|
||||
'/partner/auth/sms/send',
|
||||
'/partner/auth/login/sms',
|
||||
'/partner/auth/login/wechat',
|
||||
];
|
||||
|
||||
export type PartnerSessionProfile = Pick<
|
||||
PartnerMe,
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'phone'
|
||||
| 'companyName'
|
||||
| 'isPrimary'
|
||||
| 'permissions'
|
||||
| 'primaryAccountId'
|
||||
| 'primaryPhone'
|
||||
| 'primaryName'
|
||||
| 'hasWechat'
|
||||
| 'wxNickname'
|
||||
| 'wxAvatarUrl'
|
||||
| 'managedWarehouseId'
|
||||
| 'hasWarehouseAccess'
|
||||
> & {
|
||||
staffRole?: PartnerStaffRole;
|
||||
};
|
||||
|
||||
export type PartnerSessionPayload = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
partner?: PartnerSessionProfile;
|
||||
};
|
||||
|
||||
export type ApiRequestOptions = RequestInit & {
|
||||
/** 保留字段,兼容旧调用;错误提示由页面自行处理 */
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
export function getLastPhone() {
|
||||
return localStorage.getItem(LAST_PHONE) ?? '';
|
||||
}
|
||||
|
||||
export function getPartnerProfile(): PartnerSessionProfile | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(PARTNER_PROFILE);
|
||||
return raw ? (JSON.parse(raw) as PartnerSessionProfile) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPartnerWxSession() {
|
||||
return localStorage.getItem(PARTNER_WX_BOUND) === '1';
|
||||
}
|
||||
|
||||
export function isPartnerSessionExpired() {
|
||||
const raw = localStorage.getItem(SESSION_EXPIRES_AT);
|
||||
if (!raw) return false;
|
||||
return Date.now() > Number(raw);
|
||||
}
|
||||
|
||||
export function touchPartnerSession() {
|
||||
if (!localStorage.getItem(REFRESH_TOKEN)) return;
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
export function saveAuth(data: PartnerSessionPayload) {
|
||||
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||
if (data.partner) {
|
||||
localStorage.setItem(PARTNER_PROFILE, JSON.stringify(data.partner));
|
||||
localStorage.setItem(LAST_PHONE, data.partner.phone);
|
||||
}
|
||||
}
|
||||
|
||||
/** 手机号验证成功后写入 7 天免验证码会话 */
|
||||
export function saveRememberedSession(data: PartnerSessionPayload) {
|
||||
saveAuth(data);
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
||||
export function saveWechatSession(data: PartnerSessionPayload) {
|
||||
saveRememberedSession(data);
|
||||
localStorage.setItem(PARTNER_WX_BOUND, '1');
|
||||
}
|
||||
|
||||
export function clearAuth(options?: { keepProfile?: boolean }) {
|
||||
localStorage.removeItem(ACCESS_TOKEN);
|
||||
localStorage.removeItem(REFRESH_TOKEN);
|
||||
localStorage.removeItem(SESSION_EXPIRES_AT);
|
||||
localStorage.removeItem(PARTNER_WX_BOUND);
|
||||
if (!options?.keepProfile) {
|
||||
localStorage.removeItem(PARTNER_PROFILE);
|
||||
localStorage.removeItem(LAST_PHONE);
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
function profileFromMe(me: PartnerMe): PartnerSessionProfile {
|
||||
return {
|
||||
id: me.id,
|
||||
name: me.name,
|
||||
phone: me.phone,
|
||||
companyName: me.companyName,
|
||||
isPrimary: me.isPrimary,
|
||||
staffRole: me.staffRole ?? undefined,
|
||||
permissions: me.permissions,
|
||||
primaryAccountId: me.primaryAccountId,
|
||||
primaryPhone: me.primaryPhone,
|
||||
primaryName: me.primaryName,
|
||||
hasWechat: me.hasWechat,
|
||||
wxNickname: me.wxNickname,
|
||||
wxAvatarUrl: me.wxAvatarUrl,
|
||||
managedWarehouseId: me.managedWarehouseId,
|
||||
hasWarehouseAccess: me.hasWarehouseAccess,
|
||||
};
|
||||
}
|
||||
|
||||
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 });
|
||||
const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' }));
|
||||
if (json.code !== 0) {
|
||||
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||
err.status = json.code === 401 ? 401 : json.code;
|
||||
throw err;
|
||||
}
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
async function refreshSession(): Promise<PartnerSessionPayload | null> {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const data = await rawRequest<PartnerSessionPayload>(
|
||||
'/partner/auth/token/refresh',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
},
|
||||
null,
|
||||
);
|
||||
saveAuth(data);
|
||||
touchPartnerSession();
|
||||
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({ keepProfile: true });
|
||||
throw e;
|
||||
}
|
||||
return requestWithAuthRetry<T>(path, options, true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
clientApp: string,
|
||||
path: string,
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<T> {
|
||||
void clientApp;
|
||||
const { silent, ...fetchOptions } = options;
|
||||
try {
|
||||
return await requestWithAuthRetry<T>(path, fetchOptions);
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
const message = err.message || '请求失败';
|
||||
if (err.status === 401) {
|
||||
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||
clearAuth({ keepProfile: true });
|
||||
if (!silent) showPartnerToast(message, 'error');
|
||||
if (typeof window !== 'undefined' && !isOnAppPath('/login')) {
|
||||
window.location.href = toAppPath('/login');
|
||||
}
|
||||
}
|
||||
} else if (!silent) {
|
||||
showPartnerToast(message, 'error');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSession(): Promise<{ authenticated: boolean; partner: PartnerSessionProfile | null }> {
|
||||
if (!isLoggedIn()) {
|
||||
return { authenticated: false, partner: null };
|
||||
}
|
||||
if (isPartnerSessionExpired()) {
|
||||
clearAuth({ keepProfile: true });
|
||||
return { authenticated: false, partner: getPartnerProfile() };
|
||||
}
|
||||
try {
|
||||
const me = await rawRequest<PartnerMe>('/partner/me');
|
||||
const partner = profileFromMe(me);
|
||||
saveAuth({
|
||||
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
||||
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||||
partner,
|
||||
});
|
||||
touchPartnerSession();
|
||||
return { authenticated: true, partner };
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
if (err.status === 401) {
|
||||
const refreshed = await refreshSession();
|
||||
if (refreshed?.partner) {
|
||||
return { authenticated: true, partner: refreshed.partner };
|
||||
}
|
||||
clearAuth({ keepProfile: true });
|
||||
return { authenticated: false, partner: getPartnerProfile() };
|
||||
}
|
||||
const cached = getPartnerProfile();
|
||||
if (cached) return { authenticated: true, partner: cached };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated 使用 PartnerSessionPayload */
|
||||
export type PartnerAuthPayload = PartnerSessionPayload;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { codeToText, regionData } from 'element-china-area-data';
|
||||
|
||||
export { regionData as CHINA_REGION_OPTIONS };
|
||||
|
||||
export type OpenCityRef = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
partnerId?: string | null;
|
||||
};
|
||||
export type ParsedChinaRegion = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
provinceCode: string;
|
||||
cityCode: string;
|
||||
districtCode: string;
|
||||
};
|
||||
|
||||
export function districtCodeToCityCode(districtCode: string): string {
|
||||
if (districtCode.length < 6) return districtCode;
|
||||
return `${districtCode.slice(0, 4)}00`;
|
||||
}
|
||||
|
||||
export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null {
|
||||
if (!codes || codes.length < 3) return null;
|
||||
const [provinceCode, cityCode, districtCode] = codes;
|
||||
const province = codeToText[provinceCode];
|
||||
const city = codeToText[cityCode];
|
||||
const district = codeToText[districtCode];
|
||||
if (!province || !city || !district) return null;
|
||||
return { province, city, district, provinceCode, cityCode, districtCode };
|
||||
}
|
||||
|
||||
export function formatRegionLabel(region: ParsedChinaRegion): string {
|
||||
return `${region.province} / ${region.city} / ${region.district}`;
|
||||
}
|
||||
|
||||
/** 合伙人录店默认省市区 adcode:河南省 / 郑州市 / 金水区 */
|
||||
export const DEFAULT_REGION_CODES = ['41', '4101', '410105'] as const;
|
||||
|
||||
export function getDefaultPartnerRegionForm() {
|
||||
const regionCodes = [...DEFAULT_REGION_CODES];
|
||||
const parsed = parseRegionCodes(regionCodes);
|
||||
return {
|
||||
regionCodes,
|
||||
province: parsed?.province ?? '河南省',
|
||||
city: parsed?.city ?? '郑州市',
|
||||
district: parsed?.district ?? '金水区',
|
||||
};
|
||||
}
|
||||
|
||||
export function matchOpenCityId(cities: OpenCityRef[], districtCode: string): string | undefined {
|
||||
const cityCode = districtCodeToCityCode(districtCode);
|
||||
return (
|
||||
cities.find((c) => c.code === cityCode)?.id
|
||||
?? cities.find((c) => c.code === districtCode)?.id
|
||||
?? cities.find((c) => districtCode.startsWith(c.code.slice(0, 4)))?.id
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveRegionBinding(codes: string[], cities: OpenCityRef[]) {
|
||||
const region = parseRegionCodes(codes);
|
||||
if (!region) return null;
|
||||
const cityId = matchOpenCityId(cities, region.districtCode);
|
||||
const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined;
|
||||
return {
|
||||
region,
|
||||
cityId,
|
||||
matchedCity,
|
||||
cityCode: districtCodeToCityCode(region.districtCode),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { apiBase, getToken } from './api';
|
||||
|
||||
const CLIENT_APP = 'PARTNER_H5';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 上报客户端错误(失败静默,避免递归) */
|
||||
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,17 @@
|
||||
/** 合伙人端客服热线(可后续改为环境变量) */
|
||||
export const PARTNER_SUPPORT_PHONE = '400-000-0000';
|
||||
|
||||
export function dialPhone(phone: string) {
|
||||
const normalized = phone.replace(/\s+/g, '');
|
||||
if (!normalized) return false;
|
||||
window.location.href = `tel:${normalized}`;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function contactSupport() {
|
||||
return dialPhone(PARTNER_SUPPORT_PHONE);
|
||||
}
|
||||
|
||||
export function contactPartnerPhone(phone?: string) {
|
||||
return dialPhone(phone ?? '');
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
|
||||
import { request } from './api';
|
||||
|
||||
export function fetchPartnerLeaderboard(period: PartnerLeaderboardPeriod = 'total') {
|
||||
return request<PartnerLeaderboardResponse>(
|
||||
'PARTNER_H5',
|
||||
`/partner/dashboard/leaderboard?period=${period}`,
|
||||
{ silent: true },
|
||||
);
|
||||
}
|
||||
|
||||
export type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse };
|
||||
export type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types';
|
||||
|
||||
export type PartnerNavKind = 'primary' | 'store_staff' | 'warehouse_staff';
|
||||
|
||||
export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean {
|
||||
return account?.isPrimary !== false;
|
||||
}
|
||||
|
||||
export function isSubAccount(account: PartnerMe | null | undefined): boolean {
|
||||
return !!account && account.isPrimary === false;
|
||||
}
|
||||
|
||||
export function hasPartnerPermission(
|
||||
account: PartnerMe | null | undefined,
|
||||
permission: PartnerPermissionKey,
|
||||
): boolean {
|
||||
if (!account) return false;
|
||||
if (isPrimaryAccount(account)) return true;
|
||||
return account.permissions?.includes(permission) ?? false;
|
||||
}
|
||||
|
||||
export function hasAnyPartnerPermission(
|
||||
account: PartnerMe | null | undefined,
|
||||
permissions: PartnerPermissionKey[],
|
||||
): boolean {
|
||||
return permissions.some((p) => hasPartnerPermission(account, p));
|
||||
}
|
||||
|
||||
/** 与后端 GET /partner/dashboard 权限点一致 */
|
||||
export function canAccessPartnerDashboard(account: PartnerMe | null | undefined): boolean {
|
||||
return hasAnyPartnerPermission(account, [
|
||||
'store:create',
|
||||
'store:manage',
|
||||
'order:view',
|
||||
'warehouse:manage',
|
||||
]);
|
||||
}
|
||||
|
||||
/** 门店列表/拓店统计所需权限 */
|
||||
export function canAccessPartnerStores(account: PartnerMe | null | undefined): boolean {
|
||||
return hasAnyPartnerPermission(account, ['store:create', 'store:manage']);
|
||||
}
|
||||
|
||||
/** 编辑资料 / 开闭店 / 重新上传:主账号、门店权限,或历史未配权限的门店类子账号 */
|
||||
export function canManagePartnerStore(account: PartnerMe | null | undefined): boolean {
|
||||
if (!account) return false;
|
||||
if (isPrimaryAccount(account)) return true;
|
||||
if (isWarehouseStaff(account)) return false;
|
||||
if (hasAnyPartnerPermission(account, ['store:manage', 'store:create'])) return true;
|
||||
// 合伙人端早期创建的子账号可能 permissions 为空,按门店员工放开
|
||||
return !Array.isArray(account.permissions) || account.permissions.length === 0;
|
||||
}
|
||||
|
||||
/** 录入新店 */
|
||||
export function canCreatePartnerStore(account: PartnerMe | null | undefined): boolean {
|
||||
if (!account) return false;
|
||||
if (isPrimaryAccount(account)) return true;
|
||||
if (isWarehouseStaff(account)) return false;
|
||||
if (hasPartnerPermission(account, 'store:create')) return true;
|
||||
return !Array.isArray(account.permissions) || account.permissions.length === 0;
|
||||
}
|
||||
|
||||
/** 与后端 GET /partner/orders 权限点一致 */
|
||||
export function canAccessPartnerOrders(account: PartnerMe | null | undefined): boolean {
|
||||
return hasAnyPartnerPermission(account, ['order:view', 'warehouse:manage']);
|
||||
}
|
||||
|
||||
/** 仓库管理员:warehouse:manage,或仅有 order:view(无门店权限) */
|
||||
export function isWarehouseStaff(account: PartnerMe | null | undefined): boolean {
|
||||
if (!account || isPrimaryAccount(account)) return false;
|
||||
if (hasPartnerPermission(account, 'warehouse:manage')) return true;
|
||||
const hasStore =
|
||||
hasPartnerPermission(account, 'store:create') || hasPartnerPermission(account, 'store:manage');
|
||||
if (hasPartnerPermission(account, 'order:view') && !hasStore) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getPartnerNavKind(account: PartnerMe | null | undefined): PartnerNavKind {
|
||||
if (!account || isPrimaryAccount(account)) return 'primary';
|
||||
if (isWarehouseStaff(account)) return 'warehouse_staff';
|
||||
return 'store_staff';
|
||||
}
|
||||
|
||||
/** 是否已配置管仓:主账号看 hasWarehouseAccess;子账号还需有仓库相关权限 */
|
||||
export function hasWarehouseAccess(account: PartnerMe | null | undefined): boolean {
|
||||
if (!account) return false;
|
||||
if (account.hasWarehouseAccess === false) return false;
|
||||
if (account.hasWarehouseAccess === true) return true;
|
||||
// 旧缓存无字段时保守:主账号未知视为无,避免误展示全城订单
|
||||
return false;
|
||||
}
|
||||
|
||||
export function partnerHomePath(account: PartnerMe | null | undefined): string {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/leaderboard', '/login'];
|
||||
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/leaderboard', '/login'];
|
||||
|
||||
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
|
||||
if (pathname === '/login') return true;
|
||||
const prefixes = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_PREFIXES : STORE_STAFF_PREFIXES;
|
||||
return prefixes.some(
|
||||
(prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { PartnerProxyDeliveryMode } from '@dukang/shared-types';
|
||||
|
||||
const PROXY_ORDER_DRAFT_KEY = 'partner_proxy_order_draft_v2';
|
||||
|
||||
export type ProxyOrderDraft = {
|
||||
phone: string;
|
||||
receiverName: string;
|
||||
regionCodes: string[];
|
||||
addressDetail: string;
|
||||
productId: string;
|
||||
quantity: number;
|
||||
promoCodeId: string;
|
||||
deliveryMode: PartnerProxyDeliveryMode;
|
||||
autoReceive: boolean;
|
||||
};
|
||||
|
||||
export function saveProxyOrderDraft(draft: ProxyOrderDraft) {
|
||||
try {
|
||||
sessionStorage.setItem(PROXY_ORDER_DRAFT_KEY, JSON.stringify(draft));
|
||||
} catch {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadProxyOrderDraft(): ProxyOrderDraft | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(PROXY_ORDER_DRAFT_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<ProxyOrderDraft>;
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return {
|
||||
phone: typeof parsed.phone === 'string' ? parsed.phone : '',
|
||||
receiverName: typeof parsed.receiverName === 'string' ? parsed.receiverName : '',
|
||||
regionCodes: Array.isArray(parsed.regionCodes)
|
||||
? parsed.regionCodes.filter((c): c is string => typeof c === 'string')
|
||||
: [],
|
||||
addressDetail: typeof parsed.addressDetail === 'string' ? parsed.addressDetail : '',
|
||||
productId: typeof parsed.productId === 'string' ? parsed.productId : '',
|
||||
quantity: typeof parsed.quantity === 'number' && parsed.quantity >= 1 ? parsed.quantity : 2,
|
||||
promoCodeId: typeof parsed.promoCodeId === 'string' ? parsed.promoCodeId : '',
|
||||
deliveryMode: parsed.deliveryMode === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'ADDRESS',
|
||||
autoReceive: parsed.autoReceive === true,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearProxyOrderDraft() {
|
||||
try {
|
||||
sessionStorage.removeItem(PROXY_ORDER_DRAFT_KEY);
|
||||
sessionStorage.removeItem('partner_proxy_order_draft_v1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
OUT_WAREHOUSE: '已出库',
|
||||
SHIPPING: '配送中',
|
||||
PENDING_RECEIVE: '待收货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
const PAY_LABELS: Record<string, string> = {
|
||||
UNPAID: '未支付',
|
||||
PAYING: '支付中',
|
||||
PAID: '已付款',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
export function proxyOrderStatusLabel(status?: string | null) {
|
||||
if (!status) return '—';
|
||||
return STATUS_LABELS[status] || status;
|
||||
}
|
||||
|
||||
export function proxyOrderPayLabel(payStatus?: string | null) {
|
||||
if (!payStatus) return '—';
|
||||
return PAY_LABELS[payStatus] || payStatus;
|
||||
}
|
||||
|
||||
export function proxyOrderStatusColor(status?: string | null) {
|
||||
if (status === 'PENDING_PAY') return 'var(--color-warning, #d48806)';
|
||||
if (status === 'COMPLETED') return 'var(--color-success-green)';
|
||||
if (status === 'CANCELLED' || status === 'REFUNDED') return 'var(--color-text-muted, #999)';
|
||||
return 'var(--color-primary, #8b1e1e)';
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { PartnerStaffRole, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
|
||||
import type { CreatePartnerStaffRequest, PartnerStaffItem, UpdatePartnerStaffRequest } from '@dukang/shared-types';
|
||||
import { request } from './api';
|
||||
|
||||
export function listPartnerStaff() {
|
||||
return request<PartnerStaffItem[]>('PARTNER_H5', '/partner/staff');
|
||||
}
|
||||
|
||||
export function sendPartnerStaffPhoneSms(phone: string) {
|
||||
return request<{ ok: boolean; maskedPhone: string }>('PARTNER_H5', '/partner/staff/send-phone-sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: phone.trim() }),
|
||||
silent: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function createPartnerStaff(body: CreatePartnerStaffRequest) {
|
||||
return request<PartnerStaffItem>('PARTNER_H5', '/partner/staff', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: body.name,
|
||||
phone: body.phone,
|
||||
smsCode: body.smsCode,
|
||||
staffRole: body.staffRole ?? PartnerStaffRole.INTERNAL,
|
||||
permissions: body.permissions?.length
|
||||
? body.permissions
|
||||
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePartnerStaff(id: string, body: UpdatePartnerStaffRequest) {
|
||||
return request<PartnerStaffItem>('PARTNER_H5', `/partner/staff/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function deletePartnerStaff(id: string) {
|
||||
return request<{ ok: boolean }>('PARTNER_H5', `/partner/staff/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
export type StoreDraftForm = {
|
||||
regionCodes: string[];
|
||||
cityId: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
/** 门店坐标(定位或地理编码) */
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
openTime: string;
|
||||
closeTime: string;
|
||||
/** 是否启用第二段营业时间 */
|
||||
dualHours: boolean;
|
||||
openTime2: string;
|
||||
closeTime2: string;
|
||||
/** 人均费用(选填) */
|
||||
avgPrice: string;
|
||||
categoryParentId: string;
|
||||
categoryId: string;
|
||||
intro: string;
|
||||
/** 好客权益券使用规则 */
|
||||
benefitUsageRule: string;
|
||||
coverUrl: string;
|
||||
envPhotoUrls: string[];
|
||||
contractUrl: string;
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
/** 门店套餐(拓店第 4 步,可空) */
|
||||
packages: import('@dukang/shared-types').StorePackageItemDto[];
|
||||
};
|
||||
|
||||
export type StoreDraft = {
|
||||
step: number;
|
||||
form: StoreDraftForm;
|
||||
};
|
||||
|
||||
export const STORE_DRAFT_KEY = 'partner_store_draft_v2';
|
||||
|
||||
export function storeDraftKey(accountId?: string): string {
|
||||
return accountId ? `${STORE_DRAFT_KEY}_${accountId}` : STORE_DRAFT_KEY;
|
||||
}
|
||||
|
||||
import { getDefaultPartnerRegionForm } from './china-region';
|
||||
|
||||
export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
...getDefaultPartnerRegionForm(),
|
||||
cityId: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
openTime: '10:00',
|
||||
closeTime: '22:00',
|
||||
dualHours: false,
|
||||
openTime2: '17:00',
|
||||
closeTime2: '21:00',
|
||||
avgPrice: '',
|
||||
categoryParentId: '',
|
||||
categoryId: '',
|
||||
intro: '',
|
||||
benefitUsageRule: '',
|
||||
coverUrl: '',
|
||||
envPhotoUrls: ['', '', ''],
|
||||
contractUrl: '',
|
||||
bankAccountName: '',
|
||||
bankAccountNo: '',
|
||||
bankBranch: '',
|
||||
packages: [],
|
||||
});
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
const PHONE_RE = /^1[3-9]\d{9}$/;
|
||||
const BANK_RE = /^\d{16,19}$/;
|
||||
|
||||
function timeToMinutes(hhmm: string): number {
|
||||
const [h, m] = hhmm.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
export const MIN_ENV_PHOTO_COUNT = 3;
|
||||
|
||||
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
|
||||
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
|
||||
while (arr.length < minLen) arr.push('');
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function addEnvPhotoSlot(urls: string[]): string[] {
|
||||
return [...urls, ''];
|
||||
}
|
||||
|
||||
export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | undefined): StoreDraftForm {
|
||||
const base = defaultStoreForm();
|
||||
if (!raw) return base;
|
||||
return {
|
||||
...base,
|
||||
...raw,
|
||||
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
|
||||
cityId: String(raw.cityId ?? base.cityId),
|
||||
latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
|
||||
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
|
||||
openTime: String(raw.openTime ?? base.openTime),
|
||||
closeTime: String(raw.closeTime ?? base.closeTime),
|
||||
dualHours: Boolean(raw.dualHours),
|
||||
openTime2: String(raw.openTime2 ?? base.openTime2),
|
||||
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
||||
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT),
|
||||
packages: Array.isArray(raw.packages)
|
||||
? raw.packages.map((p, i) => ({
|
||||
name: String((p as { name?: string }).name ?? ''),
|
||||
price: String((p as { price?: string | number }).price ?? ''),
|
||||
dishes: String((p as { dishes?: string }).dishes ?? ''),
|
||||
usableTime: (p as { usableTime?: string }).usableTime ?? '',
|
||||
otherNotes: (p as { otherNotes?: string }).otherNotes ?? '',
|
||||
sortOrder: i,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function loadStoreDraft(accountId?: string): StoreDraft | null {
|
||||
try {
|
||||
const key = storeDraftKey(accountId);
|
||||
let raw = localStorage.getItem(key);
|
||||
if (!raw && accountId) {
|
||||
raw = localStorage.getItem(STORE_DRAFT_KEY);
|
||||
if (raw) {
|
||||
localStorage.setItem(key, raw);
|
||||
localStorage.removeItem(STORE_DRAFT_KEY);
|
||||
}
|
||||
}
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as StoreDraft;
|
||||
return {
|
||||
step: Number(parsed.step) || 0,
|
||||
form: normalizeStoreDraftForm(parsed.form),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStoreDraft(draft: StoreDraft, accountId?: string) {
|
||||
localStorage.setItem(storeDraftKey(accountId), JSON.stringify(draft));
|
||||
}
|
||||
|
||||
export function clearStoreDraft(accountId?: string) {
|
||||
localStorage.removeItem(storeDraftKey(accountId));
|
||||
}
|
||||
|
||||
/** 清除当前账号及遗留的全局录店草稿 */
|
||||
export function clearAllStoreDrafts(accountId?: string) {
|
||||
clearStoreDraft(accountId);
|
||||
localStorage.removeItem(STORE_DRAFT_KEY);
|
||||
}
|
||||
|
||||
export function validateStoreStep1(
|
||||
form: Pick<
|
||||
StoreDraftForm,
|
||||
| 'regionCodes'
|
||||
| 'cityId'
|
||||
| 'name'
|
||||
| 'address'
|
||||
| 'openTime'
|
||||
| 'closeTime'
|
||||
| 'dualHours'
|
||||
| 'openTime2'
|
||||
| 'closeTime2'
|
||||
| 'avgPrice'
|
||||
| 'categoryId'
|
||||
| 'intro'
|
||||
| 'benefitUsageRule'
|
||||
>,
|
||||
): string | null {
|
||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||
if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划';
|
||||
if (!form.name.trim()) return '请填写门店名称';
|
||||
if (!form.address.trim()) return '请填写详细地址';
|
||||
if (!form.openTime.trim()) return '请填写营业开始时间';
|
||||
if (!TIME_RE.test(form.openTime.trim())) return '营业开始时间格式须为 HH:MM';
|
||||
if (!form.closeTime.trim()) return '请填写营业结束时间';
|
||||
if (!TIME_RE.test(form.closeTime.trim())) return '营业结束时间格式须为 HH:MM';
|
||||
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
|
||||
return '营业结束时间须晚于开始时间';
|
||||
}
|
||||
if (form.dualHours) {
|
||||
if (!form.openTime2.trim() || !form.closeTime2.trim()) return '请完整填写第二段营业时间';
|
||||
if (!TIME_RE.test(form.openTime2.trim()) || !TIME_RE.test(form.closeTime2.trim())) {
|
||||
return '第二段时间格式须为 HH:MM';
|
||||
}
|
||||
if (timeToMinutes(form.openTime2.trim()) >= timeToMinutes(form.closeTime2.trim())) {
|
||||
return '第二段结束时间须晚于开始时间';
|
||||
}
|
||||
if (timeToMinutes(form.closeTime.trim()) >= timeToMinutes(form.openTime2.trim())) {
|
||||
return '第二段开始时间须晚于第一段结束时间';
|
||||
}
|
||||
}
|
||||
if (form.avgPrice.trim()) {
|
||||
const n = Number(form.avgPrice);
|
||||
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
|
||||
}
|
||||
if (!form.categoryId.trim()) return '请选择店铺类型';
|
||||
if (form.intro.trim()) {
|
||||
const len = form.intro.trim().length;
|
||||
if (len < 2 || len > 500) return '门店简介须为 2~500 字';
|
||||
}
|
||||
if (form.benefitUsageRule.trim().length > 1000) {
|
||||
return '好客权益券使用规则最多 1000 字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateStoreStep2(
|
||||
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrl'>,
|
||||
): string | null {
|
||||
if (!form.coverUrl.trim()) return '请上传门头照';
|
||||
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
|
||||
if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`;
|
||||
if (!form.contractUrl.trim()) return '请上传签约合同';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function patchEnvPhotoAt(urls: string[], index: number, url: string): string[] {
|
||||
const envPhotoUrls = [...urls];
|
||||
while (envPhotoUrls.length <= index) envPhotoUrls.push('');
|
||||
envPhotoUrls[index] = url;
|
||||
return envPhotoUrls;
|
||||
}
|
||||
|
||||
export function validateStoreStep3(
|
||||
form: Pick<
|
||||
StoreDraftForm,
|
||||
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone'
|
||||
>,
|
||||
): string | null {
|
||||
if (!form.bankAccountName.trim()) return '请填写户主姓名';
|
||||
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
|
||||
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
|
||||
if (!form.bankBranch.trim()) return '请填写开户支行';
|
||||
if (!form.phone.trim()) return '请填写联系电话';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { weixinSdk } from './weixin';
|
||||
|
||||
export type StorePosition = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
/** 获取当前 GPS 坐标(微信 JSSDK / 浏览器 Geolocation) */
|
||||
export async function locateStorePosition(): Promise<StorePosition> {
|
||||
const outcome = await weixinSdk.getLocationDetailed();
|
||||
if (!outcome.location) {
|
||||
throw new Error(outcome.errMsg || '定位失败,请允许位置权限后重试');
|
||||
}
|
||||
const { latitude, longitude } = outcome.location;
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
throw new Error('定位结果无效');
|
||||
}
|
||||
return { latitude, longitude };
|
||||
}
|
||||
|
||||
export function formatStoreCoords(lat?: string | number | null, lng?: string | number | null): string {
|
||||
const a = lat != null && lat !== '' ? Number(lat) : NaN;
|
||||
const b = lng != null && lng !== '' ? Number(lng) : NaN;
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return '';
|
||||
return `${a.toFixed(6)}, ${b.toFixed(6)}`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export function formatPackagePrice(price: string | number) {
|
||||
const n = typeof price === 'number' ? price : Number(price);
|
||||
if (!Number.isFinite(n)) return String(price);
|
||||
return n % 1 === 0 ? String(n) : n.toFixed(2);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { PartnerStorePhoneAvailableResponse } from '@dukang/shared-types';
|
||||
import { request } from './api';
|
||||
|
||||
export function checkStorePhoneAvailable(phone: string) {
|
||||
return request<PartnerStorePhoneAvailableResponse>(
|
||||
'PARTNER_H5',
|
||||
`/partner/stores/phone-available?phone=${encodeURIComponent(phone.trim())}`,
|
||||
);
|
||||
}
|
||||
|
||||
export type { PartnerStorePhoneAvailableResponse };
|
||||
@@ -0,0 +1,37 @@
|
||||
export type PartnerStoreAuditStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | string;
|
||||
|
||||
export function storeAuditLabel(auditStatus?: string | null): string {
|
||||
const s = String(auditStatus || 'APPROVED').toUpperCase();
|
||||
if (s === 'PENDING') return '待总部审核';
|
||||
if (s === 'REJECTED') return '审核驳回';
|
||||
if (s === 'APPROVED') return '审核通过';
|
||||
return auditStatus || '—';
|
||||
}
|
||||
|
||||
export function storeAuditPillClass(auditStatus?: string | null): string {
|
||||
const s = String(auditStatus || 'APPROVED').toUpperCase();
|
||||
if (s === 'PENDING') return 'partner-status-pill--paused';
|
||||
if (s === 'REJECTED') return 'partner-status-pill--closed';
|
||||
return 'partner-status-pill--open';
|
||||
}
|
||||
|
||||
export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED';
|
||||
|
||||
export function storeStatusLabel(status: string): string {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s === 'OPEN') return '营业中';
|
||||
if (s === 'PAUSED') return '临时闭店';
|
||||
if (s === 'CLOSED') return '永久关闭';
|
||||
return status;
|
||||
}
|
||||
|
||||
export function storeStatusPillClass(status: string): string {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s === 'OPEN') return 'partner-status-pill--open';
|
||||
if (s === 'PAUSED') return 'partner-status-pill--paused';
|
||||
return 'partner-status-pill--closed';
|
||||
}
|
||||
|
||||
export function canPartnerOpenStore(auditStatus?: string | null): boolean {
|
||||
return String(auditStatus || '').toUpperCase() === 'APPROVED';
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
export type TencentPickedLocation = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address?: string;
|
||||
name?: string;
|
||||
cityname?: string;
|
||||
};
|
||||
|
||||
export type LbsPlaceItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
city?: string;
|
||||
};
|
||||
|
||||
export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
|
||||
return {
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
address: item.address || undefined,
|
||||
name: item.title || undefined,
|
||||
cityname: item.city || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
type LocPickerMessage = {
|
||||
module?: string;
|
||||
latlng?: { lat?: number; lng?: number };
|
||||
poiaddress?: string;
|
||||
poiname?: string;
|
||||
cityname?: string;
|
||||
};
|
||||
|
||||
const REFERER = 'dukang';
|
||||
|
||||
function coerceMessageData(data: unknown): LocPickerMessage | null {
|
||||
if (data == null) return null;
|
||||
if (typeof data === 'string') {
|
||||
const trimmed = data.trim();
|
||||
if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[')) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as LocPickerMessage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof data === 'object') return data as LocPickerMessage;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 腾讯地图选点组件(iframe) */
|
||||
export function buildTencentLocPickerUrl(
|
||||
key: string,
|
||||
options?: { latitude?: number; longitude?: number },
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
search: '1',
|
||||
type: '1',
|
||||
key,
|
||||
referer: REFERER,
|
||||
policy: '1',
|
||||
total: '20',
|
||||
radius: '2000',
|
||||
mapdraggable: '1',
|
||||
});
|
||||
const lat = options?.latitude;
|
||||
const lng = options?.longitude;
|
||||
if (
|
||||
lat != null &&
|
||||
lng != null &&
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lng) &&
|
||||
!(lat === 0 && lng === 0)
|
||||
) {
|
||||
params.set('coord', `${lat},${lng}`);
|
||||
params.set('coordtype', '5');
|
||||
}
|
||||
return `https://apis.map.qq.com/tools/locpicker?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function parseTencentLocPickerMessage(data: unknown): TencentPickedLocation | null {
|
||||
const loc = coerceMessageData(data);
|
||||
if (!loc || loc.module !== 'locationPicker') return null;
|
||||
const lat = Number(loc.latlng?.lat);
|
||||
const lng = Number(loc.latlng?.lng);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
|
||||
let name = loc.poiname?.trim() || undefined;
|
||||
const address = loc.poiaddress?.trim() || undefined;
|
||||
if (name === '我的位置' && address) name = address;
|
||||
|
||||
return {
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
address,
|
||||
name,
|
||||
cityname: loc.cityname?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type PartnerToastVariant = 'success' | 'error';
|
||||
|
||||
type PartnerToastListener = (message: string, variant: PartnerToastVariant) => void;
|
||||
|
||||
let listener: PartnerToastListener | null = null;
|
||||
|
||||
export function registerPartnerToastListener(fn: PartnerToastListener | null) {
|
||||
listener = fn;
|
||||
}
|
||||
|
||||
export function showPartnerToast(message: string, variant: PartnerToastVariant = 'error') {
|
||||
const text = message.trim();
|
||||
if (!text || !listener) return;
|
||||
listener(text, variant);
|
||||
}
|
||||
|
||||
export function toastError(message: string) {
|
||||
showPartnerToast(message, 'error');
|
||||
}
|
||||
|
||||
export function toastSuccess(message: string) {
|
||||
showPartnerToast(message, 'success');
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** 串行化上传,避免微信 JSSDK 并发选图/读图导致卡死 */
|
||||
let uploadChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
export function enqueueUpload<T>(task: () => Promise<T>): Promise<T> {
|
||||
const next = uploadChain.then(task, task);
|
||||
uploadChain = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { apiBase, request } from './api';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||
|
||||
export type UploadFileResult = {
|
||||
url: string;
|
||||
ossKey: string;
|
||||
bucket: string;
|
||||
mock: boolean;
|
||||
};
|
||||
|
||||
async function uploadFileToOssInner(
|
||||
file: File,
|
||||
options: { bizType: string; mediaType?: OssMediaType },
|
||||
): Promise<UploadFileResult> {
|
||||
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('bizType', options.bizType);
|
||||
formData.append('mediaType', mediaType);
|
||||
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = { 'X-Client-App': 'PARTNER_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 || '上传失败');
|
||||
}
|
||||
|
||||
const data = json.data as UploadFileResult;
|
||||
return {
|
||||
url: data.url,
|
||||
ossKey: data.ossKey,
|
||||
bucket: data.bucket,
|
||||
mock: data.mock,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
throw new Error('上传超时,请检查网络后重试');
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
|
||||
export function uploadFileToOss(
|
||||
file: File,
|
||||
options: { bizType: string; mediaType?: OssMediaType },
|
||||
): Promise<UploadFileResult> {
|
||||
return uploadFileToOssInner(file, options);
|
||||
}
|
||||
|
||||
export type OpenCityOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
province: string;
|
||||
};
|
||||
|
||||
export async function fetchPartnerCities(): Promise<OpenCityOption[]> {
|
||||
return request<OpenCityOption[]>('PARTNER_H5', '/partner/stores/cities');
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { trackPartnerPageView } from './analytics';
|
||||
|
||||
export function usePartnerPageView(eventName: string, params?: Record<string, unknown>) {
|
||||
const fired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (fired.current) return;
|
||||
fired.current = true;
|
||||
trackPartnerPageView(eventName, params);
|
||||
}, [eventName, params]);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types';
|
||||
import type { PartnerPhoneCheckResponse } 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 { getPartnerProfile, request, saveWechatSession, type PartnerSessionPayload, type PartnerSessionProfile } from './api';
|
||||
|
||||
export const PARTNER_WECHAT_LOGIN_HINT =
|
||||
'请先使用手机验证码登录,登录后将自动关联微信';
|
||||
|
||||
export type PartnerProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
companyName?: string;
|
||||
hasWechat?: boolean;
|
||||
wxNickname?: string | null;
|
||||
wxAvatarUrl?: string | null;
|
||||
isPrimary?: boolean;
|
||||
};
|
||||
|
||||
export function partnerHasWechatBinding(
|
||||
profile: PartnerProfile | PartnerSessionProfile | null | undefined,
|
||||
): boolean {
|
||||
return !!profile?.hasWechat;
|
||||
}
|
||||
|
||||
/** 微信一键登录前:账号须已绑定 wxOpenId(本地缓存或手机号校验) */
|
||||
export async function canPartnerUseWechatLogin(options: {
|
||||
profile?: PartnerProfile | PartnerSessionProfile | null;
|
||||
phone?: string;
|
||||
}): Promise<boolean> {
|
||||
const cached = options.profile ?? getPartnerProfile();
|
||||
if (partnerHasWechatBinding(cached)) return true;
|
||||
|
||||
const normalized = options.phone?.trim() ?? '';
|
||||
if (!/^1\d{10}$/.test(normalized)) return false;
|
||||
|
||||
try {
|
||||
const res = await request<PartnerPhoneCheckResponse>('PARTNER_H5', '/partner/auth/phone/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: normalized }),
|
||||
silent: true,
|
||||
});
|
||||
return !!res.hasWechat;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||
return request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config');
|
||||
}
|
||||
|
||||
export async function fetchPartnerProfile(): Promise<PartnerProfile> {
|
||||
return request<PartnerProfile>('PARTNER_H5', '/partner/me');
|
||||
}
|
||||
|
||||
/** 微信内上传照片前需完成公众号 OAuth 绑定 */
|
||||
export function needsWechatAuth(
|
||||
profile: PartnerProfile | null,
|
||||
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
|
||||
): boolean {
|
||||
if (config && !isWxAuthorizeEnabled(config)) return false;
|
||||
return isWechatEnv() && !!profile && !profile.hasWechat;
|
||||
}
|
||||
|
||||
export async function checkNeedsWechatAuth(profile: PartnerProfile | null): Promise<boolean> {
|
||||
const config = await fetchClientConfig();
|
||||
return needsWechatAuth(profile, config);
|
||||
}
|
||||
|
||||
export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessionPayload | null {
|
||||
if (!result.accessToken || !result.refreshToken) return null;
|
||||
const partner = result.partner;
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
partner: partner
|
||||
? {
|
||||
id: String(partner.id ?? ''),
|
||||
name: String(partner.name ?? ''),
|
||||
phone: String(partner.phone ?? ''),
|
||||
companyName: partner.companyName ? String(partner.companyName) : undefined,
|
||||
isPrimary: partner.isPrimary !== false,
|
||||
hasWechat: true,
|
||||
wxNickname: partner.wxNickname ? String(partner.wxNickname) : undefined,
|
||||
wxAvatarUrl: partner.wxAvatarUrl ? String(partner.wxAvatarUrl) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 处理微信登录/绑定结果,写入 7 天免登录 session */
|
||||
export function handlePartnerWechatLoginResult(result: WechatLoginResult): PartnerSessionPayload | null {
|
||||
const session = sessionFromWechatLogin(result);
|
||||
if (!session) return null;
|
||||
saveWechatSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function handlePartnerWechatCallback(): Promise<WechatLoginResult | null> {
|
||||
if (!isWechatEnv()) return null;
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return null;
|
||||
return weixinSdk.handleOAuthCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信一键登录(已绑定微信的合伙人账号免验证码)。
|
||||
* 返回 session = 已登录;void = 已跳转授权页等待回调。
|
||||
*/
|
||||
export async function loginPartnerWithWechat(): Promise<PartnerSessionPayload | 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 handlePartnerWechatLoginResult(result);
|
||||
}
|
||||
|
||||
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
|
||||
export async function bindPartnerWechatAfterSmsLogin(): Promise<void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) return;
|
||||
await weixinSdk.login();
|
||||
}
|
||||
|
||||
export async function authorizePartnerWechat(): Promise<WechatLoginResult | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
/** OAuth 回跳统一处理(登录页 / 录店页等) */
|
||||
export async function processPartnerWechatOAuthCallback(): Promise<PartnerSessionPayload | null> {
|
||||
const result = await handlePartnerWechatCallback();
|
||||
if (!result) return null;
|
||||
return handlePartnerWechatLoginResult(result);
|
||||
}
|
||||
|
||||
/** @deprecated 使用 handlePartnerWechatLoginResult */
|
||||
export function savePartnerWechatAuth(result: WechatLoginResult): PartnerSessionPayload | null {
|
||||
return handlePartnerWechatLoginResult(result);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
|
||||
import { request } from './api';
|
||||
|
||||
export function fetchPartnerWeeklyReport(startDate?: string) {
|
||||
const query = startDate ? `?startDate=${encodeURIComponent(startDate)}` : '';
|
||||
return request<PartnerWeeklyReportResponse>('PARTNER_H5', `/partner/reports/weekly${query}`);
|
||||
}
|
||||
|
||||
export type { PartnerWeeklyReportResponse };
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
|
||||
|
||||
export const weixinSdk = createWeixinSdk({
|
||||
apiBase: '/api/v1',
|
||||
clientApp: 'PARTNER_H5',
|
||||
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||
wechatLoginPath: '/partner/auth/login/wechat',
|
||||
});
|
||||
|
||||
export { isWechatEnv };
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { getRouterBasename } from '@dukang/weixin-sdk';
|
||||
import App from './App';
|
||||
import { PartnerSessionProvider } from './contexts/PartnerSessionContext';
|
||||
import { PartnerToastProvider } from './contexts/PartnerToastContext';
|
||||
import { installClientErrorReporting } from '@dukang/client-logging';
|
||||
import { apiBase } from './lib/api';
|
||||
import './styles.css';
|
||||
import './styles/legal.css';
|
||||
|
||||
installClientErrorReporting({
|
||||
apiBase,
|
||||
clientApp: 'PARTNER_H5',
|
||||
getToken: () => localStorage.getItem('partnerAccessToken'),
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename={getRouterBasename()}>
|
||||
<PartnerToastProvider>
|
||||
<PartnerSessionProvider>
|
||||
<App />
|
||||
</PartnerSessionProvider>
|
||||
</PartnerToastProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function billStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'AWAITING_CONFIRM': return '待确认';
|
||||
case 'UNPAID': return '未打款';
|
||||
case 'PAID': return '已打款';
|
||||
case 'REJECTED': return '已驳回';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
function canConfirm(status: string) {
|
||||
return status === 'AWAITING_CONFIRM';
|
||||
}
|
||||
|
||||
export default function BillsPage() {
|
||||
usePartnerPageView('partner_bills_view');
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [bills, setBills] = useState<PartnerBillDto[]>([]);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
async function loadBills() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills');
|
||||
setBills(Array.isArray(list) ? list : []);
|
||||
} catch {
|
||||
setBills([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
void loadBills();
|
||||
}, [navigate]);
|
||||
|
||||
const billId = searchParams.get('id');
|
||||
const actionable = useMemo(
|
||||
() => bills.filter((b) => canConfirm(b.status)),
|
||||
[bills],
|
||||
);
|
||||
const bill = billId
|
||||
? bills.find((b) => String(b.id) === billId) ?? actionable[0] ?? bills[0]
|
||||
: actionable[0] ?? bills[0];
|
||||
|
||||
const status = String(bill?.status || '');
|
||||
const showApplyForm = !!bill && canConfirm(status);
|
||||
const isUnpaid = status === 'UNPAID';
|
||||
const isRejected = status === 'REJECTED';
|
||||
const isPaid = status === 'PAID';
|
||||
|
||||
function askConfirm(ids: string[]) {
|
||||
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
|
||||
return;
|
||||
}
|
||||
void doConfirm(ids);
|
||||
}
|
||||
|
||||
async function doConfirm(ids: string[]) {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (ids.length === 1) {
|
||||
await request('PARTNER_H5', `/partner/settlement/bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||
} else {
|
||||
await request('PARTNER_H5', '/partner/settlement/bills/batch-confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
toastSuccess('已确认,等待总部打款');
|
||||
setConfirmed(false);
|
||||
setSelectedIds([]);
|
||||
await loadBills();
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
}
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadBills} className="partner-bills-page">
|
||||
<PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
|
||||
|
||||
<div className="partner-bill-stepper">
|
||||
<div className="partner-stepper-inner">
|
||||
<div className="partner-stepper-line" aria-hidden>
|
||||
<div
|
||||
className="partner-stepper-line-fill"
|
||||
style={{ width: isPaid ? '100%' : isUnpaid || isRejected ? '75%' : '50%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="partner-step">
|
||||
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--done">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
|
||||
</div>
|
||||
<span className="partner-step-label partner-step-label--active">数据核算</span>
|
||||
</div>
|
||||
<div className="partner-step">
|
||||
<div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isUnpaid || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
|
||||
{showApplyForm && !confirmed ? '2' : (
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="partner-step-label partner-step-label--active">账单确认</span>
|
||||
</div>
|
||||
<div className="partner-step">
|
||||
<div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isUnpaid || isRejected ? ' partner-step-circle--active' : ''}`}>
|
||||
{isPaid ? (
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
|
||||
) : '3'}
|
||||
</div>
|
||||
<span className={`partner-step-label${isUnpaid || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
|
||||
{isRejected ? '已驳回' : isPaid ? '已打款' : '总部打款'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div className="empty">加载中…</div>}
|
||||
{!loading && !bill && <div className="empty">暂无待确认账单</div>}
|
||||
|
||||
{bill && (
|
||||
<section className="partner-bill-card">
|
||||
<div className="partner-bill-card-bar" />
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p>
|
||||
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2>
|
||||
</div>
|
||||
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isUnpaid ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
|
||||
{billStatusLabel(status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isRejected && bill.rejectReason && (
|
||||
<div className="partner-info-banner" style={{ marginBottom: 16, background: 'rgba(166,29,36,0.06)' }}>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>error</span>
|
||||
<div>
|
||||
<p className="body-md text-primary" style={{ fontWeight: 600, marginBottom: 4 }}>账单已驳回</p>
|
||||
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>{bill.rejectReason}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUnpaid && (
|
||||
<div className="partner-info-banner" style={{ marginBottom: 16 }}>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>hourglass_top</span>
|
||||
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
|
||||
您已确认账单,总部打款中,请耐心等待。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="partner-bill-amount">
|
||||
<p className="text-muted body-md" style={{ marginBottom: 8 }}>应结总金额</p>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
|
||||
<span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span>
|
||||
<span className="amount-xl">{fmtMoney(Number(bill.totalAmount || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="partner-info-row">
|
||||
<span className="text-variant body-md">订单分佣收入</span>
|
||||
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.orderCommission || 0))}</span>
|
||||
</div>
|
||||
<div className="partner-info-row">
|
||||
<span className="text-variant body-md">核销权益分佣</span>
|
||||
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.redeemCommission || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{bills.length > 0 && (
|
||||
<div style={{ padding: '0 20px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h3 className="headline-md" style={{ margin: 0 }}>全部账单</h3>
|
||||
{actionable.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-fill-max"
|
||||
style={{ fontSize: 13 }}
|
||||
disabled={!selectedIds.length || submitting}
|
||||
onClick={() => askConfirm(selectedIds)}
|
||||
>
|
||||
批量确认 ({selectedIds.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{bills.map((b) => {
|
||||
const id = String(b.id);
|
||||
const selectable = canConfirm(b.status);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="partner-store-card"
|
||||
style={{ margin: '0 0 12px', display: 'flex', gap: 10, alignItems: 'flex-start' }}
|
||||
>
|
||||
{selectable ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(id)}
|
||||
onChange={() => toggleSelect(id)}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ width: 16 }} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
style={{ flex: 1, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/center/bills?id=${id}`)}
|
||||
>
|
||||
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
|
||||
<div>
|
||||
<p className="body-md">{b.billNo}</p>
|
||||
<p className="label-md text-muted">{billStatusLabel(b.status)}</p>
|
||||
{b.status === 'REJECTED' && b.rejectReason ? (
|
||||
<p className="label-md text-primary" style={{ marginTop: 4 }}>驳回:{b.rejectReason}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showApplyForm && (
|
||||
<>
|
||||
<div className="partner-info-banner" style={{ marginTop: 16 }}>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
|
||||
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
|
||||
确认后账单变为「未打款」,由总部完成打款。如有异议请先联系城市运营经理。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<footer className="partner-bill-footer">
|
||||
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
|
||||
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
|
||||
<span>我已核对数据无误,同意根据此账单进行结算</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
disabled={!confirmed || submitting}
|
||||
style={{ opacity: confirmed ? 1 : 0.5 }}
|
||||
onClick={() => askConfirm([String(bill!.id)])}
|
||||
>
|
||||
{submitting ? '正在提交...' : '确认账单'}
|
||||
{!submitting && <span className="material-symbols-outlined">payments</span>}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { request } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { contactSupport } from '../lib/contact';
|
||||
import { isPrimaryAccount } from '../lib/partnerAccess';
|
||||
import { listPartnerStaff } from '../lib/staff';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import {
|
||||
authorizePartnerWechat,
|
||||
fetchClientConfig,
|
||||
handlePartnerWechatLoginResult,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
|
||||
type CenterPageProps = {
|
||||
/** 子账号个人中心复用 */
|
||||
variant?: 'primary' | 'sub';
|
||||
roleLabel?: string;
|
||||
};
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) {
|
||||
const { account, logout, refresh, applySession } = usePartnerSession();
|
||||
const isPrimary = variant === 'primary' && isPrimaryAccount(account);
|
||||
const [authorizing, setAuthorizing] = useState(false);
|
||||
const [bills, setBills] = useState<PartnerBillDto[]>([]);
|
||||
const [staffCount, setStaffCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = isPrimary ? '合伙人中心' : '个人中心';
|
||||
}, [isPrimary]);
|
||||
|
||||
const loadCenter = useCallback(() => {
|
||||
const tasks: Promise<unknown>[] = [Promise.resolve(refresh())];
|
||||
if (isPrimary) {
|
||||
tasks.push(
|
||||
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
|
||||
.then(setBills)
|
||||
.catch(() => setBills([])),
|
||||
listPartnerStaff()
|
||||
.then((list) => setStaffCount(list.length))
|
||||
.catch(() => setStaffCount(0)),
|
||||
);
|
||||
}
|
||||
return Promise.all(tasks).then(() => undefined);
|
||||
}, [isPrimary, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCenter();
|
||||
}, [loadCenter]);
|
||||
|
||||
const finance = useMemo(() => {
|
||||
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
|
||||
const settled = bills.filter((b) => b.status === 'PAID');
|
||||
const paidCount = settled.length;
|
||||
const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length;
|
||||
return {
|
||||
pendingBillCount: pending.length,
|
||||
rejectedCount,
|
||||
pendingTotal: bills
|
||||
.filter((b) => b.status === 'AWAITING_CONFIRM' || b.status === 'UNPAID' || b.status === 'REJECTED')
|
||||
.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||
settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||
balance: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||
paidCount,
|
||||
};
|
||||
}, [bills]);
|
||||
|
||||
function handleContactPartner() {
|
||||
if (isPrimary) {
|
||||
if (!contactSupport()) toastError('暂无客服电话');
|
||||
return;
|
||||
}
|
||||
if (!account?.primaryPhone) {
|
||||
toastError('暂无合伙人联系方式');
|
||||
return;
|
||||
}
|
||||
window.location.href = `tel:${account.primaryPhone}`;
|
||||
}
|
||||
|
||||
async function handleAvatarAuth() {
|
||||
if (authorizing) return;
|
||||
if (!isWechatEnv()) {
|
||||
toastError('请在微信内打开以授权头像昵称');
|
||||
return;
|
||||
}
|
||||
setAuthorizing(true);
|
||||
try {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) {
|
||||
toastError('微信授权暂未开启');
|
||||
return;
|
||||
}
|
||||
const result = await authorizePartnerWechat();
|
||||
if (result) {
|
||||
const session = handlePartnerWechatLoginResult(result);
|
||||
if (session) applySession(session);
|
||||
await refresh();
|
||||
toastSuccess('微信授权成功');
|
||||
}
|
||||
// result 为空表示已跳转 OAuth,无需额外处理
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '微信授权失败');
|
||||
} finally {
|
||||
setAuthorizing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const locationLabel = [account?.companyName].filter(Boolean).join(' · ') || '—';
|
||||
const displayName = account?.wxNickname?.trim() || account?.name || '合伙人';
|
||||
const avatarUrl = account?.wxAvatarUrl?.trim() || '';
|
||||
const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员');
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadCenter} className="page partner-center-page partner-home--flush-top">
|
||||
<section className="partner-profile-card">
|
||||
<button
|
||||
type="button"
|
||||
className="partner-profile-avatar"
|
||||
onClick={() => void handleAvatarAuth()}
|
||||
disabled={authorizing}
|
||||
aria-label={avatarUrl ? '更新微信头像' : '微信授权获取头像'}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" className="partner-profile-avatar-img" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
)}
|
||||
{!avatarUrl && (
|
||||
<span className="partner-profile-avatar-hint">授权</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="partner-profile-main">
|
||||
<div className="partner-profile-title-row">
|
||||
<h2 className="partner-profile-name">{displayName}</h2>
|
||||
<span className="partner-role-badge">{badgeLabel}</span>
|
||||
</div>
|
||||
<div className="partner-profile-location">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span>
|
||||
<span className="body-md line-2-clamp">{locationLabel}</span>
|
||||
</div>
|
||||
{!isPrimary && (
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{account?.phone || ''}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isPrimary && (
|
||||
<>
|
||||
<section className="partner-assets-section">
|
||||
<div className="partner-section-head">
|
||||
<h3 className="headline-md">资产概览</h3>
|
||||
<Link to="/center/settlement" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
明细 <span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{finance.pendingBillCount > 0 && (
|
||||
<Link to="/center/bills" className="partner-bills-banner">
|
||||
<div className="partner-bills-banner-left">
|
||||
<div className="partner-bills-icon">
|
||||
<span className="material-symbols-outlined">receipt_long</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="body-md text-primary" style={{ fontWeight: 600 }}>
|
||||
{finance.rejectedCount > 0 ? '待处理账单' : '待确认账单'}
|
||||
</p>
|
||||
<p className="label-md text-primary" style={{ opacity: 0.85 }}>
|
||||
{finance.rejectedCount > 0
|
||||
? `您有 ${finance.pendingBillCount} 笔账单待处理(含 ${finance.rejectedCount} 笔已驳回)`
|
||||
: `您有 ${finance.pendingBillCount} 笔账单待确认`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-primary">chevron_right</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="partner-finance-grid">
|
||||
<div className="partner-finance-card partner-finance-card--hero">
|
||||
<p className="partner-finance-hero-label">账户余额 (元)</p>
|
||||
<p className="partner-finance-hero-amount">{fmtMoney(finance.balance)}</p>
|
||||
</div>
|
||||
<Link to="/center/settlement" className="partner-finance-card">
|
||||
<p className="label-md text-muted">待结算</p>
|
||||
<p className="partner-finance-value text-primary">¥ {fmtMoney(finance.pendingTotal)}</p>
|
||||
</Link>
|
||||
<Link to="/center/settlement" className="partner-finance-card">
|
||||
<p className="label-md text-muted" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
提现记录
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>history</span>
|
||||
</p>
|
||||
<p className="partner-finance-value">{finance.paidCount} 笔</p>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-menu-section">
|
||||
<div className="partner-section-head">
|
||||
<h3 className="headline-md">运营管理</h3>
|
||||
</div>
|
||||
<div className="partner-menu-card">
|
||||
<Link to="/center/staff" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon">
|
||||
<span className="material-symbols-outlined">badge</span>
|
||||
</div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>我的员工</span>
|
||||
</div>
|
||||
<div className="partner-menu-item-right">
|
||||
{staffCount > 0 && <span className="partner-menu-badge">{staffCount}人</span>}
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Link to="/center/proxy-orders" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon">
|
||||
<span className="material-symbols-outlined">receipt_long</span>
|
||||
</div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>代下单列表</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<Link to="/stores?audit=pending" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon">
|
||||
<span className="material-symbols-outlined">storefront</span>
|
||||
</div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>门店审核记录</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<Link to="/stores" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon">
|
||||
<span className="material-symbols-outlined">description</span>
|
||||
</div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>合同管理</span>
|
||||
</div>
|
||||
<div className="partner-menu-item-right">
|
||||
<span className="partner-menu-status">待续约</span>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-menu-item"
|
||||
onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}
|
||||
>
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon">
|
||||
<span className="material-symbols-outlined">support_agent</span>
|
||||
</div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>联系客服</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isPrimary && (
|
||||
<section className="partner-menu-section">
|
||||
<div className="partner-menu-card">
|
||||
<Link to="/leaderboard" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon">
|
||||
<span className="material-symbols-outlined">emoji_events</span>
|
||||
</div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>团队贡献榜</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<button type="button" className="partner-menu-item" onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}>
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">support_agent</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>联系客服</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</button>
|
||||
<button type="button" className="partner-menu-item" onClick={handleContactPartner}>
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">call</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>联系合伙人</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<button type="button" className="partner-logout-btn" onClick={logout}>
|
||||
<span className="material-symbols-outlined">logout</span>
|
||||
退出登录
|
||||
</button>
|
||||
|
||||
<div className="partner-center-footer">
|
||||
<p className="label-md text-muted">传承千年 · 杜康好客</p>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||
import {
|
||||
canAccessPartnerDashboard,
|
||||
canAccessPartnerOrders,
|
||||
canAccessPartnerStores,
|
||||
getPartnerNavKind,
|
||||
hasWarehouseAccess,
|
||||
} from '../lib/partnerAccess';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
const LEADERBOARD_PREVIEW_LIMIT = 3;
|
||||
|
||||
function OrderSummarySection({
|
||||
title,
|
||||
todayCount,
|
||||
pendingShip,
|
||||
shipping,
|
||||
completed,
|
||||
ordersLink = '/orders',
|
||||
}: {
|
||||
title?: string;
|
||||
todayCount: number;
|
||||
pendingShip: number;
|
||||
shipping: number;
|
||||
completed: number;
|
||||
ordersLink?: string;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<div className="partner-order-summary-header">
|
||||
<h2 className="headline-md">{title || `今日订单 ${todayCount}`}</h2>
|
||||
<Link to={ordersLink} className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
查看全部 <span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="partner-order-stats">
|
||||
<div className="partner-order-stat">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待发货</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{pendingShip}</p>
|
||||
</div>
|
||||
<div className="partner-order-stat partner-order-stat--blue">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>配送中</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{shipping}</p>
|
||||
</div>
|
||||
<div className="partner-order-stat partner-order-stat--green">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>已完成</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{completed}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function isPendingShip(status: string) {
|
||||
const s = status.toUpperCase();
|
||||
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
|
||||
}
|
||||
|
||||
function isShipping(status: string) {
|
||||
const s = status.toUpperCase();
|
||||
return s.includes('SHIP') || s === 'OUT_WAREHOUSE' || s === 'PENDING_RECEIVE';
|
||||
}
|
||||
|
||||
function isCompleted(status: string) {
|
||||
return status.toUpperCase() === 'COMPLETED';
|
||||
}
|
||||
|
||||
function isToday(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear()
|
||||
&& d.getMonth() === now.getMonth()
|
||||
&& d.getDate() === now.getDate();
|
||||
}
|
||||
|
||||
function summarizeOrders(orders: Array<Record<string, unknown>>) {
|
||||
let pendingShip = 0;
|
||||
let shipping = 0;
|
||||
let completed = 0;
|
||||
let todayCount = 0;
|
||||
for (const o of orders) {
|
||||
const status = String(o.status || '');
|
||||
if (isToday(String(o.createdAt || ''))) todayCount += 1;
|
||||
if (isPendingShip(status)) pendingShip += 1;
|
||||
else if (isShipping(status)) shipping += 1;
|
||||
else if (isCompleted(status)) completed += 1;
|
||||
}
|
||||
return { pendingShip, shipping, completed, todayCount };
|
||||
}
|
||||
|
||||
function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] }) {
|
||||
return (
|
||||
<section className="partner-bento-card partner-leaderboard-preview">
|
||||
<div className="partner-leaderboard-header">
|
||||
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 20 }}>emoji_events</span>
|
||||
团队贡献榜
|
||||
</h3>
|
||||
<Link to="/leaderboard" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
查看全部 <span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<p className="label-md text-muted">暂无排行数据</p>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
|
||||
<div className={`partner-leaderboard-avatar partner-leaderboard-avatar--rank-${Math.min(entry.rank, 3)}`}>
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
<span className="partner-leaderboard-avatar-badge">{entry.rank}</span>
|
||||
</div>
|
||||
<div className="partner-leaderboard-info">
|
||||
<p className="body-md" style={{ fontWeight: 600 }}>
|
||||
{entry.name}
|
||||
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
|
||||
</p>
|
||||
<p className="label-md text-muted">累计拓店: {entry.totalStores} 间</p>
|
||||
</div>
|
||||
<div className="partner-leaderboard-stat">
|
||||
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||
<p className="label-md text-muted">本月新增</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
usePartnerPageView('partner_home_view');
|
||||
const navigate = useNavigate();
|
||||
const { account } = usePartnerSession();
|
||||
const navKind = getPartnerNavKind(account);
|
||||
const isPrimary = navKind === 'primary';
|
||||
const isWarehouse = navKind === 'warehouse_staff';
|
||||
const warehouseOk = hasWarehouseAccess(account);
|
||||
const canOrders = canAccessPartnerOrders(account) && warehouseOk && (isPrimary || isWarehouse);
|
||||
const canDashboard = canAccessPartnerDashboard(account) && !isWarehouse;
|
||||
const canStores = canAccessPartnerStores(account) && !isWarehouse;
|
||||
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = '工作台';
|
||||
}, []);
|
||||
|
||||
const loadHome = useCallback(() => {
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!account) return Promise.resolve();
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
|
||||
if (canOrders) {
|
||||
tasks.push(
|
||||
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
|
||||
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
|
||||
.catch(() => setOrders([])),
|
||||
);
|
||||
} else {
|
||||
setOrders([]);
|
||||
}
|
||||
|
||||
if (canDashboard) {
|
||||
tasks.push(
|
||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
|
||||
.then(setDash)
|
||||
.catch(() => setDash(null)),
|
||||
);
|
||||
} else {
|
||||
setDash(null);
|
||||
}
|
||||
|
||||
if (canStores) {
|
||||
tasks.push(
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
|
||||
.then((data) => setStores(Array.isArray(data) ? data : []))
|
||||
.catch(() => setStores([])),
|
||||
);
|
||||
} else {
|
||||
setStores([]);
|
||||
}
|
||||
|
||||
tasks.push(
|
||||
fetchPartnerLeaderboard('month')
|
||||
.then((data) => {
|
||||
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
|
||||
})
|
||||
.catch(() => {
|
||||
setLeaderboardEntries([]);
|
||||
}),
|
||||
);
|
||||
|
||||
return Promise.all(tasks).then(() => undefined);
|
||||
}, [navigate, account, canOrders, canDashboard, canStores]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
|
||||
const storeCount = Number(dash?.storeCount || stores.length || 0);
|
||||
const orderStats = useMemo(() => summarizeOrders(orders), [orders]);
|
||||
const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
|
||||
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
|
||||
const abnormalStores = Math.max(0, storeCount - activeStores);
|
||||
const monthNew = stores.filter((s) => {
|
||||
const created = new Date(String(s.createdAt || ''));
|
||||
const now = new Date();
|
||||
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
|
||||
}).length;
|
||||
const lastMonthNew = stores.filter((s) => {
|
||||
const created = new Date(String(s.createdAt || ''));
|
||||
const now = new Date();
|
||||
const last = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
return created.getMonth() === last.getMonth() && created.getFullYear() === last.getFullYear();
|
||||
}).length;
|
||||
const monthGrowthPct = lastMonthNew > 0
|
||||
? Math.round(((monthNew - lastMonthNew) / lastMonthNew) * 100)
|
||||
: (monthNew > 0 ? 100 : 0);
|
||||
|
||||
const orderCount = Number(dash?.orderCount || orderStats.todayCount || 0);
|
||||
const revenue = orderCount * 128.45;
|
||||
const profit = revenue * 0.25;
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadHome} className="page partner-home partner-home--flush-top">
|
||||
<main className="partner-home-body">
|
||||
{isPrimary && (
|
||||
<>
|
||||
<section className="partner-revenue-card">
|
||||
<p className="partner-revenue-label">
|
||||
实时营业额 (CNY)
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
|
||||
</p>
|
||||
<div className="partner-revenue-amount">{fmtMoney(revenue)}</div>
|
||||
<div className="partner-revenue-grid">
|
||||
<div>
|
||||
<p className="partner-revenue-label">预计利润</p>
|
||||
<p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
|
||||
<div className="partner-quick-actions">
|
||||
<Link to="/proxy-order" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
||||
<span className="material-symbols-outlined">shopping_cart_checkout</span>
|
||||
</div>
|
||||
<span className="partner-quick-action-label">代下单</span>
|
||||
</Link>
|
||||
<Link to="/stores/new" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
||||
<span className="material-symbols-outlined">add_business</span>
|
||||
</div>
|
||||
<span className="partner-quick-action-label">录入新店</span>
|
||||
</Link>
|
||||
<Link to="/center/settlement" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--green">
|
||||
<span className="material-symbols-outlined">account_balance</span>
|
||||
</div>
|
||||
<span className="partner-quick-action-label">财务对账</span>
|
||||
</Link>
|
||||
<Link to="/reports/weekly" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
|
||||
<span className="material-symbols-outlined">monitoring</span>
|
||||
</div>
|
||||
<span className="partner-quick-action-label">数据周报</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWarehouse && (
|
||||
<p className="headline-md" style={{ marginBottom: 8 }}>订单管理</p>
|
||||
)}
|
||||
|
||||
{canOrders && (
|
||||
<OrderSummarySection
|
||||
title={isWarehouse ? '今日订单' : undefined}
|
||||
todayCount={orderStats.todayCount}
|
||||
pendingShip={orderStats.pendingShip}
|
||||
shipping={orderStats.shipping}
|
||||
completed={orderStats.completed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isPrimary || isWarehouse) && !warehouseOk && (
|
||||
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
|
||||
<div className="partner-warehouse-denied">
|
||||
<span className="material-symbols-outlined text-primary">warehouse</span>
|
||||
<p className="body-md" style={{ fontWeight: 600, marginTop: 8 }}>未配置仓库管理权限</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4, lineHeight: 1.5 }}>
|
||||
购酒订单由总部履约,不会推送到本账号。如需管仓发货,请联系总部配置仓库。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{canStores && (
|
||||
<>
|
||||
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
|
||||
<div className="partner-bento-header">
|
||||
<h2 className="headline-md">门店总数</h2>
|
||||
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
|
||||
</div>
|
||||
<div className="partner-store-stats">
|
||||
<div className="partner-store-stat">
|
||||
<span className="partner-dot partner-dot--green" />
|
||||
<div>
|
||||
<p className="label-md text-muted">正常运营</p>
|
||||
<p className="headline-md">{activeStores}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-store-stat">
|
||||
<span className="partner-dot partner-dot--red" />
|
||||
<div>
|
||||
<p className="label-md text-muted">异常/闭店</p>
|
||||
<p className="headline-md">{abnormalStores}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-expansion-card">
|
||||
<h2 className="headline-md" style={{ marginBottom: 16 }}>拓店情况</h2>
|
||||
<div className="partner-expansion-split">
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>本月新增签约</p>
|
||||
<div className="partner-expansion-value-row">
|
||||
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
|
||||
{monthGrowthPct !== 0 && (
|
||||
<span className={`partner-expansion-trend${monthGrowthPct > 0 ? ' partner-expansion-trend--up' : ' partner-expansion-trend--down'}`}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>
|
||||
{monthGrowthPct > 0 ? 'trending_up' : 'trending_down'}
|
||||
</span>
|
||||
{Math.abs(monthGrowthPct)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待审核门店</p>
|
||||
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<LeaderboardPreview entries={leaderboardEntries} />
|
||||
</main>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { isPrimaryAccount } from '../lib/partnerAccess';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
|
||||
const PERIOD_TABS: { key: PartnerLeaderboardPeriod; label: string }[] = [
|
||||
{ key: 'month', label: '本月' },
|
||||
{ key: 'lastMonth', label: '上月' },
|
||||
{ key: 'total', label: '累计' },
|
||||
];
|
||||
|
||||
function periodStatLabel(period: PartnerLeaderboardPeriod): string {
|
||||
if (period === 'month') return '本月拓店';
|
||||
if (period === 'lastMonth') return '上月拓店';
|
||||
return '累计拓店';
|
||||
}
|
||||
|
||||
function periodSubLabel(period: PartnerLeaderboardPeriod): string {
|
||||
if (period === 'total') return '累计';
|
||||
if (period === 'month') return '本月新增';
|
||||
return '上月新增';
|
||||
}
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
usePartnerPageView('partner_leaderboard_view');
|
||||
const navigate = useNavigate();
|
||||
const { account } = usePartnerSession();
|
||||
const showStaffFab = isPrimaryAccount(account);
|
||||
const [period, setPeriod] = useState<PartnerLeaderboardPeriod>('month');
|
||||
const [data, setData] = useState<PartnerLeaderboardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadLeaderboard = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
return fetchPartnerLeaderboard(period)
|
||||
.then(setData)
|
||||
.catch((e) => {
|
||||
setData(null);
|
||||
setError(e instanceof Error ? e.message : '加载失败');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [period]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadLeaderboard();
|
||||
}, [loadLeaderboard]);
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadLeaderboard} className="page-no-tab">
|
||||
<PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
|
||||
|
||||
<div className="partner-leaderboard-tabs">
|
||||
{PERIOD_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className={`partner-leaderboard-tab${period === tab.key ? ' active' : ''}`}
|
||||
onClick={() => setPeriod(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="label-md text-muted" style={{ padding: '0 20px 12px' }}>
|
||||
按拓店数排行,数据实时更新
|
||||
</p>
|
||||
|
||||
{loading && <div className="empty">加载中...</div>}
|
||||
{!loading && error && <div className="empty">{error}</div>}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<div style={{ padding: '0 20px 96px' }}>
|
||||
{data.self && (
|
||||
<section className="partner-leaderboard-self-card">
|
||||
<div className="partner-leaderboard-self-main">
|
||||
<div className="partner-leaderboard-self-rank">第 {data.self.rank} 名</div>
|
||||
<div>
|
||||
<p className="headline-md" style={{ color: '#fff' }}>
|
||||
{data.self.name}
|
||||
{data.self.isSelf ? ' (我)' : ''}
|
||||
</p>
|
||||
<p className="label-md" style={{ color: 'rgba(255,255,255,0.8)' }}>{data.self.roleLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-leaderboard-self-stats">
|
||||
<span>{periodStatLabel(period)}: {data.self.periodStores} 间</span>
|
||||
{data.self.beatPercent != null && (
|
||||
<span>击败 {data.self.beatPercent}% 合伙人</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<h2 className="headline-md" style={{ margin: '16px 0 12px' }}>实时排行</h2>
|
||||
|
||||
{data.list.length === 0 ? (
|
||||
<div className="empty">暂无排行数据</div>
|
||||
) : (
|
||||
data.list.map((entry) => (
|
||||
<div key={entry.accountId} className="partner-leaderboard-row">
|
||||
<div className="partner-leaderboard-rank">{entry.rank}</div>
|
||||
<div className="partner-leaderboard-info">
|
||||
<p className="headline-md">
|
||||
{entry.name}
|
||||
<span className="label-md text-muted" style={{ marginLeft: 6, fontWeight: 400 }}>
|
||||
({entry.roleLabel})
|
||||
</span>
|
||||
</p>
|
||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
||||
</div>
|
||||
<div className="partner-leaderboard-stat">
|
||||
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||
<p className="label-md text-muted">{periodSubLabel(period)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showStaffFab && (
|
||||
<Link to="/center/staff" className="partner-fab-round" aria-label="子账号管理">
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
</Link>
|
||||
)}
|
||||
</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,410 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
import {
|
||||
getLastPhone,
|
||||
getPartnerProfile,
|
||||
hasPartnerWxSession,
|
||||
request,
|
||||
saveRememberedSession,
|
||||
type PartnerSessionPayload,
|
||||
} from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { partnerHomePath } from '../lib/partnerAccess';
|
||||
import {
|
||||
bindPartnerWechatAfterSmsLogin,
|
||||
fetchClientConfig,
|
||||
loginPartnerWithWechat,
|
||||
} from '../lib/wechat-auth';
|
||||
import { toastError } from '../lib/toast';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
|
||||
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
||||
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
||||
|
||||
function AgreementCheckbox({
|
||||
agreed,
|
||||
onChange,
|
||||
inputRef,
|
||||
}: {
|
||||
agreed: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
inputRef?: RefObject<HTMLLabelElement>;
|
||||
}) {
|
||||
return (
|
||||
<label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}>
|
||||
<input type="checkbox" checked={agreed} onChange={(e) => onChange(e.target.checked)} />
|
||||
<span>
|
||||
我已阅读并同意{' '}
|
||||
<Link to="/legal/user-agreement" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
|
||||
《用户协议》
|
||||
</Link>{' '}
|
||||
与{' '}
|
||||
<Link to="/legal/privacy-policy" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
|
||||
《隐私政策》
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
if (phone.length < 7) return phone;
|
||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
function loadRememberedPhone(): { phone: string; remember: boolean } {
|
||||
try {
|
||||
const remember = localStorage.getItem(REMEMBER_FLAG_KEY) === '1';
|
||||
const phone = remember ? localStorage.getItem(REMEMBER_PHONE_KEY) || '' : '';
|
||||
return { phone, remember };
|
||||
} catch {
|
||||
return { phone: '', remember: false };
|
||||
}
|
||||
}
|
||||
|
||||
function formatPartnerError(e: unknown): string {
|
||||
const text = e instanceof Error ? e.message : '操作失败';
|
||||
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
|
||||
return '未找到合伙人账号';
|
||||
}
|
||||
if (text.includes('合伙人账号已停用')) {
|
||||
return '合伙人账号已停用';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatWechatError(e: unknown): string {
|
||||
const text = e instanceof Error ? e.message : '微信登录失败';
|
||||
if (text.includes('首次登录') || text.includes('手机验证码')) {
|
||||
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { applySession, refresh, account } = usePartnerSession();
|
||||
const [params] = useSearchParams();
|
||||
const quick = params.get('quick') === '1';
|
||||
const savedProfile = getPartnerProfile();
|
||||
const remembered = loadRememberedPhone();
|
||||
const [phone, setPhone] = useState(remembered.phone || getLastPhone());
|
||||
const [code, setCode] = useState('');
|
||||
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wxLoading, setWxLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
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));
|
||||
}, []);
|
||||
|
||||
const quickName = savedProfile?.name ?? '城市合伙人';
|
||||
const quickCompany = savedProfile?.companyName ?? '';
|
||||
const quickPhone = savedProfile?.phone || phone;
|
||||
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
const tip = '请先勾选并同意用户协议';
|
||||
setMsg(tip);
|
||||
toastError(tip);
|
||||
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function persistRememberAccount(nextPhone: string) {
|
||||
try {
|
||||
if (rememberAccount) {
|
||||
localStorage.setItem(REMEMBER_FLAG_KEY, '1');
|
||||
localStorage.setItem(REMEMBER_PHONE_KEY, nextPhone);
|
||||
} else {
|
||||
localStorage.removeItem(REMEMBER_FLAG_KEY);
|
||||
localStorage.removeItem(REMEMBER_PHONE_KEY);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCode() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('');
|
||||
try {
|
||||
await request('PARTNER_H5', '/partner/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
|
||||
silent: true,
|
||||
});
|
||||
setMsg('验证码已发送');
|
||||
setCodeCooldown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCodeCooldown((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
setMsg(formatPartnerError(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function finishLoginNavigate() {
|
||||
const me = await refresh();
|
||||
navigate(partnerHomePath(me ?? account ?? savedProfile));
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (!ensureAgreed()) return;
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
silent: true,
|
||||
});
|
||||
saveRememberedSession(data);
|
||||
applySession(data);
|
||||
persistRememberAccount(phone);
|
||||
if (isWechatEnv() && wxAuthorize) {
|
||||
setMsg('登录成功,正在关联微信…');
|
||||
await bindPartnerWechatAfterSmsLogin();
|
||||
return;
|
||||
}
|
||||
await finishLoginNavigate();
|
||||
} catch (e) {
|
||||
setMsg(formatPartnerError(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function wechatLogin() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('');
|
||||
if (!isWechatEnv()) {
|
||||
setMsg(WECHAT_INAPP_REQUIRED_MSG);
|
||||
toastError(WECHAT_INAPP_REQUIRED_MSG);
|
||||
return;
|
||||
}
|
||||
setWxLoading(true);
|
||||
try {
|
||||
// 直接发起 OAuth;未绑定账号由后端返回「请先短信登录」提示
|
||||
const session = await loginPartnerWithWechat();
|
||||
if (session) {
|
||||
applySession(session);
|
||||
await finishLoginNavigate();
|
||||
return;
|
||||
}
|
||||
if (session === null) {
|
||||
const tip = '微信授权暂未开启,请使用验证码登录';
|
||||
setMsg(tip);
|
||||
toastError(tip);
|
||||
}
|
||||
// session === undefined:已跳转微信授权页
|
||||
} catch (e) {
|
||||
const tip = formatWechatError(e);
|
||||
setMsg(tip);
|
||||
toastError(tip);
|
||||
} finally {
|
||||
setWxLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (quick) {
|
||||
const canWechatQuick =
|
||||
wxAuthorize &&
|
||||
isWechatEnv() &&
|
||||
hasPartnerWxSession() &&
|
||||
!!savedProfile &&
|
||||
savedProfile.hasWechat === true;
|
||||
|
||||
return (
|
||||
<div className="partner-auth-page partner-auth-page--quick">
|
||||
<header className="partner-auth-brand">
|
||||
<div className="partner-quick-avatar" style={{ width: 120, height: 120, margin: '0 auto 16px' }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 48 }}>wine_bar</span>
|
||||
</div>
|
||||
<h1 className="partner-auth-title" style={{ fontSize: 20 }}>杜康好客</h1>
|
||||
<p className="partner-auth-subtitle" style={{ fontSize: 12, letterSpacing: '0.2em', textTransform: 'uppercase' }}>城市合伙人端</p>
|
||||
</header>
|
||||
|
||||
<section className="partner-glass-card">
|
||||
<div className="partner-quick-badge">已识别账号</div>
|
||||
<div className="partner-quick-avatar">
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h2 className="headline-md">
|
||||
{quickName}
|
||||
{quickCompany ? (
|
||||
<span className="text-muted body-md" style={{ fontWeight: 400 }}> ({quickCompany})</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<p className="text-muted body-md" style={{ letterSpacing: '0.1em', marginTop: 4 }}>
|
||||
{quickPhone ? maskPhone(quickPhone) : '暂无已保存账号'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
|
||||
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
|
||||
<AgreementCheckbox
|
||||
agreed={agreed}
|
||||
onChange={setAgreed}
|
||||
inputRef={agreementRef}
|
||||
/>
|
||||
{canWechatQuick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
style={{ marginTop: 16 }}
|
||||
disabled={wxLoading}
|
||||
onClick={() => void wechatLogin()}
|
||||
>
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
||||
</button>
|
||||
) : (
|
||||
<p className="partner-auth-msg" style={{ textAlign: 'center', marginTop: 12, marginBottom: 12 }}>
|
||||
{wxAuthorize && !isWechatEnv()
|
||||
? '请在微信内打开以使用一键登录'
|
||||
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
||||
</p>
|
||||
)}
|
||||
{!canWechatQuick && (
|
||||
<Link to="/login" className="partner-btn-primary" style={{ display: 'block', textAlign: 'center', textDecoration: 'none', marginTop: 12 }}>
|
||||
验证码登录
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/login" className="partner-btn-ghost" style={{ display: 'block', marginTop: 12 }}>切换账号</Link>
|
||||
</nav>
|
||||
|
||||
<footer className="partner-auth-footer">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className="material-symbols-outlined">verified_user</span>
|
||||
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||||
{canWechatQuick ? '微信验证 · 7 天内免登录' : 'Secured by Dukang Heritage'}
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="partner-auth-page">
|
||||
<div className="partner-auth-brand">
|
||||
<div className="partner-auth-logo-circle">
|
||||
<span className="material-symbols-outlined">wine_bar</span>
|
||||
</div>
|
||||
<h1 className="partner-auth-title">杜康好客</h1>
|
||||
<p className="partner-auth-subtitle">城市合伙人端</p>
|
||||
</div>
|
||||
|
||||
<main className="partner-auth-card">
|
||||
<h2 className="partner-auth-card-title">城市合伙人登录</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="partner-input-wrap">
|
||||
<span className="material-symbols-outlined partner-input-icon">smartphone</span>
|
||||
<input
|
||||
className="partner-input"
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={(e) => {
|
||||
setPhone(e.target.value);
|
||||
setMsg('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="partner-input-row">
|
||||
<div className="partner-input-wrap">
|
||||
<span className="material-symbols-outlined partner-input-icon">shield</span>
|
||||
<input
|
||||
className="partner-input"
|
||||
type="text"
|
||||
maxLength={6}
|
||||
placeholder="验证码"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="partner-code-btn" onClick={sendCode} disabled={codeCooldown > 0}>
|
||||
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="partner-remember-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberAccount}
|
||||
onChange={(e) => setRememberAccount(e.target.checked)}
|
||||
/>
|
||||
<span>记住账号</span>
|
||||
</label>
|
||||
|
||||
<AgreementCheckbox
|
||||
agreed={agreed}
|
||||
onChange={setAgreed}
|
||||
inputRef={agreementRef}
|
||||
/>
|
||||
|
||||
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
|
||||
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}>
|
||||
<span>{loading ? '登录中...' : '登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
|
||||
{wxAuthorize && (
|
||||
<>
|
||||
<div className="partner-auth-divider"><span>其他登录方式</span></div>
|
||||
<button type="button" className="partner-btn-wechat" onClick={() => void wechatLogin()} disabled={wxLoading}>
|
||||
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
|
||||
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
|
||||
</svg>
|
||||
<span>{wxLoading ? '跳转授权中...' : '微信一键登录'}</span>
|
||||
</button>
|
||||
{msg && (
|
||||
<p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="partner-auth-msg" style={{ textAlign: 'center' }}>
|
||||
请在微信内打开;首次请先用验证码登录,成功后将自动关联微信,之后可一键登录
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{hasPartnerWxSession() && savedProfile?.hasWechat && (
|
||||
<Link to="/login?quick=1" className="partner-link">微信快捷登录</Link>
|
||||
)}
|
||||
|
||||
<footer className="partner-auth-footer">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className="material-symbols-outlined">verified_user</span>
|
||||
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.15em', textTransform: 'uppercase' }}>SECURED BY DUKANG HERITAGE</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
|
||||
const TIMELINE = [
|
||||
{ key: 'confirm', label: '待确认' },
|
||||
{ key: 'accepted', label: '已接单' },
|
||||
{ key: 'delivering', label: '配送中' },
|
||||
{ key: 'shipping', label: '运输中', desc: '包裹正在送往目的地' },
|
||||
{ key: 'done', label: '已送达' },
|
||||
];
|
||||
|
||||
function statusIndex(status: string) {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s === 'COMPLETED') return 4;
|
||||
if (s.includes('SHIP') || s === 'OUT_WAREHOUSE') return 3;
|
||||
if (s === 'PENDING_RECEIVE') return 3;
|
||||
if (s.includes('DELIVER')) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function statusBanner(status: string) {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s.includes('SHIP') || s === 'OUT_WAREHOUSE') return { title: '运输中', desc: '预计今日 18:00 前送达', icon: 'local_shipping' };
|
||||
if (s === 'COMPLETED') return { title: '已完成', desc: '订单已送达', icon: 'check_circle' };
|
||||
if (s.includes('PENDING')) return { title: '待发货', desc: '商家正在备货', icon: 'inventory_2' };
|
||||
return { title: status, desc: '', icon: 'receipt_long' };
|
||||
}
|
||||
|
||||
function canShip(status: string) {
|
||||
const s = status.toUpperCase();
|
||||
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
|
||||
}
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const { id } = useParams();
|
||||
usePartnerPageView('partner_order_detail_view', id ? { orderId: id } : undefined);
|
||||
const navigate = useNavigate();
|
||||
const [order, setOrder] = useState<Record<string, unknown> | null>(null);
|
||||
const [shipping, setShipping] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = '订单详情';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) void request<Record<string, unknown>>('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
|
||||
}, [id]);
|
||||
|
||||
async function reload() {
|
||||
if (!id) return;
|
||||
const next = await request<Record<string, unknown>>('PARTNER_H5', `/partner/orders/${id}`);
|
||||
setOrder(next);
|
||||
}
|
||||
|
||||
async function advance(status: string) {
|
||||
if (!id) return;
|
||||
setShipping(true);
|
||||
try {
|
||||
await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ targetStatus: status }),
|
||||
});
|
||||
await reload();
|
||||
toastSuccess('已更新配送状态');
|
||||
} finally {
|
||||
setShipping(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleContactCourier() {
|
||||
const delivery = order?.delivery as Record<string, unknown> | undefined;
|
||||
const trackingNo = String(delivery?.trackingNo || delivery?.providerOrderNo || '').trim();
|
||||
if (trackingNo) {
|
||||
void navigator.clipboard?.writeText(trackingNo);
|
||||
toastSuccess(`运单号已复制:${trackingNo}`);
|
||||
return;
|
||||
}
|
||||
toastError('暂无配送员联系方式');
|
||||
}
|
||||
|
||||
if (!order) return <div className="empty">加载中...</div>;
|
||||
|
||||
const banner = statusBanner(String(order.status));
|
||||
const currentIdx = statusIndex(String(order.status));
|
||||
const payAmount = Number(order.payAmount || 0);
|
||||
const showShip = canShip(String(order.status));
|
||||
|
||||
return (
|
||||
<div className="partner-order-detail">
|
||||
<PageHeader title="订单详情" onBack={() => navigate('/orders')} />
|
||||
|
||||
<section className="partner-status-banner">
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontVariationSettings: "'FILL' 1" }}>{banner.icon}</span>
|
||||
<h2 className="headline-md" style={{ color: '#fff' }}>{banner.title}</h2>
|
||||
</div>
|
||||
{banner.desc && <p className="body-md" style={{ color: 'rgba(255,255,255,0.9)' }}>{banner.desc}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-timeline">
|
||||
<div className="partner-timeline-line" />
|
||||
{TIMELINE.map((item, i) => {
|
||||
const done = i < currentIdx;
|
||||
const current = i === currentIdx;
|
||||
const pending = i > currentIdx;
|
||||
return (
|
||||
<div key={item.key} className="partner-timeline-item">
|
||||
<div className={`partner-timeline-dot${done ? ' partner-timeline-dot--done' : ''}${current ? ' partner-timeline-dot--current' : ''}${pending ? ' partner-timeline-dot--pending' : ''}`}>
|
||||
{done && <span className="material-symbols-outlined">check</span>}
|
||||
</div>
|
||||
<div>
|
||||
<p className={current ? 'headline-md text-primary' : 'label-md'} style={{ fontWeight: current ? 700 : 500 }}>{item.label}</p>
|
||||
{item.desc && current && <p className="text-muted body-md" style={{ marginTop: 4 }}>{item.desc}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="partner-detail-section partner-detail-section--accent">
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<div className="partner-order-product-img" style={{ width: 96, height: 96, position: 'relative' }}>
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-surface-container)' }}>
|
||||
<span className="material-symbols-outlined text-muted" style={{ fontSize: 32 }}>liquor</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 className="headline-md">{String(order.productName || '杜康好酒')}</h3>
|
||||
<p className="text-muted body-md" style={{ marginTop: 4 }}>x{Number(order.quantity || 1)}</p>
|
||||
<div style={{ marginTop: 8, display: 'flex', alignItems: 'baseline', gap: 2 }}>
|
||||
<span className="label-md text-primary">¥</span>
|
||||
<span className="amount-lg">{payAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-detail-section">
|
||||
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
|
||||
收货地址
|
||||
</h4>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<span className="material-symbols-outlined text-muted">location_on</span>
|
||||
<div>
|
||||
<p className="headline-md" style={{ fontSize: 16 }}>
|
||||
{String(order.receiverName)} <span className="body-md text-muted" style={{ fontWeight: 400 }}>{String(order.receiverPhone)}</span>
|
||||
</p>
|
||||
<p className="text-muted body-md" style={{ marginTop: 4, lineHeight: 1.5 }}>{String(order.receiverAddress)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="partner-order-footer">
|
||||
<div className="partner-order-footer-actions">
|
||||
{showShip && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary partner-order-ship-btn--footer"
|
||||
disabled={shipping}
|
||||
onClick={() => void advance('OUT_WAREHOUSE')}
|
||||
>
|
||||
{shipping ? '发货中…' : '确认发货'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}
|
||||
onClick={handleContactCourier}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
|
||||
联系配送员
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{import.meta.env.DEV && (
|
||||
<details className="partner-dev-tools">
|
||||
<summary>Dev: Mock 推进配送</summary>
|
||||
{['OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE', 'COMPLETED'].map((s) => (
|
||||
<button key={s} type="button" onClick={() => void advance(s)}>{s}</button>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { hasWarehouseAccess } from '../lib/partnerAccess';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
|
||||
type DateFilter = 'today' | '7d' | '30d';
|
||||
type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL';
|
||||
|
||||
function orderStatusLabel(status: string) {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s.includes('SHIP') || s === 'OUT_WAREHOUSE') return { label: '运输中', color: 'var(--color-status-blue)' };
|
||||
if (s === 'COMPLETED') return { label: '已完成', color: 'var(--color-success-green)' };
|
||||
if (s.includes('PENDING') || s === 'PAID') return { label: '待发货', color: 'var(--color-secondary)' };
|
||||
if (s.includes('ERROR') || s.includes('ABNORMAL')) return { label: '异常', color: 'var(--color-error)' };
|
||||
return { label: status, color: 'var(--color-subtle-gray)' };
|
||||
}
|
||||
|
||||
const DATE_FILTERS: { key: DateFilter; label: string }[] = [
|
||||
{ key: 'today', label: '今日' },
|
||||
{ key: '7d', label: '近7天' },
|
||||
{ key: '30d', label: '近30天' },
|
||||
];
|
||||
|
||||
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'PENDING_SHIP', label: '待发货' },
|
||||
{ key: 'SHIPPING', label: '运输中' },
|
||||
{ key: 'COMPLETED', label: '已完成' },
|
||||
{ key: 'ABNORMAL', label: '异常' },
|
||||
];
|
||||
|
||||
type OrderListPageProps = {
|
||||
/** Tab 根页:无返回顶栏 */
|
||||
tabRoot?: boolean;
|
||||
};
|
||||
|
||||
type OrdersResponse = {
|
||||
list: Array<Record<string, unknown>>;
|
||||
hasWarehouseAccess?: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
usePartnerPageView('partner_order_list_view');
|
||||
const navigate = useNavigate();
|
||||
const { account } = usePartnerSession();
|
||||
const warehouseOk = hasWarehouseAccess(account);
|
||||
const [data, setData] = useState<OrdersResponse>({ list: [] });
|
||||
const [tab, setTab] = useState<'orders' | 'coupons'>('orders');
|
||||
const [dateFilter, setDateFilter] = useState<DateFilter>('today');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||
const [shippingId, setShippingId] = useState<string | null>(null);
|
||||
const [shipForm, setShipForm] = useState({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
|
||||
const [shipModalId, setShipModalId] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = '订单管理';
|
||||
}, []);
|
||||
|
||||
const loadOrders = useCallback(() => {
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!warehouseOk) {
|
||||
setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' });
|
||||
setLoaded(true);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return request<OrdersResponse>('PARTNER_H5', '/partner/orders')
|
||||
.then((res) => {
|
||||
setData({
|
||||
list: Array.isArray(res.list) ? res.list : [],
|
||||
hasWarehouseAccess: res.hasWarehouseAccess,
|
||||
message: res.message,
|
||||
});
|
||||
})
|
||||
.catch(() => setData({ list: [] }))
|
||||
.finally(() => setLoaded(true));
|
||||
}, [navigate, warehouseOk]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOrders();
|
||||
}, [loadOrders]);
|
||||
|
||||
const filtered = useMemo(() => data.list.filter((o) => {
|
||||
if (statusFilter === 'ALL') return true;
|
||||
const s = String(o.status).toUpperCase();
|
||||
if (statusFilter === 'PENDING_SHIP') return s.includes('PENDING') || s === 'PAID';
|
||||
if (statusFilter === 'SHIPPING') return s.includes('SHIP') || s === 'OUT_WAREHOUSE';
|
||||
if (statusFilter === 'COMPLETED') return s === 'COMPLETED';
|
||||
if (statusFilter === 'ABNORMAL') return s.includes('ERROR') || s.includes('ABNORMAL');
|
||||
return true;
|
||||
}), [data.list, statusFilter]);
|
||||
|
||||
async function handleShip(orderId: string, e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShipModalId(orderId);
|
||||
setShipForm({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
|
||||
}
|
||||
|
||||
async function submitManualShip() {
|
||||
if (!shipModalId) return;
|
||||
if (!shipForm.logisticsCompany.trim() || !shipForm.trackingNo.trim()) {
|
||||
window.alert('请填写快递公司和运单号');
|
||||
return;
|
||||
}
|
||||
setShippingId(shipModalId);
|
||||
try {
|
||||
await request('PARTNER_H5', `/partner/orders/${shipModalId}/manual-ship`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(shipForm),
|
||||
});
|
||||
setShipModalId(null);
|
||||
const next = await request<OrdersResponse>('PARTNER_H5', '/partner/orders');
|
||||
setData({
|
||||
list: Array.isArray(next.list) ? next.list : [],
|
||||
hasWarehouseAccess: next.hasWarehouseAccess,
|
||||
message: next.message,
|
||||
});
|
||||
} catch (err) {
|
||||
window.alert(err instanceof Error ? err.message : '发货失败');
|
||||
} finally {
|
||||
setShippingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function canShip(status: string) {
|
||||
const s = status.toUpperCase();
|
||||
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
|
||||
}
|
||||
|
||||
if (loaded && !warehouseOk) {
|
||||
return (
|
||||
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
|
||||
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
|
||||
<div className="partner-warehouse-denied" style={{ margin: 20, padding: 32, textAlign: 'center' }}>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 40 }}>warehouse</span>
|
||||
<p className="headline-md" style={{ marginTop: 12 }}>未配置仓库管理权限</p>
|
||||
<p className="body-md text-muted" style={{ marginTop: 8, lineHeight: 1.6 }}>
|
||||
购酒订单由总部履约,不会推送到本账号。如需管仓发货,请联系总部配置仓库。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadOrders} className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
|
||||
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
|
||||
|
||||
<div className="partner-segment">
|
||||
<button type="button" className={tab === 'orders' ? 'active' : ''} onClick={() => setTab('orders')}>订单列表</button>
|
||||
{!tabRoot && (
|
||||
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}>权益记录</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tab === 'orders' && (
|
||||
<>
|
||||
<div className="partner-filter-row" style={{ marginBottom: 8 }}>
|
||||
{DATE_FILTERS.map((f) => (
|
||||
<button key={f.key} type="button" className={`partner-chip${dateFilter === f.key ? ' active' : ''}`} style={{ border: dateFilter === f.key ? 'none' : '1px solid var(--color-outline-variant)', background: dateFilter === f.key ? undefined : '#fff' }} onClick={() => setDateFilter(f.key)}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="partner-filter-row" style={{ borderBottom: '1px solid var(--color-surface-container-high)', paddingBottom: 8, marginBottom: 16 }}>
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button key={f.key} type="button" className={`partner-filter-tab${statusFilter === f.key ? ' active' : ''}`} onClick={() => setStatusFilter(f.key)}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && <div className="empty">暂无订单</div>}
|
||||
|
||||
{filtered.map((o) => {
|
||||
const st = orderStatusLabel(String(o.status));
|
||||
const payAmount = Number(o.payAmount || 0);
|
||||
const orderId = String(o.id);
|
||||
return (
|
||||
<div key={orderId} className="partner-order-card-wrap">
|
||||
<Link to={`/orders/${orderId}`} className="partner-order-card">
|
||||
<div className="partner-order-card-top">
|
||||
<span className="label-md text-muted">NO. {String(o.orderNo)}</span>
|
||||
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
|
||||
</div>
|
||||
<div className="partner-order-product">
|
||||
<div className="partner-order-product-img" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span className="material-symbols-outlined text-muted">liquor</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
|
||||
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-order-address">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
|
||||
<p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
|
||||
</div>
|
||||
</Link>
|
||||
{canShip(String(o.status)) && !(o.delivery as { trackingNo?: string } | undefined)?.trackingNo && (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-order-ship-btn"
|
||||
disabled={shippingId === orderId}
|
||||
onClick={(e) => void handleShip(orderId, e)}
|
||||
>
|
||||
填写运单
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'coupons' && !tabRoot && (
|
||||
<div style={{ padding: '0 20px' }}>
|
||||
<div className="partner-revenue-card" style={{ marginBottom: 16 }}>
|
||||
<p className="partner-revenue-label">累计已发放权益金额</p>
|
||||
<div className="partner-revenue-amount" style={{ fontSize: 28 }}>42,800.00</div>
|
||||
</div>
|
||||
<p className="text-muted body-md text-center">权益记录 preV1 占位</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shipModalId && (
|
||||
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setShipModalId(null)}>
|
||||
<div className="partner-ship-modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="headline-md">填写运单</h3>
|
||||
<label className="partner-ship-field">
|
||||
<span>快递公司</span>
|
||||
<input
|
||||
value={shipForm.logisticsCompany}
|
||||
onChange={(e) => setShipForm((f) => ({ ...f, logisticsCompany: e.target.value }))}
|
||||
placeholder="如 顺丰速运"
|
||||
/>
|
||||
</label>
|
||||
<label className="partner-ship-field">
|
||||
<span>运单号</span>
|
||||
<input
|
||||
value={shipForm.trackingNo}
|
||||
onChange={(e) => setShipForm((f) => ({ ...f, trackingNo: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="partner-ship-field">
|
||||
<span>查询链接(可选)</span>
|
||||
<input
|
||||
value={shipForm.manualQueryUrl}
|
||||
onChange={(e) => setShipForm((f) => ({ ...f, manualQueryUrl: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</label>
|
||||
<div className="partner-ship-actions">
|
||||
<button type="button" className="partner-btn-secondary" onClick={() => setShipModalId(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
disabled={shippingId === shipModalId}
|
||||
onClick={() => void submitManualShip()}
|
||||
>
|
||||
{shippingId === shipModalId ? '提交中…' : '确认发货'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getPartnerNavKind } from '../lib/partnerAccess';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import CenterPage from './CenterPage';
|
||||
|
||||
/** 子账号个人中心:复用精简版 Center 布局 */
|
||||
export default function PartnerMePage() {
|
||||
const { account } = usePartnerSession();
|
||||
const navKind = getPartnerNavKind(account);
|
||||
const isWarehouse = navKind === 'warehouse_staff';
|
||||
|
||||
return <CenterPage variant="sub" roleLabel={isWarehouse ? '仓库管理员' : '拓店员'} />;
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import QRCode from 'qrcode';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { invokeWechatPay } from '@dukang/weixin-sdk';
|
||||
import type { PartnerProxyOrderListItem, ProxyOrderPayResponse, ProxyPayMethod } from '@dukang/shared-types';
|
||||
import { getToken, request } from '../lib/api';
|
||||
import {
|
||||
proxyOrderPayLabel,
|
||||
proxyOrderStatusColor,
|
||||
proxyOrderStatusLabel,
|
||||
} from '../lib/proxyOrderStatus';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
import { fetchPartnerProfile, partnerHasWechatBinding } from '../lib/wechat-auth';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function fmtTime(value: string | undefined) {
|
||||
if (!value) return '—';
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function deliveryLabel(order: PartnerProxyOrderListItem) {
|
||||
const t = String(order.deliveryType || '').toUpperCase();
|
||||
if (t === 'ON_SITE_PICKUP') return '现场提货';
|
||||
if (t === 'CROSS_CITY') return '跨城配送';
|
||||
if (t === 'LOCAL') return '同城配送';
|
||||
return '代下单';
|
||||
}
|
||||
|
||||
type TrackNode = {
|
||||
time?: string;
|
||||
status?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type TrackResult = {
|
||||
nodes?: TrackNode[];
|
||||
trackingNo?: string | null;
|
||||
provider?: string | null;
|
||||
manualQueryUrl?: string | null;
|
||||
};
|
||||
|
||||
export default function ProxyOrderDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [order, setOrder] = useState<PartnerProxyOrderListItem | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [track, setTrack] = useState<TrackResult | null>(null);
|
||||
const [trackError, setTrackError] = useState('');
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [payMsg, setPayMsg] = useState('');
|
||||
const [payMethod, setPayMethod] = useState<ProxyPayMethod>('NATIVE');
|
||||
const [codeUrl, setCodeUrl] = useState<string | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const pollRef = useRef<number | null>(null);
|
||||
|
||||
function stopPoll() {
|
||||
if (pollRef.current != null) {
|
||||
window.clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => () => stopPoll(), []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = '代下单详情';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
void request<PartnerProxyOrderListItem>('PARTNER_H5', `/partner/proxy-orders/${id}`)
|
||||
.then((data) => {
|
||||
setOrder(data);
|
||||
if (data.proxyPayMethod) {
|
||||
setPayMethod(data.proxyPayMethod);
|
||||
}
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !order) return;
|
||||
const dt = String(order.deliveryType || '').toUpperCase();
|
||||
if (dt === 'ON_SITE_PICKUP' || order.payStatus !== 'PAID') {
|
||||
setTrack(null);
|
||||
return;
|
||||
}
|
||||
void request<TrackResult>('PARTNER_H5', `/partner/proxy-orders/${id}/track`, { silent: true })
|
||||
.then(setTrack)
|
||||
.catch((e) => setTrackError(e instanceof Error ? e.message : '物流暂不可用'));
|
||||
}, [id, order]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!codeUrl) {
|
||||
setQrDataUrl(null);
|
||||
return;
|
||||
}
|
||||
void QRCode.toDataURL(codeUrl, { width: 220, margin: 1 })
|
||||
.then(setQrDataUrl)
|
||||
.catch(() => setQrDataUrl(null));
|
||||
}, [codeUrl]);
|
||||
|
||||
const unpaid = order?.payStatus === 'UNPAID' || order?.status === 'PENDING_PAY';
|
||||
|
||||
function startPoll(orderId: string) {
|
||||
stopPoll();
|
||||
pollRef.current = window.setInterval(() => {
|
||||
void request<{ payStatus: string; orderNo: string }>(
|
||||
'PARTNER_H5',
|
||||
`/partner/proxy-orders/${orderId}/pay-status`,
|
||||
{ silent: true },
|
||||
)
|
||||
.then((st) => {
|
||||
if (st.payStatus === 'PAID') {
|
||||
stopPoll();
|
||||
toastSuccess(`支付成功:${st.orderNo}`);
|
||||
void request<PartnerProxyOrderListItem>('PARTNER_H5', `/partner/proxy-orders/${orderId}`)
|
||||
.then(setOrder)
|
||||
.catch(() => undefined);
|
||||
setCodeUrl(null);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function startPay(method: ProxyPayMethod) {
|
||||
if (!id) return;
|
||||
const locked = order?.proxyPayMethod;
|
||||
if (locked && locked !== method) {
|
||||
setPayMsg(
|
||||
locked === 'JSAPI'
|
||||
? '该订单已发起微信代付,请先取消支付后重新下单'
|
||||
: '该订单已生成收款码,请先取消支付后重新下单',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setPaying(true);
|
||||
setPayMsg('');
|
||||
setPayMethod(method);
|
||||
try {
|
||||
if (method === 'JSAPI') {
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error('请在微信内打开合伙人端以使用微信代付');
|
||||
}
|
||||
const profile = await fetchPartnerProfile();
|
||||
if (!partnerHasWechatBinding(profile)) {
|
||||
throw new Error('请先绑定微信后再代付');
|
||||
}
|
||||
}
|
||||
|
||||
const pay = await request<ProxyOrderPayResponse>(
|
||||
'PARTNER_H5',
|
||||
`/partner/proxy-orders/${id}/pay`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payMethod: method }),
|
||||
silent: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (pay.mode === 'mock') {
|
||||
toastSuccess('支付成功');
|
||||
const refreshed = await request<PartnerProxyOrderListItem>(
|
||||
'PARTNER_H5',
|
||||
`/partner/proxy-orders/${id}`,
|
||||
);
|
||||
setOrder(refreshed);
|
||||
setCodeUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pay.mode === 'native' && pay.codeUrl) {
|
||||
setCodeUrl(pay.codeUrl);
|
||||
setOrder((prev) => (prev ? { ...prev, proxyPayMethod: method } : prev));
|
||||
startPoll(id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pay.mode === 'jsapi' && pay.prepay) {
|
||||
setCodeUrl(null);
|
||||
setOrder((prev) => (prev ? { ...prev, proxyPayMethod: method } : prev));
|
||||
await invokeWechatPay(pay.prepay, {
|
||||
apiBase: '/api/v1',
|
||||
clientApp: 'PARTNER_H5',
|
||||
getAccessToken: getToken,
|
||||
platform: 'wechat-h5',
|
||||
});
|
||||
startPoll(id);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('支付发起失败');
|
||||
} catch (e) {
|
||||
setPayMsg(e instanceof Error ? e.message : '支付失败');
|
||||
toastError(e instanceof Error ? e.message : '支付失败');
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelPay() {
|
||||
if (!id) return;
|
||||
setCancelling(true);
|
||||
setPayMsg('');
|
||||
stopPoll();
|
||||
try {
|
||||
await request('PARTNER_H5', `/partner/proxy-orders/${id}/cancel-pay`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
});
|
||||
toastSuccess('已取消支付,可重新下单并选择其他支付方式');
|
||||
navigate('/center/proxy-orders');
|
||||
} catch (e) {
|
||||
setPayMsg(e instanceof Error ? e.message : '取消失败');
|
||||
toastError(e instanceof Error ? e.message : '取消失败');
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
|
||||
const lockedPayMethod = order?.proxyPayMethod ?? null;
|
||||
|
||||
const img = order?.imageResource?.url || '';
|
||||
const isOnSite = String(order?.deliveryType || '').toUpperCase() === 'ON_SITE_PICKUP';
|
||||
|
||||
return (
|
||||
<div className="page-no-tab partner-orders-page">
|
||||
<PageHeader title="代下单详情" onBack={() => navigate('/center/proxy-orders')} />
|
||||
|
||||
{error && (
|
||||
<p className="partner-form-error" role="alert" style={{ margin: '16px 20px' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!order && !error && <p className="label-md text-muted" style={{ margin: 20 }}>加载中…</p>}
|
||||
|
||||
{order && (
|
||||
<div style={{ padding: '0 16px 32px' }}>
|
||||
<div className="partner-order-card" style={{ pointerEvents: 'none' }}>
|
||||
<div className="partner-order-card-top">
|
||||
<span className="label-md text-muted">NO. {order.orderNo}</span>
|
||||
<span
|
||||
className="label-md"
|
||||
style={{ color: proxyOrderStatusColor(order.status), fontWeight: 600 }}
|
||||
>
|
||||
{proxyOrderStatusLabel(order.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="partner-order-product">
|
||||
<div
|
||||
className="partner-order-product-img"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
background: img ? `center/cover no-repeat url(${img})` : undefined,
|
||||
}}
|
||||
>
|
||||
{!img && <span className="material-symbols-outlined text-muted">liquor</span>}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3 className="headline-md">{order.productName || '杜康好酒'}</h3>
|
||||
{order.productSpec && (
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>
|
||||
{order.productSpec}
|
||||
</p>
|
||||
)}
|
||||
<p className="body-md" style={{ marginTop: 8 }}>
|
||||
×{order.quantity} · ¥{fmtMoney(Number(order.payAmount || 0))}
|
||||
</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>
|
||||
权益 ¥{fmtMoney(Number(order.benefitAmount || 0))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="partner-form-card" style={{ marginTop: 12 }}>
|
||||
<h3 className="headline-md" style={{ marginBottom: 12 }}>客户信息</h3>
|
||||
<p className="body-md">姓名:{order.receiverName || '—'}</p>
|
||||
<p className="body-md" style={{ marginTop: 8 }}>手机:{order.receiverPhone || '—'}</p>
|
||||
<p className="body-md" style={{ marginTop: 8 }}>
|
||||
地址:{order.receiverAddress || (isOnSite ? '现场提货' : '—')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card" style={{ marginTop: 12 }}>
|
||||
<h3 className="headline-md" style={{ marginBottom: 12 }}>订单信息</h3>
|
||||
<p className="body-md">配送方式:{deliveryLabel(order)}</p>
|
||||
<p className="body-md" style={{ marginTop: 8 }}>下单时间:{fmtTime(order.createdAt)}</p>
|
||||
<p className="body-md" style={{ marginTop: 8 }}>
|
||||
支付状态:{proxyOrderPayLabel(order.payStatus)}
|
||||
</p>
|
||||
<p className="body-md" style={{ marginTop: 8 }}>
|
||||
订单状态:{proxyOrderStatusLabel(order.status)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{unpaid ? (
|
||||
<section className="partner-form-card" style={{ marginTop: 12 }}>
|
||||
<h3 className="headline-md" style={{ marginBottom: 12 }}>继续支付</h3>
|
||||
<div className="partner-proxy-mode-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`partner-proxy-mode-tab${payMethod === 'NATIVE' ? ' is-active' : ''}`}
|
||||
disabled={!!lockedPayMethod && lockedPayMethod !== 'NATIVE'}
|
||||
onClick={() => void startPay('NATIVE')}
|
||||
>
|
||||
收款码
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`partner-proxy-mode-tab${payMethod === 'JSAPI' ? ' is-active' : ''}`}
|
||||
disabled={!!lockedPayMethod && lockedPayMethod !== 'JSAPI'}
|
||||
onClick={() => void startPay('JSAPI')}
|
||||
>
|
||||
微信代付
|
||||
</button>
|
||||
</div>
|
||||
{lockedPayMethod ? (
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
已选择{lockedPayMethod === 'NATIVE' ? '收款码' : '微信代付'},切换方式请先取消支付
|
||||
</p>
|
||||
) : (
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
请选择支付方式并点击下方按钮发起支付
|
||||
</p>
|
||||
)}
|
||||
|
||||
{payMethod === 'NATIVE' ? (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
{qrDataUrl ? (
|
||||
<img src={qrDataUrl} alt="收款码" width={220} height={220} style={{ margin: '0 auto' }} />
|
||||
) : (
|
||||
<p className="label-md text-muted">{paying ? '生成收款码中…' : '暂无收款码'}</p>
|
||||
)}
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
请使用微信扫码支付,支付成功后自动更新
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-block"
|
||||
style={{ marginTop: 12 }}
|
||||
disabled={paying}
|
||||
onClick={() => void startPay('NATIVE')}
|
||||
>
|
||||
{paying ? '生成中…' : '重新生成收款码'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p className="label-md text-muted">将调起微信支付,由合伙人微信完成代付</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-block"
|
||||
style={{ marginTop: 12 }}
|
||||
disabled={paying}
|
||||
onClick={() => void startPay('JSAPI')}
|
||||
>
|
||||
{paying ? '支付中…' : '调起微信代付'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{payMsg ? (
|
||||
<p className="partner-form-error" role="alert" style={{ marginTop: 12 }}>
|
||||
{payMsg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-block"
|
||||
style={{ marginTop: 16 }}
|
||||
disabled={cancelling || paying}
|
||||
onClick={() => void cancelPay()}
|
||||
>
|
||||
{cancelling ? '取消中…' : '取消支付'}
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!isOnSite && order.payStatus === 'PAID' ? (
|
||||
<section className="partner-form-card" style={{ marginTop: 12 }}>
|
||||
<h3 className="headline-md" style={{ marginBottom: 12 }}>物流信息</h3>
|
||||
{trackError ? (
|
||||
<p className="label-md text-muted">{trackError}</p>
|
||||
) : !track ? (
|
||||
<p className="label-md text-muted">加载物流…</p>
|
||||
) : (
|
||||
<>
|
||||
{track.trackingNo ? (
|
||||
<p className="body-md" style={{ marginBottom: 8 }}>
|
||||
运单号:{track.trackingNo}
|
||||
{track.provider ? `(${track.provider})` : ''}
|
||||
</p>
|
||||
) : null}
|
||||
{track.manualQueryUrl ? (
|
||||
<p className="body-md" style={{ marginBottom: 8 }}>
|
||||
<a href={track.manualQueryUrl} target="_blank" rel="noreferrer">
|
||||
查看物流官网
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
{(track.nodes ?? []).length === 0 ? (
|
||||
<p className="label-md text-muted">暂无物流轨迹(待总部发货后更新)</p>
|
||||
) : (
|
||||
<ul style={{ paddingLeft: 18, margin: 0 }}>
|
||||
{(track.nodes ?? []).map((n, i) => (
|
||||
<li key={`${n.time}-${i}`} className="body-md" style={{ marginBottom: 8 }}>
|
||||
<div className="label-md text-muted">{fmtTime(n.time)}</div>
|
||||
<div>{n.description || n.status || '—'}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import type { PartnerProxyOrderListItem, PartnerProxyOrderListResponse } from '@dukang/shared-types';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import {
|
||||
proxyOrderStatusColor,
|
||||
proxyOrderStatusLabel,
|
||||
} from '../lib/proxyOrderStatus';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function fmtTime(value: string | undefined) {
|
||||
if (!value) return '—';
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function deliveryLabel(item: PartnerProxyOrderListItem) {
|
||||
const t = String(item.deliveryType || '').toUpperCase();
|
||||
if (t === 'ON_SITE_PICKUP') return '现场提货';
|
||||
if (t === 'CROSS_CITY') return '跨城配送';
|
||||
if (t === 'LOCAL') return '同城配送';
|
||||
return '线下代下单';
|
||||
}
|
||||
|
||||
function coverUrl(item: PartnerProxyOrderListItem) {
|
||||
return item.imageResource?.url || '';
|
||||
}
|
||||
|
||||
export default function ProxyOrderListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [list, setList] = useState<PartnerProxyOrderListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadList = useCallback(() => {
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return Promise.resolve();
|
||||
}
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: '1', pageSize: '50' });
|
||||
if (q.trim()) params.set('keyword', q.trim());
|
||||
return request<PartnerProxyOrderListResponse>('PARTNER_H5', `/partner/proxy-orders?${params}`)
|
||||
.then((res) => {
|
||||
setList(Array.isArray(res.list) ? res.list : []);
|
||||
setTotal(Number(res.total) || 0);
|
||||
})
|
||||
.catch(() => {
|
||||
setList([]);
|
||||
setTotal(0);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [navigate, q]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = '代下单列表';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadList();
|
||||
}, [loadList]);
|
||||
|
||||
function submitSearch(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setQ(keyword.trim());
|
||||
}
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadList} className="page-no-tab partner-orders-page">
|
||||
<PageHeader title="代下单列表" onBack={() => navigate('/center')} />
|
||||
|
||||
<div style={{ padding: '0 20px 12px' }}>
|
||||
<form className="partner-search" onSubmit={submitSearch}>
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
<input
|
||||
placeholder="订单号 / 手机号 / 姓名 / 商品"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
{loading ? '加载中…' : `共 ${total} 笔代下单`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 16px 24px' }}>
|
||||
{!loading && list.length === 0 && <div className="empty">暂无代下单记录</div>}
|
||||
|
||||
{list.map((o) => {
|
||||
const orderId = String(o.id);
|
||||
const img = coverUrl(o);
|
||||
return (
|
||||
<Link key={orderId} to={`/center/proxy-orders/${orderId}`} className="partner-order-card" style={{ marginBottom: 12 }}>
|
||||
<div className="partner-order-card-top">
|
||||
<span className="label-md text-muted">NO. {o.orderNo}</span>
|
||||
<span
|
||||
className="label-md"
|
||||
style={{ color: proxyOrderStatusColor(o.status), fontWeight: 600 }}
|
||||
>
|
||||
{proxyOrderStatusLabel(o.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="partner-order-product">
|
||||
<div
|
||||
className="partner-order-product-img"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
background: img ? `center/cover no-repeat url(${img})` : undefined,
|
||||
}}
|
||||
>
|
||||
{!img && <span className="material-symbols-outlined text-muted">liquor</span>}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3 className="headline-md line-2-clamp">{o.productName || '杜康好酒'}</h3>
|
||||
<p className="text-variant body-md" style={{ marginTop: 4 }}>
|
||||
×{o.quantity} · ¥{fmtMoney(Number(o.payAmount || 0))}
|
||||
</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>
|
||||
{deliveryLabel(o)} · {fmtTime(o.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-order-address">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>person</span>
|
||||
<p className="line-2-clamp">
|
||||
{o.receiverName || '客户'}
|
||||
{o.receiverPhone ? ` · ${o.receiverPhone}` : ''}
|
||||
{o.receiverAddress ? ` · ${o.receiverAddress}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import QRCode from 'qrcode';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { invokeWechatPay } from '@dukang/weixin-sdk';
|
||||
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
||||
import { getToken, request } from '../lib/api';
|
||||
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
|
||||
import {
|
||||
clearProxyOrderDraft,
|
||||
loadProxyOrderDraft,
|
||||
saveProxyOrderDraft,
|
||||
type ProxyOrderDraft,
|
||||
} from '../lib/proxyOrderDraft';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
import { fetchPartnerProfile, partnerHasWechatBinding } from '../lib/wechat-auth';
|
||||
import type {
|
||||
PartnerProxyDeliveryMode,
|
||||
PartnerProxyOrderCreateRequest,
|
||||
PartnerProxyOrderOptions,
|
||||
PartnerProxyOrderPreviewResult,
|
||||
ProxyOrderCreateResponse,
|
||||
ProxyOrderPayResponse,
|
||||
ProxyPayMethod,
|
||||
} from '@dukang/shared-types';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function initialDraft(): ProxyOrderDraft {
|
||||
return (
|
||||
loadProxyOrderDraft() ?? {
|
||||
phone: '',
|
||||
receiverName: '',
|
||||
regionCodes: [],
|
||||
addressDetail: '',
|
||||
productId: '',
|
||||
quantity: 2,
|
||||
promoCodeId: '',
|
||||
deliveryMode: 'ADDRESS',
|
||||
autoReceive: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProxyOrderPage() {
|
||||
usePartnerPageView('partner_proxy_order_view');
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const draft0 = useMemo(() => initialDraft(), []);
|
||||
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
|
||||
const [loadingOptions, setLoadingOptions] = useState(true);
|
||||
const [phone, setPhone] = useState(draft0.phone);
|
||||
const [receiverName, setReceiverName] = useState(draft0.receiverName);
|
||||
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
|
||||
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
|
||||
const [productId, setProductId] = useState(draft0.productId);
|
||||
const [quantity, setQuantity] = useState(draft0.quantity);
|
||||
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
|
||||
const [autoReceive, setAutoReceive] = useState(draft0.autoReceive);
|
||||
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [step, setStep] = useState<'form' | 'pay'>('form');
|
||||
const [created, setCreated] = useState<ProxyOrderCreateResponse | null>(null);
|
||||
const [payMethod, setPayMethod] = useState<ProxyPayMethod>('NATIVE');
|
||||
const [codeUrl, setCodeUrl] = useState<string | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [lockedPayMethod, setLockedPayMethod] = useState<ProxyPayMethod | null>(null);
|
||||
const pollRef = useRef<number | null>(null);
|
||||
|
||||
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
|
||||
const regionLabel = region ? formatRegionLabel(region) : '';
|
||||
|
||||
function stopPoll() {
|
||||
if (pollRef.current != null) {
|
||||
window.clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => () => stopPoll(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!codeUrl) {
|
||||
setQrDataUrl(null);
|
||||
return;
|
||||
}
|
||||
void QRCode.toDataURL(codeUrl, { width: 220, margin: 1 }).then(setQrDataUrl).catch(() => setQrDataUrl(null));
|
||||
}, [codeUrl]);
|
||||
|
||||
function persistDraft(overrides?: Partial<ProxyOrderDraft>) {
|
||||
saveProxyOrderDraft({
|
||||
phone,
|
||||
receiverName,
|
||||
regionCodes,
|
||||
addressDetail,
|
||||
productId,
|
||||
quantity,
|
||||
promoCodeId,
|
||||
deliveryMode,
|
||||
autoReceive,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
|
||||
.then((data) => {
|
||||
setOptions({
|
||||
...data,
|
||||
stores: Array.isArray(data.stores) ? data.stores : [],
|
||||
});
|
||||
const fromQuery = searchParams.get('productId');
|
||||
if (fromQuery && data.products.some((p) => p.id === fromQuery)) {
|
||||
setProductId(fromQuery);
|
||||
persistDraft({ productId: fromQuery });
|
||||
} else if (data.products[0] && !productId) {
|
||||
setProductId(data.products[0].id);
|
||||
}
|
||||
})
|
||||
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoadingOptions(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- hydrate once; productId from query
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const fromQuery = searchParams.get('productId');
|
||||
if (fromQuery) {
|
||||
setProductId(fromQuery);
|
||||
persistDraft({ productId: fromQuery });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync productId from URL
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
persistDraft();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keep draft in sync for product-picker roundtrip
|
||||
}, [
|
||||
phone,
|
||||
receiverName,
|
||||
regionCodes,
|
||||
addressDetail,
|
||||
productId,
|
||||
quantity,
|
||||
promoCodeId,
|
||||
deliveryMode,
|
||||
autoReceive,
|
||||
]);
|
||||
|
||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
|
||||
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
|
||||
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProduct) return;
|
||||
if (!allowOnline && allowOnSite && deliveryMode !== 'ON_SITE_PICKUP') {
|
||||
setDeliveryMode('ON_SITE_PICKUP');
|
||||
return;
|
||||
}
|
||||
if (allowOnline && !allowOnSite && deliveryMode !== 'ADDRESS') {
|
||||
setDeliveryMode('ADDRESS');
|
||||
}
|
||||
}, [selectedProduct, allowOnline, allowOnSite, deliveryMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId || quantity < 1) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setPreviewLoading(true);
|
||||
request<PartnerProxyOrderPreviewResult>('PARTNER_H5', '/partner/proxy-orders/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
productId,
|
||||
quantity,
|
||||
deliveryMode,
|
||||
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||
}),
|
||||
silent: true,
|
||||
})
|
||||
.then((data) => {
|
||||
setPreview(data);
|
||||
setMsg('');
|
||||
})
|
||||
.catch((e) => {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '费用预览失败');
|
||||
})
|
||||
.finally(() => setPreviewLoading(false));
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [productId, quantity, deliveryMode, region?.city, region?.district]);
|
||||
|
||||
function validateForm(): string | null {
|
||||
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
||||
if (!productId) return '请选择商品';
|
||||
if (deliveryMode === 'ADDRESS') {
|
||||
if (!allowOnline) return '该商品不支持线上购买';
|
||||
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
||||
if (!addressDetail.trim()) return '请填写详细地址';
|
||||
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
||||
} else if (!allowOnSite) {
|
||||
return '该商品不支持现场提货';
|
||||
}
|
||||
if (!preview) return msg || '请等待费用计算完成';
|
||||
return null;
|
||||
}
|
||||
|
||||
function startPoll(orderId: string) {
|
||||
stopPoll();
|
||||
pollRef.current = window.setInterval(() => {
|
||||
void request<{ payStatus: string; orderNo: string }>(
|
||||
'PARTNER_H5',
|
||||
`/partner/proxy-orders/${orderId}/pay-status`,
|
||||
{ silent: true },
|
||||
)
|
||||
.then((st) => {
|
||||
if (st.payStatus === 'PAID') {
|
||||
stopPoll();
|
||||
toastSuccess(`支付成功:${st.orderNo}`);
|
||||
clearProxyOrderDraft();
|
||||
navigate(`/center/proxy-orders/${orderId}`, { replace: true });
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function startPay(orderId: string, method: ProxyPayMethod) {
|
||||
if (lockedPayMethod && lockedPayMethod !== method) {
|
||||
setMsg(
|
||||
lockedPayMethod === 'JSAPI'
|
||||
? '该订单已发起微信代付,请先取消支付后重新下单'
|
||||
: '该订单已生成收款码,请先取消支付后重新下单',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setPaying(true);
|
||||
setMsg('');
|
||||
setPayMethod(method);
|
||||
try {
|
||||
if (method === 'JSAPI') {
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error('请在微信内打开合伙人端以使用微信代付');
|
||||
}
|
||||
const profile = await fetchPartnerProfile();
|
||||
if (!partnerHasWechatBinding(profile)) {
|
||||
throw new Error('请先绑定微信后再代付');
|
||||
}
|
||||
}
|
||||
|
||||
const pay = await request<ProxyOrderPayResponse>(
|
||||
'PARTNER_H5',
|
||||
`/partner/proxy-orders/${orderId}/pay`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payMethod: method }),
|
||||
silent: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (pay.mode === 'mock') {
|
||||
toastSuccess('支付成功');
|
||||
clearProxyOrderDraft();
|
||||
navigate(`/center/proxy-orders/${orderId}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pay.mode === 'native' && pay.codeUrl) {
|
||||
setCodeUrl(pay.codeUrl);
|
||||
setLockedPayMethod(method);
|
||||
startPoll(orderId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pay.mode === 'jsapi' && pay.prepay) {
|
||||
setCodeUrl(null);
|
||||
setLockedPayMethod(method);
|
||||
await invokeWechatPay(pay.prepay, {
|
||||
apiBase: '/api/v1',
|
||||
clientApp: 'PARTNER_H5',
|
||||
getAccessToken: getToken,
|
||||
platform: 'wechat-h5',
|
||||
});
|
||||
startPoll(orderId);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('支付发起失败');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '支付失败');
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelPay(orderId: string) {
|
||||
setCancelling(true);
|
||||
setMsg('');
|
||||
stopPoll();
|
||||
try {
|
||||
await request('PARTNER_H5', `/partner/proxy-orders/${orderId}/cancel-pay`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
});
|
||||
toastSuccess('已取消支付,可重新下单并选择其他支付方式');
|
||||
setStep('form');
|
||||
setCreated(null);
|
||||
setCodeUrl(null);
|
||||
setLockedPayMethod(null);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '取消失败');
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
|
||||
function switchPayMethod(method: ProxyPayMethod) {
|
||||
if (lockedPayMethod && lockedPayMethod !== method) {
|
||||
setMsg(
|
||||
lockedPayMethod === 'JSAPI'
|
||||
? '该订单已发起微信代付,请先取消支付后重新下单'
|
||||
: '该订单已生成收款码,请先取消支付后重新下单',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setPayMethod(method);
|
||||
setMsg('');
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
setMsg('');
|
||||
const err = validateForm();
|
||||
if (err) {
|
||||
setMsg(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: PartnerProxyOrderCreateRequest = {
|
||||
phone: phone.trim(),
|
||||
deliveryMode,
|
||||
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
|
||||
receiverName: receiverName.trim() || undefined,
|
||||
province: deliveryMode === 'ADDRESS' ? region?.province : undefined,
|
||||
city: deliveryMode === 'ADDRESS' ? region?.city : undefined,
|
||||
district: deliveryMode === 'ADDRESS' ? region?.district : undefined,
|
||||
addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
|
||||
productId,
|
||||
quantity,
|
||||
promoCodeId: promoCodeId || undefined,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const order = await request<ProxyOrderCreateResponse>('PARTNER_H5', '/partner/proxy-orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
silent: true,
|
||||
});
|
||||
clearProxyOrderDraft();
|
||||
setCreated(order);
|
||||
setStep('pay');
|
||||
setCodeUrl(null);
|
||||
setLockedPayMethod(null);
|
||||
await startPay(order.id, payMethod);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const deliveryLabel =
|
||||
preview?.deliveryType === 'ON_SITE_PICKUP'
|
||||
? '现场提货'
|
||||
: preview?.deliveryType === 'CROSS_CITY'
|
||||
? '跨城配送'
|
||||
: '同城配送';
|
||||
|
||||
return (
|
||||
<div className="page partner-proxy-order-page">
|
||||
<PageHeader title={step === 'pay' ? '代下单支付' : '代下单'} onBack={() => navigate(-1)} />
|
||||
|
||||
<main className="partner-form-card" style={{ margin: '0 16px 24px' }}>
|
||||
{step === 'pay' && created ? (
|
||||
<>
|
||||
<p className="body-md">
|
||||
订单 {created.orderNo} · 应付 ¥{fmtMoney(created.payAmount)}
|
||||
</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
{created.deliveryType === 'ON_SITE_PICKUP'
|
||||
? '支付成功后订单即为已完成,并发放好客权益'
|
||||
: '支付成功后进入待发货,由总部履约'}
|
||||
</p>
|
||||
|
||||
<section className="partner-form-section" style={{ marginTop: 16 }}>
|
||||
<label className="partner-form-label">支付方式</label>
|
||||
<div className="partner-proxy-mode-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`partner-proxy-mode-tab${payMethod === 'NATIVE' ? ' is-active' : ''}`}
|
||||
disabled={!!lockedPayMethod && lockedPayMethod !== 'NATIVE'}
|
||||
onClick={() => switchPayMethod('NATIVE')}
|
||||
>
|
||||
收款码
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`partner-proxy-mode-tab${payMethod === 'JSAPI' ? ' is-active' : ''}`}
|
||||
disabled={!!lockedPayMethod && lockedPayMethod !== 'JSAPI'}
|
||||
onClick={() => switchPayMethod('JSAPI')}
|
||||
>
|
||||
微信代付
|
||||
</button>
|
||||
</div>
|
||||
{lockedPayMethod ? (
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
已选择{lockedPayMethod === 'NATIVE' ? '收款码' : '微信代付'},切换方式请先取消支付
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{payMethod === 'NATIVE' ? (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
{qrDataUrl ? (
|
||||
<img src={qrDataUrl} alt="收款码" width={220} height={220} style={{ margin: '0 auto' }} />
|
||||
) : (
|
||||
<p className="label-md text-muted">{paying ? '生成收款码中…' : '暂无收款码'}</p>
|
||||
)}
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
请使用微信扫码支付,支付成功后自动跳转
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-block"
|
||||
style={{ marginTop: 16 }}
|
||||
disabled={paying}
|
||||
onClick={() => void startPay(created.id, 'NATIVE')}
|
||||
>
|
||||
{paying ? '生成中…' : '重新生成收款码'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p className="label-md text-muted">将调起微信支付,由合伙人微信完成代付</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-block"
|
||||
style={{ marginTop: 12 }}
|
||||
disabled={paying}
|
||||
onClick={() => void startPay(created.id, 'JSAPI')}
|
||||
>
|
||||
{paying ? '支付中…' : '重新调起微信代付'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg ? (
|
||||
<p className="partner-form-error" role="alert" style={{ marginTop: 12 }}>
|
||||
{msg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-block"
|
||||
style={{ marginTop: 16 }}
|
||||
disabled={cancelling || paying}
|
||||
onClick={() => void cancelPay(created.id)}
|
||||
>
|
||||
{cancelling ? '取消中…' : '取消支付'}
|
||||
</button>
|
||||
</>
|
||||
) : loadingOptions ? (
|
||||
<p className="label-md text-muted">加载商品…</p>
|
||||
) : (
|
||||
<>
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">用户手机号</label>
|
||||
<div className="partner-input-wrap">
|
||||
<input
|
||||
className="partner-input"
|
||||
placeholder="11 位手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">酒品</label>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-proxy-product-picker"
|
||||
onClick={() => {
|
||||
persistDraft();
|
||||
navigate(
|
||||
`/proxy-order/products${productId ? `?selected=${encodeURIComponent(productId)}` : ''}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{selectedProduct ? (
|
||||
<>
|
||||
<span className="partner-proxy-product-name">{selectedProduct.name}</span>
|
||||
<span className="label-md text-muted">
|
||||
{selectedProduct.spec} · ¥{fmtMoney(selectedProduct.price)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="label-md text-muted">点击选择酒品</span>
|
||||
)}
|
||||
<span className="partner-proxy-product-picker-arrow">›</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">数量</label>
|
||||
<div className="partner-input-wrap">
|
||||
<input
|
||||
className="partner-input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(options?.promoCodes.length ?? 0) > 0 && (
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">绑定推广码(选填)</label>
|
||||
<select
|
||||
className="partner-input"
|
||||
value={promoCodeId}
|
||||
onChange={(e) => setPromoCodeId(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 14px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<option value="">不绑定</option>
|
||||
{options!.promoCodes.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}({p.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">履约方式</label>
|
||||
{allowOnline && allowOnSite ? (
|
||||
<div className="partner-proxy-mode-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`partner-proxy-mode-tab${deliveryMode === 'ADDRESS' ? ' is-active' : ''}`}
|
||||
onClick={() => setDeliveryMode('ADDRESS')}
|
||||
>
|
||||
配送到址
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`partner-proxy-mode-tab${deliveryMode === 'ON_SITE_PICKUP' ? ' is-active' : ''}`}
|
||||
onClick={() => setDeliveryMode('ON_SITE_PICKUP')}
|
||||
>
|
||||
现场提货
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="label-md text-muted">
|
||||
{!allowOnline && allowOnSite
|
||||
? '该商品仅支持现场提货'
|
||||
: allowOnline && !allowOnSite
|
||||
? allowCrossCity
|
||||
? '该商品仅支持配送到址(含跨城)'
|
||||
: '该商品仅支持配送到址(不可跨城)'
|
||||
: '该商品暂无可选履约方式'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{deliveryMode === 'ADDRESS' && allowOnline ? (
|
||||
<>
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">收货人(选填)</label>
|
||||
<div className="partner-input-wrap">
|
||||
<input
|
||||
className="partner-input"
|
||||
placeholder="默认:用户+手机尾号"
|
||||
value={receiverName}
|
||||
onChange={(e) => setReceiverName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">收货地区</label>
|
||||
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
|
||||
{regionLabel ? (
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
{regionLabel}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">详细地址</label>
|
||||
<div className="partner-field-input partner-field-input--block">
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="街道、门牌号等"
|
||||
value={addressDetail}
|
||||
onChange={(e) => setAddressDetail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label className="partner-proxy-auto-receive">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoReceive}
|
||||
onChange={(e) => setAutoReceive(e.target.checked)}
|
||||
/>
|
||||
<span>同意自动收货(配送到址必选)</span>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">支付方式</label>
|
||||
<div className="partner-proxy-mode-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`partner-proxy-mode-tab${payMethod === 'NATIVE' ? ' is-active' : ''}`}
|
||||
onClick={() => setPayMethod('NATIVE')}
|
||||
>
|
||||
收款码
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`partner-proxy-mode-tab${payMethod === 'JSAPI' ? ' is-active' : ''}`}
|
||||
onClick={() => setPayMethod('JSAPI')}
|
||||
>
|
||||
微信代付
|
||||
</button>
|
||||
</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8, lineHeight: 1.5 }}>
|
||||
{payMethod === 'NATIVE'
|
||||
? '提交后展示商家收款码,客户或现场扫码支付'
|
||||
: '提交后在微信内由您代客户完成支付(需已绑定微信)'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="partner-proxy-fee-card">
|
||||
<h3 className="headline-md">费用明细</h3>
|
||||
{previewLoading ? (
|
||||
<p className="label-md text-muted">计算中…</p>
|
||||
) : preview ? (
|
||||
<>
|
||||
<div className="partner-proxy-fee-row">
|
||||
<span className="label-md text-muted">商品单价</span>
|
||||
<span className="body-md">¥{fmtMoney(preview.unitPrice)}</span>
|
||||
</div>
|
||||
<div className="partner-proxy-fee-row">
|
||||
<span className="label-md text-muted">数量</span>
|
||||
<span className="body-md">×{quantity}</span>
|
||||
</div>
|
||||
<div className="partner-proxy-fee-row">
|
||||
<span className="label-md text-muted">履约类型</span>
|
||||
<span className="body-md">{deliveryLabel}</span>
|
||||
</div>
|
||||
<div className="partner-proxy-fee-row">
|
||||
<span className="label-md text-muted">权益额</span>
|
||||
<span className="body-md">¥{fmtMoney(preview.benefitAmount)}</span>
|
||||
</div>
|
||||
<div className="partner-proxy-fee-row partner-proxy-fee-row--total">
|
||||
<span className="headline-md">应付金额</span>
|
||||
<span className="amount-lg text-primary">¥{fmtMoney(preview.payAmount)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="label-md text-muted">
|
||||
{selectedProduct ? '请确认数量与履约信息后查看费用' : '请选择商品'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{msg ? (
|
||||
<p className="partner-form-error" role="alert">
|
||||
{msg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-block"
|
||||
disabled={submitting || paying || !preview}
|
||||
onClick={() => void submit()}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
{submitting || paying ? '处理中…' : '提交并支付'}
|
||||
</button>
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}>
|
||||
提交后进入在线支付;支付成功后发放权益。配送单将进入待发货由总部履约,现场提货走自提闭环。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import { toastError } from '../lib/toast';
|
||||
import type { PartnerProxyOrderOptions, PartnerProxyOrderProductOption } from '@dukang/shared-types';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
type FulfillmentFilter = 'ALL' | 'ONLINE' | 'CROSS_CITY' | 'ON_SITE';
|
||||
|
||||
const FILTERS: Array<{ key: FulfillmentFilter; label: string }> = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'ONLINE', label: '线上' },
|
||||
{ key: 'CROSS_CITY', label: '跨城' },
|
||||
{ key: 'ON_SITE', label: '现场' },
|
||||
];
|
||||
|
||||
function matchFilter(p: PartnerProxyOrderProductOption, filter: FulfillmentFilter): boolean {
|
||||
if (filter === 'ALL') return true;
|
||||
if (filter === 'ONLINE') return p.allowOnlinePurchase !== false;
|
||||
if (filter === 'CROSS_CITY') return p.allowCrossCityDelivery !== false;
|
||||
return !!p.allowOnSitePickup;
|
||||
}
|
||||
|
||||
export default function ProxyOrderProductsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const selectedId = searchParams.get('selected') || '';
|
||||
const [products, setProducts] = useState<PartnerProxyOrderProductOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filter, setFilter] = useState<FulfillmentFilter>('ALL');
|
||||
|
||||
useEffect(() => {
|
||||
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
|
||||
.then((data) => setProducts(Array.isArray(data.products) ? data.products : []))
|
||||
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
return products.filter((p) => {
|
||||
if (!matchFilter(p, filter)) return false;
|
||||
if (!q) return true;
|
||||
return `${p.name} ${p.spec}`.toLowerCase().includes(q);
|
||||
});
|
||||
}, [products, keyword, filter]);
|
||||
|
||||
function pick(product: PartnerProxyOrderProductOption) {
|
||||
navigate(`/proxy-order?productId=${encodeURIComponent(product.id)}`, { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page partner-proxy-order-page">
|
||||
<PageHeader title="选择酒品" onBack={() => navigate(-1)} />
|
||||
<div className="partner-proxy-product-toolbar">
|
||||
<input
|
||||
className="partner-input"
|
||||
placeholder="搜索酒品名称 / 规格"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<div className="partner-proxy-product-filters">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
className={`partner-proxy-product-filter${filter === f.key ? ' is-active' : ''}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<main className="partner-proxy-product-list">
|
||||
{loading ? <p className="label-md text-muted">加载中…</p> : null}
|
||||
{!loading && filtered.length === 0 ? (
|
||||
<p className="label-md text-muted">暂无符合条件的商品</p>
|
||||
) : null}
|
||||
{filtered.map((p) => {
|
||||
const benefit = p.benefitAmount != null ? Number(p.benefitAmount) : Number(p.price);
|
||||
const active = p.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`partner-proxy-product-card${active ? ' partner-proxy-product-card--active' : ''}`}
|
||||
onClick={() => pick(p)}
|
||||
>
|
||||
<div className="partner-proxy-product-thumb">
|
||||
{p.coverUrl ? (
|
||||
<img src={p.coverUrl} alt="" />
|
||||
) : (
|
||||
<span className="partner-proxy-product-thumb-empty" />
|
||||
)}
|
||||
</div>
|
||||
<div className="partner-proxy-product-main">
|
||||
<div className="partner-proxy-product-row">
|
||||
<span className="partner-proxy-product-name">{p.name}</span>
|
||||
<span className="partner-proxy-product-price">¥{fmtMoney(p.price)}</span>
|
||||
</div>
|
||||
<p className="partner-proxy-product-spec">{p.spec}</p>
|
||||
<p className="partner-proxy-product-benefit">好客权益 ¥{fmtMoney(benefit)}</p>
|
||||
<div className="partner-proxy-product-tags">
|
||||
{p.allowOnlinePurchase !== false ? <span className="partner-proxy-tag">线上</span> : null}
|
||||
{p.allowCrossCityDelivery !== false ? <span className="partner-proxy-tag">跨城</span> : null}
|
||||
{p.allowOnSitePickup ? <span className="partner-proxy-tag">现场</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
|
||||
type ReshipStatus = 'ALL' | 'PENDING' | 'SHIPPING' | 'DONE';
|
||||
|
||||
const MOCK_ITEMS = [
|
||||
{ id: '1', orderNo: 'DK20260301001', status: 'PENDING' as const, productName: '杜康窖藏', originalOrderNo: 'DK20260228088', address: '郑州市金水区花园路 88 号' },
|
||||
{ id: '2', orderNo: 'DK20260302002', status: 'SHIPPING' as const, productName: '杜康特曲', originalOrderNo: 'DK20260215012', address: '郑州市二七区大学路 12 号' },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
PENDING: '待配送',
|
||||
SHIPPING: '配送中',
|
||||
DONE: '已完成',
|
||||
};
|
||||
|
||||
const FILTERS: { key: ReshipStatus; label: string }[] = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'PENDING', label: '待配送' },
|
||||
{ key: 'SHIPPING', label: '配送中' },
|
||||
{ key: 'DONE', label: '已完成' },
|
||||
];
|
||||
|
||||
/** BUWR-23:补发处理 */
|
||||
export default function ReshipPage() {
|
||||
const navigate = useNavigate();
|
||||
const [filter, setFilter] = useState<ReshipStatus>('ALL');
|
||||
|
||||
const list = MOCK_ITEMS.filter((item) => filter === 'ALL' || item.status === filter);
|
||||
|
||||
return (
|
||||
<div className="page-no-tab partner-orders-page">
|
||||
<PageHeader title="补发处理" onBack={() => navigate('/')} />
|
||||
|
||||
<p className="label-md text-muted" style={{ padding: '0 20px 8px' }}>
|
||||
总部下达的补发工单,确认后可推进配送
|
||||
</p>
|
||||
|
||||
<div className="partner-filter-row" style={{ padding: '0 20px 12px' }}>
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
className={`partner-chip${filter === f.key ? ' active' : ''}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{list.length === 0 && <div className="empty">暂无补发工单</div>}
|
||||
|
||||
{list.map((item) => (
|
||||
<div key={item.id} className="partner-order-card" style={{ margin: '0 20px 12px' }}>
|
||||
<div className="partner-order-card-top">
|
||||
<span className="label-md text-muted">原单 {item.originalOrderNo}</span>
|
||||
<span className="label-md text-primary" style={{ fontWeight: 600 }}>{STATUS_LABEL[item.status]}</span>
|
||||
</div>
|
||||
<h3 className="headline-md" style={{ marginTop: 8 }}>{item.productName}</h3>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>补发单号 {item.orderNo}</p>
|
||||
<div className="partner-order-address" style={{ marginTop: 12 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
|
||||
<p className="line-2-clamp">{item.address}</p>
|
||||
</div>
|
||||
{item.status === 'PENDING' && (
|
||||
<button type="button" className="partner-btn-primary" style={{ marginTop: 16, width: '100%' }}>
|
||||
开始配送
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
|
||||
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing' | 'rejected';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function billStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'AWAITING_CONFIRM': return '待确认';
|
||||
case 'UNPAID': return '未打款';
|
||||
case 'PAID': return '已打款';
|
||||
case 'REJECTED': return '已驳回';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
function billStatusClass(status: string) {
|
||||
switch (status) {
|
||||
case 'AWAITING_CONFIRM': return 'partner-settlement-status--pending';
|
||||
case 'UNPAID': return 'partner-settlement-status--reviewing';
|
||||
case 'PAID': return 'partner-settlement-status--settled';
|
||||
case 'REJECTED': return 'partner-settlement-status--rejected';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function billPeriodLabel(periodStart: string) {
|
||||
const d = new Date(periodStart);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function billMonthLabel(year: number, month: number) {
|
||||
return `${year}年${month}月`;
|
||||
}
|
||||
|
||||
export default function SettlementPage() {
|
||||
const navigate = useNavigate();
|
||||
const [bills, setBills] = useState<PartnerBillDto[]>([]);
|
||||
const now = new Date();
|
||||
const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 });
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
|
||||
const loadBills = useCallback(() => {
|
||||
return request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
void loadBills();
|
||||
}, [navigate, loadBills]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
|
||||
const settled = bills.filter((b) => b.status === 'PAID');
|
||||
const currentMonth = bills.filter((b) => {
|
||||
const d = new Date(b.periodStart);
|
||||
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
|
||||
});
|
||||
return {
|
||||
pendingTotal: pending.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||
settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||
monthEstimate: currentMonth.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||
};
|
||||
}, [bills, now]);
|
||||
|
||||
const filteredBills = useMemo(() => {
|
||||
return bills.filter((b) => {
|
||||
const d = new Date(b.periodStart);
|
||||
const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
|
||||
if (!matchMonth) return false;
|
||||
if (statusFilter === 'all') return true;
|
||||
if (statusFilter === 'pending') return b.status === 'AWAITING_CONFIRM';
|
||||
if (statusFilter === 'settled') return b.status === 'PAID';
|
||||
if (statusFilter === 'reviewing') return b.status === 'UNPAID';
|
||||
if (statusFilter === 'rejected') return b.status === 'REJECTED';
|
||||
return true;
|
||||
});
|
||||
}, [bills, month, statusFilter]);
|
||||
|
||||
const monthOptions = useMemo(() => {
|
||||
const opts: Array<{ year: number; month: number }> = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
opts.push({ year: d.getFullYear(), month: d.getMonth() + 1 });
|
||||
}
|
||||
return opts;
|
||||
}, [now]);
|
||||
|
||||
function openBill(bill: PartnerBillDto) {
|
||||
navigate(bill.id ? `/center/bills?id=${bill.id}` : '/center/bills');
|
||||
}
|
||||
|
||||
const statusTabs: Array<{ key: StatusFilter; label: string }> = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'pending', label: '待确认' },
|
||||
{ key: 'reviewing', label: '未打款' },
|
||||
{ key: 'rejected', label: '已驳回' },
|
||||
{ key: 'settled', label: '已打款' },
|
||||
];
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadBills} className="page-no-tab partner-settlement-page">
|
||||
<PageHeader title="财务对账中心" onBack={() => navigate('/')} />
|
||||
|
||||
<main className="partner-settlement-body">
|
||||
<section className="partner-settlement-summary">
|
||||
<div className="partner-settlement-summary-deco" aria-hidden>
|
||||
<span className="material-symbols-outlined">account_balance_wallet</span>
|
||||
</div>
|
||||
<p className="partner-settlement-summary-label">待结算总额</p>
|
||||
<div className="partner-settlement-summary-amount">
|
||||
<span className="partner-settlement-currency">¥</span>
|
||||
{fmtMoney(summary.pendingTotal)}
|
||||
</div>
|
||||
<div className="partner-settlement-summary-grid">
|
||||
<div>
|
||||
<p className="partner-settlement-summary-sub">累计已结算</p>
|
||||
<p className="partner-settlement-summary-value">¥{fmtMoney(summary.settledTotal)}</p>
|
||||
</div>
|
||||
<div className="partner-settlement-summary-divider" />
|
||||
<div>
|
||||
<p className="partner-settlement-summary-sub">本月预估收益</p>
|
||||
<p className="partner-settlement-summary-value partner-settlement-summary-value--accent">
|
||||
¥{fmtMoney(summary.monthEstimate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-settlement-filters">
|
||||
<select
|
||||
className="partner-settlement-month"
|
||||
value={`${month.year}-${month.month}`}
|
||||
onChange={(e) => {
|
||||
const [y, m] = e.target.value.split('-').map(Number);
|
||||
setMonth({ year: y, month: m });
|
||||
}}
|
||||
>
|
||||
{monthOptions.map((opt) => (
|
||||
<option key={`${opt.year}-${opt.month}`} value={`${opt.year}-${opt.month}`}>
|
||||
{billMonthLabel(opt.year, opt.month)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="partner-settlement-status-btn">
|
||||
<span className="material-symbols-outlined">filter_list</span>
|
||||
状态
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="partner-settlement-chips">
|
||||
{statusTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className={`partner-settlement-chip${statusFilter === tab.key ? ' partner-settlement-chip--active' : ''}`}
|
||||
onClick={() => setStatusFilter(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="partner-settlement-list-section">
|
||||
<h3 className="partner-settlement-list-title">对账明细记录</h3>
|
||||
|
||||
{filteredBills.length === 0 && (
|
||||
<div className="empty" style={{ padding: '32px 0' }}>暂无对账记录</div>
|
||||
)}
|
||||
|
||||
<div className="partner-settlement-list">
|
||||
{filteredBills.map((bill) => (
|
||||
<button
|
||||
key={bill.id}
|
||||
type="button"
|
||||
className="partner-settlement-item"
|
||||
onClick={() => openBill(bill)}
|
||||
>
|
||||
<div className="partner-settlement-item-main">
|
||||
<div className="partner-settlement-item-head">
|
||||
<span className="partner-settlement-item-no">{bill.billNo}</span>
|
||||
<span className={`partner-settlement-status ${billStatusClass(bill.status)}`}>
|
||||
{billStatusLabel(bill.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="partner-settlement-item-meta">
|
||||
<span className="material-symbols-outlined">event</span>
|
||||
{billPeriodLabel(bill.periodStart)}
|
||||
<span className="partner-settlement-dot" />
|
||||
订单分佣 ¥{fmtMoney(Number(bill.orderCommission || 0))}
|
||||
</p>
|
||||
{bill.status === 'REJECTED' && bill.rejectReason ? (
|
||||
<p className="label-md text-primary" style={{ marginTop: 6 }}>
|
||||
驳回:{bill.rejectReason}
|
||||
</p>
|
||||
) : null}
|
||||
<p className={`partner-settlement-item-amount${bill.status === 'AWAITING_CONFIRM' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
|
||||
¥{fmtMoney(Number(bill.totalAmount || 0))}
|
||||
</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredBills.length > 0 && (
|
||||
<p className="partner-settlement-footer-note">已展示全部记录</p>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { createPartnerStaff, sendPartnerStaffPhoneSms } from '../lib/staff';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
|
||||
type FieldErrors = {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
smsCode?: string;
|
||||
};
|
||||
|
||||
export default function StaffCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [smsCode, setSmsCode] = useState('');
|
||||
const [smsCooldown, setSmsCooldown] = useState(0);
|
||||
const [smsHint, setSmsHint] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
|
||||
function patchName(value: string) {
|
||||
setName(value);
|
||||
setSubmitError('');
|
||||
setFieldErrors((prev) => ({ ...prev, name: undefined }));
|
||||
}
|
||||
|
||||
function patchPhone(value: string) {
|
||||
setPhone(value);
|
||||
setSmsHint('');
|
||||
setSubmitError('');
|
||||
setFieldErrors((prev) => ({ ...prev, phone: undefined, smsCode: undefined }));
|
||||
if (smsCode) setSmsCode('');
|
||||
}
|
||||
|
||||
function patchSmsCode(value: string) {
|
||||
setSmsCode(value.replace(/\D/g, ''));
|
||||
setSubmitError('');
|
||||
setFieldErrors((prev) => ({ ...prev, smsCode: undefined }));
|
||||
}
|
||||
|
||||
async function sendPhoneCode() {
|
||||
const trimmedPhone = phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(trimmedPhone)) {
|
||||
setFieldErrors({ phone: '请先填写正确的11位手机号' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSmsHint('');
|
||||
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
|
||||
|
||||
try {
|
||||
const res = await sendPartnerStaffPhoneSms(trimmedPhone);
|
||||
setSmsHint(`验证码已发送至 ${res.maskedPhone}`);
|
||||
setSmsCooldown(60);
|
||||
const timer = setInterval(() => {
|
||||
setSmsCooldown((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '验证码发送失败';
|
||||
setFieldErrors({ phone: msg });
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const trimmedName = name.trim();
|
||||
const trimmedPhone = phone.trim();
|
||||
const trimmedSmsCode = smsCode.trim();
|
||||
const nextFieldErrors: FieldErrors = {};
|
||||
|
||||
if (!trimmedName) nextFieldErrors.name = '请填写真实姓名';
|
||||
if (!trimmedPhone) {
|
||||
nextFieldErrors.phone = '请填写手机号码';
|
||||
} else if (!/^1[3-9]\d{9}$/.test(trimmedPhone)) {
|
||||
nextFieldErrors.phone = '请输入正确的11位手机号';
|
||||
}
|
||||
if (!trimmedSmsCode) {
|
||||
nextFieldErrors.smsCode = '请输入手机号验证码';
|
||||
} else if (!/^\d{4,6}$/.test(trimmedSmsCode)) {
|
||||
nextFieldErrors.smsCode = '验证码格式不正确';
|
||||
}
|
||||
|
||||
if (Object.keys(nextFieldErrors).length) {
|
||||
setFieldErrors(nextFieldErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setSubmitError('');
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
await createPartnerStaff({
|
||||
name: trimmedName,
|
||||
phone: trimmedPhone,
|
||||
smsCode: trimmedSmsCode,
|
||||
});
|
||||
toastSuccess('子账号创建成功');
|
||||
navigate('/center/staff');
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : '创建失败';
|
||||
if (message.includes('验证码')) {
|
||||
setFieldErrors({ smsCode: message });
|
||||
return;
|
||||
}
|
||||
if (message.includes('手机号')) {
|
||||
setFieldErrors({ phone: message });
|
||||
return;
|
||||
}
|
||||
setSubmitError(message);
|
||||
toastError(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="partner-page-sticky">
|
||||
<PageHeader title="添加子账号" onBack={() => navigate('/center/staff')} />
|
||||
|
||||
{submitError && (
|
||||
<p className="partner-form-error" role="alert">{submitError}</p>
|
||||
)}
|
||||
|
||||
<section className="partner-form-card">
|
||||
<div className="partner-section-title">
|
||||
<div className="partner-section-bar" />
|
||||
<h2 className="headline-md">子账号信息</h2>
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
<label>真实姓名 <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
<input
|
||||
placeholder="请输入员工姓名"
|
||||
value={name}
|
||||
onChange={(e) => patchName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{fieldErrors.name && (
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
<label>手机号码 <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="请输入11位手机号"
|
||||
value={phone}
|
||||
onChange={(e) => patchPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{fieldErrors.phone && (
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
<label>手机号验证码 <span className="text-primary">*</span></label>
|
||||
<div className="partner-input-row">
|
||||
<div className="partner-input-wrap" style={{ flex: 1 }}>
|
||||
<span className="material-symbols-outlined partner-input-icon">shield</span>
|
||||
<input
|
||||
className="partner-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="请输入短信验证码"
|
||||
value={smsCode}
|
||||
onChange={(e) => patchSmsCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-code-btn"
|
||||
disabled={smsCooldown > 0 || submitting}
|
||||
onClick={() => void sendPhoneCode()}
|
||||
>
|
||||
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
{smsHint && (
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
|
||||
)}
|
||||
{fieldErrors.smsCode && (
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.smsCode}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="partner-info-banner">
|
||||
<div className="partner-bills-icon">
|
||||
<span className="material-symbols-outlined">info</span>
|
||||
</div>
|
||||
<p className="label-md text-variant" style={{ marginTop: 0, lineHeight: 1.5 }}>
|
||||
子账号创建后默认禁用,需在子账号列表中手动启用后方可登录。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<footer className="partner-sticky-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>person_add</span>
|
||||
{submitting ? '创建中…' : '确认创建'}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import {
|
||||
AccountStatus,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
PartnerStaffRole,
|
||||
type PartnerStaffItem,
|
||||
} from '@dukang/shared-types';
|
||||
import { deletePartnerStaff, listPartnerStaff, updatePartnerStaff } from '../lib/staff';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
|
||||
export default function StaffListPage() {
|
||||
usePartnerPageView('partner_staff_list_view');
|
||||
const navigate = useNavigate();
|
||||
const [staff, setStaff] = useState<PartnerStaffItem[]>([]);
|
||||
const [q, setQ] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const loadStaff = useCallback(() => {
|
||||
return listPartnerStaff().then(setStaff).catch((e) => {
|
||||
setError(e instanceof Error ? e.message : '加载失败');
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStaff();
|
||||
}, [loadStaff]);
|
||||
|
||||
const filtered = useMemo(() => staff.filter((item) => {
|
||||
if (!q.trim()) return true;
|
||||
const keyword = q.trim();
|
||||
return item.name.includes(keyword) || item.phone.includes(keyword);
|
||||
}), [staff, q]);
|
||||
|
||||
async function toggleStatus(item: PartnerStaffItem) {
|
||||
const next = item.status === AccountStatus.ACTIVE ? AccountStatus.DISABLED : AccountStatus.ACTIVE;
|
||||
setBusyId(item.id);
|
||||
setError('');
|
||||
try {
|
||||
await updatePartnerStaff(item.id, { status: next });
|
||||
await loadStaff();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeStaff(item: PartnerStaffItem) {
|
||||
const ok = window.confirm(`确认删除子账号「${item.name}」?`);
|
||||
if (!ok) return;
|
||||
setBusyId(item.id);
|
||||
setError('');
|
||||
try {
|
||||
await deletePartnerStaff(item.id);
|
||||
await loadStaff();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '删除失败');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function editStaff(item: PartnerStaffItem) {
|
||||
const name = window.prompt('修改姓名', item.name);
|
||||
if (name == null || !name.trim()) return;
|
||||
const roleOptions = Object.values(PartnerStaffRole);
|
||||
const roleLabels = roleOptions.map((r) => PARTNER_STAFF_ROLE_LABELS[r]).join(' / ');
|
||||
const roleInput = window.prompt(`分配角色(${roleLabels})`, item.staffRole);
|
||||
if (roleInput == null) return;
|
||||
const staffRole = roleOptions.find((r) => r === roleInput || PARTNER_STAFF_ROLE_LABELS[r] === roleInput);
|
||||
if (!staffRole) {
|
||||
setError('无效的角色');
|
||||
return;
|
||||
}
|
||||
setBusyId(item.id);
|
||||
setError('');
|
||||
try {
|
||||
await updatePartnerStaff(item.id, { name: name.trim(), staffRole });
|
||||
await loadStaff();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadStaff} className="page-no-tab partner-staff-page">
|
||||
<PageHeader title="子账号管理" onBack={() => navigate('/center')} />
|
||||
|
||||
<div style={{ padding: '0 20px 16px' }}>
|
||||
<div className="partner-search">
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
<input placeholder="搜索姓名或手机号" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
||||
|
||||
<div style={{ padding: '0 20px 100px' }}>
|
||||
{filtered.length === 0 && (
|
||||
<div className="empty">
|
||||
<p>暂无子账号</p>
|
||||
<p className="label-md text-muted">点击下方按钮添加首个子账号</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.map((item) => {
|
||||
const active = item.status === AccountStatus.ACTIVE;
|
||||
const busy = busyId === item.id;
|
||||
return (
|
||||
<div key={item.id} className="partner-staff-card">
|
||||
<div className="partner-staff-avatar">{item.name.slice(0, 1)}</div>
|
||||
<div className="partner-staff-info">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span className="headline-md">{item.name}</span>
|
||||
<span className="partner-role-badge partner-role-badge--subtle">
|
||||
{PARTNER_STAFF_ROLE_LABELS[item.staffRole]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="label-md text-muted">{item.phone}</p>
|
||||
</div>
|
||||
<div className="partner-staff-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`partner-toggle${active ? ' partner-toggle--on' : ''}`}
|
||||
disabled={busy}
|
||||
aria-label={active ? '禁用' : '启用'}
|
||||
onClick={() => void toggleStatus(item)}
|
||||
>
|
||||
<span className="partner-toggle-thumb" />
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button type="button" className="partner-icon-btn" disabled={busy} onClick={() => void editStaff(item)}>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
<button type="button" className="partner-icon-btn partner-icon-btn--danger" disabled={busy} onClick={() => void removeStaff(item)}>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="partner-sticky-footer">
|
||||
<Link to="/center/staff/new" className="partner-btn-primary" style={{ display: 'flex', textDecoration: 'none' }}>
|
||||
<span className="material-symbols-outlined">person_add</span>
|
||||
添加子账号
|
||||
</Link>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,517 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { canManagePartnerStore } from '../lib/partnerAccess';
|
||||
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray, patchEnvPhotoAt, addEnvPhotoSlot } from '../lib/storeDraft';
|
||||
import OssUploadField from '../components/OssUploadField';
|
||||
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
|
||||
import {
|
||||
canPartnerOpenStore,
|
||||
storeAuditLabel,
|
||||
storeAuditPillClass,
|
||||
storeStatusLabel,
|
||||
storeStatusPillClass,
|
||||
type StoreStatusValue,
|
||||
} from '../lib/storeStatus';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
|
||||
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
||||
|
||||
function uniqueEnvUrls(urls: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of urls) {
|
||||
const url = raw.trim();
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function StoreDetailPage() {
|
||||
const { id } = useParams();
|
||||
usePartnerPageView('partner_store_detail_view', id ? { storeId: id } : undefined);
|
||||
const navigate = useNavigate();
|
||||
const { account } = usePartnerSession();
|
||||
const canMutate = canManagePartnerStore(account);
|
||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
intro: '',
|
||||
benefitUsageRule: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
});
|
||||
const [coverUrl, setCoverUrl] = useState('');
|
||||
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
|
||||
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
||||
const [statusSaving, setStatusSaving] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [mediaSaving, setMediaSaving] = useState(false);
|
||||
const [mapPickerOpen, setMapPickerOpen] = useState(false);
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
||||
|
||||
function applyStore(data: Record<string, unknown>) {
|
||||
setStore(data);
|
||||
setForm({
|
||||
name: String(data.name || ''),
|
||||
phone: String(data.phone || ''),
|
||||
address: String(data.address || ''),
|
||||
intro: String(data.intro || ''),
|
||||
benefitUsageRule: (() => {
|
||||
const raw = String(data.benefitUsageRule || '').trim();
|
||||
return raw && !/^null$/i.test(raw) ? raw : '';
|
||||
})(),
|
||||
latitude: data.latitude != null && data.latitude !== '' ? String(data.latitude) : '',
|
||||
longitude: data.longitude != null && data.longitude !== '' ? String(data.longitude) : '',
|
||||
});
|
||||
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
|
||||
setCoverUrl(String(data.coverUrl || ''));
|
||||
const envFromMedia = Array.isArray(data.media)
|
||||
? uniqueEnvUrls(
|
||||
(data.media as Array<{ url?: string; bizType?: string }>)
|
||||
.filter((m) => m.bizType === 'ENV')
|
||||
.map((m) => String(m.url || '')),
|
||||
)
|
||||
: [];
|
||||
setEnvPhotoUrls(normalizeStringArray(envFromMedia, MIN_ENV_PHOTO_COUNT));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoadError('');
|
||||
request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}`)
|
||||
.then(applyStore)
|
||||
.catch((e) => {
|
||||
setStore(null);
|
||||
setLoadError(e instanceof Error ? e.message : '加载失败');
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
async function changeStatus(next: StoreStatusValue) {
|
||||
if (!id || next === status || statusSaving) return;
|
||||
if (status === 'CLOSED') return;
|
||||
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
||||
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
|
||||
setActionError(
|
||||
auditStatus === 'REJECTED'
|
||||
? `门店审核未通过${store?.rejectReason ? `:${String(store.rejectReason)}` : ''}`
|
||||
: '门店尚在总部审核中,通过后方可开门',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (next === 'CLOSED') {
|
||||
setCloseConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
setStatusSaving(true);
|
||||
setActionError('');
|
||||
try {
|
||||
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
setStatus(next);
|
||||
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
|
||||
toastSuccess(next === 'OPEN' ? '开店成功' : '状态已更新');
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : '状态更新失败');
|
||||
} finally {
|
||||
setStatusSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCloseStore() {
|
||||
if (!id || statusSaving) return;
|
||||
setCloseConfirmOpen(false);
|
||||
setStatusSaving(true);
|
||||
setActionError('');
|
||||
try {
|
||||
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: 'CLOSED' }),
|
||||
});
|
||||
setStatus('CLOSED');
|
||||
setStore((prev) => (prev ? { ...prev, ...updated, status: 'CLOSED' } : prev));
|
||||
toastSuccess('门店已关闭');
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : '状态更新失败');
|
||||
} finally {
|
||||
setStatusSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBasic() {
|
||||
if (!id || saving || status === 'CLOSED') return;
|
||||
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
||||
if (auditStatus === 'PENDING') {
|
||||
setActionError('门店审核中,暂不可修改资料');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setActionError('');
|
||||
try {
|
||||
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
address: form.address.trim(),
|
||||
intro: form.intro.trim(),
|
||||
benefitUsageRule: form.benefitUsageRule.trim() || null,
|
||||
...(form.latitude.trim() && form.longitude.trim()
|
||||
? {
|
||||
latitude: Number(form.latitude),
|
||||
longitude: Number(form.longitude),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
applyStore(data);
|
||||
toastSuccess(auditStatus === 'REJECTED' ? '已保存并重新提交审核' : '已保存');
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMedia() {
|
||||
if (!id || mediaSaving || status === 'CLOSED') return;
|
||||
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
||||
if (auditStatus === 'PENDING') {
|
||||
setActionError('门店审核中,暂不可修改资料');
|
||||
return;
|
||||
}
|
||||
const nextCover = coverUrl.trim();
|
||||
const nextEnv = uniqueEnvUrls(envPhotoUrls);
|
||||
if (!nextCover) {
|
||||
setActionError('请上传门头照');
|
||||
return;
|
||||
}
|
||||
if (nextEnv.length < MIN_ENV_PHOTO_COUNT) {
|
||||
setActionError(`请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`);
|
||||
return;
|
||||
}
|
||||
setMediaSaving(true);
|
||||
setActionError('');
|
||||
try {
|
||||
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
coverUrl: nextCover,
|
||||
envPhotoUrls: nextEnv,
|
||||
}),
|
||||
});
|
||||
applyStore(data);
|
||||
toastSuccess(auditStatus === 'REJECTED' ? '照片已更新并重新提交审核' : '照片已更新');
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : '照片更新失败');
|
||||
} finally {
|
||||
setMediaSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="partner-detail-page partner-home--flush-top">
|
||||
<div className="empty">{loadError}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!store) return <div className="empty">加载中...</div>;
|
||||
|
||||
const envPhotos = uniqueEnvUrls(
|
||||
Array.isArray(store.media)
|
||||
? (store.media as Array<{ url?: string; bizType?: string }>)
|
||||
.filter((m) => m.bizType === 'ENV')
|
||||
.map((m) => String(m.url || ''))
|
||||
: [],
|
||||
);
|
||||
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
|
||||
const auditPending = auditStatus === 'PENDING';
|
||||
const auditRejected = auditStatus === 'REJECTED';
|
||||
const readOnly = !canMutate || status === 'CLOSED' || auditPending;
|
||||
const canOpen = canPartnerOpenStore(auditStatus);
|
||||
|
||||
return (
|
||||
<div className="partner-detail-page partner-home--flush-top">
|
||||
<main style={{ padding: '12px 20px 16px' }}>
|
||||
{actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>}
|
||||
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>审核状态</h3>
|
||||
<span className={`partner-status-pill ${storeAuditPillClass(auditStatus)}`}>
|
||||
{storeAuditLabel(auditStatus)}
|
||||
</span>
|
||||
</div>
|
||||
{auditPending && (
|
||||
<p className="body-md" style={{ color: 'var(--color-secondary)' }}>
|
||||
资料已提交总部审核,通过后即可开门营业。审核期间不可修改资料。
|
||||
</p>
|
||||
)}
|
||||
{auditRejected && (
|
||||
<div>
|
||||
<p className="body-md" style={{ color: 'var(--color-heritage-red)', fontWeight: 600, marginBottom: 8 }}>
|
||||
审核未通过
|
||||
</p>
|
||||
<p className="body-md" style={{ background: 'rgba(180,35,24,0.06)', padding: 12, borderRadius: 8 }}>
|
||||
{String(store.rejectReason || '未填写驳回原因')}
|
||||
</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
请按驳回原因修改资料并保存,将自动重新提交审核。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{auditStatus === 'APPROVED' && (
|
||||
<p className="label-md text-muted">总部审核已通过,可将门店设为营业中。</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{canMutate && !auditPending && status !== 'CLOSED' && (
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>门店套餐</h3>
|
||||
<button type="button" className="partner-btn-outline" onClick={() => navigate(`/stores/${id}/packages`)}>
|
||||
编辑套餐
|
||||
</button>
|
||||
</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>维护可核销套餐信息,提交后由总部审核。</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{canMutate && (
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>运营状态</h3>
|
||||
<span className={`partner-status-pill ${storeStatusPillClass(status)}`}>
|
||||
{status === 'OPEN' && <span className="partner-dot partner-dot--green" style={{ width: 8, height: 8, display: 'inline-block', marginRight: 4 }} />}
|
||||
{storeStatusLabel(status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="partner-status-toggle">
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={status === s ? 'active' : ''}
|
||||
disabled={readOnly || statusSaving || (s === 'OPEN' && !canOpen) || status === 'CLOSED'}
|
||||
onClick={() => void changeStatus(s)}
|
||||
>
|
||||
{s === 'OPEN' && !canOpen ? '待审核' : storeStatusLabel(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{status === 'CLOSED' && (
|
||||
<p className="label-md text-muted" style={{ marginTop: 12 }}>门店已关闭,不可再变更状态或编辑资料</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}>基本信息</h3>
|
||||
{!canMutate || readOnly ? (
|
||||
<div className="partner-cover">
|
||||
<AppImage
|
||||
src={coverUrl || null}
|
||||
alt={form.name}
|
||||
wrapperClassName="app-image--fill"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 8 }}>门头照</p>
|
||||
<OssUploadField
|
||||
wide
|
||||
bizType="STORE_TITLE"
|
||||
mediaType="IMAGE"
|
||||
value={coverUrl}
|
||||
onChange={setCoverUrl}
|
||||
label="拍照 / 从相册选择"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="partner-field">
|
||||
<label>门店名称</label>
|
||||
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>联系电话</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<input disabled={readOnly} type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店地址</label>
|
||||
<textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||
{!readOnly ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
style={{ padding: '8px 12px', fontSize: 13 }}
|
||||
onClick={() => setMapPickerOpen(true)}
|
||||
>
|
||||
地图选点
|
||||
</button>
|
||||
<span className="label-md text-muted">
|
||||
{form.latitude.trim() && form.longitude.trim() ? '已选点' : '未选点'}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<input type="hidden" name="latitude" value={form.latitude} readOnly />
|
||||
<input type="hidden" name="longitude" value={form.longitude} readOnly />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店简介</label>
|
||||
<textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(2-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
|
||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||
<span className="label-md text-muted">{form.intro.length} / 500</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>好客权益券使用规则</label>
|
||||
<textarea
|
||||
disabled={readOnly}
|
||||
rows={4}
|
||||
placeholder="选填,展示在用户端门店详情,最多1000字"
|
||||
maxLength={1000}
|
||||
value={form.benefitUsageRule}
|
||||
onChange={(e) => setForm({ ...form, benefitUsageRule: e.target.value })}
|
||||
/>
|
||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||
<span className="label-md text-muted">{form.benefitUsageRule.length} / 1000</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
|
||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}>店内环境</h3>
|
||||
<span className="label-md text-muted">
|
||||
{canMutate && !readOnly
|
||||
? `至少 ${MIN_ENV_PHOTO_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length} 张`
|
||||
: envPhotos.length
|
||||
? `已上传 ${envPhotos.length} 张`
|
||||
: '暂无照片'}
|
||||
</span>
|
||||
</div>
|
||||
{canMutate && !readOnly ? (
|
||||
<>
|
||||
<div className="partner-upload-grid">
|
||||
{envPhotoUrls.map((url, index) => (
|
||||
<OssUploadField
|
||||
key={index}
|
||||
compact
|
||||
bizType="STORE_ENV"
|
||||
mediaType="IMAGE"
|
||||
value={url}
|
||||
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
style={{ width: '100%', marginTop: 12 }}
|
||||
onClick={() => setEnvPhotoUrls((prev) => addEnvPhotoSlot(prev))}
|
||||
>
|
||||
添加环境照片
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
style={{ width: '100%', marginTop: 16 }}
|
||||
disabled={mediaSaving}
|
||||
onClick={() => void saveMedia()}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18, verticalAlign: 'middle', marginRight: 4 }}>
|
||||
upload
|
||||
</span>
|
||||
{mediaSaving ? '上传中…' : '重新上传照片'}
|
||||
</button>
|
||||
</>
|
||||
) : envPhotos.length > 0 ? (
|
||||
<div className="partner-photo-grid">
|
||||
{envPhotos.map((url, index) => (
|
||||
<div key={`${url}-${index}`} className="partner-cover" style={{ aspectRatio: '1', marginBottom: 0 }}>
|
||||
<AppImage src={url || null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="label-md text-muted">录入门店时上传的环境照片将显示在这里</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card" style={{ margin: 0, background: 'var(--color-surface-container)' }}>
|
||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-subtle-gray)', paddingLeft: 12, marginBottom: 16 }}>管理信息</h3>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label className="label-md text-muted">营业状态</label>
|
||||
<p className="body-md" style={{ fontWeight: 500 }}>{storeStatusLabel(status)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-md text-muted">审核状态</label>
|
||||
<p className="body-md" style={{ fontWeight: 500 }}>{storeAuditLabel(auditStatus)}</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="partner-save-footer">
|
||||
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
||||
{canMutate && (
|
||||
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{closeConfirmOpen && (
|
||||
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseConfirmOpen(false)}>
|
||||
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>确认关闭门店?</h3>
|
||||
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
|
||||
关闭后不可恢复营业,确认关闭该门店?
|
||||
</p>
|
||||
<div className="partner-ship-actions">
|
||||
<button type="button" className="partner-btn-secondary" onClick={() => setCloseConfirmOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
||||
确认关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TencentLocPickerOverlay
|
||||
open={mapPickerOpen}
|
||||
onClose={() => setMapPickerOpen(false)}
|
||||
latitude={form.latitude ? Number(form.latitude) : undefined}
|
||||
longitude={form.longitude ? Number(form.longitude) : undefined}
|
||||
onPick={(loc) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
latitude: String(loc.latitude),
|
||||
longitude: String(loc.longitude),
|
||||
...(loc.address && !prev.address.trim() ? { address: loc.address } : {}),
|
||||
}));
|
||||
toastSuccess(loc.name ? `已选定:${loc.name}` : '位置已选定');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { toastSuccess } from '../lib/toast';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
|
||||
import {
|
||||
canPartnerOpenStore,
|
||||
storeAuditLabel,
|
||||
storeAuditPillClass,
|
||||
storeStatusLabel,
|
||||
storeStatusPillClass,
|
||||
type StoreStatusValue,
|
||||
} from '../lib/storeStatus';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
|
||||
type StatusFilter = 'ALL' | StoreStatusValue | 'PENDING_AUDIT' | 'REJECTED';
|
||||
|
||||
const FILTERS: { key: StatusFilter; label: string }[] = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'OPEN', label: '营业中' },
|
||||
{ key: 'PAUSED', label: '暂时闭店' },
|
||||
{ key: 'PENDING_AUDIT', label: '待审核' },
|
||||
{ key: 'REJECTED', label: '已驳回' },
|
||||
{ key: 'CLOSED', label: '关闭' },
|
||||
];
|
||||
|
||||
export default function StoreListPage() {
|
||||
usePartnerPageView('partner_store_list_view');
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { account } = usePartnerSession();
|
||||
const canMutate = canManagePartnerStore(account);
|
||||
const canCreate = canCreatePartnerStore(account);
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [q, setQ] = useState('');
|
||||
const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
|
||||
const [filter, setFilter] = useState<StatusFilter>(initialFilter);
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [closeTarget, setCloseTarget] = useState<string | null>(null);
|
||||
|
||||
const loadStores = useCallback(() => {
|
||||
return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
void loadStores();
|
||||
}, [navigate, loadStores]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = canMutate ? '门店管理' : '我的门店';
|
||||
}, [canMutate]);
|
||||
|
||||
const filtered = useMemo(() => stores.filter((s) => {
|
||||
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
||||
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
||||
const status = String(s.status).toUpperCase();
|
||||
let matchStatus = true;
|
||||
if (filter === 'PENDING_AUDIT') matchStatus = audit === 'PENDING';
|
||||
else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED';
|
||||
else if (filter !== 'ALL') matchStatus = status === filter;
|
||||
return matchQ && matchStatus;
|
||||
}), [stores, q, filter]);
|
||||
|
||||
async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) {
|
||||
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
|
||||
setError(auditStatus === 'REJECTED' ? '门店审核未通过,请查看驳回原因并修改后重新提交' : '门店尚在总部审核中,通过后方可开门');
|
||||
return;
|
||||
}
|
||||
if (next === 'CLOSED') {
|
||||
setCloseTarget(storeId);
|
||||
return;
|
||||
}
|
||||
setUpdatingId(storeId);
|
||||
setError('');
|
||||
try {
|
||||
await request('PARTNER_H5', `/partner/stores/${storeId}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
await loadStores();
|
||||
if (next === 'OPEN') toastSuccess('开店成功');
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCloseStore() {
|
||||
if (!closeTarget) return;
|
||||
const storeId = closeTarget;
|
||||
setCloseTarget(null);
|
||||
setUpdatingId(storeId);
|
||||
setError('');
|
||||
try {
|
||||
await request('PARTNER_H5', `/partner/stores/${storeId}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: 'CLOSED' }),
|
||||
});
|
||||
await loadStores();
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
|
||||
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
||||
|
||||
<div className="partner-sticky-filter">
|
||||
<div className="partner-search">
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
<input placeholder="搜索门店名称/地址" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<div className="partner-chips">
|
||||
{FILTERS.map((f) => (
|
||||
<button key={f.key} type="button" className={`partner-chip${filter === f.key ? ' active' : ''}`} onClick={() => setFilter(f.key)}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canCreate && (
|
||||
<Link to="/stores/new" className="partner-fab-link">
|
||||
<button type="button" className="partner-btn-primary">
|
||||
<span className="material-symbols-outlined">add_business</span>
|
||||
录入新门店
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
||||
|
||||
{filtered.map((s) => {
|
||||
const storeId = String(s.id);
|
||||
const currentStatus = String(s.status).toUpperCase() as StoreStatusValue;
|
||||
const auditStatus = String(s.auditStatus || 'APPROVED').toUpperCase();
|
||||
const dim = currentStatus === 'CLOSED';
|
||||
const storeName = String(s.name || '未命名门店');
|
||||
const busy = updatingId === storeId;
|
||||
const canOpen = canPartnerOpenStore(auditStatus);
|
||||
return (
|
||||
<div key={storeId} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
|
||||
<Link to={`/stores/${storeId}`} className="partner-store-card-hit" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<div className="partner-store-card-header">
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 2 }}>门店名称</p>
|
||||
<h3 className="headline-md">{storeName}</h3>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p>
|
||||
{auditStatus !== 'APPROVED' && (
|
||||
<p className="label-md" style={{ marginTop: 8, color: auditStatus === 'REJECTED' ? 'var(--color-heritage-red)' : 'var(--color-secondary)' }}>
|
||||
{storeAuditLabel(auditStatus)}
|
||||
{auditStatus === 'REJECTED' && s.rejectReason ? `:${String(s.rejectReason)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
||||
{auditStatus !== 'APPROVED' ? (
|
||||
<span className={`partner-status-pill ${storeAuditPillClass(auditStatus)}`}>
|
||||
{storeAuditLabel(auditStatus)}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`partner-status-pill ${storeStatusPillClass(currentStatus)}`}>
|
||||
{storeStatusLabel(currentStatus)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{canMutate && (
|
||||
<div className="partner-store-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ fontSize: 12, padding: '8px 12px' }}
|
||||
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED' || auditStatus === 'PENDING'}
|
||||
onClick={() => void updateStatus(storeId, 'PAUSED', auditStatus)}
|
||||
>
|
||||
暂时闭店
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ fontSize: 12, padding: '8px 12px', borderColor: 'var(--color-subtle-gray)', color: 'var(--color-subtle-gray)' }}
|
||||
disabled={busy || currentStatus === 'CLOSED'}
|
||||
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
{currentStatus === 'PAUSED' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ fontSize: 12, padding: '8px 12px' }}
|
||||
disabled={busy || !canOpen}
|
||||
onClick={() => void updateStatus(storeId, 'OPEN', auditStatus)}
|
||||
>
|
||||
{canOpen ? '开门营业' : '待审核通过'}
|
||||
</button>
|
||||
)}
|
||||
<Link to={`/stores/${storeId}`} className="partner-menu-icon" style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{closeTarget && (
|
||||
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseTarget(null)}>
|
||||
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>确认关闭门店?</h3>
|
||||
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
|
||||
关闭后不可恢复营业,确认关闭该门店?
|
||||
</p>
|
||||
<div className="partner-ship-actions">
|
||||
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
||||
确认关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import type { StorePackagesResponse } from '@dukang/shared-types';
|
||||
import StorePackagesForm from '../components/StorePackagesForm';
|
||||
import { request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import {
|
||||
emptyPackage,
|
||||
normalizePackageFormItems,
|
||||
validatePackageFormItems,
|
||||
type PackageFormItem,
|
||||
} from '../lib/storePackages';
|
||||
|
||||
export default function StorePackagesPage() {
|
||||
const { id } = useParams();
|
||||
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 [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
request<StorePackagesResponse>('PARTNER_H5', `/partner/stores/${id}/packages`)
|
||||
.then((data) => {
|
||||
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);
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
async function submitAudit() {
|
||||
if (!id) return;
|
||||
const msg = validatePackageFormItems(items);
|
||||
if (msg) {
|
||||
toastError(msg);
|
||||
return;
|
||||
}
|
||||
if (pending?.status === 'PENDING') {
|
||||
toastError('已有套餐变更审核中,请等待总部处理');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const packages = normalizePackageFormItems(items).map((p) => ({
|
||||
...p,
|
||||
price: Number(p.price).toFixed(2),
|
||||
}));
|
||||
await request('PARTNER_H5', `/partner/stores/${id}/packages`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ packages }),
|
||||
});
|
||||
toastSuccess('已提交审核');
|
||||
navigate(`/stores/${id}`);
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="partner-detail-page partner-home--flush-top">
|
||||
<main style={{ padding: '24px 20px' }}>
|
||||
<p className="label-md text-muted">加载中…</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="partner-page-sticky partner-home--flush-top">
|
||||
<header className="partner-packages-page-header">
|
||||
<button type="button" className="partner-packages-back" onClick={() => navigate(`/stores/${id}`)}>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className="headline-md">门店套餐</h1>
|
||||
</header>
|
||||
|
||||
<main className="partner-packages-page-main">
|
||||
{error ? <p className="partner-form-error" role="alert">{error}</p> : null}
|
||||
|
||||
{pending?.status === 'PENDING' ? (
|
||||
<div className="partner-info-banner">
|
||||
<span className="material-symbols-outlined" style={{ color: 'var(--color-heritage-red)' }}>hourglass_top</span>
|
||||
<p className="body-md">套餐变更审核中,C 端仍展示上一版生效套餐。</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pending?.status === 'REJECTED' && pending.rejectReason ? (
|
||||
<div className="partner-info-banner" style={{ background: 'rgba(180,35,24,0.06)', borderColor: 'rgba(180,35,24,0.15)' }}>
|
||||
<span className="material-symbols-outlined" style={{ color: 'var(--color-heritage-red)' }}>error</span>
|
||||
<p className="body-md">上次驳回:{pending.rejectReason}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="partner-form-card">
|
||||
<div className="partner-section-title">
|
||||
<div className="partner-section-bar" />
|
||||
<h2 className="headline-md">套餐信息</h2>
|
||||
</div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 16 }}>
|
||||
提交后将由总部审核,通过后 C 端展示生效套餐。
|
||||
</p>
|
||||
<StorePackagesForm
|
||||
items={items}
|
||||
onChange={setItems}
|
||||
disabled={pending?.status === 'PENDING' || submitting}
|
||||
embedded
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="partner-sticky-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
onClick={() => navigate(`/stores/${id}`)}
|
||||
disabled={submitting}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
disabled={submitting || pending?.status === 'PENDING'}
|
||||
onClick={() => void submitAudit()}
|
||||
>
|
||||
{submitting ? '提交中…' : '提交审核'}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function fmtGrowth(n: number) {
|
||||
const sign = n > 0 ? '+' : '';
|
||||
return `${sign}${n}%`;
|
||||
}
|
||||
|
||||
export default function WeeklyReportPage() {
|
||||
usePartnerPageView('partner_weekly_report_view');
|
||||
const navigate = useNavigate();
|
||||
const [selectedStart, setSelectedStart] = useState<string | undefined>();
|
||||
const [data, setData] = useState<PartnerWeeklyReportResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadWeeklyReport = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
return fetchPartnerWeeklyReport(selectedStart)
|
||||
.then(setData)
|
||||
.catch((e) => {
|
||||
setData(null);
|
||||
setError(e instanceof Error ? e.message : '加载失败');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedStart]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadWeeklyReport();
|
||||
}, [loadWeeklyReport]);
|
||||
|
||||
const maxDailyGmv = useMemo(() => {
|
||||
if (!data?.dailyGmv.length) return 1;
|
||||
return Math.max(1, ...data.dailyGmv.map((item) => item.amount));
|
||||
}, [data]);
|
||||
|
||||
const topStores = data?.storeRanking.slice(0, 3) ?? [];
|
||||
const summary = data?.summary;
|
||||
const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0;
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadWeeklyReport} className="page-no-tab">
|
||||
<PageHeader title="数据周报" onBack={() => navigate('/')} />
|
||||
|
||||
<main className="partner-weekly-page">
|
||||
<section className="partner-weekly-title-section">
|
||||
<h2>经营数据周报</h2>
|
||||
{data && (
|
||||
<div className="partner-weekly-tabs">
|
||||
{data.availablePeriods.map((period) => (
|
||||
<button
|
||||
key={period.startDate}
|
||||
type="button"
|
||||
className={`partner-weekly-tab${
|
||||
period.startDate === data.period.startDate ? ' active' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedStart(period.startDate)}
|
||||
>
|
||||
{period.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{loading && <p className="label-md text-muted partner-weekly-state">加载中…</p>}
|
||||
{!loading && error && (
|
||||
<p className="label-md text-error partner-weekly-state">{error}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && data && summary && (
|
||||
<>
|
||||
<section className="partner-weekly-hero">
|
||||
<div className="partner-weekly-hero-top">
|
||||
<div>
|
||||
<p className="partner-weekly-hero-label">累计成交额 (GMV)</p>
|
||||
<p className="partner-weekly-hero-gmv">¥{fmtMoney(summary.gmv)}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`partner-weekly-growth-badge${
|
||||
growthPositive ? ' partner-weekly-growth-badge--up' : ' partner-weekly-growth-badge--down'
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{growthPositive ? 'trending_up' : 'trending_down'}
|
||||
</span>
|
||||
<span>{fmtGrowth(summary.gmvGrowthPercent)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-weekly-hero-grid">
|
||||
<div>
|
||||
<p className="label-md text-muted">活跃门店</p>
|
||||
<p className="partner-weekly-stat-value">
|
||||
{summary.activeStoreCount}
|
||||
<span className="partner-weekly-stat-sub">/ {summary.totalStoreCount}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="label-md text-muted">订单总量</p>
|
||||
<p className="partner-weekly-stat-value">{summary.orderCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-weekly-card">
|
||||
<div className="partner-weekly-card-header">
|
||||
<div className="partner-weekly-card-title">
|
||||
<span className="material-symbols-outlined partner-weekly-icon-amber">storefront</span>
|
||||
<h3 className="headline-md">本周新签约门店</h3>
|
||||
</div>
|
||||
<p className="partner-weekly-target">
|
||||
{summary.newStoreCount}
|
||||
<span className="partner-weekly-target-sub">/ {summary.newStoreTarget}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="partner-weekly-progress-track">
|
||||
<div
|
||||
className="partner-weekly-progress-bar"
|
||||
style={{ width: `${summary.newStoreProgressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="label-md text-muted partner-weekly-progress-hint">
|
||||
已完成本周目标的 {summary.newStoreProgressPercent}%。
|
||||
{summary.newStoreCount < summary.newStoreTarget
|
||||
? `还差 ${summary.newStoreTarget - summary.newStoreCount} 家!`
|
||||
: '目标已达成!'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="partner-weekly-card">
|
||||
<div className="partner-weekly-card-header">
|
||||
<h3 className="headline-md">每日成交趋势</h3>
|
||||
<span className="label-md text-muted">过去 7 天</span>
|
||||
</div>
|
||||
<div className="partner-weekly-chart">
|
||||
<div className="partner-weekly-chart-bars">
|
||||
{data.dailyGmv.map((item) => {
|
||||
const height = Math.max(8, Math.round((item.amount / maxDailyGmv) * 100));
|
||||
const isMax = item.amount === maxDailyGmv && item.amount > 0;
|
||||
return (
|
||||
<div
|
||||
key={item.date}
|
||||
className={`partner-weekly-chart-bar${isMax ? ' partner-weekly-chart-bar--peak' : ''}`}
|
||||
style={{ height: `${height}%` }}
|
||||
title={`${item.weekdayLabel} ¥${fmtMoney(item.amount)}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="partner-weekly-chart-labels">
|
||||
{data.dailyGmv.map((item) => (
|
||||
<span key={item.date}>{item.weekdayLabel}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-weekly-rank-section">
|
||||
<div className="partner-weekly-card-header">
|
||||
<h3 className="headline-md">门店业绩排行</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-weekly-link"
|
||||
onClick={() => navigate('/stores')}
|
||||
>
|
||||
查看全部
|
||||
<span className="material-symbols-outlined">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
{topStores.length === 0 ? (
|
||||
<p className="label-md text-muted partner-weekly-empty">本周暂无核销排行数据</p>
|
||||
) : (
|
||||
<div className="partner-weekly-rank-list">
|
||||
{topStores.map((store) => (
|
||||
<div
|
||||
key={store.storeId}
|
||||
className={`partner-weekly-rank-row${
|
||||
store.rank === 1 ? ' partner-weekly-rank-row--first' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="partner-weekly-rank-badge">
|
||||
{store.rank === 1 ? (
|
||||
<span className="material-symbols-outlined">workspace_premium</span>
|
||||
) : (
|
||||
<span>{store.rank}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="partner-weekly-rank-info">
|
||||
<p className="partner-weekly-rank-name">{store.name}</p>
|
||||
{store.subtitle && (
|
||||
<p className="label-md text-muted">{store.subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="partner-weekly-rank-amount">¥{fmtMoney(store.redeemAmount)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="partner-weekly-insight">
|
||||
<span className="material-symbols-outlined">tips_and_updates</span>
|
||||
<div>
|
||||
<p className="headline-md">合伙人经营策略</p>
|
||||
<p className="body-md text-muted">{data.insight}</p>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</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