fix:提交修复6个问题
This commit is contained in:
+44
-67
@@ -1,67 +1,44 @@
|
||||
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import TabLayout from './layouts/TabLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import StoreListPage from './pages/StoreListPage';
|
||||
import StoreDetailPage from './pages/StoreDetailPage';
|
||||
import StoreCreatePage from './pages/StoreCreatePage';
|
||||
import OrderListPage from './pages/OrderListPage';
|
||||
import OrderDetailPage from './pages/OrderDetailPage';
|
||||
import CenterPage from './pages/CenterPage';
|
||||
import BillsPage from './pages/BillsPage';
|
||||
import ReshipPage from './pages/ReshipPage';
|
||||
import WeeklyReportPage from './pages/WeeklyReportPage';
|
||||
import LeaderboardPage from './pages/LeaderboardPage';
|
||||
import { handlePartnerWechatCallback, handlePartnerWechatLoginResult } from './lib/wechat-auth';
|
||||
import { isLoggedIn } from './lib/api';
|
||||
import { isWechatEnv } from './lib/weixin';
|
||||
|
||||
function WechatOAuthHandler() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !location.search.includes('code=')) return;
|
||||
if (location.pathname === '/login') return;
|
||||
void handlePartnerWechatCallback()
|
||||
.then((result) => {
|
||||
if (!result || !handlePartnerWechatLoginResult(result)) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete('code');
|
||||
params.delete('state');
|
||||
const qs = params.toString();
|
||||
navigate(`${location.pathname}${qs ? `?${qs}` : ''}`, { replace: true });
|
||||
})
|
||||
.catch(() => {
|
||||
/* 页面内组件会提示 */
|
||||
});
|
||||
}, [location.pathname, location.search, navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<WechatOAuthHandler />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<TabLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/stores" element={<StoreListPage />} />
|
||||
<Route path="/orders" element={<OrderListPage />} />
|
||||
<Route path="/center" element={<CenterPage />} />
|
||||
</Route>
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="/center/bills" element={<BillsPage />} />
|
||||
<Route path="/reshipments" element={<ReshipPage />} />
|
||||
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||
<Route path="*" element={<Navigate to={isLoggedIn() ? '/' : '/login'} replace />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import LoginPage from './pages/LoginPage';
|
||||
|
||||
import PartnerAppRoutes from './PartnerAppRoutes';
|
||||
|
||||
import { handlePartnerWechatCallback, handlePartnerWechatLoginResult } from './lib/wechat-auth';
|
||||
|
||||
import { isLoggedIn } from './lib/api';
|
||||
|
||||
import { isWechatEnv } from './lib/weixin';
|
||||
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
|
||||
|
||||
function WechatOAuthHandler() {
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (!isWechatEnv() || !location.search.includes('code=')) return;
|
||||
|
||||
if (location.pathname === '/login') return;
|
||||
|
||||
void handlePartnerWechatCallback()
|
||||
|
||||
.then((result) => {
|
||||
|
||||
if (!result || !handlePartnerWechatLoginResult(result)) return;
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
params.delete('code');
|
||||
|
||||
params.delete('state');
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { usePartnerSession } from './contexts/PartnerSessionContext';
|
||||
import { isLoggedIn } from './lib/api';
|
||||
import { 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 StoreCreatePage from './pages/StoreCreatePage';
|
||||
import OrderListPage from './pages/OrderListPage';
|
||||
import OrderDetailPage from './pages/OrderDetailPage';
|
||||
import CenterPage from './pages/CenterPage';
|
||||
import BillsPage from './pages/BillsPage';
|
||||
import SettlementPage from './pages/SettlementPage';
|
||||
import ReshipPage from './pages/ReshipPage';
|
||||
import WeeklyReportPage from './pages/WeeklyReportPage';
|
||||
import LeaderboardPage from './pages/LeaderboardPage';
|
||||
import StaffListPage from './pages/StaffListPage';
|
||||
import StaffCreatePage from './pages/StaffCreatePage';
|
||||
|
||||
function SessionLoading() {
|
||||
return <div className="empty">加载中...</div>;
|
||||
}
|
||||
|
||||
function PrimaryRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<TabLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/stores" element={<StoreListPage />} />
|
||||
<Route path="/orders" element={<OrderListPage />} />
|
||||
<Route path="/center" element={<CenterPage />} />
|
||||
</Route>
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<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="/reshipments" element={<ReshipPage />} />
|
||||
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function SubAccountRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<SubAccountLayout />}>
|
||||
<Route path="/stores" element={<StoreListPage />} />
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/stores/new" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PartnerAppRoutes() {
|
||||
const { account, loading, loggedIn } = usePartnerSession();
|
||||
|
||||
if (!loggedIn && !isLoggedIn()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
if (loading) {
|
||||
return <SessionLoading />;
|
||||
}
|
||||
if (isSubAccount(account)) {
|
||||
return <SubAccountRoutes />;
|
||||
}
|
||||
return <PrimaryRoutes />;
|
||||
}
|
||||
@@ -26,6 +26,17 @@ type OssUploadFieldProps = {
|
||||
|
||||
const DEFAULT_MAX_MB = 10;
|
||||
|
||||
function formatWechatUploadError(e: unknown): string {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
if (/invalid signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
|
||||
}
|
||||
if (/permission|denied|拒绝/i.test(msg)) {
|
||||
return '微信选图权限被拒绝,请在微信设置中允许相册/相机访问后重试';
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
export default function OssUploadField({
|
||||
value,
|
||||
onChange,
|
||||
@@ -48,7 +59,7 @@ export default function OssUploadField({
|
||||
|
||||
const resolvedAccept =
|
||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
|
||||
const useWechatPicker = isWechatEnv() && mediaType !== 'VIDEO';
|
||||
const useWechatPicker = isWechatEnv() && mediaType === 'IMAGE';
|
||||
const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -100,6 +111,22 @@ export default function OssUploadField({
|
||||
}
|
||||
}
|
||||
|
||||
function openNativeFilePicker() {
|
||||
inputRef.current?.click();
|
||||
}
|
||||
|
||||
async function pickWechatImage() {
|
||||
const files = await weixinSdk.chooseImages({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
});
|
||||
if (files?.[0]) {
|
||||
await uploadSelectedFile(files[0]);
|
||||
return;
|
||||
}
|
||||
throw new Error('未能获取图片,请重试');
|
||||
}
|
||||
|
||||
async function pickFile() {
|
||||
if (uploading || authorizing) return;
|
||||
setError('');
|
||||
@@ -111,27 +138,17 @@ export default function OssUploadField({
|
||||
|
||||
if (useWechatPicker) {
|
||||
try {
|
||||
const files = await weixinSdk.chooseImages({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
});
|
||||
if (files?.[0]) {
|
||||
await uploadSelectedFile(files[0]);
|
||||
return;
|
||||
}
|
||||
setError('未能获取图片,请重试');
|
||||
await pickWechatImage();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
if (!/cancel/i.test(msg)) {
|
||||
setError(/permission|auth|denied|授权|拒绝/i.test(msg)
|
||||
? '微信选图授权失败,请刷新页面后重试'
|
||||
: msg);
|
||||
}
|
||||
if (/cancel/i.test(msg)) return;
|
||||
setError(formatWechatUploadError(e));
|
||||
openNativeFilePicker();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.current?.click();
|
||||
openNativeFilePicker();
|
||||
}
|
||||
|
||||
const isImage = mediaType === 'IMAGE' && value;
|
||||
@@ -165,6 +182,7 @@ export default function OssUploadField({
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={resolvedAccept}
|
||||
capture={mediaType === 'FILE' && isWechatEnv() ? 'environment' : undefined}
|
||||
className="partner-oss-upload-input"
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -2,13 +2,10 @@ import { createContext, useCallback, useContext, useEffect, useState, type React
|
||||
import { toAppPath } from '@dukang/weixin-sdk';
|
||||
import { clearAuth, isLoggedIn, request } from '../lib/api';
|
||||
|
||||
export type PartnerAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
isPrimary?: boolean;
|
||||
companyName?: string;
|
||||
hasWechat?: boolean;
|
||||
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
|
||||
|
||||
export type PartnerAccount = PartnerMe & {
|
||||
staffRole?: PartnerStaffRole;
|
||||
};
|
||||
|
||||
type PartnerSessionValue = {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/stores/new', icon: 'add_business', label: '录入新店' },
|
||||
{ to: '/stores', end: true, icon: 'store', label: '我的门店' },
|
||||
] as const;
|
||||
|
||||
export default function SubAccountLayout() {
|
||||
const { account, logout } = usePartnerSession();
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="header app-page-header partner-sub-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>
|
||||
<h1 className="app-page-title" style={{ fontSize: 18 }}>{account?.name || '拓店账号'}</h1>
|
||||
<p className="label-md text-muted">{account?.companyName || '门店入驻'}</p>
|
||||
</div>
|
||||
<button type="button" className="partner-menu-icon" onClick={logout} aria-label="退出登录">
|
||||
<span className="material-symbols-outlined">logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
export type LeaderboardEntry = {
|
||||
rank: number;
|
||||
name: string;
|
||||
role: string;
|
||||
totalStores: number;
|
||||
monthStores: number;
|
||||
};
|
||||
import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
|
||||
import { request } from './api';
|
||||
|
||||
/** preV1:贡献榜 Mock,待 `/partner/dashboard/leaderboard` 接入后替换 */
|
||||
export const LEADERBOARD_MOCK: LeaderboardEntry[] = [
|
||||
{ rank: 1, name: '陈经理', role: '内部员工', totalStores: 45, monthStores: 8 },
|
||||
{ rank: 2, name: '李华', role: '城市合伙人', totalStores: 32, monthStores: 5 },
|
||||
{ rank: 3, name: '王拓', role: '推广员', totalStores: 18, monthStores: 3 },
|
||||
{ rank: 4, name: '赵敏', role: '内部员工', totalStores: 12, monthStores: 2 },
|
||||
];
|
||||
export function fetchPartnerLeaderboard(period: PartnerLeaderboardPeriod = 'total') {
|
||||
return request<PartnerLeaderboardResponse>(
|
||||
'PARTNER_H5',
|
||||
`/partner/dashboard/leaderboard?period=${period}`,
|
||||
);
|
||||
}
|
||||
|
||||
export type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse };
|
||||
export type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PartnerMe } from '@dukang/shared-types';
|
||||
|
||||
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 partnerHomePath(account: PartnerMe | null | undefined): string {
|
||||
return isSubAccount(account) ? '/stores/new' : '/';
|
||||
}
|
||||
|
||||
export const SUB_ACCOUNT_ALLOWED_PREFIXES = ['/stores', '/login'];
|
||||
|
||||
export function isSubAccountPath(pathname: string): boolean {
|
||||
if (pathname === '/login') return true;
|
||||
return SUB_ACCOUNT_ALLOWED_PREFIXES.some(
|
||||
(prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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 createPartnerStaff(body: CreatePartnerStaffRequest) {
|
||||
return request<PartnerStaffItem>('PARTNER_H5', '/partner/staff', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
export function sendStaffAddSms(phone: string) {
|
||||
return request('PARTNER_H5', '/partner/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'PARTNER_STAFF_ADD' }),
|
||||
});
|
||||
}
|
||||
@@ -116,6 +116,16 @@ export function validateStoreStep1(
|
||||
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 < 3) return '请上传至少 3 张环境照片';
|
||||
if (!form.contractUrl.trim()) return '请上传签约合同';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateStoreStep3(
|
||||
form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>,
|
||||
): string | null {
|
||||
|
||||
@@ -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,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 };
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
|
||||
export default function BillsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -14,7 +15,10 @@ export default function BillsPage() {
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
||||
}, [navigate]);
|
||||
|
||||
const bill = bills[0];
|
||||
const billId = searchParams.get('id');
|
||||
const bill = billId
|
||||
? bills.find((b) => String(b.id) === billId) ?? bills[0]
|
||||
: bills[0];
|
||||
|
||||
function confirmBill() {
|
||||
if (!confirmed) return;
|
||||
@@ -27,7 +31,7 @@ export default function BillsPage() {
|
||||
|
||||
return (
|
||||
<div className="partner-bills-page">
|
||||
<PageHeader title="待确认账单" onBack={() => navigate('/center')} />
|
||||
<PageHeader title="待确认账单" onBack={() => navigate('/center/settlement')} />
|
||||
|
||||
<div className="partner-bill-stepper">
|
||||
<div className="partner-stepper-inner">
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { isPrimaryAccount } from '../lib/partnerAccess';
|
||||
|
||||
export default function CenterPage() {
|
||||
const navigate = useNavigate();
|
||||
const { account, logout } = usePartnerSession();
|
||||
const showPrimaryMenus = isPrimaryAccount(account);
|
||||
const me = account as unknown as Record<string, unknown> | null;
|
||||
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
|
||||
|
||||
@@ -101,7 +103,7 @@ export default function CenterPage() {
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<Link to="/center/bills" className="partner-menu-item">
|
||||
<Link to="/center/settlement" 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>
|
||||
@@ -132,6 +134,15 @@ export default function CenterPage() {
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
{showPrimaryMenus && (
|
||||
<Link to="/center/staff" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">group</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>子账号管理</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { LEADERBOARD_MOCK } from '../lib/leaderboard';
|
||||
import { storeStatusLabel } from '../lib/storeStatus';
|
||||
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||
import { storeStatusLabel, storeStatusPillClass } from '../lib/storeStatus';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
const HOME_STORE_PREVIEW_LIMIT = 6;
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [leaderboardPreview, setLeaderboardPreview] = useState<PartnerLeaderboardEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash);
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
||||
fetchPartnerLeaderboard('month')
|
||||
.then((data) => setLeaderboardPreview(data.list.slice(0, 2)))
|
||||
.catch(() => setLeaderboardPreview([]));
|
||||
}, [navigate]);
|
||||
|
||||
const storeCount = Number(dash?.storeCount || stores.length || 0);
|
||||
const orderCount = Number(dash?.orderCount || 0);
|
||||
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 revenue = orderCount * 128.45;
|
||||
@@ -34,9 +42,7 @@ export default function HomePage() {
|
||||
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
|
||||
}).length;
|
||||
|
||||
const recentFromDash = (dash?.recentStores as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const displayStores = stores.length > 0 ? stores : recentFromDash;
|
||||
const previewLeaderboard = LEADERBOARD_MOCK.slice(0, 2);
|
||||
const previewStores = stores.slice(0, HOME_STORE_PREVIEW_LIMIT);
|
||||
|
||||
return (
|
||||
<div className="page partner-home">
|
||||
@@ -107,7 +113,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
<span className="partner-quick-action-label">补发处理</span>
|
||||
</Link>
|
||||
<Link to="/center/bills" className="partner-quick-action">
|
||||
<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>
|
||||
@@ -146,6 +152,35 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-home-store-section">
|
||||
<div className="partner-home-store-header">
|
||||
<h2 className="headline-md">辖区门店</h2>
|
||||
<Link to="/stores" className="label-md text-primary">查看全部</Link>
|
||||
</div>
|
||||
{previewStores.length === 0 ? (
|
||||
<div className="partner-home-store-empty">
|
||||
<p className="label-md text-muted">暂无门店</p>
|
||||
<Link to="/stores/new" className="label-md text-primary">录入新店</Link>
|
||||
</div>
|
||||
) : (
|
||||
previewStores.map((s) => {
|
||||
const storeId = String(s.id);
|
||||
const status = String(s.status || 'PAUSED').toUpperCase();
|
||||
return (
|
||||
<Link key={storeId} to={`/stores/${storeId}`} className="partner-home-store-row">
|
||||
<div className="partner-home-store-info">
|
||||
<p className="headline-md">{String(s.name || '未命名门店')}</p>
|
||||
<p className="label-md text-muted">{String(s.address || s.district || '')}</p>
|
||||
</div>
|
||||
<span className={`partner-status-pill ${storeStatusPillClass(status)}`}>
|
||||
{storeStatusLabel(status)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="partner-expansion-card">
|
||||
<h2 className="headline-md" style={{ marginBottom: 16 }}>拓店情况</h2>
|
||||
<div className="partner-expansion-split">
|
||||
@@ -155,23 +190,12 @@ export default function HomePage() {
|
||||
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</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>
|
||||
|
||||
{displayStores.length > 0 && (
|
||||
<ul className="partner-store-name-list">
|
||||
<li className="partner-store-name-list-header">
|
||||
<span className="label-md text-muted">门店名称</span>
|
||||
<span className="label-md text-muted">状态</span>
|
||||
</li>
|
||||
{displayStores.slice(0, 5).map((s) => (
|
||||
<li key={String(s.id)}>
|
||||
<span className="headline-md">{String(s.name || '未命名门店')}</span>
|
||||
<span className="label-md text-muted">{storeStatusLabel(String(s.status))}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="partner-leaderboard-section">
|
||||
<div className="partner-leaderboard-header">
|
||||
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
@@ -180,22 +204,26 @@ export default function HomePage() {
|
||||
</h3>
|
||||
<Link to="/leaderboard" className="label-md text-primary">查看全部</Link>
|
||||
</div>
|
||||
{previewLeaderboard.map((entry) => (
|
||||
<div key={entry.rank} className="partner-leaderboard-row partner-leaderboard-row--compact">
|
||||
<div className="partner-leaderboard-rank">{entry.rank}</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.role})</span>
|
||||
</p>
|
||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
||||
{leaderboardPreview.length === 0 ? (
|
||||
<p className="label-md text-muted">暂无排行数据</p>
|
||||
) : (
|
||||
leaderboardPreview.map((entry) => (
|
||||
<div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
|
||||
<div className="partner-leaderboard-rank">{entry.rank}</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>
|
||||
<div className="partner-leaderboard-stat">
|
||||
<p className="headline-md text-primary">{entry.monthStores} 间</p>
|
||||
<p className="label-md text-muted">本月新增</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
|
||||
|
||||
@@ -1,36 +1,129 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { 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 { LEADERBOARD_MOCK } from '../lib/leaderboard';
|
||||
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { isPrimaryAccount } from '../lib/partnerAccess';
|
||||
|
||||
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() {
|
||||
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('');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
fetchPartnerLeaderboard(period)
|
||||
.then(setData)
|
||||
.catch((e) => {
|
||||
setData(null);
|
||||
setError(e instanceof Error ? e.message : '加载失败');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [period]);
|
||||
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} />
|
||||
|
||||
<p className="label-md text-muted" style={{ padding: '0 20px 12px' }}>
|
||||
按拓店数排行,数据每周更新
|
||||
</p>
|
||||
|
||||
<div style={{ padding: '0 20px 24px' }}>
|
||||
{LEADERBOARD_MOCK.map((entry) => (
|
||||
<div key={entry.rank} 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.role})</span>
|
||||
</p>
|
||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
||||
</div>
|
||||
<div className="partner-leaderboard-stat">
|
||||
<p className="headline-md text-primary">{entry.monthStores} 间</p>
|
||||
<p className="label-md text-muted">本月新增</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { request, saveAuth } from '../lib/api';
|
||||
import { partnerHomePath } from '../lib/partnerAccess';
|
||||
import type { PartnerMe } from '@dukang/shared-types';
|
||||
import {
|
||||
bindPartnerWechatAfterSmsLogin,
|
||||
fetchClientConfig,
|
||||
@@ -132,18 +134,20 @@ export default function LoginPage() {
|
||||
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
|
||||
});
|
||||
}
|
||||
const data = await request<{ accessToken: string }>('PARTNER_H5', '/partner/auth/login/sms', {
|
||||
const data = await request<{ accessToken: string; partner?: PartnerMe }>('PARTNER_H5', '/partner/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
saveAuth(data);
|
||||
persistRememberAccount(phone);
|
||||
const home = partnerHomePath(data.partner);
|
||||
if (isWechatEnv() && wxAuthorize) {
|
||||
setMsg('登录成功,正在关联微信…');
|
||||
await bindPartnerWechatAfterSmsLogin();
|
||||
navigate(home);
|
||||
return;
|
||||
}
|
||||
navigate('/');
|
||||
navigate(home);
|
||||
} catch (e) {
|
||||
setMsg(formatPartnerError(e));
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
|
||||
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function billStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'DRAFT': return '待结算';
|
||||
case 'CONFIRMED': return '审核中';
|
||||
case 'PAID': return '已结算';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
function billStatusClass(status: string) {
|
||||
switch (status) {
|
||||
case 'DRAFT': return 'partner-settlement-status--pending';
|
||||
case 'CONFIRMED': return 'partner-settlement-status--reviewing';
|
||||
case 'PAID': return 'partner-settlement-status--settled';
|
||||
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');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
||||
}, [navigate]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const pending = bills.filter((b) => b.status === 'DRAFT');
|
||||
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 === 'DRAFT';
|
||||
if (statusFilter === 'settled') return b.status === 'PAID';
|
||||
if (statusFilter === 'reviewing') return b.status === 'CONFIRMED';
|
||||
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: 'settled', label: '已结算' },
|
||||
{ key: 'reviewing', label: '审核中' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div 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>
|
||||
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' ? ' 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole } from '@dukang/shared-types';
|
||||
import { createPartnerStaff, sendStaffAddSms } from '../lib/staff';
|
||||
|
||||
const ROLE_OPTIONS = Object.values(PartnerStaffRole);
|
||||
|
||||
export default function StaffCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [staffRole, setStaffRole] = useState<PartnerStaffRole>(PartnerStaffRole.INTERNAL);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
async function sendCode() {
|
||||
if (!/^1[3-9]\d{9}$/.test(phone.trim())) {
|
||||
setMsg('请输入正确的11位手机号');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await sendStaffAddSms(phone.trim());
|
||||
setMsg('验证码已发送');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '发送失败');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim()) {
|
||||
setMsg('请填写真实姓名');
|
||||
return;
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(phone.trim())) {
|
||||
setMsg('请输入正确的11位手机号');
|
||||
return;
|
||||
}
|
||||
if (!code.trim()) {
|
||||
setMsg('请输入验证码');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await createPartnerStaff({
|
||||
name: name.trim(),
|
||||
phone: phone.trim(),
|
||||
staffRole,
|
||||
code: code.trim(),
|
||||
});
|
||||
navigate('/center/staff');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '创建失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-no-tab partner-staff-create-page">
|
||||
<PageHeader title="添加子账号" onBack={() => navigate('/center/staff')} />
|
||||
|
||||
<main style={{ padding: '16px 20px 120px' }}>
|
||||
{msg && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{msg}</p>}
|
||||
|
||||
<div className="partner-form-card">
|
||||
<label className="partner-form-label">真实姓名 *</label>
|
||||
<div className="partner-form-input-wrap">
|
||||
<input
|
||||
className="partner-form-input"
|
||||
placeholder="请输入员工姓名"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="partner-form-card">
|
||||
<label className="partner-form-label">手机号码 *</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
className="partner-form-input"
|
||||
placeholder="请输入11位手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<button type="button" className="btn btn-outline" disabled={sending} onClick={() => void sendCode()}>
|
||||
{sending ? '发送中' : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="partner-form-input"
|
||||
style={{ marginTop: 8 }}
|
||||
placeholder="验证码"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="partner-form-card">
|
||||
<label className="partner-form-label">分配角色 *</label>
|
||||
<div className="partner-form-input-wrap">
|
||||
<select
|
||||
className="partner-form-input"
|
||||
value={staffRole}
|
||||
onChange={(e) => setStaffRole(e.target.value as PartnerStaffRole)}
|
||||
>
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<option key={role} value={role}>{PARTNER_STAFF_ROLE_LABELS[role]}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="material-symbols-outlined">expand_more</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
创建即代表同意合伙人管理协议。子账号默认禁用,需手动启用。
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<div className="partner-sticky-footer">
|
||||
<button type="button" className="partner-btn-primary" disabled={submitting} onClick={() => void submit()}>
|
||||
<span className="material-symbols-outlined">person_add</span>
|
||||
{submitting ? '创建中...' : '确认创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import {
|
||||
AccountStatus,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
PartnerStaffRole,
|
||||
type PartnerStaffItem,
|
||||
} from '@dukang/shared-types';
|
||||
import { deletePartnerStaff, listPartnerStaff, updatePartnerStaff } from '../lib/staff';
|
||||
|
||||
export default function StaffListPage() {
|
||||
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 (
|
||||
<div 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,367 +1,929 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
|
||||
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
||||
|
||||
import OssUploadField from '../components/OssUploadField';
|
||||
|
||||
import { request } from '../lib/api';
|
||||
|
||||
import { resolveRegionBinding } from '../lib/china-region';
|
||||
|
||||
import { checkStorePhoneAvailable } from '../lib/storePhone';
|
||||
|
||||
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
||||
|
||||
import {
|
||||
|
||||
fetchPartnerProfile,
|
||||
|
||||
handlePartnerWechatCallback,
|
||||
|
||||
savePartnerWechatAuth,
|
||||
|
||||
} from '../lib/wechat-auth';
|
||||
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
|
||||
import {
|
||||
|
||||
clearStoreDraft,
|
||||
|
||||
defaultStoreForm,
|
||||
|
||||
loadStoreDraft,
|
||||
|
||||
saveStoreDraft,
|
||||
|
||||
type StoreDraftForm,
|
||||
|
||||
validateStoreStep1,
|
||||
|
||||
validateStoreStep2,
|
||||
|
||||
validateStoreStep3,
|
||||
|
||||
} from '../lib/storeDraft';
|
||||
|
||||
|
||||
|
||||
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
||||
|
||||
|
||||
|
||||
type FieldErrors = {
|
||||
|
||||
phone?: string;
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
function isPhoneConflictMessage(message: string) {
|
||||
|
||||
return message.includes('手机号已绑定');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function StoreCreatePage() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
const saved = loadStoreDraft();
|
||||
|
||||
const [form, setForm] = useState<StoreDraftForm>(saved?.form ?? defaultStoreForm());
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [checkingPhone, setCheckingPhone] = useState(false);
|
||||
|
||||
const [cities, setCities] = useState<OpenCityOption[]>([]);
|
||||
|
||||
const [citiesError, setCitiesError] = useState('');
|
||||
|
||||
const [wechatReady, setWechatReady] = useState(false);
|
||||
|
||||
|
||||
|
||||
const stepFromUrl = Number(params.get('step') || 0);
|
||||
|
||||
const step = stepFromUrl >= 1 && stepFromUrl <= 3 ? stepFromUrl : (saved?.step ?? 1);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (!stepFromUrl && saved?.step) {
|
||||
|
||||
setParams({ step: String(saved.step) }, { replace: true });
|
||||
|
||||
}
|
||||
|
||||
}, [stepFromUrl, saved?.step, setParams]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
saveStoreDraft({ step, form });
|
||||
|
||||
}, [step, form]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (step !== 2 || !isWechatEnv()) return;
|
||||
|
||||
void fetchPartnerProfile()
|
||||
|
||||
.then((me) => setWechatReady(!!me.hasWechat))
|
||||
|
||||
.catch(() => setWechatReady(false));
|
||||
|
||||
void weixinSdk.init().catch(() => {
|
||||
|
||||
/* OssUploadField 点击时会再次初始化 */
|
||||
|
||||
});
|
||||
|
||||
}, [step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !params.get('code')) return;
|
||||
void handlePartnerWechatCallback()
|
||||
.then((result) => {
|
||||
if (result && savePartnerWechatAuth(result)) {
|
||||
setWechatReady(true);
|
||||
}
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '微信授权失败'));
|
||||
}, [params]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPartnerCities()
|
||||
.then((items) => {
|
||||
setCities(items);
|
||||
if (!items.length) setCitiesError('暂无开城城市,请联系总部在后台配置');
|
||||
|
||||
if (!isWechatEnv() || !params.get('code')) return;
|
||||
|
||||
void handlePartnerWechatCallback()
|
||||
|
||||
.then((result) => {
|
||||
|
||||
if (result && savePartnerWechatAuth(result)) {
|
||||
|
||||
setWechatReady(true);
|
||||
|
||||
}
|
||||
|
||||
stripOAuthParamsFromLocation();
|
||||
|
||||
const next = new URLSearchParams(params);
|
||||
|
||||
next.delete('code');
|
||||
|
||||
next.delete('state');
|
||||
|
||||
setParams(next, { replace: true });
|
||||
|
||||
void weixinSdk.init().catch(() => {
|
||||
|
||||
/* OssUploadField 点击时会再次初始化 */
|
||||
|
||||
});
|
||||
|
||||
})
|
||||
|
||||
.catch((e) => setSubmitError(e instanceof Error ? e.message : '微信授权失败'));
|
||||
|
||||
}, [params, setParams]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
void fetchPartnerCities()
|
||||
|
||||
.then((items) => {
|
||||
|
||||
setCities(items);
|
||||
|
||||
if (!items.length) setCitiesError('暂无开城城市,请联系总部在后台配置');
|
||||
|
||||
})
|
||||
|
||||
.catch((e) => setCitiesError(e instanceof Error ? e.message : '加载开城城市失败'));
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const regionBindingHint = useMemo(() => {
|
||||
|
||||
if (!form.regionCodes.length) return null;
|
||||
|
||||
const binding = resolveRegionBinding(form.regionCodes, cities);
|
||||
|
||||
if (!binding) return null;
|
||||
|
||||
if (binding.cityId && binding.matchedCity) {
|
||||
|
||||
return `已匹配开城城市:${binding.matchedCity.name}`;
|
||||
|
||||
}
|
||||
|
||||
return '所选地区暂未开城,请联系总部配置对应区划';
|
||||
|
||||
}, [form.regionCodes, cities]);
|
||||
|
||||
|
||||
|
||||
function patchForm(patch: Partial<StoreDraftForm>) {
|
||||
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
setError('');
|
||||
|
||||
setSubmitError('');
|
||||
|
||||
if ('phone' in patch) {
|
||||
|
||||
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function bindRegionSelection(codes: string[]) {
|
||||
|
||||
const binding = resolveRegionBinding(codes, cities);
|
||||
|
||||
if (!binding) {
|
||||
|
||||
patchForm({ regionCodes: codes, cityId: '', province: '', city: '', district: '' });
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
patchForm({
|
||||
|
||||
regionCodes: codes,
|
||||
|
||||
cityId: binding.cityId ?? '',
|
||||
|
||||
province: binding.region.province,
|
||||
|
||||
city: binding.region.city,
|
||||
|
||||
district: binding.region.district,
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function goStep(n: number) {
|
||||
|
||||
setParams({ step: String(n) });
|
||||
setError('');
|
||||
|
||||
setSubmitError('');
|
||||
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
|
||||
|
||||
async function handleNext() {
|
||||
|
||||
if (step === 1) {
|
||||
|
||||
const msg = validateStoreStep1(form);
|
||||
if (msg) { setError(msg); return; }
|
||||
|
||||
if (msg) {
|
||||
|
||||
setSubmitError(msg);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
setCheckingPhone(true);
|
||||
|
||||
setSubmitError('');
|
||||
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
|
||||
const phoneCheck = await checkStorePhoneAvailable(form.phone.trim());
|
||||
|
||||
if (!phoneCheck.available) {
|
||||
|
||||
setFieldErrors({
|
||||
|
||||
phone: phoneCheck.message ?? '该手机号已绑定门店,请更换',
|
||||
|
||||
});
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
|
||||
setSubmitError(e instanceof Error ? e.message : '手机号校验失败');
|
||||
|
||||
return;
|
||||
|
||||
} finally {
|
||||
|
||||
setCheckingPhone(false);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
|
||||
const msg = validateStoreStep2(form);
|
||||
|
||||
if (msg) {
|
||||
|
||||
setSubmitError(msg);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
goStep(step + 1);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function submit() {
|
||||
|
||||
const msg = validateStoreStep3(form);
|
||||
if (msg) { setError(msg); return; }
|
||||
|
||||
if (msg) {
|
||||
|
||||
setSubmitError(msg);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const step1Msg = validateStoreStep1(form);
|
||||
if (step1Msg) { setError(step1Msg); return; }
|
||||
|
||||
if (step1Msg) {
|
||||
|
||||
setSubmitError(step1Msg);
|
||||
|
||||
goStep(1);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const step2Msg = validateStoreStep2(form);
|
||||
|
||||
if (step2Msg) {
|
||||
|
||||
setSubmitError(step2Msg);
|
||||
|
||||
goStep(2);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
setSubmitError('');
|
||||
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
|
||||
const phoneCheck = await checkStorePhoneAvailable(form.phone.trim());
|
||||
|
||||
if (!phoneCheck.available) {
|
||||
|
||||
setFieldErrors({
|
||||
|
||||
phone: phoneCheck.message ?? '该手机号已绑定门店,请更换',
|
||||
|
||||
});
|
||||
|
||||
goStep(1);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean);
|
||||
|
||||
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
||||
|
||||
method: 'POST',
|
||||
|
||||
body: JSON.stringify({
|
||||
|
||||
cityId: form.cityId,
|
||||
|
||||
province: form.province,
|
||||
|
||||
city: form.city,
|
||||
|
||||
name: form.name.trim(),
|
||||
|
||||
phone: form.phone.trim(),
|
||||
|
||||
district: form.district.trim(),
|
||||
|
||||
address: form.address.trim(),
|
||||
|
||||
intro: form.intro.trim() || undefined,
|
||||
|
||||
coverUrl: form.coverUrl.trim() || undefined,
|
||||
|
||||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||
|
||||
contractUrl: form.contractUrl.trim() || undefined,
|
||||
|
||||
bankAccountName: form.bankAccountName.trim(),
|
||||
|
||||
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
|
||||
|
||||
bankBranch: form.bankBranch.trim(),
|
||||
|
||||
}),
|
||||
|
||||
});
|
||||
|
||||
clearStoreDraft();
|
||||
|
||||
navigate(`/stores/${result.store.id}`);
|
||||
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '提交失败');
|
||||
|
||||
const message = e instanceof Error ? e.message : '提交失败';
|
||||
|
||||
if (isPhoneConflictMessage(message)) {
|
||||
|
||||
setFieldErrors({ phone: message });
|
||||
|
||||
goStep(1);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
setSubmitError(message);
|
||||
|
||||
} finally {
|
||||
|
||||
setSubmitting(false);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
|
||||
|
||||
const nextDisabled = submitting || checkingPhone;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div className="partner-page-sticky">
|
||||
|
||||
<PageHeader title="录入新门店" onBack={() => navigate('/stores')} />
|
||||
|
||||
|
||||
|
||||
<nav className="partner-stepper">
|
||||
|
||||
<div className="partner-stepper-inner">
|
||||
|
||||
<div className="partner-stepper-line" aria-hidden>
|
||||
|
||||
<div className="partner-stepper-line-fill" style={{ width: `${progress}%` }} />
|
||||
|
||||
</div>
|
||||
|
||||
{STEPS.map((label, i) => {
|
||||
|
||||
const n = i + 1;
|
||||
|
||||
const done = step > n;
|
||||
|
||||
const active = step === n;
|
||||
|
||||
return (
|
||||
|
||||
<div key={label} className="partner-step">
|
||||
|
||||
<div className={`partner-step-circle${done ? ' partner-step-circle--done' : ''}${active ? ' partner-step-circle--active' : ''}`}>
|
||||
|
||||
{done ? <span className="material-symbols-outlined" style={{ fontSize: 16, fontVariationSettings: "'FILL' 1" }}>check</span> : n}
|
||||
|
||||
</div>
|
||||
|
||||
<span className={`partner-step-label${active || done ? ' partner-step-label--active' : ''}`}>{label}</span>
|
||||
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
{(error || citiesError) && (
|
||||
<p className="partner-form-error" role="alert">{error || citiesError}</p>
|
||||
|
||||
|
||||
{(submitError || citiesError) && (
|
||||
|
||||
<p className="partner-form-error" role="alert">{submitError || citiesError}</p>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{step === 1 && (
|
||||
|
||||
<>
|
||||
|
||||
<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>
|
||||
|
||||
<ChinaRegionPicker value={form.regionCodes} onChange={bindRegionSelection} />
|
||||
|
||||
{regionBindingHint && (
|
||||
|
||||
<p className={`label-md${form.cityId ? ' text-muted' : ' text-primary'}`} style={{ marginTop: 8 }}>
|
||||
|
||||
{regionBindingHint}
|
||||
|
||||
</p>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>门店名称 <span className="text-primary">*</span></label>
|
||||
|
||||
<div className="partner-field-input">
|
||||
|
||||
<span className="material-symbols-outlined">store</span>
|
||||
|
||||
<input placeholder="请输入门店名称" value={form.name} onChange={(e) => patchForm({ 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">call</span>
|
||||
<input type="tel" placeholder="请输入11位手机号" value={form.phone} onChange={(e) => patchForm({ phone: e.target.value })} />
|
||||
|
||||
<input
|
||||
|
||||
type="tel"
|
||||
|
||||
placeholder="请输入11位手机号"
|
||||
|
||||
value={form.phone}
|
||||
|
||||
onChange={(e) => patchForm({ phone: 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>
|
||||
|
||||
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>门店简介</label>
|
||||
|
||||
<textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} />
|
||||
|
||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||
|
||||
<span className="label-md text-muted">{form.intro.length} / 500</span>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<div className="partner-info-banner">
|
||||
|
||||
<div className="partner-bills-icon">
|
||||
|
||||
<span className="material-symbols-outlined">verified_user</span>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<h3 className="body-md text-primary" style={{ fontWeight: 700 }}>杜康合伙人身份认证</h3>
|
||||
|
||||
<p className="label-md text-variant" style={{ marginTop: 4, lineHeight: 1.4 }}>
|
||||
|
||||
填写内容将自动保存,退出后可继续录入。
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{step === 2 && (
|
||||
|
||||
<>
|
||||
|
||||
<div className="partner-section-title" style={{ padding: '0 20px', marginBottom: 16 }}>
|
||||
|
||||
<div className="partner-bills-icon" style={{ background: '#ffdad7', borderRadius: 8, width: 40, height: 40 }}>
|
||||
|
||||
<span className="material-symbols-outlined text-primary">photo_library</span>
|
||||
|
||||
</div>
|
||||
|
||||
<h2 className="headline-md">门店图片上传</h2>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<section className="partner-form-card">
|
||||
<h3 className="headline-md">门头照</h3>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>建议至少 1 张,需包含完整招牌</p>
|
||||
|
||||
<h3 className="headline-md">门头照 <span className="text-primary">*</span></h3>
|
||||
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少 1 张,需包含完整招牌</p>
|
||||
|
||||
<OssUploadField
|
||||
|
||||
wide
|
||||
|
||||
bizType="STORE_TITLE"
|
||||
|
||||
mediaType="IMAGE"
|
||||
|
||||
value={form.coverUrl}
|
||||
|
||||
wechatReady={wechatReady}
|
||||
|
||||
onWechatReadyChange={setWechatReady}
|
||||
|
||||
onChange={(coverUrl) => patchForm({ coverUrl })}
|
||||
|
||||
label="点击或拖拽上传"
|
||||
|
||||
/>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<section className="partner-form-card">
|
||||
<h3 className="headline-md">环境照片</h3>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>建议至少 3 张,展示店内整洁环境</p>
|
||||
|
||||
<h3 className="headline-md">环境照片 <span className="text-primary">*</span></h3>
|
||||
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少 3 张,展示店内整洁环境</p>
|
||||
|
||||
<div className="partner-upload-grid">
|
||||
|
||||
{form.envPhotoUrls.map((url, index) => (
|
||||
|
||||
<OssUploadField
|
||||
|
||||
key={index}
|
||||
|
||||
compact
|
||||
|
||||
bizType="STORE_ENV"
|
||||
|
||||
mediaType="IMAGE"
|
||||
|
||||
value={url}
|
||||
|
||||
wechatReady={wechatReady}
|
||||
|
||||
onWechatReadyChange={setWechatReady}
|
||||
label="添加照片"
|
||||
|
||||
onChange={(nextUrl) => {
|
||||
|
||||
const envPhotoUrls = [...form.envPhotoUrls];
|
||||
|
||||
envPhotoUrls[index] = nextUrl;
|
||||
|
||||
patchForm({ envPhotoUrls });
|
||||
|
||||
}}
|
||||
|
||||
/>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<section className="partner-form-card">
|
||||
<h3 className="headline-md">签约合同</h3>
|
||||
|
||||
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
||||
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>拍照上传签约协议首页与盖章页</p>
|
||||
|
||||
<OssUploadField
|
||||
|
||||
bizType="STORE_CONTRACT"
|
||||
|
||||
mediaType="FILE"
|
||||
|
||||
accept="image/*,.pdf"
|
||||
|
||||
value={form.contractUrl}
|
||||
|
||||
wechatReady={wechatReady}
|
||||
|
||||
onWechatReadyChange={setWechatReady}
|
||||
|
||||
onChange={(contractUrl) => patchForm({ contractUrl })}
|
||||
|
||||
label="上传合同副本"
|
||||
|
||||
/>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.2)', borderColor: 'rgba(254,214,91,0.5)' }}>
|
||||
|
||||
<span className="material-symbols-outlined text-secondary" style={{ fontSize: 18 }}>info</span>
|
||||
|
||||
<p className="label-md" style={{ lineHeight: 1.5, color: 'var(--color-on-secondary-container)' }}>
|
||||
照片与合同将上传至 OSS 存储;与总部后台一致,选填项可跳过直接下一步。
|
||||
|
||||
温馨提示:请确保照片清晰无反光,避免遮挡关键信息。如上传失败,请检查网络或联系客户经理。
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{step === 3 && (
|
||||
|
||||
<>
|
||||
|
||||
<div className="partner-section-title" style={{ padding: '0 20px', marginBottom: 16 }}>
|
||||
|
||||
<span className="material-symbols-outlined text-primary">account_balance</span>
|
||||
|
||||
<h2 className="headline-md">结算信息配置</h2>
|
||||
|
||||
</div>
|
||||
<section className="partner-form-card">
|
||||
<div className="partner-field">
|
||||
<label>户主姓名 *</label>
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>银行卡号 *</label>
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => patchForm({ bankAccountNo: e.target.value })} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>开户支行 *</label>
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
|
||||
|
||||
<section className="partner-form-card partner-store-phone-summary">
|
||||
|
||||
<div className="partner-store-phone-summary-main">
|
||||
|
||||
<span className="label-md text-muted">门店登录手机号</span>
|
||||
|
||||
<span className="body-md">{form.phone.trim() || '未填写'}</span>
|
||||
|
||||
</div>
|
||||
|
||||
<button type="button" className="partner-weekly-link" onClick={() => goStep(1)}>
|
||||
|
||||
修改
|
||||
|
||||
</button>
|
||||
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card">
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>户主姓名 *</label>
|
||||
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>银行卡号 *</label>
|
||||
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => patchForm({ bankAccountNo: e.target.value })} />
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>开户支行 *</label>
|
||||
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
|
||||
|
||||
<span className="material-symbols-outlined text-secondary">info</span>
|
||||
|
||||
<p className="body-md" style={{ color: 'var(--color-on-secondary-container)' }}>
|
||||
|
||||
请确保银行卡信息准确,以免影响每月的餐费结算。
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<footer className="partner-sticky-footer">
|
||||
|
||||
{step > 1 && (
|
||||
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)} disabled={submitting}>上一步</button>
|
||||
|
||||
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)} disabled={nextDisabled}>上一步</button>
|
||||
|
||||
)}
|
||||
|
||||
{step < 3 ? (
|
||||
<button type="button" className="partner-btn-primary" onClick={handleNext}>
|
||||
<span>下一步</span>
|
||||
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}>
|
||||
|
||||
<span>{checkingPhone ? '校验中…' : '下一步'}</span>
|
||||
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
|
||||
|
||||
</button>
|
||||
|
||||
) : (
|
||||
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void submit()} disabled={submitting}>
|
||||
|
||||
{submitting ? '提交中…' : '提交'}
|
||||
|
||||
</button>
|
||||
|
||||
)}
|
||||
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { isSubAccount } from '../lib/partnerAccess';
|
||||
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
|
||||
|
||||
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
||||
@@ -10,6 +12,8 @@ const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
||||
export default function StoreDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { account } = usePartnerSession();
|
||||
const subReadonly = isSubAccount(account);
|
||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
|
||||
@@ -107,7 +111,7 @@ export default function StoreDetailPage() {
|
||||
const envPhotos = Array.isArray(store.media)
|
||||
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
|
||||
: [];
|
||||
const readOnly = status === 'CLOSED';
|
||||
const readOnly = subReadonly || status === 'CLOSED';
|
||||
|
||||
return (
|
||||
<div className="partner-detail-page">
|
||||
@@ -116,6 +120,7 @@ export default function StoreDetailPage() {
|
||||
<main style={{ padding: '16px 20px' }}>
|
||||
{error && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{error}</p>}
|
||||
|
||||
{!subReadonly && (
|
||||
<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>
|
||||
@@ -137,10 +142,11 @@ export default function StoreDetailPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{readOnly && (
|
||||
{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>
|
||||
@@ -204,10 +210,12 @@ export default function StoreDetailPage() {
|
||||
|
||||
<footer className="partner-save-footer">
|
||||
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
||||
{!subReadonly && (
|
||||
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
{saving ? '保存中…' : '保存修改'}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{toast && <div className="partner-toast">{toast}</div>}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { isSubAccount } from '../lib/partnerAccess';
|
||||
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
|
||||
|
||||
type StatusFilter = 'ALL' | StoreStatusValue;
|
||||
@@ -14,6 +16,8 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
|
||||
|
||||
export default function StoreListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { account } = usePartnerSession();
|
||||
const readonly = isSubAccount(account);
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [q, setQ] = useState('');
|
||||
const [filter, setFilter] = useState<StatusFilter>('ALL');
|
||||
@@ -62,8 +66,8 @@ export default function StoreListPage() {
|
||||
</header>
|
||||
|
||||
<div className="partner-page-title-block">
|
||||
<h2>门店管理</h2>
|
||||
<p className="text-muted body-md">管理您的合作门店及其运营状态</p>
|
||||
<h2>{readonly ? '我的门店' : '门店管理'}</h2>
|
||||
<p className="text-muted body-md">{readonly ? '查看您录入的合作门店' : '管理您的合作门店及其运营状态'}</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
||||
@@ -111,6 +115,7 @@ export default function StoreListPage() {
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
{!readonly && (
|
||||
<div className="partner-store-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -145,6 +150,7 @@ export default function StoreListPage() {
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,42 +1,215 @@
|
||||
import { 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 { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function fmtGrowth(n: number) {
|
||||
const sign = n > 0 ? '+' : '';
|
||||
return `${sign}${n}%`;
|
||||
}
|
||||
|
||||
/** BUWR-25:经营周报(preV1 占位,待 `/partner/reports/weekly` 接入) */
|
||||
export default function WeeklyReportPage() {
|
||||
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('');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
fetchPartnerWeeklyReport(selectedStart)
|
||||
.then(setData)
|
||||
.catch((e) => {
|
||||
setData(null);
|
||||
setError(e instanceof Error ? e.message : '加载失败');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedStart]);
|
||||
|
||||
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 (
|
||||
<div className="page-no-tab">
|
||||
<PageHeader title="数据周报" onBack={() => navigate('/')} />
|
||||
|
||||
<main style={{ padding: '0 20px 24px' }}>
|
||||
<section className="partner-revenue-card" style={{ marginBottom: 16 }}>
|
||||
<p className="partner-revenue-label">近 7 日 GMV (CNY)</p>
|
||||
<div className="partner-revenue-amount">{fmtMoney(128450)}</div>
|
||||
<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>
|
||||
|
||||
<section className="partner-bento-card" style={{ marginBottom: 12 }}>
|
||||
<div className="partner-expansion-split">
|
||||
<div>
|
||||
<p className="label-md text-muted">购酒订单量</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, marginTop: 4 }}>86</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="label-md text-muted">活跃门店数</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, marginTop: 4 }}>12</p>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<section className="partner-bento-card">
|
||||
<h2 className="headline-md" style={{ marginBottom: 12 }}>本周新签门店</h2>
|
||||
<p className="headline-lg text-primary">3 家</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>详细趋势图待 API 接入后展示</p>
|
||||
</section>
|
||||
{!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>
|
||||
</div>
|
||||
);
|
||||
|
||||
+915
-16
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user