Compare commits
49 Commits
dev_ljy
...
15b878f2a4
| Author | SHA1 | Date | |
|---|---|---|---|
| 15b878f2a4 | |||
| 09eda9a11e | |||
| 23c0cc7a5f | |||
| 1725b9e4b4 | |||
| cc9933d1ce | |||
| b810cabc6d | |||
| 25aee745cc | |||
| 097c7dec4b | |||
| 92cf51ba3d | |||
| 94d2a88581 | |||
| da21cd3daa | |||
| 19cb312465 | |||
| 2b7ef65cce | |||
| 745c192693 | |||
| 34532dc9bf | |||
| f053908acb | |||
| 959e0b66cb | |||
| ac1e0a9f13 | |||
| 2a208d5dea | |||
| 82c447b193 | |||
| 75765bf9d4 | |||
| 6479365d6a | |||
| d11757c854 | |||
| 479de04f7c | |||
| cebeefdb22 | |||
| e93e4a7721 | |||
| cd6b82abd8 | |||
| 047ffd7b16 | |||
| bca1d8afea | |||
| 0418370c31 | |||
| cd206d6baa | |||
| aeb72154a7 | |||
| e0d3c840a2 | |||
| f2303f7fb8 | |||
| fb530e2ff5 | |||
| 8c4419e04e | |||
| 85f2c156b0 | |||
| addfcfb0b1 | |||
| b392c28787 | |||
| b534a569f8 | |||
| b0f565103f | |||
| 5e33dd9847 | |||
| 9a550cbaaf | |||
| 823e439101 | |||
| 6c299f1a1d | |||
| 7c9827875f | |||
| ae7c63c08d | |||
| 270cebc79a | |||
| ba21cc89c6 |
@@ -20,6 +20,7 @@ import CityPartnersPage from './pages/CityPartnersPage';
|
||||
import CityWarehousesPage from './pages/CityWarehousesPage';
|
||||
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
|
||||
import StoreMediaPage from './pages/StoreMediaPage';
|
||||
import StoreCategoriesPage from './pages/StoreCategoriesPage';
|
||||
import PromoCodesPage from './pages/PromoCodesPage';
|
||||
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
|
||||
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
|
||||
@@ -30,7 +31,9 @@ import ResourcesPage from './pages/ResourcesPage';
|
||||
import StoreBillsPage from './pages/StoreBillsPage';
|
||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||
import WineryBillsPage from './pages/WineryBillsPage';
|
||||
import LogisticsBillsPage from './pages/LogisticsBillsPage';
|
||||
import TicketsPage from './pages/TicketsPage';
|
||||
import SupportTicketsPage from './pages/SupportTicketsPage';
|
||||
import InvoicesPage from './pages/InvoicesPage';
|
||||
import UserLogsPage from './pages/UserLogsPage';
|
||||
import HqLogsPage from './pages/HqLogsPage';
|
||||
@@ -40,6 +43,9 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
|
||||
import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||
import HqPermissionsPage from './pages/HqPermissionsPage';
|
||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||
import WecomBotsPage from './pages/WecomBotsPage';
|
||||
import LlmConfigsPage from './pages/LlmConfigsPage';
|
||||
import KnowledgeBasesPage from './pages/KnowledgeBasesPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
@@ -66,9 +72,13 @@ export default function App() {
|
||||
<Route index element={<PromoCodeDetailPage />} />
|
||||
<Route path="users" element={<PromoCodeUsersPage />} />
|
||||
</Route>
|
||||
<Route path="/wecom-bots" element={<WecomBotsPage />} />
|
||||
<Route path="/llm-configs" element={<LlmConfigsPage />} />
|
||||
<Route path="/knowledge-bases" element={<KnowledgeBasesPage />} />
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
<Route path="/store-categories" element={<StoreCategoriesPage />} />
|
||||
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
||||
<Route path="/store-media" element={<StoreMediaPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
@@ -86,10 +96,12 @@ export default function App() {
|
||||
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
|
||||
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
|
||||
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
||||
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
|
||||
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
|
||||
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
||||
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/tickets/support" element={<SupportTicketsPage />} />
|
||||
<Route path="/invoices" element={<InvoicesPage />} />
|
||||
<Route path="/logs/users" element={<UserLogsPage />} />
|
||||
<Route path="/logs/stores" element={<StoreLogsPage />} />
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { getDistrictOptionsByCityCode } from '../lib/china-region';
|
||||
|
||||
type Props = {
|
||||
cityCode?: string | null;
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/** 已选开城城市后,仅多选该市下区县(不再选省/市) */
|
||||
export default function CityDistrictMultiSelect({
|
||||
cityCode,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const options = useMemo(() => getDistrictOptionsByCityCode(cityCode), [cityCode]);
|
||||
const ready = Boolean(cityCode) && options.length > 0;
|
||||
|
||||
return (
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled || !ready}
|
||||
placeholder={
|
||||
placeholder ??
|
||||
(cityCode ? (ready ? '请选择区县(可多选)' : '该城市暂无区县数据') : '请先选择开城城市')
|
||||
}
|
||||
options={options}
|
||||
style={{ width: '100%' }}
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Cascader,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Form,
|
||||
@@ -27,8 +26,9 @@ import {
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
|
||||
import PartnerSubAccountList from './PartnerSubAccountList';
|
||||
|
||||
type PartnerRow = {
|
||||
@@ -37,6 +37,7 @@ type PartnerRow = {
|
||||
phone: string;
|
||||
name: string;
|
||||
scopeType?: string;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
accountCount: number;
|
||||
@@ -65,6 +66,7 @@ type PartnerDetail = PartnerRow & {
|
||||
|
||||
type Props = {
|
||||
cityId: string;
|
||||
cityCode?: string;
|
||||
maxPartnerCommissionRate?: number;
|
||||
onChanged?: () => void;
|
||||
};
|
||||
@@ -92,14 +94,31 @@ function commissionSumError(
|
||||
redeemPercent: number,
|
||||
maxRate: number,
|
||||
): string | null {
|
||||
const sum = orderPercent / 100 + redeemPercent / 100;
|
||||
if (sum > maxRate + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||
const max = Number(maxRate);
|
||||
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||
if (sum > max + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0.05, onChanged }: Props) {
|
||||
function formatApiError(err: unknown): string | null {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||
if (!(err instanceof Error)) return '操作失败';
|
||||
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||
const label = districtCodeLabel(code);
|
||||
return label !== code ? `${label}(${code})` : code;
|
||||
});
|
||||
}
|
||||
|
||||
export default function CityPartnersPanel({
|
||||
cityId,
|
||||
cityCode,
|
||||
maxPartnerCommissionRate = 0.05,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
@@ -153,29 +172,34 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request(`/admin/partners/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
await request(`/admin/partners/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}
|
||||
|
||||
async function deleteSubAccount(subId: string) {
|
||||
@@ -187,6 +211,14 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
||||
}
|
||||
|
||||
const columns: ColumnsType<PartnerRow> = [
|
||||
{
|
||||
title: '区县',
|
||||
dataIndex: 'districtCodes',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (codes: string[] | null | undefined, row) =>
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
@@ -261,8 +293,12 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县">
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
@@ -316,30 +352,35 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
||||
</Drawer>
|
||||
|
||||
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
try {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
cityId,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
cityId,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
@@ -350,8 +391,13 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
rules={[{ required: true, message: '请选择至少一个区县' }]}
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Space } from 'antd';
|
||||
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
parseMiniHomeBanners,
|
||||
serializeMiniHomeBanners,
|
||||
} from '@dukang/shared-types';
|
||||
import OssUpload from './OssUpload';
|
||||
|
||||
const MAX_BANNERS = 8;
|
||||
|
||||
/** 编辑态保留空位;下发/入库仍用 parseMiniHomeBanners 过滤空串 */
|
||||
function parseBannersForEdit(raw?: string | null): string[] {
|
||||
if (!raw?.trim()) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw.trim()) as unknown;
|
||||
if (!Array.isArray(parsed)) return parseMiniHomeBanners(raw);
|
||||
return parsed
|
||||
.filter((u): u is string => typeof u === 'string')
|
||||
.map((u) => u.trim())
|
||||
.slice(0, MAX_BANNERS);
|
||||
} catch {
|
||||
return parseMiniHomeBanners(raw);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ant Form 控件:单图 URL 字符串(默认 OSS 路径 footer) */
|
||||
export function ConfigImageField({
|
||||
value,
|
||||
onChange,
|
||||
bizType = 'footer',
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (url: string) => void;
|
||||
bizType?: string;
|
||||
}) {
|
||||
return (
|
||||
<OssUpload
|
||||
bizType={bizType}
|
||||
mediaType="IMAGE"
|
||||
value={value ?? ''}
|
||||
onChange={(url) => onChange?.(url ?? '')}
|
||||
placeholder="上传或粘贴图片 URL"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ant Form 控件:多图 JSON 字符串(默认 OSS 路径 swiper) */
|
||||
export function ConfigImageListField({
|
||||
value,
|
||||
onChange,
|
||||
bizType = 'swiper',
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (json: string) => void;
|
||||
bizType?: string;
|
||||
}) {
|
||||
const [urls, setUrls] = useState<string[]>(() => parseBannersForEdit(value));
|
||||
|
||||
useEffect(() => {
|
||||
setUrls(parseBannersForEdit(value));
|
||||
}, [value]);
|
||||
|
||||
/** 本地可含空位;写入 Form 时去掉空串 */
|
||||
function commit(next: string[]) {
|
||||
const clipped = next.slice(0, MAX_BANNERS);
|
||||
setUrls(clipped);
|
||||
onChange?.(serializeMiniHomeBanners(clipped));
|
||||
}
|
||||
|
||||
function updateAt(index: number, url: string) {
|
||||
const next = [...urls];
|
||||
next[index] = url;
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
commit(urls.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function move(index: number, delta: number) {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= urls.length) return;
|
||||
const next = [...urls];
|
||||
const tmp = next[index];
|
||||
next[index] = next[target];
|
||||
next[target] = tmp;
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function add() {
|
||||
if (urls.length >= MAX_BANNERS) return;
|
||||
// 只加本地空位,避免 serialize 过滤空串导致「点击无反应」
|
||||
setUrls((prev) => [...prev, '']);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{urls.map((url, index) => (
|
||||
<Space key={`banner-${index}`} align="start" style={{ width: '100%' }} wrap>
|
||||
<div style={{ flex: 1, minWidth: 240 }}>
|
||||
<OssUpload
|
||||
bizType={bizType}
|
||||
mediaType="IMAGE"
|
||||
value={url}
|
||||
onChange={(u) => updateAt(index, u)}
|
||||
placeholder="上传或粘贴图片 URL"
|
||||
/>
|
||||
</div>
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowUpOutlined />}
|
||||
disabled={index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowDownOutlined />}
|
||||
disabled={index === urls.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
/>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeAt(index)} />
|
||||
</Space>
|
||||
</Space>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
disabled={urls.length >= MAX_BANNERS}
|
||||
onClick={add}
|
||||
>
|
||||
添加轮播图({urls.length}/{MAX_BANNERS})
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Layout, Menu, Typography, Button, Space } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
RobotOutlined,
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
ShoppingOutlined,
|
||||
@@ -18,11 +19,14 @@ import {
|
||||
SettingOutlined,
|
||||
AccountBookOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
type MenuItem = NonNullable<MenuProps['items']>[number];
|
||||
|
||||
const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
||||
@@ -38,12 +42,16 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
},
|
||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
||||
{ key: '/wecom-bots', icon: <TeamOutlined />, label: '企微机器人' },
|
||||
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
|
||||
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
|
||||
{
|
||||
key: 'stores-group',
|
||||
icon: <ShopOutlined />,
|
||||
label: '门店',
|
||||
children: [
|
||||
{ key: '/stores', label: '门店列表' },
|
||||
{ key: '/store-categories', label: '门店分类' },
|
||||
{ key: '/store-accounts', label: '门店账户' },
|
||||
{ key: '/store-media', label: '门店资源' },
|
||||
],
|
||||
@@ -67,6 +75,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/finance/store-bills', label: '门店账单' },
|
||||
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
||||
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
||||
{ key: '/finance/logistics-bills', label: '物流对账' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -90,7 +99,15 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
|
||||
],
|
||||
},
|
||||
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
||||
{
|
||||
key: 'tickets-group',
|
||||
icon: <CarOutlined />,
|
||||
label: '工单',
|
||||
children: [
|
||||
{ key: '/tickets', label: '工单中心' },
|
||||
{ key: '/tickets/support', label: '技术支持' },
|
||||
],
|
||||
},
|
||||
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
|
||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||
{
|
||||
@@ -110,6 +127,83 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||
];
|
||||
|
||||
function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
if (key === 'tickets-group') {
|
||||
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
|
||||
}
|
||||
const map: Record<string, string | 'system_settings_any'> = {
|
||||
'/': 'dashboard',
|
||||
'/users': 'users',
|
||||
'/wechat-bindings': 'wechat_bindings',
|
||||
'products-group': 'products',
|
||||
'/products': 'products',
|
||||
'/product-detail-templates': 'products',
|
||||
'/orders': 'orders',
|
||||
'/promo-codes': 'promo_codes',
|
||||
'/wecom-bots': 'wecom_bots',
|
||||
'/llm-configs': 'llm_configs',
|
||||
'/knowledge-bases': 'knowledge_bases',
|
||||
'stores-group': 'stores',
|
||||
'/stores': 'stores',
|
||||
'/store-categories': 'stores',
|
||||
'/store-accounts': 'stores',
|
||||
'/store-media': 'stores',
|
||||
'partners-group': 'partners',
|
||||
'/cities': 'partners',
|
||||
'/city-partners': 'partners',
|
||||
'/city-warehouses': 'partners',
|
||||
'/fulfillment-providers': 'partners',
|
||||
'finance-group': 'finance',
|
||||
'/finance/store-bills': 'finance',
|
||||
'/finance/partner-bills': 'finance',
|
||||
'/finance/winery-bills': 'finance',
|
||||
'/finance/logistics-bills': 'finance',
|
||||
'benefit-group': 'benefit',
|
||||
'/benefit/coupons': 'benefit',
|
||||
'/benefit/ledgers': 'benefit',
|
||||
'/redeem-records': 'benefit',
|
||||
'/redeem-pending': 'benefit',
|
||||
'/redeem/debug': 'benefit',
|
||||
'deliveries-group': 'deliveries',
|
||||
'/deliveries': 'deliveries',
|
||||
'/deliveries/xiaofeixia': 'deliveries',
|
||||
'/tickets': 'tickets',
|
||||
'/tickets/support': 'tech_support',
|
||||
'/invoices': 'invoices',
|
||||
'/resources': 'resources',
|
||||
'logs-group': 'logs',
|
||||
'/logs/users': 'logs',
|
||||
'/logs/stores': 'logs',
|
||||
'/logs/partners': 'logs',
|
||||
'/logs/hq': 'logs',
|
||||
'/logs/third-party': 'logs',
|
||||
'/hq-permissions': 'hq_permissions',
|
||||
'/system-settings': 'system_settings_any',
|
||||
'/hq-accounts': 'hq_accounts',
|
||||
};
|
||||
const need = map[key];
|
||||
if (!need) return true;
|
||||
if (need === 'system_settings_any') return hasAnySystemSettingsPermission(permissionKeys);
|
||||
return permissionKeys.includes(need);
|
||||
}
|
||||
|
||||
function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): MenuProps['items'] {
|
||||
if (!items) return items;
|
||||
return items
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object' || !('key' in item)) return item;
|
||||
const key = String(item.key);
|
||||
if ('children' in item && Array.isArray(item.children)) {
|
||||
if (!menuAllowed(key, permissionKeys)) return null;
|
||||
const children = filterMenuItems(item.children as MenuProps['items'], permissionKeys);
|
||||
if (!children?.length) return null;
|
||||
return { ...item, children } as MenuItem;
|
||||
}
|
||||
return menuAllowed(key, permissionKeys) ? item : null;
|
||||
})
|
||||
.filter(Boolean) as MenuProps['items'];
|
||||
}
|
||||
|
||||
export default function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -137,6 +231,12 @@ export default function AdminLayout() {
|
||||
? '/promo-codes'
|
||||
: location.pathname;
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
if (!profile) return MENU_ITEMS;
|
||||
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS;
|
||||
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
||||
}, [profile]);
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||
<Sider
|
||||
@@ -155,8 +255,8 @@ export default function AdminLayout() {
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'finance-group', 'benefit-group', 'logs-group', 'deliveries-group']}
|
||||
items={MENU_ITEMS}
|
||||
defaultOpenKeys={[]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => {
|
||||
if (key.startsWith('/')) navigate(key);
|
||||
}}
|
||||
|
||||
@@ -7,6 +7,7 @@ export type HqProfile = {
|
||||
name: string;
|
||||
adminRole: string;
|
||||
status: string;
|
||||
permissionKeys?: string[];
|
||||
};
|
||||
|
||||
export function getToken() {
|
||||
@@ -145,6 +146,8 @@ export type AdminOrderRow = {
|
||||
city?: { id: string; name: string; code: string };
|
||||
fulfillmentWarehouseId?: string | null;
|
||||
fulfillmentWarehouse?: { id: string; name: string } | null;
|
||||
fulfillmentHold?: boolean;
|
||||
fulfillmentHoldReason?: string | null;
|
||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||
delivery?: {
|
||||
provider: string;
|
||||
|
||||
@@ -79,6 +79,39 @@ export function districtCodeToCityCode(districtCode: string): string {
|
||||
return `${districtCode.slice(0, 4)}00`;
|
||||
}
|
||||
|
||||
export type DistrictOption = { value: string; label: string };
|
||||
|
||||
/** 按开城城市 code(4 位或 6 位)取该市下区县列表 */
|
||||
export function getDistrictOptionsByCityCode(cityCode?: string | null): DistrictOption[] {
|
||||
if (!cityCode?.trim()) return [];
|
||||
const normalized = normalizeCityAdcode(cityCode.trim());
|
||||
const prefix4 = normalized.slice(0, 4);
|
||||
|
||||
for (const province of regionData) {
|
||||
for (const city of province.children ?? []) {
|
||||
const cityValue = String(city.value);
|
||||
const cityNorm = normalizeCityAdcode(cityValue);
|
||||
if (cityNorm === normalized || cityValue === prefix4 || cityValue === cityCode.trim()) {
|
||||
return (city.children ?? []).map((d) => ({
|
||||
value: String(d.value),
|
||||
label: String(d.label),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function districtCodeLabel(code: string): string {
|
||||
return codeToText[code] || code;
|
||||
}
|
||||
|
||||
/** 列表展示:区县码 → 中文名,顿号拼接 */
|
||||
export function formatDistrictLabels(codes?: string[] | null): string {
|
||||
if (!codes?.length) return '—';
|
||||
return codes.map((c) => districtCodeLabel(String(c))).join('、');
|
||||
}
|
||||
|
||||
export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null {
|
||||
if (!codes || codes.length < 3) return null;
|
||||
const [provinceCode, cityCode, districtCode] = codes;
|
||||
|
||||
@@ -34,8 +34,8 @@ export const ORDER_STATUS_COLORS: Record<string, string> = {
|
||||
|
||||
export const STORE_STATUS_LABELS: Record<string, string> = {
|
||||
OPEN: '营业中',
|
||||
PAUSED: '暂停',
|
||||
CLOSED: '已关闭',
|
||||
PAUSED: '临时闭店',
|
||||
CLOSED: '永久关闭',
|
||||
};
|
||||
|
||||
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
|
||||
@@ -62,9 +62,11 @@ export const CITY_STATUS_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: '草稿',
|
||||
CONFIRMED: '已确认',
|
||||
PAID: '已结算',
|
||||
PENDING_REVIEW: '待审核',
|
||||
AWAITING_CONFIRM: '待合伙人确认',
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
export const MEDIA_TYPE_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -27,6 +27,10 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
||||
{ value: 'STORE_AUDIT', label: '门店审核' },
|
||||
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
|
||||
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
|
||||
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
|
||||
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
|
||||
{ value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' },
|
||||
{ value: 'STORE_CATEGORY_ENSURE', label: '初始化默认门店分类' },
|
||||
{ value: 'PRODUCT_CREATE', label: '新增商品' },
|
||||
{ value: 'PRODUCT_UPDATE', label: '编辑商品' },
|
||||
{ value: 'PRODUCT_DELETE', label: '删除商品' },
|
||||
@@ -34,11 +38,27 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
||||
{ value: 'DELIVERY_UPDATE', label: '编辑配送单' },
|
||||
{ value: 'TICKET_APPROVE', label: '工单通过' },
|
||||
{ value: 'TICKET_REJECT', label: '工单驳回' },
|
||||
{ value: 'TICKET_CREATE', label: '创建工单' },
|
||||
{ value: 'SUPPORT_TICKET_CREATE', label: '创建技术支持工单' },
|
||||
{ value: 'SUPPORT_TICKET_APPROVE', label: '技术支持评审通过' },
|
||||
{ value: 'SUPPORT_TICKET_REJECT', label: '技术支持评审驳回' },
|
||||
{ value: 'SUPPORT_TICKET_START_TESTING', label: '技术支持转入测试' },
|
||||
{ value: 'SUPPORT_TICKET_PASS', label: '技术支持测试通过' },
|
||||
{ value: 'INVOICE_CREATE', label: '创建发票申请' },
|
||||
{ value: 'INVOICE_ISSUE', label: '开具发票' },
|
||||
{ value: 'INVOICE_REJECT', label: '驳回发票' },
|
||||
{ value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' },
|
||||
{ value: 'STORE_PAYOUT_BATCH_CONFIRM', label: '批量门店打款' },
|
||||
{ value: 'STORE_BILL_CONFIRM', label: '门店对账单确认打款' },
|
||||
{ value: 'STORE_BILL_BATCH_CONFIRM', label: '批量门店对账单打款' },
|
||||
{ value: 'PARTNER_BILL_GENERATE', label: '生成合伙人账单' },
|
||||
{ value: 'PARTNER_BILL_SEND', label: '发送合伙人账单' },
|
||||
{ value: 'PARTNER_BILL_BATCH_SEND', label: '批量发送合伙人账单' },
|
||||
{ value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' },
|
||||
{ value: 'PARTNER_BILL_MARK_PAID', label: '合伙人账单结算' },
|
||||
{ value: 'PARTNER_BILL_BATCH_MARK_PAID', label: '批量合伙人账单结算' },
|
||||
{ value: 'WINERY_BILL_CONFIRM', label: '酒厂对账单确认打款' },
|
||||
{ value: 'WINERY_BILL_BATCH_CONFIRM', label: '批量酒厂对账单打款' },
|
||||
] as const;
|
||||
|
||||
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
|
||||
|
||||
@@ -6,10 +6,19 @@ export type StoreCreateForm = {
|
||||
city?: string;
|
||||
district: string;
|
||||
districtCode?: string;
|
||||
categoryParentId?: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
intro?: string;
|
||||
openTime?: string;
|
||||
closeTime?: string;
|
||||
openTime2?: string;
|
||||
closeTime2?: string;
|
||||
avgPrice?: number | null;
|
||||
coverUrl?: string;
|
||||
envPhotoUrls?: string[];
|
||||
contractUrl?: string;
|
||||
@@ -21,20 +30,63 @@ export type StoreCreateForm = {
|
||||
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
const BANK_RE = /^\d{16,19}$/;
|
||||
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
function timeToMinutes(hhmm: string): number {
|
||||
const [h, m] = hhmm.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
export function validateStoreCreateStep1(
|
||||
form: Pick<StoreCreateForm, 'partnerAccountId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
|
||||
form: Pick<
|
||||
StoreCreateForm,
|
||||
| 'partnerAccountId'
|
||||
| 'cityId'
|
||||
| 'regionCodes'
|
||||
| 'categoryId'
|
||||
| 'name'
|
||||
| 'phone'
|
||||
| 'address'
|
||||
| 'intro'
|
||||
| 'openTime'
|
||||
| 'closeTime'
|
||||
| 'openTime2'
|
||||
| 'closeTime2'
|
||||
| 'avgPrice'
|
||||
>,
|
||||
): string | null {
|
||||
if (!form.partnerAccountId) return '请选择开城合伙人';
|
||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
|
||||
if (!form.categoryId?.trim()) return '请选择门店分类(细类)';
|
||||
if (!form.name?.trim()) return '请填写门店名称';
|
||||
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||
if (!form.address?.trim()) return '请填写详细地址';
|
||||
|
||||
const openTime = String(form.openTime || '').trim();
|
||||
const closeTime = String(form.closeTime || '').trim();
|
||||
if (!openTime || !closeTime) return '请填写营业时间';
|
||||
if (!TIME_RE.test(openTime) || !TIME_RE.test(closeTime)) return '营业时间格式须为 HH:MM';
|
||||
if (timeToMinutes(openTime) >= timeToMinutes(closeTime)) return '营业结束时间须晚于开始时间';
|
||||
|
||||
const openTime2 = String(form.openTime2 || '').trim();
|
||||
const closeTime2 = String(form.closeTime2 || '').trim();
|
||||
if (openTime2 || closeTime2) {
|
||||
if (!openTime2 || !closeTime2) return '请完整填写第二段营业时间';
|
||||
if (!TIME_RE.test(openTime2) || !TIME_RE.test(closeTime2)) return '第二段时间格式须为 HH:MM';
|
||||
if (timeToMinutes(openTime2) >= timeToMinutes(closeTime2)) return '第二段结束时间须晚于开始时间';
|
||||
if (timeToMinutes(closeTime) >= timeToMinutes(openTime2)) return '第二段开始时间须晚于第一段结束时间';
|
||||
}
|
||||
|
||||
if (form.avgPrice != null && String(form.avgPrice).trim() !== '') {
|
||||
const n = Number(form.avgPrice);
|
||||
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
|
||||
}
|
||||
|
||||
if (form.intro?.trim()) {
|
||||
const len = form.intro.trim().length;
|
||||
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
|
||||
if (len < 2 || len > 500) return '门店简介须为 2~500 字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,34 @@ type Row = {
|
||||
order?: { orderNo: string } | null;
|
||||
};
|
||||
|
||||
type CouponRedeemRecord = {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
settleAmount: number;
|
||||
couponAmount?: number;
|
||||
role?: 'PRIMARY' | 'SECONDARY';
|
||||
createdAt: string;
|
||||
store?: { id: string; name: string; cityName?: string | null } | null;
|
||||
};
|
||||
|
||||
type CouponRedeemSummary = {
|
||||
couponNo: string;
|
||||
totalAmount: number;
|
||||
usedAmount: number;
|
||||
balance: number;
|
||||
status: string;
|
||||
redeemCount: number;
|
||||
redeemRecordSum: number;
|
||||
};
|
||||
|
||||
type CouponDetail = Row & {
|
||||
redeemSummary?: CouponRedeemSummary | null;
|
||||
redeemRecords?: CouponRedeemRecord[];
|
||||
user?: { userNo?: string; phone?: string | null };
|
||||
order?: { orderNo?: string } | null;
|
||||
};
|
||||
|
||||
export default function BenefitCouponsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
|
||||
@@ -50,8 +78,25 @@ export default function BenefitCouponsPage() {
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [detail, setDetail] = useState<CouponDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
||||
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
||||
|
||||
async function openRedeemDetail(redeemId: string) {
|
||||
setRedeemDetailLoading(true);
|
||||
setRedeemDrawerOpen(true);
|
||||
try {
|
||||
const res = await request<Record<string, unknown>>(`/admin/redeem-records/${redeemId}`);
|
||||
setRedeemDetail(res);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载核销详情失败');
|
||||
setRedeemDrawerOpen(false);
|
||||
} finally {
|
||||
setRedeemDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
|
||||
@@ -197,7 +242,7 @@ export default function BenefitCouponsPage() {
|
||||
|
||||
<Drawer
|
||||
title="权益券详情"
|
||||
width={560}
|
||||
width={720}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
@@ -218,25 +263,202 @@ export default function BenefitCouponsPage() {
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">{detail.user?.userNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联订单">{detail.order?.orderNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{COUPON_STATUS_LABELS[detail.status] || detail.status}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="来源">{detail.sourceProduct}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
|
||||
权益核销
|
||||
</Typography.Title>
|
||||
{detail.redeemSummary ? (
|
||||
<>
|
||||
<Descriptions column={2} bordered size="small" style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="权益券号">{detail.redeemSummary.couponNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="券状态">{detail.redeemSummary.status}</Descriptions.Item>
|
||||
<Descriptions.Item label="权益总额">
|
||||
¥{Number(detail.redeemSummary.totalAmount).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="已核销">
|
||||
<Typography.Text type="danger" strong>
|
||||
¥{Number(detail.redeemSummary.usedAmount).toFixed(2)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
({detail.redeemSummary.redeemCount} 笔核销单)
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="剩余余额">
|
||||
¥{Number(detail.redeemSummary.balance).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销单合计额">
|
||||
¥{Number(detail.redeemSummary.redeemRecordSum).toFixed(2)}
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
(本券分摊合计)
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
locale={{ emptyText: '暂无关联核销单' }}
|
||||
dataSource={detail.redeemRecords ?? []}
|
||||
columns={[
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 70,
|
||||
render: (v: string | undefined) =>
|
||||
v === 'SECONDARY' ? <Tag color="orange">次券</Tag> : <Tag color="blue">主券</Tag>,
|
||||
},
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: ['store', 'name'],
|
||||
ellipsis: true,
|
||||
render: (v: string | undefined, row: CouponRedeemRecord) =>
|
||||
v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—',
|
||||
},
|
||||
{
|
||||
title: '本券分摊',
|
||||
dataIndex: 'couponAmount',
|
||||
width: 95,
|
||||
render: (v: number | undefined, row: CouponRedeemRecord) =>
|
||||
`¥${Number(v ?? row.amount).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '核销总额',
|
||||
dataIndex: 'amount',
|
||||
width: 90,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '结算额',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 90,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 150,
|
||||
render: (v: string) => fmtTime(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 70,
|
||||
render: (_: unknown, row: CouponRedeemRecord) => (
|
||||
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无核销摘要</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
title="核销单详情"
|
||||
width={520}
|
||||
open={redeemDrawerOpen}
|
||||
onClose={() => {
|
||||
setRedeemDrawerOpen(false);
|
||||
setRedeemDetail(null);
|
||||
}}
|
||||
destroyOnClose
|
||||
>
|
||||
{redeemDetailLoading ? (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : redeemDetail ? (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="券号">{String(detail.couponNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">
|
||||
{String((detail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
|
||||
<Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销额">
|
||||
¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">
|
||||
{String((detail.user as { phone?: string } | undefined)?.phone ?? '—')}
|
||||
<Descriptions.Item label="结算额">
|
||||
¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(redeemDetail.createdAt ?? ''))}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">
|
||||
{String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
|
||||
{(redeemDetail.user as { phone?: string | null } | undefined)?.phone
|
||||
? ` / ${(redeemDetail.user as { phone?: string | null }).phone}`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">
|
||||
{String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')}
|
||||
{(redeemDetail.store as { cityName?: string } | undefined)?.cityName
|
||||
? `(${(redeemDetail.store as { cityName?: string }).cityName})`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门店地址">
|
||||
{String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="合伙人">
|
||||
{String(
|
||||
(redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined)
|
||||
?.partnerAccount?.companyName ?? '—',
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="权益券号">
|
||||
{String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关联订单">
|
||||
{String((detail.order as { orderNo?: string } | null | undefined)?.orderNo ?? '—')}
|
||||
{String(
|
||||
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
|
||||
'—',
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="总额">¥{String(detail.totalAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="余额">¥{String(detail.balance)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="来源">{String(detail.sourceProduct)}</Descriptions.Item>
|
||||
{Array.isArray(redeemDetail.allocations) &&
|
||||
(redeemDetail.allocations as unknown[]).length > 0 ? (
|
||||
<Descriptions.Item label="券分摊">
|
||||
{(
|
||||
redeemDetail.allocations as Array<{
|
||||
couponNo?: string;
|
||||
orderNo?: string | null;
|
||||
amount?: number;
|
||||
sortOrder?: number;
|
||||
}>
|
||||
)
|
||||
.map((a, idx) => {
|
||||
const role = idx === 0 ? '主' : '次';
|
||||
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
|
||||
a.orderNo ? `(订单 ${a.orderNo})` : ''
|
||||
}`;
|
||||
})
|
||||
.join(';')}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{redeemDetail.payout ? (
|
||||
<Descriptions.Item label="门店结算单">
|
||||
¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
|
||||
{' / '}
|
||||
{String((redeemDetail.payout as { status?: string }).status ?? '—')}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{redeemDetail.rating ? (
|
||||
<Descriptions.Item label="评价">
|
||||
服务 {(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
||||
{(redeemDetail.rating as { envScore?: number }).envScore ?? '—'} 分
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
)}
|
||||
) : null}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -274,6 +274,7 @@ export default function CitiesPage() {
|
||||
children: detail?.id ? (
|
||||
<CityPartnersPanel
|
||||
cityId={String(detail.id)}
|
||||
cityCode={detail.code != null ? String(detail.code) : undefined}
|
||||
maxPartnerCommissionRate={
|
||||
detail.maxPartnerCommissionRate != null
|
||||
? Number(detail.maxPartnerCommissionRate)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Cascader,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
@@ -32,9 +31,10 @@ import {
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
||||
|
||||
type SubRow = PartnerSubAccountRow;
|
||||
@@ -48,6 +48,7 @@ type Row = {
|
||||
cityId?: string | null;
|
||||
cityName?: string | null;
|
||||
scopeType?: string;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
bindingStatus?: string;
|
||||
@@ -98,13 +99,25 @@ function flattenDistrictCodes(values: string[] | string[][] | undefined): string
|
||||
}
|
||||
|
||||
function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null {
|
||||
const sum = orderPercent / 100 + redeemPercent / 100;
|
||||
if (sum > maxRate + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||
const max = Number(maxRate);
|
||||
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||
if (sum > max + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatApiError(err: unknown): string | null {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||
if (!(err instanceof Error)) return '操作失败';
|
||||
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||
const label = districtCodeLabel(code);
|
||||
return label !== code ? `${label}(${code})` : code;
|
||||
});
|
||||
}
|
||||
|
||||
export default function CityPartnersPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
@@ -135,6 +148,7 @@ export default function CityPartnersPage() {
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
|
||||
const [createMaxRate, setCreateMaxRate] = useState(0.05);
|
||||
const [createCityCode, setCreateCityCode] = useState<string | undefined>();
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
@@ -145,11 +159,15 @@ export default function CityPartnersPage() {
|
||||
void loadCities();
|
||||
}, [loadCities]);
|
||||
|
||||
const editCityCode = detail?.cityId
|
||||
? cities.find((c) => c.id === detail.cityId)?.code
|
||||
: undefined;
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
setMaxCommissionRate(d.maxPartnerCommissionRate ?? 0.05);
|
||||
setMaxCommissionRate(Number(d.maxPartnerCommissionRate ?? 0.05));
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
@@ -171,28 +189,33 @@ export default function CityPartnersPage() {
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request(`/admin/partners/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
await request(`/admin/partners/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}
|
||||
|
||||
async function refreshPartnerContext(parentId: string) {
|
||||
@@ -229,13 +252,24 @@ export default function CityPartnersPage() {
|
||||
}
|
||||
|
||||
async function onCreateCityChange(cityId: string) {
|
||||
createForm.setFieldsValue({ districtCodes: undefined });
|
||||
const matched = cities.find((c) => c.id === cityId);
|
||||
setCreateCityCode(matched?.code);
|
||||
const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
|
||||
setCreateMaxRate(cityRes.maxPartnerCommissionRate ?? 0.05);
|
||||
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||
{
|
||||
title: '区县',
|
||||
dataIndex: 'districtCodes',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (codes: string[] | null | undefined, row) =>
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
|
||||
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
|
||||
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
@@ -299,6 +333,7 @@ export default function CityPartnersPage() {
|
||||
});
|
||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||
setCreateMaxRate(0.05);
|
||||
setCreateCityCode(undefined);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -327,12 +362,6 @@ export default function CityPartnersPage() {
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="companyName" label="公司">
|
||||
<Input allowClear placeholder="公司名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机">
|
||||
<Input allowClear placeholder="登录手机" />
|
||||
</Form.Item>
|
||||
<Form.Item name="cityId" label="城市">
|
||||
<Select
|
||||
allowClear
|
||||
@@ -343,6 +372,12 @@ export default function CityPartnersPage() {
|
||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司">
|
||||
<Input allowClear placeholder="公司名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机">
|
||||
<Input allowClear placeholder="登录手机" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
@@ -447,8 +482,12 @@ export default function CityPartnersPage() {
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县">
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={editCityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
@@ -502,29 +541,34 @@ export default function CityPartnersPage() {
|
||||
width={560}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
createMaxRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
try {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
createMaxRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes:
|
||||
createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes:
|
||||
createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
@@ -552,8 +596,13 @@ export default function CityPartnersPage() {
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
rules={[{ required: true, message: '请选择至少一个区县' }]}
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={createCityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
@@ -14,10 +16,13 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
DEFAULT_XFX_LOGISTICS_PRICING,
|
||||
FULFILLMENT_PROVIDER_STATUS_LABELS,
|
||||
FULFILLMENT_PROVIDER_TYPE_LABELS,
|
||||
FulfillmentProviderStatus,
|
||||
FulfillmentProviderType,
|
||||
LOGISTICS_SETTLEMENT_METHOD_LABELS,
|
||||
LogisticsSettlementMethod,
|
||||
isXfxProviderCode,
|
||||
type FulfillmentProviderDto,
|
||||
} from '@dukang/shared-types';
|
||||
@@ -32,6 +37,9 @@ const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
const SETTLEMENT_OPTIONS = Object.entries(LOGISTICS_SETTLEMENT_METHOD_LABELS).map(
|
||||
([value, label]) => ({ value, label }),
|
||||
);
|
||||
const SIGN_OPTIONS = [
|
||||
{ value: 'MD5', label: 'MD5' },
|
||||
{ value: 'HMAC-SHA256', label: 'HMAC-SHA256' },
|
||||
@@ -77,6 +85,12 @@ export default function FulfillmentProvidersPage() {
|
||||
status: FulfillmentProviderStatus.ACTIVE,
|
||||
apiUrl: DEFAULT_XFX_API_URL,
|
||||
signType: 'MD5',
|
||||
settlementMethod: LogisticsSettlementMethod.PREPAID,
|
||||
baseBottles: DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
|
||||
baseFee: DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
|
||||
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
@@ -84,6 +98,7 @@ export default function FulfillmentProvidersPage() {
|
||||
function openEdit(row: FulfillmentProviderDto) {
|
||||
setEditRow(row);
|
||||
const xfx = row.xiaofeixiaConfig;
|
||||
const pricing = row.pricingRules;
|
||||
form.setFieldsValue({
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
@@ -94,6 +109,16 @@ export default function FulfillmentProvidersPage() {
|
||||
apiKey: '',
|
||||
signType: xfx?.signType || 'MD5',
|
||||
appId: xfx?.appId || '',
|
||||
bankAccountName: row.bankAccountName || '',
|
||||
bankName: row.bankName || '',
|
||||
bankBranch: row.bankBranch || '',
|
||||
bankAccountNo: row.bankAccountNo || '',
|
||||
settlementMethod: row.settlementMethod || LogisticsSettlementMethod.PREPAID,
|
||||
baseBottles: pricing?.baseBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
|
||||
baseFee: pricing?.baseFee ?? DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
|
||||
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
@@ -104,6 +129,18 @@ export default function FulfillmentProvidersPage() {
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
status: v.status,
|
||||
bankAccountName: v.bankAccountName || null,
|
||||
bankName: v.bankName || null,
|
||||
bankBranch: v.bankBranch || null,
|
||||
bankAccountNo: v.bankAccountNo || null,
|
||||
settlementMethod: v.settlementMethod,
|
||||
pricingRules: {
|
||||
baseBottles: Number(v.baseBottles),
|
||||
baseFee: Number(v.baseFee),
|
||||
extraBottleFee: Number(v.extraBottleFee),
|
||||
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
|
||||
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
|
||||
@@ -144,6 +181,19 @@ export default function FulfillmentProvidersPage() {
|
||||
dataIndex: 'type',
|
||||
render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v,
|
||||
},
|
||||
{
|
||||
title: '结算',
|
||||
dataIndex: 'settlementMethod',
|
||||
width: 100,
|
||||
render: (v) =>
|
||||
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v || '—',
|
||||
},
|
||||
{
|
||||
title: '充值余额',
|
||||
dataIndex: 'prepaidBalance',
|
||||
width: 100,
|
||||
render: (v) => `¥${Number(v || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -188,7 +238,7 @@ export default function FulfillmentProvidersPage() {
|
||||
仓配管理
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
注册第三方履约接口并填写凭证;仓库绑定后,推单将使用此处配置的 API 地址与密钥
|
||||
注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
@@ -200,7 +250,7 @@ export default function FulfillmentProvidersPage() {
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="小飞侠配置已从「系统设置 → 同城配送」迁至本页。寄件地址以仓库联系人/地址为准。"
|
||||
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
|
||||
/>
|
||||
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
|
||||
@@ -210,7 +260,7 @@ export default function FulfillmentProvidersPage() {
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={() => void submit()}
|
||||
width={560}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
@@ -227,11 +277,58 @@ export default function FulfillmentProvidersPage() {
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="left">物流对账</Divider>
|
||||
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
|
||||
<Select options={SETTLEMENT_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="收款户名">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankName" label="开户银行">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户支行">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="银行账号">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item
|
||||
name="baseBottles"
|
||||
label="起送瓶数"
|
||||
rules={[{ required: true }]}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="baseFee"
|
||||
label="起送费用(元)"
|
||||
rules={[{ required: true }]}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="extraBottleFee"
|
||||
label="加一瓶(元)"
|
||||
rules={[{ required: true }]}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="boxBottles" label="箱规瓶数" style={{ marginBottom: 12 }}>
|
||||
<InputNumber min={1} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="boxFee" label="一箱费用(元)" style={{ marginBottom: 12 }}>
|
||||
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
{showXfxFields && (
|
||||
<>
|
||||
<Typography.Title level={5} style={{ marginTop: 8 }}>
|
||||
小飞侠接口参数
|
||||
</Typography.Title>
|
||||
<Divider orientation="left">小飞侠接口参数</Divider>
|
||||
<Form.Item
|
||||
name="apiUrl"
|
||||
label="API 地址"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Divider,
|
||||
Form,
|
||||
Row,
|
||||
Select,
|
||||
@@ -33,6 +34,8 @@ type AccountPermRes = {
|
||||
|
||||
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
|
||||
|
||||
const CATALOG_GROUPS = [...new Set(HQ_PERMISSION_CATALOG.map((p) => p.group ?? '其他'))];
|
||||
|
||||
function PermissionChecklist({
|
||||
value,
|
||||
onChange,
|
||||
@@ -49,13 +52,24 @@ function PermissionChecklist({
|
||||
disabled={disabled}
|
||||
onChange={(checked) => onChange(checked as string[])}
|
||||
>
|
||||
<Row gutter={[8, 8]}>
|
||||
{HQ_PERMISSION_CATALOG.map((item) => (
|
||||
<Col key={item.key} span={8}>
|
||||
<Checkbox value={item.key}>{item.label}</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{CATALOG_GROUPS.map((group) => {
|
||||
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
|
||||
return (
|
||||
<div key={group} style={{ marginBottom: 16 }}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
{group}
|
||||
</Typography.Text>
|
||||
<Row gutter={[8, 8]}>
|
||||
{items.map((item) => (
|
||||
<Col key={item.key} span={8}>
|
||||
<Checkbox value={item.key}>{item.label}</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<Divider style={{ margin: '12px 0 0' }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Checkbox.Group>
|
||||
);
|
||||
}
|
||||
@@ -159,6 +173,7 @@ export default function HqPermissionsPage() {
|
||||
<Typography.Title level={4}>权限分配</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限(超级管理员始终拥有全部权限)。
|
||||
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Tabs
|
||||
@@ -231,7 +246,7 @@ export default function HqPermissionsPage() {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
||||
return (
|
||||
<Tag key={key} color="blue">
|
||||
{item?.label || key}
|
||||
{item?.group ? `${item.group}·${item.label}` : item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
@@ -244,7 +259,11 @@ export default function HqPermissionsPage() {
|
||||
<span>合并生效:</span>
|
||||
{previewEffectiveKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||
return <Tag key={key}>{item?.label || key}</Tag>;
|
||||
return (
|
||||
<Tag key={key}>
|
||||
{item?.group ? `${item.group}·${item.label}` : item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
@@ -47,6 +49,19 @@ type Row = {
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
type CreateFormValues = {
|
||||
orderNo: string;
|
||||
titleType: InvoiceTitleType;
|
||||
invoiceKind: InvoiceKind;
|
||||
titleName: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
bankAccount?: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
@@ -61,6 +76,11 @@ export default function InvoicesPage() {
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createForm] = Form.useForm<CreateFormValues>();
|
||||
const invoiceKind = Form.useWatch('invoiceKind', createForm);
|
||||
const titleType = Form.useWatch('titleType', createForm);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetail(await request(`/admin/invoices/${id}`));
|
||||
@@ -98,6 +118,36 @@ export default function InvoicesPage() {
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const values = await createForm.validateFields();
|
||||
setCreating(true);
|
||||
try {
|
||||
await request('/admin/invoices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
orderNo: values.orderNo.trim(),
|
||||
titleType: values.titleType,
|
||||
invoiceKind: values.invoiceKind,
|
||||
titleName: values.titleName.trim(),
|
||||
taxNo: values.taxNo?.trim() || undefined,
|
||||
addressPhone: values.addressPhone?.trim() || undefined,
|
||||
bankAccount: values.bankAccount?.trim() || undefined,
|
||||
email: values.email.trim(),
|
||||
phone: values.phone.trim(),
|
||||
remark: values.remark?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('发票申请已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||
@@ -138,7 +188,30 @@ export default function InvoicesPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>发票管理</Typography.Title>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
发票管理
|
||||
</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.setFieldsValue({
|
||||
titleType: 'PERSONAL',
|
||||
invoiceKind: 'NORMAL',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
创建发票申请
|
||||
</Button>
|
||||
</div>
|
||||
<Form
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
@@ -187,7 +260,7 @@ export default function InvoicesPage() {
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
detail?.status === 'PENDING' ? (
|
||||
<>
|
||||
<Space>
|
||||
<Upload
|
||||
accept="image/*,.pdf"
|
||||
showUploadList={false}
|
||||
@@ -196,14 +269,14 @@ export default function InvoicesPage() {
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button type="primary" loading={uploading} style={{ marginRight: 8 }}>
|
||||
<Button type="primary" loading={uploading}>
|
||||
上传并开票
|
||||
</Button>
|
||||
</Upload>
|
||||
<Button danger onClick={() => void reject()}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
@@ -242,6 +315,110 @@ export default function InvoicesPage() {
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="创建发票申请"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => void submitCreate()}
|
||||
confirmLoading={creating}
|
||||
destroyOnClose
|
||||
okText="提交"
|
||||
width={520}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="orderNo"
|
||||
label="订单号"
|
||||
rules={[{ required: true, message: '请填写已完成订单号' }]}
|
||||
extra="仅已完成订单可开票"
|
||||
>
|
||||
<Input placeholder="订单号" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="invoiceKind"
|
||||
label="发票类型"
|
||||
rules={[{ required: true, message: '请选择发票类型' }]}
|
||||
>
|
||||
<Select
|
||||
options={(Object.keys(INVOICE_KIND_LABELS) as InvoiceKind[]).map((k) => ({
|
||||
value: k,
|
||||
label: INVOICE_KIND_LABELS[k],
|
||||
}))}
|
||||
onChange={(k: InvoiceKind) => {
|
||||
if (k === 'SPECIAL') createForm.setFieldValue('titleType', 'ENTERPRISE');
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="titleType"
|
||||
label="抬头类型"
|
||||
rules={[{ required: true, message: '请选择抬头类型' }]}
|
||||
>
|
||||
<Select
|
||||
options={(Object.keys(INVOICE_TITLE_TYPE_LABELS) as InvoiceTitleType[]).map((t) => ({
|
||||
value: t,
|
||||
label: INVOICE_TITLE_TYPE_LABELS[t],
|
||||
disabled: invoiceKind === 'SPECIAL' && t === 'PERSONAL',
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="titleName"
|
||||
label="抬头名称"
|
||||
rules={[{ required: true, message: '请填写抬头名称' }]}
|
||||
>
|
||||
<Input placeholder="个人姓名或企业全称" />
|
||||
</Form.Item>
|
||||
{(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && (
|
||||
<Form.Item
|
||||
name="taxNo"
|
||||
label="税号"
|
||||
rules={[{ required: true, message: '企业抬头须填写税号' }]}
|
||||
>
|
||||
<Input placeholder="纳税人识别号" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{invoiceKind === 'SPECIAL' && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="addressPhone"
|
||||
label="地址电话"
|
||||
rules={[{ required: true, message: '专用发票须填写地址电话' }]}
|
||||
>
|
||||
<Input placeholder="注册地址及电话" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bankAccount"
|
||||
label="开户行账号"
|
||||
rules={[{ required: true, message: '专用发票须填写开户行账号' }]}
|
||||
>
|
||||
<Input placeholder="开户行及账号" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="接收邮箱"
|
||||
rules={[
|
||||
{ required: true, message: '请填写邮箱' },
|
||||
{ type: 'email', message: '邮箱格式不正确' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="发票发送邮箱" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
rules={[{ required: true, message: '请填写手机号' }]}
|
||||
>
|
||||
<Input placeholder="联系手机" />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={512} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
KnowledgeBaseDto,
|
||||
KnowledgeDocumentDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type DocForm = {
|
||||
title: string;
|
||||
contentText?: string;
|
||||
};
|
||||
|
||||
export default function KnowledgeBasesPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [docForm] = Form.useForm<DocForm>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<KnowledgeBaseDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [drawerKb, setDrawerKb] = useState<KnowledgeBaseDto | null>(null);
|
||||
const [docs, setDocs] = useState<KnowledgeDocumentDto[]>([]);
|
||||
const [docsLoading, setDocsLoading] = useState(false);
|
||||
const [docModalOpen, setDocModalOpen] = useState(false);
|
||||
const [docSaving, setDocSaving] = useState(false);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||
useAdminList<KnowledgeBaseDto>(
|
||||
'/admin/knowledge-bases',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
const loadDocs = async (kb: KnowledgeBaseDto) => {
|
||||
setDrawerKb(kb);
|
||||
setDocsLoading(true);
|
||||
try {
|
||||
const res = await request<{ items: KnowledgeDocumentDto[] }>(
|
||||
`/admin/knowledge-bases/${kb.id}/documents`,
|
||||
);
|
||||
setDocs(res.items);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setDocsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({ name: '', description: '', enabled: true });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: KnowledgeBaseDto) => {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
enabled: row.enabled,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await request(`/admin/knowledge-bases/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
message.success('已保存');
|
||||
} else {
|
||||
await request('/admin/knowledge-bases', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<KnowledgeBaseDto> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true },
|
||||
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
width: 90,
|
||||
render: (v: boolean) => (v ? <Tag color="green">开</Tag> : <Tag>关</Tag>),
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
dataIndex: 'createdByName',
|
||||
width: 100,
|
||||
render: (v: string | null, row) => v || row.createdByHqAccountId,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: (t: string) => fmtTime(t),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 240,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => void loadDocs(row)}>
|
||||
文档
|
||||
</Button>
|
||||
{row.canEditFull ? (
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
{row.canEditFull ? (
|
||||
<Popconfirm
|
||||
title="确认删除知识库及全部文档?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const docColumns: ColumnsType<KnowledgeDocumentDto> = [
|
||||
{ title: '标题', dataIndex: 'title' },
|
||||
{ title: '文件', dataIndex: 'fileName', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) =>
|
||||
s === 'READY' ? <Tag color="green">可检索</Tag> : s === 'FAILED' ? <Tag color="red">失败</Tag> : <Tag>无正文</Tag>,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (t: string) => fmtTime(t),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) =>
|
||||
drawerKb?.canEditFull ? (
|
||||
<Popconfirm
|
||||
title="删除文档?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
message.success('已删除');
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
知识库
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) =>
|
||||
setFilters({
|
||||
name: v.name || '',
|
||||
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建知识库
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑知识库' : '新建知识库'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void onSave()}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={drawerKb ? `文档 · ${drawerKb.name}` : '文档'}
|
||||
open={!!drawerKb}
|
||||
width={720}
|
||||
onClose={() => setDrawerKb(null)}
|
||||
extra={
|
||||
drawerKb?.canEditFull ? (
|
||||
<Button type="primary" onClick={() => { docForm.resetFields(); setDocModalOpen(true); }}>
|
||||
上传/粘贴
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={docsLoading}
|
||||
columns={docColumns}
|
||||
dataSource={docs}
|
||||
pagination={false}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="添加知识文档"
|
||||
open={docModalOpen}
|
||||
onCancel={() => setDocModalOpen(false)}
|
||||
confirmLoading={docSaving}
|
||||
onOk={async () => {
|
||||
if (!drawerKb) return;
|
||||
const values = await docForm.validateFields();
|
||||
setDocSaving(true);
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: values.title,
|
||||
contentText: values.contentText || null,
|
||||
}),
|
||||
});
|
||||
message.success('已添加');
|
||||
setDocModalOpen(false);
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setDocSaving(false);
|
||||
}
|
||||
}}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form form={docForm} layout="vertical">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contentText" label="正文(可粘贴)">
|
||||
<Input.TextArea rows={8} placeholder="支持 Markdown / 纯文本" />
|
||||
</Form.Item>
|
||||
<Form.Item label="或上传文本文件">
|
||||
<Upload
|
||||
accept=".txt,.md,.markdown,.csv,.json,.log,text/*"
|
||||
maxCount={1}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
if (!drawerKb) return;
|
||||
try {
|
||||
const raw = file as File;
|
||||
const uploaded = await uploadFileToOss(raw, {
|
||||
bizType: 'KB_DOCUMENT',
|
||||
mediaType: 'FILE',
|
||||
});
|
||||
const title =
|
||||
docForm.getFieldValue('title') || raw.name.replace(/\.[^.]+$/, '');
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
fileName: raw.name,
|
||||
fileUrl: uploaded.url,
|
||||
mimeType: raw.type || null,
|
||||
sizeBytes: raw.size,
|
||||
}),
|
||||
});
|
||||
message.success('上传成功');
|
||||
setDocModalOpen(false);
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
onSuccess?.(uploaded);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
message.error(err.message);
|
||||
onError?.(err);
|
||||
}
|
||||
}}
|
||||
showUploadList={false}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>上传到 OSS 并入库</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
LLM_PROVIDERS,
|
||||
LLM_PROVIDER_LABELS,
|
||||
LLM_PROVIDER_PRESETS,
|
||||
type LlmApiConfigDto,
|
||||
type LlmProvider,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
modelName?: string;
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
systemPrompt?: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export default function LlmConfigsPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LlmApiConfigDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const providerWatch = Form.useWatch('provider', form);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<LlmApiConfigDto>(
|
||||
'/admin/llm-configs',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providerWatch || editing) return;
|
||||
const preset = LLM_PROVIDER_PRESETS[providerWatch as LlmProvider];
|
||||
if (!preset) return;
|
||||
form.setFieldsValue({
|
||||
baseUrl: preset.defaultBaseUrl || undefined,
|
||||
modelName: preset.defaultModel || undefined,
|
||||
});
|
||||
}, [providerWatch, editing, form]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
provider: 'DEEPSEEK',
|
||||
baseUrl: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultBaseUrl,
|
||||
apiKey: '',
|
||||
modelName: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultModel,
|
||||
temperature: 0.3,
|
||||
maxTokens: 1024,
|
||||
systemPrompt: '',
|
||||
enabled: true,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: LlmApiConfigDto) => {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
provider: row.provider,
|
||||
baseUrl: row.baseUrl,
|
||||
apiKey: '',
|
||||
modelName: row.modelName,
|
||||
temperature: row.temperature,
|
||||
maxTokens: row.maxTokens,
|
||||
systemPrompt: row.systemPrompt || '',
|
||||
enabled: row.enabled,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
if (!editing.canEditFull) {
|
||||
await request(`/admin/llm-configs/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: values.enabled }),
|
||||
});
|
||||
} else {
|
||||
await request(`/admin/llm-configs/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
baseUrl: values.baseUrl,
|
||||
apiKey: values.apiKey || undefined,
|
||||
modelName: values.modelName,
|
||||
temperature: values.temperature ?? null,
|
||||
maxTokens: values.maxTokens ?? null,
|
||||
systemPrompt: values.systemPrompt || null,
|
||||
enabled: values.enabled,
|
||||
}),
|
||||
});
|
||||
}
|
||||
message.success('已保存');
|
||||
} else {
|
||||
await request('/admin/llm-configs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
baseUrl: values.baseUrl,
|
||||
apiKey: values.apiKey,
|
||||
modelName: values.modelName,
|
||||
temperature: values.temperature ?? null,
|
||||
maxTokens: values.maxTokens ?? null,
|
||||
systemPrompt: values.systemPrompt || null,
|
||||
enabled: values.enabled,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<LlmApiConfigDto> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '提供商',
|
||||
dataIndex: 'provider',
|
||||
width: 120,
|
||||
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
|
||||
},
|
||||
{ title: '模型', dataIndex: 'modelName', ellipsis: true },
|
||||
{
|
||||
title: 'Key',
|
||||
dataIndex: 'apiKeyConfigured',
|
||||
width: 80,
|
||||
render: (v: boolean) => (v ? <Tag color="green">已配</Tag> : <Tag>无</Tag>),
|
||||
},
|
||||
{
|
||||
title: '生效',
|
||||
dataIndex: 'enabled',
|
||||
width: 90,
|
||||
render: (v: boolean, row) => (
|
||||
<Switch
|
||||
checked={v}
|
||||
disabled={!row.isOwner && !row.canEditFull}
|
||||
onChange={async (checked) => {
|
||||
try {
|
||||
await request(`/admin/llm-configs/${row.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: checked }),
|
||||
});
|
||||
message.success(checked ? '已启用' : '已停用');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
dataIndex: 'createdByName',
|
||||
width: 100,
|
||||
render: (v: string | null, row) => v || row.createdByHqAccountId,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: (t: string) => fmtTime(t),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
{row.canEditFull ? '编辑' : '开关'}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await request<{ reply: string }>(`/admin/llm-configs/${row.id}/test`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
});
|
||||
message.success(res.reply || '测试成功');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
{row.canEditFull ? (
|
||||
<Popconfirm
|
||||
title="确认删除?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/llm-configs/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const fullEdit = !editing || editing.canEditFull;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
语言模型配置
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
非超管仅可见自己创建的配置,且创建后只能改是否生效
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) =>
|
||||
setFilters({
|
||||
name: v.name || '',
|
||||
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input allowClear placeholder="搜索" />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="生效">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
options={[
|
||||
{ value: true, label: '开' },
|
||||
{ value: false, label: '关' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? (fullEdit ? '编辑语言模型' : '修改是否生效') : '新建语言模型'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void onSave()}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '必填' }]}>
|
||||
<Input disabled={!fullEdit} />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="提供商" rules={[{ required: true }]}>
|
||||
<Select
|
||||
disabled={!fullEdit}
|
||||
options={LLM_PROVIDERS.map((p) => ({ value: p, label: LLM_PROVIDER_LABELS[p] }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="baseUrl"
|
||||
label="Base URL"
|
||||
rules={[{ required: fullEdit, message: '必填' }]}
|
||||
extra="填主机根地址即可,如 https://api.deepseek.com(不要带 /v1)"
|
||||
>
|
||||
<Input disabled={!fullEdit} placeholder="https://api.deepseek.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="apiKey"
|
||||
label="API Key"
|
||||
rules={editing ? [] : [{ required: true, message: '必填' }]}
|
||||
extra={editing ? '留空表示不修改' : undefined}
|
||||
>
|
||||
<Input.Password disabled={!fullEdit} />
|
||||
</Form.Item>
|
||||
<Form.Item name="modelName" label="模型名" rules={[{ required: fullEdit, message: '必填' }]}>
|
||||
<Input disabled={!fullEdit} />
|
||||
</Form.Item>
|
||||
<Space style={{ display: 'flex' }} size="large">
|
||||
<Form.Item name="temperature" label="Temperature">
|
||||
<InputNumber disabled={!fullEdit} min={0} max={2} step={0.1} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxTokens" label="Max Tokens">
|
||||
<InputNumber disabled={!fullEdit} min={1} max={128000} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="systemPrompt" label="系统提示词">
|
||||
<Input.TextArea disabled={!fullEdit} rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="生效" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
LOGISTICS_SETTLEMENT_METHOD_LABELS,
|
||||
type LogisticsSettlementMethod,
|
||||
} from '@dukang/shared-types';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { request } from '../lib/api';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type BillRow = {
|
||||
id: string;
|
||||
billNo: string;
|
||||
fulfillmentProviderId: string;
|
||||
providerCode?: string;
|
||||
providerName?: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
orderCount: number;
|
||||
bottleCount: number;
|
||||
logisticsAmount: number;
|
||||
settlementMethod: string;
|
||||
status: string;
|
||||
paidAt?: string | null;
|
||||
};
|
||||
|
||||
type BillItem = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
quantity: number;
|
||||
logisticsAmount: number;
|
||||
shippedAt: string;
|
||||
};
|
||||
|
||||
type ProviderSummary = {
|
||||
providerId: string;
|
||||
providerCode: string;
|
||||
providerName: string;
|
||||
settlementMethod: string;
|
||||
prepaidBalance: number;
|
||||
bankAccountName?: string | null;
|
||||
bankName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
orderCount: number;
|
||||
bottleCount: number;
|
||||
logisticsAmount: number;
|
||||
billId?: string | null;
|
||||
billStatus?: string | null;
|
||||
pricingRules?: {
|
||||
baseBottles: number;
|
||||
baseFee: number;
|
||||
extraBottleFee: number;
|
||||
boxBottles?: number;
|
||||
boxFee?: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
UNPAID: '未结算',
|
||||
PAID: '已结算',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
UNPAID: 'red',
|
||||
PAID: 'green',
|
||||
};
|
||||
|
||||
export default function LogisticsBillsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [summaryForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<BillRow>(
|
||||
'/admin/logistics-bills',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.providerId) qs.set('providerId', filters.providerId);
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const [detail, setDetail] = useState<(BillRow & { items?: BillItem[] }) | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [providers, setProviders] = useState<Array<{ id: string; code: string; name: string }>>([]);
|
||||
const [summaryRows, setSummaryRows] = useState<ProviderSummary[]>([]);
|
||||
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||
const [summaryPeriod, setSummaryPeriod] = useState(() => dayjs().subtract(1, 'month'));
|
||||
const [genOpen, setGenOpen] = useState(false);
|
||||
const [genForm] = Form.useForm();
|
||||
const [rechargeOpen, setRechargeOpen] = useState(false);
|
||||
const [rechargeTarget, setRechargeTarget] = useState<ProviderSummary | null>(null);
|
||||
const [rechargeForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
request<Array<{ id: string; code: string; name: string }>>('/admin/fulfillment-providers')
|
||||
.then((rows) => setProviders(rows.map((r) => ({ id: r.id, code: r.code, name: r.name }))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadSummary(period: Dayjs) {
|
||||
setSummaryLoading(true);
|
||||
try {
|
||||
const res = await request<{ items: ProviderSummary[] }>(
|
||||
`/admin/logistics-bills/provider-summary?year=${period.year()}&month=${period.month() + 1}`,
|
||||
);
|
||||
setSummaryRows(res.items ?? []);
|
||||
} finally {
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadSummary(summaryPeriod);
|
||||
}, [summaryPeriod]);
|
||||
|
||||
function confirmPay(ids: string[], amountHint?: number) {
|
||||
Modal.confirm({
|
||||
title: '确认结算?',
|
||||
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`,
|
||||
okText: '确认结算',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (ids.length === 1) {
|
||||
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||
} else {
|
||||
await request('/admin/logistics-bills/batch-confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
message.success('已确认结算');
|
||||
setSelectedKeys([]);
|
||||
reload();
|
||||
void loadSummary(summaryPeriod);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const d = await request<BillRow & { items?: BillItem[] }>(`/admin/logistics-bills/${id}`);
|
||||
setDetail(d);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
setExporting(true);
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.providerId) qs.set('providerId', filters.providerId);
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
const result = await request<{ csv: string; count: number }>(
|
||||
`/admin/logistics-bills/export?${qs}`,
|
||||
);
|
||||
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
||||
downloadExcelCsv(result.csv, `物流对账单_${suffix}.csv`);
|
||||
message.success(`已导出 ${result.count} 条`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateBills() {
|
||||
const v = await genForm.validateFields();
|
||||
const month = v.month as Dayjs;
|
||||
await request('/admin/logistics-bills/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
year: month.year(),
|
||||
month: month.month() + 1,
|
||||
providerId: v.providerId || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已生成物流对账单');
|
||||
setGenOpen(false);
|
||||
reload();
|
||||
void loadSummary(summaryPeriod);
|
||||
}
|
||||
|
||||
async function submitRecharge() {
|
||||
if (!rechargeTarget) return;
|
||||
const v = await rechargeForm.validateFields();
|
||||
await request(`/admin/fulfillment-providers/${rechargeTarget.providerId}/recharge`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ amount: v.amount, remark: v.remark }),
|
||||
});
|
||||
message.success('充值成功');
|
||||
setRechargeOpen(false);
|
||||
void loadSummary(summaryPeriod);
|
||||
}
|
||||
|
||||
const summary = data?.summary;
|
||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
|
||||
|
||||
const billColumns: ColumnsType<BillRow> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||
{
|
||||
title: '承运商',
|
||||
width: 140,
|
||||
render: (_, r) => `${r.providerName || ''} (${r.providerCode || ''})`,
|
||||
},
|
||||
{
|
||||
title: '账期',
|
||||
width: 200,
|
||||
render: (_, r) =>
|
||||
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
|
||||
},
|
||||
{
|
||||
title: '结算方式',
|
||||
dataIndex: 'settlementMethod',
|
||||
width: 100,
|
||||
render: (v) =>
|
||||
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
|
||||
},
|
||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
|
||||
{
|
||||
title: '物流费',
|
||||
dataIndex: 'logisticsAmount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
明细
|
||||
</Button>
|
||||
{row.status === 'UNPAID' && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => confirmPay([row.id], Number(row.logisticsAmount))}
|
||||
>
|
||||
确认结算
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const summaryColumns: ColumnsType<ProviderSummary> = [
|
||||
{
|
||||
title: '承运商',
|
||||
render: (_, r) => `${r.providerName} (${r.providerCode})`,
|
||||
},
|
||||
{
|
||||
title: '结算方式',
|
||||
dataIndex: 'settlementMethod',
|
||||
width: 110,
|
||||
render: (v) =>
|
||||
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
|
||||
},
|
||||
{
|
||||
title: '计价标准',
|
||||
width: 220,
|
||||
render: (_, r) => {
|
||||
const p = r.pricingRules;
|
||||
if (!p) return '—';
|
||||
return `${p.baseBottles}瓶¥${p.baseFee},加瓶¥${p.extraBottleFee}${
|
||||
p.boxBottles ? `,${p.boxBottles}瓶箱¥${p.boxFee}` : ''
|
||||
}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '银行账户',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (_, r) =>
|
||||
r.bankAccountName
|
||||
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
|
||||
: '—',
|
||||
},
|
||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
|
||||
{
|
||||
title: '应付物流费',
|
||||
dataIndex: 'logisticsAmount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '充值余额',
|
||||
dataIndex: 'prepaidBalance',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '账单',
|
||||
width: 100,
|
||||
render: (_, r) =>
|
||||
r.billStatus ? (
|
||||
<Tag color={STATUS_COLORS[r.billStatus] || 'default'}>
|
||||
{STATUS_LABELS[r.billStatus] || r.billStatus}
|
||||
</Tag>
|
||||
) : (
|
||||
'未生成'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
render: (_, r) =>
|
||||
r.settlementMethod === 'PREPAID' ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setRechargeTarget(r);
|
||||
rechargeForm.resetFields();
|
||||
setRechargeOpen(true);
|
||||
}}
|
||||
>
|
||||
充值
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
物流对账
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'summary',
|
||||
label: '按承运商汇总',
|
||||
children: (
|
||||
<>
|
||||
<Form
|
||||
form={summaryForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
initialValues={{ month: summaryPeriod }}
|
||||
onFinish={(v: { month?: Dayjs }) => {
|
||||
const m = v.month || dayjs().subtract(1, 'month');
|
||||
setSummaryPeriod(m);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="month" label="账期">
|
||||
<DatePicker picker="month" allowClear={false} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="providerId"
|
||||
loading={summaryLoading}
|
||||
columns={summaryColumns}
|
||||
dataSource={summaryRows}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bills',
|
||||
label: '月账单',
|
||||
children: (
|
||||
<>
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space size="large" wrap>
|
||||
<Statistic title="账单数" value={summary.count} />
|
||||
<Statistic
|
||||
title="瓶数合计"
|
||||
value={summary.bottleCount ?? 0}
|
||||
/>
|
||||
<Statistic
|
||||
title="物流费合计"
|
||||
value={summary.logisticsAmount ?? summary.totalAmount ?? 0}
|
||||
prefix="¥"
|
||||
precision={2}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v: {
|
||||
status?: string;
|
||||
providerId?: string;
|
||||
month?: Dayjs;
|
||||
}) => {
|
||||
setFilters({
|
||||
status: v.status || '',
|
||||
providerId: v.providerId || '',
|
||||
year: v.month ? String(v.month.year()) : '',
|
||||
month: v.month ? String(v.month.month() + 1) : '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'UNPAID', label: '未结算' },
|
||||
{ value: 'PAID', label: '已结算' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="providerId" label="承运商">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
options={providers.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.code})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="month" label="账期">
|
||||
<DatePicker picker="month" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
genForm.setFieldsValue({ month: dayjs().subtract(1, 'month') });
|
||||
setGenOpen(true);
|
||||
}}
|
||||
>
|
||||
生成账单
|
||||
</Button>
|
||||
<Button loading={exporting} onClick={() => void exportExcel()}>
|
||||
导出
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedKeys.length}
|
||||
onClick={() =>
|
||||
confirmPay(
|
||||
selectedKeys.map(String),
|
||||
selectedAmount,
|
||||
)
|
||||
}
|
||||
>
|
||||
批量结算
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={billColumns}
|
||||
dataSource={data?.items ?? []}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="物流对账明细"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="承运商">
|
||||
{detail.providerName} ({detail.providerCode})
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账期">
|
||||
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="物流费">
|
||||
¥{Number(detail.logisticsAmount).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{STATUS_LABELS[detail.status] || detail.status}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结算时间">
|
||||
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={detail.items ?? []}
|
||||
columns={[
|
||||
{ title: '订单号', dataIndex: 'orderNo' },
|
||||
{ title: '瓶数', dataIndex: 'quantity', width: 70 },
|
||||
{
|
||||
title: '物流费',
|
||||
dataIndex: 'logisticsAmount',
|
||||
width: 90,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '发货时间',
|
||||
dataIndex: 'shippedAt',
|
||||
width: 160,
|
||||
render: fmtTime,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="生成物流月账单"
|
||||
open={genOpen}
|
||||
onCancel={() => setGenOpen(false)}
|
||||
onOk={() => void generateBills()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={genForm} layout="vertical">
|
||||
<Form.Item name="month" label="账期月" rules={[{ required: true }]}>
|
||||
<DatePicker picker="month" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="providerId" label="承运商(空=全部)">
|
||||
<Select
|
||||
allowClear
|
||||
options={providers.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.code})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={rechargeTarget ? `充值 · ${rechargeTarget.providerName}` : '充值'}
|
||||
open={rechargeOpen}
|
||||
onCancel={() => setRechargeOpen(false)}
|
||||
onOk={() => void submitRecharge()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={rechargeForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="amount"
|
||||
label="金额"
|
||||
rules={[{ required: true, message: '请输入充值金额' }]}
|
||||
>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input placeholder="可选,如预充运费" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ORDER_STATUS_LABELS,
|
||||
fmtTime,
|
||||
} from '../lib/constants';
|
||||
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
||||
|
||||
type ShipDefaults = {
|
||||
provider: string;
|
||||
@@ -55,6 +56,28 @@ type WarehouseOption = {
|
||||
lat?: number | null;
|
||||
};
|
||||
|
||||
type OrderRedeemRecord = {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
settleAmount: number;
|
||||
/** 本单权益券在该核销单中的分摊额 */
|
||||
couponAmount?: number;
|
||||
role?: 'PRIMARY' | 'SECONDARY';
|
||||
createdAt: string;
|
||||
store?: { id: string; name: string; cityName?: string | null } | null;
|
||||
};
|
||||
|
||||
type OrderRedeemSummary = {
|
||||
couponNo: string;
|
||||
totalAmount: number;
|
||||
usedAmount: number;
|
||||
balance: number;
|
||||
status: string;
|
||||
redeemCount: number;
|
||||
redeemRecordSum: number;
|
||||
};
|
||||
|
||||
type OrderDetail = AdminOrderRow & {
|
||||
receiverAddress?: string;
|
||||
receiverProvince?: string;
|
||||
@@ -81,6 +104,8 @@ type OrderDetail = AdminOrderRow & {
|
||||
payment?: Record<string, unknown> | null;
|
||||
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
|
||||
benefitCoupons?: Array<Record<string, unknown>>;
|
||||
redeemSummary?: OrderRedeemSummary | null;
|
||||
redeemRecords?: OrderRedeemRecord[];
|
||||
fulfillmentWarehouse?: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -135,6 +160,9 @@ export default function OrdersPage() {
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
||||
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
|
||||
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||
@@ -152,6 +180,20 @@ export default function OrdersPage() {
|
||||
[data?.items, selectedRowKeys],
|
||||
);
|
||||
|
||||
async function openRedeemDetail(redeemId: string) {
|
||||
setRedeemDetailLoading(true);
|
||||
setRedeemDrawerOpen(true);
|
||||
try {
|
||||
const res = await request<Record<string, unknown>>(`/admin/redeem-records/${redeemId}`);
|
||||
setRedeemDetail(res);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载核销详情失败');
|
||||
setRedeemDrawerOpen(false);
|
||||
} finally {
|
||||
setRedeemDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -161,6 +203,7 @@ export default function OrdersPage() {
|
||||
if (values.status) qs.set('status', values.status);
|
||||
if (values.cityId) qs.set('cityId', values.cityId);
|
||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||
setData(res);
|
||||
} finally {
|
||||
@@ -354,9 +397,12 @@ export default function OrdersPage() {
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s) => (
|
||||
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
||||
width: 140,
|
||||
render: (s, row) => (
|
||||
<Space size={4} wrap>
|
||||
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
||||
{row.fulfillmentHold ? <Tag color="orange">大单待确认</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -436,6 +482,14 @@ export default function OrdersPage() {
|
||||
<Form.Item name="receiverPhone" label="收货手机">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部"
|
||||
options={[{ value: true, label: '仅待确认大单' }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
@@ -468,7 +522,7 @@ export default function OrdersPage() {
|
||||
|
||||
<Drawer
|
||||
title="订单详情"
|
||||
width={640}
|
||||
width={720}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
@@ -509,9 +563,17 @@ export default function OrdersPage() {
|
||||
{detail.fulfillmentWarehouse?.name || '未分配'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
|
||||
{ORDER_STATUS_LABELS[detail.status] || detail.status}
|
||||
</Tag>
|
||||
<Space>
|
||||
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
|
||||
{ORDER_STATUS_LABELS[detail.status] || detail.status}
|
||||
</Tag>
|
||||
{detail.fulfillmentHold ? (
|
||||
<Tag color="orange">
|
||||
{FULFILLMENT_HOLD_REASON_LABELS[detail.fulfillmentHoldReason || ''] ||
|
||||
'大单待确认'}
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
|
||||
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
|
||||
@@ -570,6 +632,98 @@ export default function OrdersPage() {
|
||||
<Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
|
||||
权益核销
|
||||
</Typography.Title>
|
||||
{detail.redeemSummary ? (
|
||||
<>
|
||||
<Descriptions column={2} bordered size="small" style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="权益券号">{detail.redeemSummary.couponNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="券状态">{detail.redeemSummary.status}</Descriptions.Item>
|
||||
<Descriptions.Item label="权益总额">
|
||||
¥{Number(detail.redeemSummary.totalAmount).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="已核销">
|
||||
<Typography.Text type="danger" strong>
|
||||
¥{Number(detail.redeemSummary.usedAmount).toFixed(2)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
({detail.redeemSummary.redeemCount} 笔核销单)
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="剩余余额">
|
||||
¥{Number(detail.redeemSummary.balance).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销单合计额">
|
||||
¥{Number(detail.redeemSummary.redeemRecordSum).toFixed(2)}
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
(本券分摊合计)
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
locale={{ emptyText: '暂无关联核销单' }}
|
||||
dataSource={detail.redeemRecords ?? []}
|
||||
columns={[
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 70,
|
||||
render: (v: string | undefined) =>
|
||||
v === 'SECONDARY' ? <Tag color="orange">次券</Tag> : <Tag color="blue">主券</Tag>,
|
||||
},
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: ['store', 'name'],
|
||||
ellipsis: true,
|
||||
render: (v: string | undefined, row) =>
|
||||
v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—',
|
||||
},
|
||||
{
|
||||
title: '本券分摊',
|
||||
dataIndex: 'couponAmount',
|
||||
width: 95,
|
||||
render: (v: number | undefined, row) =>
|
||||
`¥${Number(v ?? row.amount).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '核销总额',
|
||||
dataIndex: 'amount',
|
||||
width: 90,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '结算额',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 90,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 150,
|
||||
render: (v: string) => fmtTime(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 70,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Typography.Text type="secondary">该订单尚未生成权益券,无核销记录</Typography.Text>
|
||||
)}
|
||||
|
||||
<Descriptions column={1} bordered size="small" title="位置快照(方案C)" style={{ marginTop: 16 }}>
|
||||
<Descriptions.Item label="clientIp">{detail.clientIp || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="IP解析">{[detail.ipProvince, detail.ipCity, detail.ipDistrict].filter(Boolean).join(' / ') || '—'}</Descriptions.Item>
|
||||
@@ -618,6 +772,95 @@ export default function OrdersPage() {
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
title="核销单详情"
|
||||
width={520}
|
||||
open={redeemDrawerOpen}
|
||||
onClose={() => {
|
||||
setRedeemDrawerOpen(false);
|
||||
setRedeemDetail(null);
|
||||
}}
|
||||
destroyOnClose
|
||||
>
|
||||
{redeemDetailLoading ? (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : redeemDetail ? (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销额">
|
||||
¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结算额">
|
||||
¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(redeemDetail.createdAt ?? ''))}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">
|
||||
{String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
|
||||
{(redeemDetail.user as { phone?: string | null } | undefined)?.phone
|
||||
? ` / ${(redeemDetail.user as { phone?: string | null }).phone}`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">
|
||||
{String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')}
|
||||
{(redeemDetail.store as { cityName?: string } | undefined)?.cityName
|
||||
? `(${(redeemDetail.store as { cityName?: string }).cityName})`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门店地址">
|
||||
{String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="合伙人">
|
||||
{String(
|
||||
(redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined)
|
||||
?.partnerAccount?.companyName ?? '—',
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="权益券号">
|
||||
{String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关联订单">
|
||||
{String(
|
||||
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
|
||||
'—',
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
{Array.isArray(redeemDetail.allocations) &&
|
||||
(redeemDetail.allocations as unknown[]).length > 0 ? (
|
||||
<Descriptions.Item label="券分摊">
|
||||
{(
|
||||
redeemDetail.allocations as Array<{
|
||||
couponNo?: string;
|
||||
orderNo?: string | null;
|
||||
amount?: number;
|
||||
sortOrder?: number;
|
||||
}>
|
||||
)
|
||||
.map((a, idx) => {
|
||||
const role = idx === 0 ? '主' : '次';
|
||||
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
|
||||
a.orderNo ? `(订单 ${a.orderNo})` : ''
|
||||
}`;
|
||||
})
|
||||
.join(';')}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{redeemDetail.payout ? (
|
||||
<Descriptions.Item label="门店结算单">
|
||||
¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
|
||||
{' / '}
|
||||
{String((redeemDetail.payout as { status?: string }).status ?? '—')}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{redeemDetail.rating ? (
|
||||
<Descriptions.Item label="评价">
|
||||
服务 {(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
||||
{(redeemDetail.rating as { envScore?: number }).envScore ?? '—'} 分
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title={shipTarget ? `配送发货 · ${shipTarget.orderNo}` : '配送发货'}
|
||||
open={shipModalOpen}
|
||||
@@ -642,6 +885,18 @@ export default function OrdersPage() {
|
||||
>
|
||||
{shipTarget && (
|
||||
<>
|
||||
{shipTarget.fulfillmentHold ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="大单已拦截自动推小飞侠"
|
||||
description={
|
||||
FULFILLMENT_HOLD_REASON_LABELS[shipTarget.fulfillmentHoldReason || ''] ||
|
||||
'≥10箱订单需总部确认:可选仓推小飞侠,或改用快递自配送。'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Descriptions size="small" column={1} style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="城市">{shipTarget.city?.name || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="收货">
|
||||
|
||||
@@ -38,15 +38,17 @@ type Row = {
|
||||
type PartnerOption = { id: string; companyName: string; phone?: string };
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: '待合伙人确认',
|
||||
CONFIRMED: '待打款审核',
|
||||
PENDING_REVIEW: '待审核',
|
||||
AWAITING_CONFIRM: '待合伙人确认',
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: 'default',
|
||||
CONFIRMED: 'orange',
|
||||
PENDING_REVIEW: 'gold',
|
||||
AWAITING_CONFIRM: 'blue',
|
||||
UNPAID: 'red',
|
||||
PAID: 'green',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
@@ -74,6 +76,7 @@ export default function PartnerBillsPage() {
|
||||
const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
|
||||
const [rejectForm] = Form.useForm();
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
@@ -81,10 +84,10 @@ export default function PartnerBillsPage() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function generateBill(values: { partnerId?: string; month: Dayjs; all?: boolean }) {
|
||||
async function generateBill(values: { partnerId?: string; month: Dayjs }) {
|
||||
const year = values.month.year();
|
||||
const month = values.month.month() + 1;
|
||||
if (values.all || !values.partnerId) {
|
||||
if (!values.partnerId) {
|
||||
const result = await request<{ success: number; failed: number; total: number }>(
|
||||
'/admin/partner-bills/generate-all',
|
||||
{ method: 'POST', body: JSON.stringify({ year, month }) },
|
||||
@@ -95,31 +98,71 @@ export default function PartnerBillsPage() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ partnerId: values.partnerId, year, month }),
|
||||
});
|
||||
message.success('账单已生成');
|
||||
message.success('账单已生成(待审核)');
|
||||
}
|
||||
setGenOpen(false);
|
||||
reload();
|
||||
}
|
||||
|
||||
async function confirmBill(id: string) {
|
||||
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
|
||||
message.success('已代确认,进入待打款审核');
|
||||
reload();
|
||||
function sendBills(ids: string[]) {
|
||||
Modal.confirm({
|
||||
title: '发送给合伙人?',
|
||||
content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`,
|
||||
okText: '确认发送',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (ids.length === 1) {
|
||||
await request(`/admin/partner-bills/${ids[0]}/send`, { method: 'POST' });
|
||||
} else {
|
||||
await request('/admin/partner-bills/batch-send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
message.success('已发送');
|
||||
setSelectedKeys([]);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function markPaid(id: string) {
|
||||
await request(`/admin/partner-bills/${id}/mark-paid`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
||||
function markPaid(ids: string[], amountHint?: number) {
|
||||
Modal.confirm({
|
||||
title: '确认打款?',
|
||||
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。`,
|
||||
okText: '确认打款',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (ids.length === 1) {
|
||||
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
||||
});
|
||||
} else {
|
||||
await request('/admin/partner-bills/batch-mark-paid', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
message.success('已标记打款');
|
||||
setSelectedKeys([]);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
message.success('已通过并标记打款');
|
||||
reload();
|
||||
}
|
||||
|
||||
function openReject(row: Row) {
|
||||
setRejectTarget(row);
|
||||
rejectForm.resetFields();
|
||||
setRejectOpen(true);
|
||||
Modal.confirm({
|
||||
title: '驳回该账单?',
|
||||
content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。',
|
||||
okText: '继续填写理由',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
setRejectTarget(row);
|
||||
rejectForm.resetFields();
|
||||
setRejectOpen(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function submitReject(values: { reason: string }) {
|
||||
@@ -130,7 +173,7 @@ export default function PartnerBillsPage() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: values.reason.trim() }),
|
||||
});
|
||||
message.success('已驳回打款申请');
|
||||
message.success('已驳回');
|
||||
setRejectOpen(false);
|
||||
setRejectTarget(null);
|
||||
reload();
|
||||
@@ -158,6 +201,10 @@ export default function PartnerBillsPage() {
|
||||
|
||||
const summary = data?.summary;
|
||||
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
|
||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
|
||||
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
|
||||
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
|
||||
@@ -194,7 +241,7 @@ export default function PartnerBillsPage() {
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 120,
|
||||
width: 130,
|
||||
render: (s, row) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
|
||||
@@ -212,21 +259,26 @@ export default function PartnerBillsPage() {
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0} wrap>
|
||||
{row.status === 'DRAFT' && (
|
||||
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
|
||||
代确认
|
||||
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
|
||||
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
|
||||
发送
|
||||
</Button>
|
||||
)}
|
||||
{row.status === 'CONFIRMED' && (
|
||||
{row.status === 'UNPAID' && (
|
||||
<>
|
||||
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
|
||||
通过打款
|
||||
<Button type="link" size="small" onClick={() => markPaid([row.id], Number(row.totalAmount))}>
|
||||
确认打款
|
||||
</Button>
|
||||
<Button type="link" size="small" danger onClick={() => openReject(row)}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{row.status === 'AWAITING_CONFIRM' && (
|
||||
<Button type="link" size="small" danger onClick={() => openReject(row)}>
|
||||
驳回
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -239,7 +291,7 @@ export default function PartnerBillsPage() {
|
||||
合伙人账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
T+30 结算:合伙人确认并申请打款后,总部在此审核通过或驳回(驳回须填写理由)
|
||||
每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
@@ -287,7 +339,7 @@ export default function PartnerBillsPage() {
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
style={{ width: 150 }}
|
||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -317,6 +369,20 @@ export default function PartnerBillsPage() {
|
||||
导出 Excel
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button disabled={!canSend.length} onClick={() => sendBills(canSend.map((r) => r.id))}>
|
||||
批量发送 ({canSend.length})
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canPay.length}
|
||||
onClick={() => markPaid(canPay.map((r) => r.id), payAmount)}
|
||||
>
|
||||
批量打款 ({canPay.length})
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
@@ -325,6 +391,10 @@ export default function PartnerBillsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
}}
|
||||
scroll={{ x: 1280 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
@@ -338,7 +408,7 @@ export default function PartnerBillsPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal title="生成合伙人账单(T+30)" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
|
||||
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
|
||||
<Form
|
||||
form={genForm}
|
||||
layout="vertical"
|
||||
@@ -361,7 +431,7 @@ export default function PartnerBillsPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
生成
|
||||
生成(待审核)
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -378,8 +448,6 @@ export default function PartnerBillsPage() {
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
账单 {rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''}
|
||||
<br />
|
||||
驳回理由将展示在合伙人 H5 账单页。
|
||||
</Typography.Paragraph>
|
||||
<Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}>
|
||||
<Form.Item
|
||||
@@ -390,7 +458,7 @@ export default function PartnerBillsPage() {
|
||||
{ max: 500, message: '不超过 500 字' },
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="请说明驳回原因,便于合伙人核对后重新申请" maxLength={500} showCount />
|
||||
<Input.TextArea rows={4} placeholder="请说明驳回原因" maxLength={500} showCount />
|
||||
</Form.Item>
|
||||
<Button type="primary" danger htmlType="submit" block loading={rejecting}>
|
||||
确认驳回
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Cascader,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
@@ -24,9 +23,10 @@ import {
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -37,6 +37,7 @@ type Row = {
|
||||
cityId?: string | null;
|
||||
cityName?: string | null;
|
||||
scopeType?: string;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
storeCount: number;
|
||||
@@ -102,6 +103,18 @@ export default function PartnersPage() {
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
||||
|
||||
const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined;
|
||||
const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined;
|
||||
|
||||
function formatApiError(err: unknown): string | null {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||
if (!(err instanceof Error)) return '操作失败';
|
||||
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||
const label = districtCodeLabel(code);
|
||||
return label !== code ? `${label}(${code})` : code;
|
||||
});
|
||||
}
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setCities(res.items);
|
||||
@@ -142,22 +155,35 @@ export default function PartnersPage() {
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
const body = {
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
};
|
||||
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const body = {
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
};
|
||||
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '城市', dataIndex: 'cityName', width: 100 },
|
||||
{
|
||||
title: '区县',
|
||||
dataIndex: 'districtCodes',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (codes: string[] | null | undefined, row) =>
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
||||
{
|
||||
title: '管辖',
|
||||
@@ -231,8 +257,12 @@ export default function PartnersPage() {
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县">
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={editCityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
@@ -286,27 +316,36 @@ export default function PartnersPage() {
|
||||
width={560}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
try {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
|
||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
||||
onChange={(id) => { setCreateCityId(id); void loadWarehouses(id); }}
|
||||
onChange={(id) => {
|
||||
setCreateCityId(id);
|
||||
createForm.setFieldsValue({ districtCodes: undefined });
|
||||
void loadWarehouses(id);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
@@ -317,8 +356,13 @@ export default function PartnersPage() {
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
rules={[{ required: true, message: '请选择至少一个区县' }]}
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={createCityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
||||
Table, Tabs, Tag, Typography, message,
|
||||
Switch, Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
@@ -31,6 +31,7 @@ type Row = {
|
||||
benefitAmount: number;
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
allowOnSitePickup?: boolean;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[];
|
||||
detailImageUrls?: string[];
|
||||
@@ -49,6 +50,7 @@ type ProductFormValues = {
|
||||
benefitAmount?: number;
|
||||
status?: string;
|
||||
sortOrder?: number;
|
||||
allowOnSitePickup?: boolean;
|
||||
coverUrl?: string;
|
||||
carouselUrls?: string[];
|
||||
detailImageUrls?: string[];
|
||||
@@ -100,6 +102,7 @@ function buildProductPayload(v: ProductFormValues) {
|
||||
benefitAmount: v.benefitAmount,
|
||||
status: v.status,
|
||||
sortOrder: v.sortOrder,
|
||||
allowOnSitePickup: !!v.allowOnSitePickup,
|
||||
coverUrl: v.coverUrl,
|
||||
carouselUrls,
|
||||
detailImageUrls,
|
||||
@@ -217,6 +220,9 @@ function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
|
||||
<Form.Item name="sortOrder" label="排序">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面">
|
||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
@@ -265,7 +271,15 @@ export default function ProductsPage() {
|
||||
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
|
||||
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
|
||||
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{PRODUCT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
|
||||
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
||||
) },
|
||||
{
|
||||
title: '现场取货',
|
||||
dataIndex: 'allowOnSitePickup',
|
||||
width: 90,
|
||||
render: (v: boolean) => (v ? <Tag color="green">允许</Tag> : <Tag>否</Tag>),
|
||||
},
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
@@ -357,7 +371,7 @@ export default function ProductsPage() {
|
||||
void reload();
|
||||
}} width={720}>
|
||||
<Form form={createForm} layout="vertical" initialValues={{
|
||||
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
||||
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, allowOnSitePickup: false,
|
||||
carouselUrls: [''], detailImageUrls: [''],
|
||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||
}}>
|
||||
|
||||
@@ -1,236 +1,244 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
|
||||
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
|
||||
} from 'antd';
|
||||
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import {
|
||||
|
||||
PROMO_CODE_SCENE_LABELS,
|
||||
|
||||
PROMO_CODE_STATUS_LABELS,
|
||||
|
||||
promoConversion,
|
||||
|
||||
type PromoCodeItem,
|
||||
|
||||
type PromoCodeScene,
|
||||
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
import { request } from '../lib/api';
|
||||
|
||||
import { fmtTime } from '../lib/constants';
|
||||
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
|
||||
|
||||
type Row = PromoCodeItem;
|
||||
|
||||
|
||||
|
||||
type SceneOption = { value: PromoCodeScene; label: string };
|
||||
|
||||
|
||||
|
||||
async function downloadQrcode(url: string, filename: string) {
|
||||
|
||||
try {
|
||||
|
||||
const res = await fetch(url);
|
||||
|
||||
const blob = await res.blob();
|
||||
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
|
||||
a.href = objectUrl;
|
||||
|
||||
a.download = filename;
|
||||
|
||||
a.click();
|
||||
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
||||
} catch {
|
||||
|
||||
window.open(url, '_blank');
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function PromoCodesPage() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [filterForm] = Form.useForm();
|
||||
|
||||
const [createForm] = Form.useForm();
|
||||
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
|
||||
const [scenes, setScenes] = useState<SceneOption[]>(
|
||||
|
||||
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
|
||||
|
||||
value: value as PromoCodeScene,
|
||||
|
||||
label,
|
||||
|
||||
})),
|
||||
|
||||
);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
|
||||
'/admin/promo-codes',
|
||||
|
||||
() => {
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
|
||||
if (filters.code) qs.set('code', filters.code);
|
||||
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
|
||||
if (filters.scene) qs.set('scene', filters.scene);
|
||||
|
||||
return qs;
|
||||
|
||||
},
|
||||
|
||||
[filters],
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
async function loadScenes() {
|
||||
|
||||
try {
|
||||
|
||||
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
|
||||
|
||||
if (list.length) setScenes(list);
|
||||
|
||||
} catch {
|
||||
|
||||
/* 使用本地默认场景 */
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
void loadScenes();
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
|
||||
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
|
||||
{ title: '码值', dataIndex: 'code', width: 110 },
|
||||
|
||||
{
|
||||
|
||||
title: '场景',
|
||||
|
||||
dataIndex: 'scene',
|
||||
|
||||
width: 110,
|
||||
|
||||
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
title: '状态',
|
||||
|
||||
dataIndex: 'status',
|
||||
|
||||
width: 90,
|
||||
|
||||
render: (s) => (
|
||||
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
|
||||
|
||||
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
|
||||
|
||||
</Tag>
|
||||
|
||||
),
|
||||
|
||||
},
|
||||
|
||||
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
|
||||
|
||||
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
||||
|
||||
{
|
||||
|
||||
title: '转化率',
|
||||
|
||||
width: 90,
|
||||
|
||||
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
title: '渠道负责人',
|
||||
|
||||
width: 120,
|
||||
|
||||
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
|
||||
|
||||
},
|
||||
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
|
||||
{
|
||||
|
||||
title: '操作',
|
||||
|
||||
width: 220,
|
||||
|
||||
fixed: 'right',
|
||||
|
||||
render: (_, row) => (
|
||||
|
||||
<Space size="small" wrap>
|
||||
|
||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
|
||||
|
||||
详情
|
||||
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PROMO_CODE_SCENE_LABELS,
|
||||
PROMO_CODE_STATUS_LABELS,
|
||||
promoConversion,
|
||||
type PromoCodeItem,
|
||||
type PromoCodeScene,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = PromoCodeItem;
|
||||
|
||||
type SceneOption = { value: PromoCodeScene; label: string };
|
||||
|
||||
async function downloadQrcode(url: string, filename: string) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = objectUrl;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
} catch {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
export default function PromoCodesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [filterForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [scenes, setScenes] = useState<SceneOption[]>(
|
||||
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
|
||||
value: value as PromoCodeScene,
|
||||
label,
|
||||
})),
|
||||
);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/promo-codes',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.code) qs.set('code', filters.code);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.scene) qs.set('scene', filters.scene);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
async function loadScenes() {
|
||||
try {
|
||||
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
|
||||
if (list.length) setScenes(list);
|
||||
} catch {
|
||||
/* 使用本地默认场景 */
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadScenes();
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '码值', dataIndex: 'code', width: 110 },
|
||||
{
|
||||
title: '场景',
|
||||
dataIndex: 'scene',
|
||||
width: 110,
|
||||
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => (
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
|
||||
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
|
||||
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
||||
{
|
||||
title: '转化率',
|
||||
width: 90,
|
||||
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
|
||||
},
|
||||
{
|
||||
title: '渠道负责人',
|
||||
width: 120,
|
||||
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size="small" wrap>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={!row.qrcodeUrl}
|
||||
onClick={() => {
|
||||
if (!row.qrcodeUrl) return;
|
||||
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
|
||||
}}
|
||||
>
|
||||
下载小程序码
|
||||
</Button>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
|
||||
关联用户
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
async function handleCreate(values: Record<string, string>) {
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await request<PromoCodeItem>('/admin/promo-codes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
code: values.code?.trim() || undefined,
|
||||
scene: values.scene,
|
||||
ownerUserId: values.ownerUserId?.trim() || undefined,
|
||||
remark: values.remark?.trim() || undefined,
|
||||
page: values.page?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('推广码已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
navigate(`/promo-codes/${created.id}`);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>推广码管理</Typography.Title>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => { setFilters(v); setPage(1); }}
|
||||
>
|
||||
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="code" label="码值"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="scene" label="场景">
|
||||
<Select allowClear style={{ width: 130 }} options={scenes} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="创建推广码"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ scene: 'ONLINE_LINK' }}>
|
||||
<Form.Item name="name" label="推广码名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||
<Input placeholder="如:郑州品鉴会、门店地推" />
|
||||
</Form.Item>
|
||||
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
|
||||
<Select options={scenes} />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="自定义码值(选填)">
|
||||
<Input placeholder="留空自动生成,如 DKDEMO1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="page"
|
||||
label="小程序落地页(选填)"
|
||||
extra="如 pages/home/index;留空则使用服务端环境变量 WX_MINI_PROMO_PAGE"
|
||||
>
|
||||
<Input placeholder="pages/home/index" />
|
||||
</Form.Item>
|
||||
<Form.Item name="ownerUserId" label="关联用户 ID(选填)">
|
||||
<Input placeholder="渠道负责人,填写用户数据库 ID" />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={creating} block>
|
||||
创建并生成小程序码
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
@@ -23,26 +24,26 @@ import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
billNo: string;
|
||||
billDate: string;
|
||||
redeemCount: number;
|
||||
redeemAmount: number;
|
||||
payoutAmount: number;
|
||||
settlementRate: number;
|
||||
payoutAmount: number;
|
||||
status: string;
|
||||
expectedPayAt: string;
|
||||
paidAt?: string;
|
||||
createdAt: string;
|
||||
paidAt?: string | null;
|
||||
store?: { id: string; name: string; cityName: string; phone?: string };
|
||||
redeemRecord?: { redeemNo: string; amount?: number; createdAt?: string };
|
||||
};
|
||||
|
||||
type StoreOption = { id: string; name: string; phone: string };
|
||||
|
||||
const PAYOUT_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: '待打款',
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
};
|
||||
|
||||
const PAYOUT_STATUS_COLORS: Record<string, string> = {
|
||||
PENDING: 'orange',
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
UNPAID: 'red',
|
||||
PAID: 'green',
|
||||
};
|
||||
|
||||
@@ -51,7 +52,7 @@ export default function StoreBillsPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/store-payouts',
|
||||
'/admin/store-bills',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
@@ -65,6 +66,8 @@ export default function StoreBillsPage() {
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
@@ -72,13 +75,37 @@ export default function StoreBillsPage() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function confirmPayout(id: string) {
|
||||
await request(`/admin/store-payouts/${id}/confirm`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ remark: '财务确认打款' }),
|
||||
function confirmPay(ids: string[], amountHint?: number) {
|
||||
Modal.confirm({
|
||||
title: '确认打款?',
|
||||
content: `将确认 ${ids.length} 笔门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
||||
okText: '确认打款',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (ids.length === 1) {
|
||||
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||
} else {
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
await request('/admin/store-bills/batch-confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
}
|
||||
message.success('已确认打款');
|
||||
setSelectedKeys([]);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
message.success('已确认打款');
|
||||
reload();
|
||||
}
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const d = await request<Record<string, unknown>>(`/admin/store-bills/${id}`);
|
||||
setDetail(d);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
@@ -89,10 +116,8 @@ export default function StoreBillsPage() {
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||
const result = await request<{ csv: string; count: number }>(
|
||||
`/admin/store-payouts/export?${qs}`,
|
||||
);
|
||||
downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
||||
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
|
||||
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
||||
message.success(`已导出 ${result.count} 条`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
@@ -100,62 +125,56 @@ export default function StoreBillsPage() {
|
||||
}
|
||||
|
||||
const summary = data?.summary;
|
||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.payoutAmount), 0);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||
{
|
||||
title: '核销日期',
|
||||
title: '账单日',
|
||||
dataIndex: 'billDate',
|
||||
width: 110,
|
||||
render: (_, r) =>
|
||||
(r.redeemRecord?.createdAt || r.createdAt || '').toString().slice(0, 10) || '—',
|
||||
render: (v) => String(v || '').slice(0, 10),
|
||||
},
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
|
||||
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
||||
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
||||
{ title: '核销笔数', dataIndex: 'redeemCount', width: 90 },
|
||||
{
|
||||
title: '核销单号',
|
||||
dataIndex: ['redeemRecord', 'redeemNo'],
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
title: '核销金额',
|
||||
dataIndex: 'redeemAmount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
|
||||
{
|
||||
title: '核销比例',
|
||||
title: '结算比例',
|
||||
dataIndex: 'settlementRate',
|
||||
width: 90,
|
||||
render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'),
|
||||
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: '应付门店',
|
||||
title: '应付金额',
|
||||
dataIndex: 'payoutAmount',
|
||||
width: 100,
|
||||
render: (v) => `¥${v}`,
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '打款状态',
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>,
|
||||
width: 90,
|
||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{ title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/store-payouts/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
明细
|
||||
</Button>
|
||||
{row.status === 'PENDING' && (
|
||||
<Button type="link" size="small" onClick={() => void confirmPayout(row.id)}>
|
||||
{row.status === 'UNPAID' && (
|
||||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.payoutAmount))}>
|
||||
确认打款
|
||||
</Button>
|
||||
)}
|
||||
@@ -168,19 +187,19 @@ export default function StoreBillsPage() {
|
||||
<div>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
门店账单
|
||||
门店对账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
T+1 结算:按日列出门店核销订单,应付 = 核销面额 × 门店核销比例
|
||||
按核销日汇总(每日 8:00 自动出账);未打款红色、已打款绿色
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space size="large" wrap>
|
||||
<Statistic title="明细笔数" value={summary.count} />
|
||||
<Statistic title="核销面额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="应付门店合计" value={summary.payoutAmount ?? summary.totalAmount} prefix="¥" precision={2} />
|
||||
<Statistic title="账单数" value={summary.count} />
|
||||
<Statistic title="核销金额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="应付合计" value={summary.payoutAmount ?? 0} prefix="¥" precision={2} />
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
@@ -189,16 +208,12 @@ export default function StoreBillsPage() {
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v: {
|
||||
storeId?: string;
|
||||
status?: string;
|
||||
dateRange?: [Dayjs, Dayjs];
|
||||
}) => {
|
||||
onFinish={(v: { storeId?: string; status?: string; range?: [Dayjs, Dayjs] }) => {
|
||||
setFilters({
|
||||
storeId: v.storeId || '',
|
||||
status: v.status || '',
|
||||
dateFrom: v.dateRange?.[0]?.format('YYYY-MM-DD') || '',
|
||||
dateTo: v.dateRange?.[1]?.format('YYYY-MM-DD') || '',
|
||||
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
|
||||
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
@@ -210,21 +225,17 @@ export default function StoreBillsPage() {
|
||||
placeholder="全部门店"
|
||||
style={{ width: 200 }}
|
||||
optionFilterProp="label"
|
||||
options={stores.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name}(${s.phone})`,
|
||||
}))}
|
||||
options={stores.map((s) => ({ value: s.id, label: s.name || s.phone || s.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="打款状态">
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
placeholder="全部"
|
||||
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange" label="核销日期">
|
||||
<Form.Item name="range" label="账单日">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
@@ -248,6 +259,16 @@ export default function StoreBillsPage() {
|
||||
导出 Excel
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!selectedKeys.length}
|
||||
loading={batchLoading}
|
||||
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
|
||||
>
|
||||
批量确认打款 ({selectedKeys.length})
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
@@ -256,7 +277,12 @@ export default function StoreBillsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1300 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
||||
}}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -269,28 +295,44 @@ export default function StoreBillsPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
<Drawer title="门店对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="门店">
|
||||
{String((detail.store as { name?: string })?.name ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销单号">
|
||||
{String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="应付金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算比例">
|
||||
{detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
|
||||
<Descriptions.Item label="实际打款">
|
||||
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="账单日">{String(detail.billDate || '').slice(0, 10)}</Descriptions.Item>
|
||||
<Descriptions.Item label="应付">¥{Number(detail.payoutAmount ?? 0).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{STATUS_LABELS[String(detail.status)] || String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="打款时间">
|
||||
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
核销明细
|
||||
</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={(detail.payouts as Array<Record<string, unknown>>) || []}
|
||||
columns={[
|
||||
{
|
||||
title: '核销单号',
|
||||
render: (_, r) => String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'payoutAmount',
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (s) => (s === 'PAID' ? '已打款' : '未打款'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type CategoryNode = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
sort: number;
|
||||
parentId: string | null;
|
||||
status: string;
|
||||
children?: CategoryNode[];
|
||||
};
|
||||
|
||||
type FlatRow = CategoryNode & { level: 1 | 2; parentName?: string };
|
||||
|
||||
function flattenTree(tree: CategoryNode[]): FlatRow[] {
|
||||
const rows: FlatRow[] = [];
|
||||
for (const root of tree) {
|
||||
rows.push({ ...root, level: 1, children: undefined });
|
||||
for (const child of root.children ?? []) {
|
||||
rows.push({
|
||||
...child,
|
||||
level: 2,
|
||||
parentName: root.name,
|
||||
children: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export default function StoreCategoriesPage() {
|
||||
const [tree, setTree] = useState<CategoryNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FlatRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const rows = useMemo(() => flattenTree(tree), [tree]);
|
||||
const rootOptions = useMemo(
|
||||
() => tree.filter((n) => n.status === 'ACTIVE').map((n) => ({ value: n.id, label: n.name })),
|
||||
[tree],
|
||||
);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request<CategoryNode[]>('/admin/store-categories');
|
||||
setTree(Array.isArray(data) ? data : []);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
function openCreate(parentId?: string) {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({
|
||||
code: '',
|
||||
name: '',
|
||||
sort: 0,
|
||||
parentId: parentId || undefined,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: FlatRow) {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
sort: row.sort,
|
||||
parentId: row.parentId || undefined,
|
||||
status: row.status,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
code: String(values.code).trim().toUpperCase(),
|
||||
name: String(values.name).trim(),
|
||||
sort: Number(values.sort ?? 0),
|
||||
parentId: values.parentId || null,
|
||||
status: values.status || 'ACTIVE',
|
||||
};
|
||||
if (editing) {
|
||||
await request(`/admin/store-categories/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
message.success('已保存');
|
||||
} else {
|
||||
await request('/admin/store-categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
void reload();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<FlatRow> = [
|
||||
{
|
||||
title: '层级',
|
||||
dataIndex: 'level',
|
||||
width: 80,
|
||||
render: (level) => (level === 1 ? <Tag color="blue">一级</Tag> : <Tag>二级</Tag>),
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
render: (name, row) => (
|
||||
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
|
||||
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
|
||||
{name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: '编码', dataIndex: 'code', width: 140 },
|
||||
{ title: '排序', dataIndex: 'sort', width: 80 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => (
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_, row) => (
|
||||
<Space wrap>
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||
{row.level === 1 ? (
|
||||
<Button type="link" size="small" onClick={() => openCreate(row.id)}>加二级</Button>
|
||||
) : null}
|
||||
<Popconfirm
|
||||
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
||||
onConfirm={async () => {
|
||||
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已处理');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店分类</Typography.Title>
|
||||
<Typography.Text type="secondary">两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
|
||||
message.success('已同步默认分类');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
同步默认分类
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
className="admin-table-nowrap"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑分类' : '新增分类'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void submit()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请填写编码' }]}>
|
||||
<Input placeholder="如 DINING / HOTPOT" disabled={!!editing} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||
<Input placeholder="分类名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="parentId" label="上级分类(空=一级)">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="不选则为一级分类"
|
||||
options={rootOptions.filter((o) => o.value !== editing?.id)}
|
||||
disabled={editing?.level === 1 && (tree.find((t) => t.id === editing.id)?.children?.length ?? 0) > 0}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="sort" label="排序" initialValue={0}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" initialValue="ACTIVE">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'ACTIVE', label: '启用' },
|
||||
{ value: 'DISABLED', label: '停用' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,8 +19,15 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { FilePdfOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, STORE_AUDIT_STATUS_LABELS, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import {
|
||||
ADMIN_OPTIONS_PAGE_SIZE,
|
||||
RESOURCE_BIZ_TYPE_LABELS,
|
||||
STORE_AUDIT_STATUS_LABELS,
|
||||
STORE_STATUS_LABELS,
|
||||
fmtTime,
|
||||
} from '../lib/constants';
|
||||
import {
|
||||
validateStoreCreateStep1,
|
||||
validateStoreCreateStep3,
|
||||
@@ -37,6 +44,209 @@ const CREATE_STEPS = [
|
||||
{ title: '结算资质' },
|
||||
];
|
||||
|
||||
type StoreMediaItem = {
|
||||
id?: string;
|
||||
bizType?: string;
|
||||
mediaType?: string;
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
function isImageMedia(url: string, mediaType?: string) {
|
||||
if (mediaType === 'IMAGE') return true;
|
||||
if (mediaType === 'VIDEO' || mediaType === 'FILE') {
|
||||
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
|
||||
}
|
||||
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
|
||||
}
|
||||
|
||||
function isPdfUrl(url: string) {
|
||||
return /\.pdf(\?|#|$)/i.test(url);
|
||||
}
|
||||
|
||||
function collectMediaUrls(detail: Record<string, unknown>) {
|
||||
const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : [];
|
||||
const byType = (bizType: string) =>
|
||||
media
|
||||
.filter((item) => String(item.bizType || '').toUpperCase() === bizType)
|
||||
.map((item) => ({
|
||||
id: String(item.id || item.url || ''),
|
||||
url: String(item.url || '').trim(),
|
||||
mediaType: item.mediaType ? String(item.mediaType) : undefined,
|
||||
}))
|
||||
.filter((item) => item.url);
|
||||
|
||||
const covers = byType('COVER');
|
||||
const coverUrl = detail.coverUrl ? String(detail.coverUrl).trim() : '';
|
||||
if (coverUrl && !covers.some((item) => item.url === coverUrl)) {
|
||||
covers.unshift({ id: 'cover', url: coverUrl, mediaType: 'IMAGE' });
|
||||
}
|
||||
|
||||
return {
|
||||
covers,
|
||||
envs: byType('ENV'),
|
||||
contracts: byType('CONTRACT'),
|
||||
};
|
||||
}
|
||||
|
||||
function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> }) {
|
||||
const { covers, envs, contracts } = collectMediaUrls(detail);
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const gallery = [...covers, ...envs].filter((item) => isImageMedia(item.url, item.mediaType));
|
||||
|
||||
if (covers.length === 0 && envs.length === 0 && contracts.length === 0) {
|
||||
return (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="暂无门头照 / 环境照 / 签约合同,请谨慎审核"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
审核材料
|
||||
</Typography.Title>
|
||||
|
||||
{(covers.length > 0 || envs.length > 0) && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
门头照 / 环境照(点击可放大浏览)
|
||||
</Typography.Text>
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={12}>
|
||||
{gallery.map((item) => (
|
||||
<div key={item.id} style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={item.url}
|
||||
width={112}
|
||||
height={84}
|
||||
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 4 }}>
|
||||
{covers.some((c) => c.id === item.id) ? '门头照' : '环境照'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contracts.length > 0 && (
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
签约合同
|
||||
</Typography.Text>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{contracts.map((item, index) => {
|
||||
const imageLike = isImageMedia(item.url, item.mediaType);
|
||||
const pdf = isPdfUrl(item.url);
|
||||
return (
|
||||
<div
|
||||
key={item.id || `${item.url}-${index}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{imageLike ? (
|
||||
<Image.PreviewGroup>
|
||||
<Image
|
||||
src={item.url}
|
||||
width={96}
|
||||
height={72}
|
||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||
/>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 96,
|
||||
height: 72,
|
||||
borderRadius: 6,
|
||||
background: '#fff',
|
||||
border: '1px dashed #d9d9d9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#cf1322',
|
||||
}}
|
||||
>
|
||||
<FilePdfOutlined style={{ fontSize: 28 }} />
|
||||
</div>
|
||||
)}
|
||||
<Space direction="vertical" size={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text strong>
|
||||
{RESOURCE_BIZ_TYPE_LABELS.CONTRACT || '合同'}
|
||||
{contracts.length > 1 ? ` ${index + 1}` : ''}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis style={{ maxWidth: '100%' }}>
|
||||
{item.url}
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
{imageLike ? (
|
||||
<Typography.Text type="secondary">点击缩略图放大查看</Typography.Text>
|
||||
) : null}
|
||||
{pdf ? (
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setPdfUrl(item.url)}>
|
||||
页内预览 PDF
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
style={{ padding: 0 }}
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
新窗口打开
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="合同预览"
|
||||
open={!!pdfUrl}
|
||||
onCancel={() => setPdfUrl(null)}
|
||||
width={900}
|
||||
footer={[
|
||||
<Button key="open" href={pdfUrl || undefined} target="_blank" rel="noreferrer">
|
||||
新窗口打开
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={() => setPdfUrl(null)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
destroyOnClose
|
||||
>
|
||||
{pdfUrl ? (
|
||||
<iframe
|
||||
title="合同 PDF 预览"
|
||||
src={pdfUrl}
|
||||
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 8 }}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StoreRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -52,7 +262,21 @@ type StoreRow = {
|
||||
createdAt: string;
|
||||
cityRef?: { name: string; code: string };
|
||||
partner?: { companyName: string };
|
||||
account?: { phone: string; name: string; status: string };
|
||||
account?: {
|
||||
phone: string;
|
||||
name: string;
|
||||
status: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
};
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
openTime2?: string | null;
|
||||
closeTime2?: string | null;
|
||||
avgPrice?: number | null;
|
||||
settlementRate?: number;
|
||||
category?: { id: string; name: string; parentId?: string | null } | null;
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
@@ -62,6 +286,12 @@ type CityOption = {
|
||||
code: string;
|
||||
partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>;
|
||||
};
|
||||
type CategoryNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
children?: CategoryNode[];
|
||||
};
|
||||
|
||||
export default function StoresPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -89,13 +319,30 @@ export default function StoresPage() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createStep, setCreateStep] = useState(0);
|
||||
const [createError, setCreateError] = useState('');
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||
|
||||
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
|
||||
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||
const selectedCityId = Form.useWatch('cityId', createForm);
|
||||
const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm);
|
||||
|
||||
const categoryParentOptions = useMemo(
|
||||
() =>
|
||||
categoryTree
|
||||
.filter((n) => n.status !== 'INACTIVE')
|
||||
.map((n) => ({ value: n.id, label: n.name })),
|
||||
[categoryTree],
|
||||
);
|
||||
const categoryChildOptions = useMemo(() => {
|
||||
const parent = categoryTree.find((n) => n.id === selectedCategoryParentId);
|
||||
return (parent?.children ?? [])
|
||||
.filter((n) => n.status !== 'INACTIVE')
|
||||
.map((n) => ({ value: n.id, label: n.name }));
|
||||
}, [categoryTree, selectedCategoryParentId]);
|
||||
|
||||
function bindRegionSelection(codes: string[], partnerAccountId?: string) {
|
||||
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
|
||||
@@ -127,16 +374,21 @@ export default function StoresPage() {
|
||||
setOptionsLoading(true);
|
||||
try {
|
||||
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
|
||||
const [p, c] = await Promise.all([
|
||||
const [p, c, cats] = await Promise.all([
|
||||
request<Paginated<PartnerOption>>(`/admin/partners?${qs}`),
|
||||
request<Paginated<CityOption>>(`/admin/cities?${qs}`),
|
||||
request<CategoryNode[]>('/admin/store-categories'),
|
||||
]);
|
||||
setPartners(p.items);
|
||||
setCities(c.items);
|
||||
setCategoryTree(Array.isArray(cats) ? cats : []);
|
||||
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
|
||||
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
|
||||
if (!Array.isArray(cats) || !cats.length) {
|
||||
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置');
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载合伙人/城市失败');
|
||||
message.error(e instanceof Error ? e.message : '加载合伙人/城市/分类失败');
|
||||
} finally {
|
||||
setOptionsLoading(false);
|
||||
}
|
||||
@@ -154,6 +406,11 @@ export default function StoresPage() {
|
||||
createForm.setFieldsValue({
|
||||
envPhotoUrls: ['', '', ''],
|
||||
settlementRate: 60,
|
||||
openTime: '10:00',
|
||||
closeTime: '22:00',
|
||||
openTime2: undefined,
|
||||
closeTime2: undefined,
|
||||
avgPrice: undefined,
|
||||
});
|
||||
setCreateStep(0);
|
||||
setCreateError('');
|
||||
@@ -169,7 +426,16 @@ export default function StoresPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createForm.validateFields(['partnerAccountId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
|
||||
await createForm.validateFields([
|
||||
'partnerAccountId',
|
||||
'regionCodes',
|
||||
'cityId',
|
||||
'categoryParentId',
|
||||
'categoryId',
|
||||
'name',
|
||||
'phone',
|
||||
'address',
|
||||
]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -193,13 +459,31 @@ export default function StoresPage() {
|
||||
body: JSON.stringify({
|
||||
partnerAccountId: values.partnerAccountId,
|
||||
cityId: values.cityId,
|
||||
categoryId: values.categoryId,
|
||||
province: values.province,
|
||||
city: values.city,
|
||||
name: values.name.trim(),
|
||||
phone: values.phone.trim(),
|
||||
district: values.district.trim(),
|
||||
address: values.address.trim(),
|
||||
...(values.latitude != null &&
|
||||
values.longitude != null &&
|
||||
Number.isFinite(Number(values.latitude)) &&
|
||||
Number.isFinite(Number(values.longitude))
|
||||
? {
|
||||
latitude: Number(values.latitude),
|
||||
longitude: Number(values.longitude),
|
||||
}
|
||||
: {}),
|
||||
intro: values.intro?.trim() || undefined,
|
||||
openTime: values.openTime?.trim() || '10:00',
|
||||
closeTime: values.closeTime?.trim() || '22:00',
|
||||
...(values.openTime2?.trim() && values.closeTime2?.trim()
|
||||
? { openTime2: values.openTime2.trim(), closeTime2: values.closeTime2.trim() }
|
||||
: {}),
|
||||
...(values.avgPrice != null && values.avgPrice !== ''
|
||||
? { avgPrice: Number(values.avgPrice) }
|
||||
: {}),
|
||||
coverUrl: values.coverUrl?.trim() || undefined,
|
||||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||
contractUrl: values.contractUrl?.trim() || undefined,
|
||||
@@ -231,6 +515,11 @@ export default function StoresPage() {
|
||||
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
||||
},
|
||||
{ title: '门店名', dataIndex: 'name', width: 140 },
|
||||
{
|
||||
title: '分类',
|
||||
width: 100,
|
||||
render: (_, row) => row.category?.name || '—',
|
||||
},
|
||||
{ title: '城市', dataIndex: 'cityName', width: 80 },
|
||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
@@ -272,6 +561,11 @@ export default function StoresPage() {
|
||||
address: d.address,
|
||||
district: d.district,
|
||||
settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60,
|
||||
openTime: d.openTime || '10:00',
|
||||
closeTime: d.closeTime || '22:00',
|
||||
openTime2: d.openTime2 || undefined,
|
||||
closeTime2: d.closeTime2 || undefined,
|
||||
avgPrice: d.avgPrice != null ? Number(d.avgPrice) : undefined,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
@@ -307,7 +601,7 @@ export default function StoresPage() {
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="门店详情" width={600} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Space wrap>
|
||||
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
|
||||
@@ -367,8 +661,14 @@ export default function StoresPage() {
|
||||
)}>
|
||||
{detail && (
|
||||
<>
|
||||
<StoreAuditMediaSection detail={detail} />
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店分类">
|
||||
{detail.category && typeof detail.category === 'object' && 'name' in detail.category
|
||||
? String((detail.category as { name?: string }).name || '—')
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="审核状态">
|
||||
<Tag color={
|
||||
String(detail.auditStatus) === 'PENDING' ? 'orange'
|
||||
@@ -381,20 +681,45 @@ export default function StoresPage() {
|
||||
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
|
||||
<Descriptions.Item label="营业时间">
|
||||
{[
|
||||
detail.openTime && detail.closeTime
|
||||
? `${String(detail.openTime)}-${String(detail.closeTime)}`
|
||||
: null,
|
||||
detail.openTime2 && detail.closeTime2
|
||||
? `${String(detail.openTime2)}-${String(detail.closeTime2)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(',') || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="人均费用">
|
||||
{detail.avgPrice != null ? `¥${Number(detail.avgPrice).toFixed(0)}` : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销结算比例">
|
||||
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结算户名">
|
||||
{detail.account && typeof detail.account === 'object' && 'bankAccountName' in detail.account
|
||||
? String((detail.account as { bankAccountName?: string | null }).bankAccountName || '—')
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开户银行">
|
||||
{detail.account && typeof detail.account === 'object' && 'bankBranch' in detail.account
|
||||
? String((detail.account as { bankBranch?: string | null }).bankBranch || '—')
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="银行卡号">
|
||||
{detail.account && typeof detail.account === 'object' && 'bankAccountNo' in detail.account
|
||||
? String((detail.account as { bankAccountNo?: string | null }).bankAccountNo || '—')
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作">
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
|
||||
查看商户日志
|
||||
</Button>
|
||||
</Descriptions.Item>
|
||||
{detail.coverUrl ? (
|
||||
<Descriptions.Item label="封面">
|
||||
<Image src={String(detail.coverUrl)} width={120} />
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
|
||||
<Descriptions.Item label="审核记录">
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
@@ -416,6 +741,25 @@ export default function StoresPage() {
|
||||
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item>
|
||||
<Form.Item name="district" label="区县"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="详细地址"><Input /></Form.Item>
|
||||
<Form.Item name="avgPrice" label="人均费用(选填)">
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeTime" label="营业结束" rules={[{ required: true }]}>
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="openTime2" label="第二段开始(选填)">
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeTime2" label="第二段结束">
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||
</Form.Item>
|
||||
@@ -479,6 +823,38 @@ export default function StoresPage() {
|
||||
<Form.Item name="city" hidden><Input /></Form.Item>
|
||||
<Form.Item name="district" hidden><Input /></Form.Item>
|
||||
<Form.Item name="districtCode" hidden><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="categoryParentId"
|
||||
label="门店分类(大类)"
|
||||
rules={[{ required: true, message: '请选择门店大类' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
loading={optionsLoading}
|
||||
optionFilterProp="label"
|
||||
placeholder={optionsLoading ? '加载中…' : '选择大类'}
|
||||
options={categoryParentOptions}
|
||||
onChange={() => createForm.setFieldValue('categoryId', undefined)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="categoryId"
|
||||
label="门店分类(细类)"
|
||||
rules={[{ required: true, message: '请选择门店细类' }]}
|
||||
extra={
|
||||
<Typography.Link onClick={() => navigate('/store-categories')}>
|
||||
去配置门店分类
|
||||
</Typography.Link>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder={selectedCategoryParentId ? '选择细类' : '请先选大类'}
|
||||
disabled={!selectedCategoryParentId}
|
||||
options={categoryChildOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
||||
<Input placeholder="请输入门店名称" />
|
||||
</Form.Item>
|
||||
@@ -488,8 +864,79 @@ export default function StoresPage() {
|
||||
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
||||
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="latitude" label="纬度" style={{ marginBottom: 8 }}>
|
||||
<InputNumber
|
||||
style={{ width: 180 }}
|
||||
precision={7}
|
||||
step={0.000001}
|
||||
placeholder="如 34.7466000"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="longitude" label="经度" style={{ marginBottom: 8 }}>
|
||||
<InputNumber
|
||||
style={{ width: 180 }}
|
||||
precision={7}
|
||||
step={0.000001}
|
||||
placeholder="如 113.6253000"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
loading={locating}
|
||||
onClick={() => {
|
||||
if (!navigator.geolocation) {
|
||||
message.error('当前浏览器不支持定位');
|
||||
return;
|
||||
}
|
||||
setLocating(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
createForm.setFieldsValue({
|
||||
latitude: pos.coords.latitude,
|
||||
longitude: pos.coords.longitude,
|
||||
});
|
||||
message.success(
|
||||
`已获取坐标 ${pos.coords.latitude.toFixed(6)}, ${pos.coords.longitude.toFixed(6)}`,
|
||||
);
|
||||
setLocating(false);
|
||||
},
|
||||
(err) => {
|
||||
message.error(err.message || '定位失败');
|
||||
setLocating(false);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 },
|
||||
);
|
||||
}}
|
||||
>
|
||||
获取当前位置
|
||||
</Button>
|
||||
<Typography.Text type="secondary">
|
||||
可手动填写,或点击定位填入;便于用户端导航与距离
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="openTime" label="营业开始" rules={[{ required: true, message: '请填写营业开始时间' }]}>
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeTime" label="营业结束" rules={[{ required: true, message: '请填写营业结束时间' }]}>
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="openTime2" label="第二段开始(选填)">
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeTime2" label="第二段结束">
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="avgPrice" label="人均费用(选填)">
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
|
||||
</Form.Item>
|
||||
<Form.Item name="intro" label="门店简介">
|
||||
<Input.TextArea rows={3} placeholder="选填,10~500字" showCount maxLength={500} />
|
||||
<Input.TextArea rows={3} placeholder="选填,2~500字" showCount maxLength={500} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: createStep === 1 ? 'block' : 'none' }}>
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
SUPPORT_TICKET_STATUS_LABELS,
|
||||
SUPPORT_TICKET_TYPE_LABELS,
|
||||
type SupportTicketDto,
|
||||
type SupportTicketStatusDto,
|
||||
type SupportTicketTypeDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
const STATUS_COLOR: Record<SupportTicketStatusDto, string> = {
|
||||
PENDING_REVIEW: 'orange',
|
||||
REJECTED: 'red',
|
||||
DEVELOPING: 'blue',
|
||||
TESTING: 'purple',
|
||||
PASSED: 'green',
|
||||
};
|
||||
|
||||
const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map(
|
||||
(value) => ({ value, label: SUPPORT_TICKET_TYPE_LABELS[value] }),
|
||||
);
|
||||
|
||||
const STATUS_OPTIONS = (Object.keys(SUPPORT_TICKET_STATUS_LABELS) as SupportTicketStatusDto[]).map(
|
||||
(value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }),
|
||||
);
|
||||
|
||||
export default function SupportTicketsPage() {
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||
useAdminList<SupportTicketDto>('/admin/support-tickets', () => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.ticketType) qs.set('ticketType', filters.ticketType);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
}, [filters]);
|
||||
|
||||
const [detail, setDetail] = useState<SupportTicketDto | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [acting, setActing] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [createForm] = Form.useForm<{
|
||||
ticketType: SupportTicketTypeDto;
|
||||
title: string;
|
||||
content?: string;
|
||||
remark?: string;
|
||||
}>();
|
||||
const [rejectForm] = Form.useForm<{ rejectReason: string }>();
|
||||
|
||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
useEffect(() => {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetail(await request<SupportTicketDto>(`/admin/support-tickets/${id}`));
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const values = await createForm.validateFields();
|
||||
setCreating(true);
|
||||
try {
|
||||
await request('/admin/support-tickets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ticketType: values.ticketType,
|
||||
title: values.title.trim(),
|
||||
content: values.content?.trim() || undefined,
|
||||
remark: values.remark?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('技术支持工单已创建,等待最高管理员评审');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function approve() {
|
||||
if (!detail) return;
|
||||
setActing(true);
|
||||
try {
|
||||
await request(`/admin/support-tickets/${detail.id}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
message.success('评审通过,已进入开发');
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReject() {
|
||||
if (!detail) return;
|
||||
const values = await rejectForm.validateFields();
|
||||
setActing(true);
|
||||
try {
|
||||
await request(`/admin/support-tickets/${detail.id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rejectReason: values.rejectReason.trim() }),
|
||||
});
|
||||
message.success('已驳回');
|
||||
setRejectOpen(false);
|
||||
rejectForm.resetFields();
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startTesting() {
|
||||
if (!detail) return;
|
||||
setActing(true);
|
||||
try {
|
||||
await request(`/admin/support-tickets/${detail.id}/start-testing`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
message.success('已转入测试');
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pass() {
|
||||
if (!detail) return;
|
||||
setActing(true);
|
||||
try {
|
||||
await request(`/admin/support-tickets/${detail.id}/pass`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
message.success('测试通过');
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<SupportTicketDto> = [
|
||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'ticketType',
|
||||
width: 90,
|
||||
render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: SupportTicketStatusDto) => (
|
||||
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||
{ title: '创建人', dataIndex: 'creatorName', width: 100 },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openDetail(String(row.id))}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const drawerExtra = (() => {
|
||||
if (!detail) return null;
|
||||
if (detail.status === 'PENDING_REVIEW' && isSuperAdmin) {
|
||||
return (
|
||||
<Space>
|
||||
<Button type="primary" loading={acting} onClick={() => void approve()}>
|
||||
评审通过
|
||||
</Button>
|
||||
<Button danger loading={acting} onClick={() => setRejectOpen(true)}>
|
||||
驳回
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
if (detail.status === 'DEVELOPING') {
|
||||
return (
|
||||
<Button type="primary" loading={acting} onClick={() => void startTesting()}>
|
||||
开发完成 · 转入测试
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (detail.status === 'TESTING') {
|
||||
return (
|
||||
<Button type="primary" loading={acting} onClick={() => void pass()}>
|
||||
测试通过
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
技术支持
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
创建工单
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="ticketType" label="类型">
|
||||
<Select allowClear style={{ width: 120 }} options={TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
筛选
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="技术支持详情"
|
||||
width={520}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={drawerExtra}
|
||||
>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="工单号">{detail.ticketNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">
|
||||
{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLOR[detail.status]}>
|
||||
{SUPPORT_TICKET_STATUS_LABELS[detail.status] ?? detail.status}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="标题">{detail.title}</Descriptions.Item>
|
||||
<Descriptions.Item label="内容">
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>{detail.content || '—'}</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建人">{detail.creatorName}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="评审人">{detail.reviewerName || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="评审时间">
|
||||
{detail.reviewedAt ? fmtTime(detail.reviewedAt) : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="驳回理由">{detail.rejectReason || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="创建技术支持工单"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => void submitCreate()}
|
||||
confirmLoading={creating}
|
||||
destroyOnClose
|
||||
okText="提交"
|
||||
>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG' }}>
|
||||
<Form.Item
|
||||
name="ticketType"
|
||||
label="类型"
|
||||
rules={[{ required: true, message: '请选择类型' }]}
|
||||
>
|
||||
<Select options={TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="标题"
|
||||
rules={[{ required: true, message: '请填写标题' }]}
|
||||
>
|
||||
<Input placeholder="简要描述问题或建议" maxLength={128} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="详细说明">
|
||||
<Input.TextArea rows={5} placeholder="复现步骤、期望结果等" maxLength={4000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="可选" maxLength={512} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="驳回技术支持工单"
|
||||
open={rejectOpen}
|
||||
onCancel={() => setRejectOpen(false)}
|
||||
onOk={() => void submitReject()}
|
||||
confirmLoading={acting}
|
||||
destroyOnClose
|
||||
okText="确认驳回"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Form form={rejectForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="rejectReason"
|
||||
label="驳回理由"
|
||||
rules={[{ required: true, message: '请填写驳回理由' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="必填" maxLength={512} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,283 +1,182 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
|
||||
Alert,
|
||||
|
||||
Button,
|
||||
|
||||
Card,
|
||||
|
||||
Collapse,
|
||||
|
||||
Form,
|
||||
|
||||
Input,
|
||||
|
||||
InputNumber,
|
||||
|
||||
Modal,
|
||||
Space,
|
||||
|
||||
Switch,
|
||||
|
||||
Table,
|
||||
|
||||
Tag,
|
||||
|
||||
Typography,
|
||||
|
||||
message,
|
||||
|
||||
} from 'antd';
|
||||
|
||||
import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types';
|
||||
|
||||
import { request } from '../lib/api';
|
||||
|
||||
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
import { ConfigImageField, ConfigImageListField } from '../components/ConfigMediaFields';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
|
||||
|
||||
function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loading?: boolean }) {
|
||||
|
||||
return (
|
||||
|
||||
<div style={{ marginTop: -8, marginBottom: 16, marginLeft: 0 }}>
|
||||
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
|
||||
最近 Mock 验证码(写入数据库,最新 50 条)
|
||||
|
||||
</Typography.Text>
|
||||
|
||||
<Table<MockSmsCodeItem>
|
||||
|
||||
size="small"
|
||||
|
||||
rowKey="id"
|
||||
|
||||
loading={loading}
|
||||
|
||||
pagination={false}
|
||||
|
||||
scroll={{ y: 240 }}
|
||||
|
||||
locale={{ emptyText: '暂无记录,触发短信发送后将显示在此' }}
|
||||
|
||||
columns={[
|
||||
|
||||
{
|
||||
|
||||
title: '时间',
|
||||
|
||||
dataIndex: 'createdAt',
|
||||
|
||||
width: 168,
|
||||
|
||||
render: (v: string) => new Date(v).toLocaleString(),
|
||||
|
||||
},
|
||||
|
||||
{ title: '手机号', dataIndex: 'phone', width: 120 },
|
||||
|
||||
{ title: '场景', dataIndex: 'scene', width: 160 },
|
||||
|
||||
{
|
||||
|
||||
title: '验证码',
|
||||
|
||||
dataIndex: 'code',
|
||||
|
||||
width: 88,
|
||||
|
||||
render: (code: string) => (
|
||||
|
||||
<Typography.Text copyable strong>
|
||||
|
||||
{code}
|
||||
|
||||
</Typography.Text>
|
||||
|
||||
),
|
||||
|
||||
},
|
||||
|
||||
]}
|
||||
|
||||
dataSource={codes}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function renderField(
|
||||
|
||||
field: SystemConfigFieldMeta,
|
||||
|
||||
configuredSecrets: string[],
|
||||
|
||||
extra?: ReactNode,
|
||||
|
||||
) {
|
||||
|
||||
const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key);
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
|
||||
return (
|
||||
|
||||
<div key={field.key}>
|
||||
|
||||
<Form.Item
|
||||
|
||||
name={field.key}
|
||||
|
||||
label={
|
||||
|
||||
<Space size={4}>
|
||||
|
||||
<span>{field.label}</span>
|
||||
|
||||
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
|
||||
|
||||
{field.key}
|
||||
|
||||
</Typography.Text>
|
||||
|
||||
{field.requiresRestart ? <Tag color="orange">需重启</Tag> : <Tag color="green">即时</Tag>}
|
||||
|
||||
</Space>
|
||||
|
||||
}
|
||||
|
||||
tooltip={field.description}
|
||||
|
||||
valuePropName="checked"
|
||||
|
||||
getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')}
|
||||
|
||||
getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })}
|
||||
|
||||
>
|
||||
|
||||
<Switch />
|
||||
|
||||
</Form.Item>
|
||||
|
||||
{extra}
|
||||
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (field.type === 'image' || field.type === 'imageList') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={field.key}
|
||||
label={
|
||||
<Space size={4} wrap>
|
||||
<span>{field.label}</span>
|
||||
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
|
||||
{field.key}
|
||||
</Typography.Text>
|
||||
{field.requiresRestart ? <Tag color="orange">需重启</Tag> : <Tag color="green">即时</Tag>}
|
||||
</Space>
|
||||
}
|
||||
tooltip={field.description}
|
||||
trigger="onChange"
|
||||
getValueFromEvent={(v: unknown) => (typeof v === 'string' ? v : '')}
|
||||
normalize={(v) => (typeof v === 'string' ? v : '')}
|
||||
>
|
||||
{field.type === 'image' ? (
|
||||
<ConfigImageField bizType="footer" />
|
||||
) : (
|
||||
<ConfigImageListField bizType="swiper" />
|
||||
)}
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
const input =
|
||||
|
||||
field.type === 'textarea' ? (
|
||||
|
||||
<TextArea rows={3} placeholder={field.placeholder} />
|
||||
|
||||
) : field.type === 'number' ? (
|
||||
|
||||
<InputNumber style={{ width: '100%' }} placeholder={field.placeholder} />
|
||||
|
||||
) : field.secret ? (
|
||||
|
||||
<Input.Password
|
||||
|
||||
placeholder={isConfiguredSecret ? '已配置,留空则不修改' : field.placeholder}
|
||||
|
||||
autoComplete="new-password"
|
||||
|
||||
/>
|
||||
|
||||
) : (
|
||||
|
||||
<Input placeholder={field.placeholder} />
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<Form.Item
|
||||
|
||||
key={field.key}
|
||||
|
||||
name={field.key}
|
||||
|
||||
label={
|
||||
|
||||
<Space size={4} wrap>
|
||||
|
||||
<span>{field.label}</span>
|
||||
|
||||
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
|
||||
|
||||
{field.key}
|
||||
|
||||
</Typography.Text>
|
||||
|
||||
{field.requiresRestart ? <Tag color="orange">需重启</Tag> : <Tag color="green">即时</Tag>}
|
||||
|
||||
{isConfiguredSecret ? <Tag>已配置</Tag> : null}
|
||||
|
||||
</Space>
|
||||
|
||||
}
|
||||
|
||||
tooltip={field.description}
|
||||
|
||||
>
|
||||
|
||||
{input}
|
||||
|
||||
</Form.Item>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function SystemSettingsPage() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm<Record<string, string>>();
|
||||
|
||||
const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null);
|
||||
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const dirtyRef = useRef(false);
|
||||
const bypassLeaveRef = useRef(false);
|
||||
const mockSmsEnabled = Form.useWatch('MOCK_SMS', form) === 'true';
|
||||
|
||||
|
||||
dirtyRef.current = dirty;
|
||||
|
||||
async function load(silent = false) {
|
||||
|
||||
if (!silent) setLoading(true);
|
||||
|
||||
try {
|
||||
|
||||
const data = await request<SystemConfigFormResponse>('/admin/system-config');
|
||||
|
||||
if (silent) {
|
||||
// 仅刷新 Mock 验证码列表,避免轮询用服务端值覆盖未保存的表单(含 MOCK_SMS 开关)
|
||||
setMeta((prev) =>
|
||||
prev
|
||||
? { ...prev, mockSmsCodes: data.mockSmsCodes, updatedAt: data.updatedAt }
|
||||
@@ -285,300 +184,226 @@ export default function SystemSettingsPage() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setMeta(data);
|
||||
|
||||
form.setFieldsValue(data.values);
|
||||
|
||||
setDirty(false);
|
||||
} catch (e) {
|
||||
|
||||
if (!silent) message.error(e instanceof Error ? e.message : '加载失败');
|
||||
|
||||
} finally {
|
||||
|
||||
if (!silent) setLoading(false);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
void load();
|
||||
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
// 以表单开关为准展示验证码;轮询只刷列表,不回写表单
|
||||
if (!mockSmsEnabled) return;
|
||||
|
||||
const timer = window.setInterval(() => void load(true), 5000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
|
||||
}, [mockSmsEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (!dirtyRef.current) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (!dirtyRef.current || bypassLeaveRef.current) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
const anchor = target?.closest?.('a');
|
||||
if (!anchor || !(anchor instanceof HTMLAnchorElement)) return;
|
||||
if (anchor.target === '_blank' || anchor.hasAttribute('download')) return;
|
||||
const url = new URL(anchor.href, window.location.href);
|
||||
if (url.origin !== window.location.origin) return;
|
||||
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
Modal.confirm({
|
||||
title: '有未保存的更改',
|
||||
content: '离开前请先保存,否则更改将丢失。',
|
||||
okText: '仍要离开',
|
||||
cancelText: '留下',
|
||||
onOk: () => {
|
||||
bypassLeaveRef.current = true;
|
||||
setDirty(false);
|
||||
navigate(`${url.pathname}${url.search}${url.hash}`);
|
||||
window.setTimeout(() => {
|
||||
bypassLeaveRef.current = false;
|
||||
}, 0);
|
||||
},
|
||||
});
|
||||
};
|
||||
document.addEventListener('click', onDocClick, true);
|
||||
return () => document.removeEventListener('click', onDocClick, true);
|
||||
}, [navigate]);
|
||||
|
||||
const collapseItems = useMemo(() => {
|
||||
|
||||
if (!meta) return [];
|
||||
|
||||
return meta.groups.map((group) => ({
|
||||
|
||||
key: group.key,
|
||||
|
||||
label: group.label,
|
||||
|
||||
forceRender: true,
|
||||
children: (
|
||||
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
|
||||
{meta.fields
|
||||
|
||||
.filter((f) => f.group === group.key)
|
||||
|
||||
.map((f) =>
|
||||
|
||||
renderField(
|
||||
|
||||
f,
|
||||
|
||||
meta.configuredSecrets,
|
||||
|
||||
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
||||
|
||||
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
||||
|
||||
) : undefined,
|
||||
|
||||
),
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
),
|
||||
|
||||
}));
|
||||
|
||||
}, [meta, mockSmsEnabled, loading]);
|
||||
|
||||
|
||||
|
||||
async function onSave() {
|
||||
|
||||
const values = await form.validateFields();
|
||||
|
||||
await form.validateFields();
|
||||
const values = form.getFieldsValue(true);
|
||||
const payload: Record<string, string> = {};
|
||||
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
|
||||
payload[k] = v === undefined || v === null ? '' : String(v);
|
||||
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
|
||||
const res = await request<{ updatedKeys: string[]; requiresRestartKeys: string[] }>(
|
||||
|
||||
'/admin/system-config',
|
||||
|
||||
{ method: 'PUT', body: JSON.stringify({ values: payload }) },
|
||||
|
||||
);
|
||||
|
||||
message.success(`已保存 ${res.updatedKeys.length} 项`);
|
||||
|
||||
if (res.requiresRestartKeys.length) {
|
||||
|
||||
message.warning(`以下配置需重启 API 后生效:${res.requiresRestartKeys.join(', ')}`);
|
||||
|
||||
}
|
||||
|
||||
setDirty(false);
|
||||
await load();
|
||||
|
||||
} catch (e) {
|
||||
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
|
||||
} finally {
|
||||
|
||||
setSaving(false);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function onSyncEnv() {
|
||||
|
||||
setSyncing(true);
|
||||
|
||||
try {
|
||||
|
||||
const res = await request<{ message: string; envFilePath: string }>(
|
||||
|
||||
'/admin/system-config/sync-env',
|
||||
|
||||
{ method: 'POST' },
|
||||
|
||||
);
|
||||
|
||||
message.success(res.message || '已同步到 env 文件');
|
||||
|
||||
} catch (e) {
|
||||
|
||||
message.error(e instanceof Error ? e.message : '同步失败');
|
||||
|
||||
} finally {
|
||||
|
||||
setSyncing(false);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function onImportEnv() {
|
||||
|
||||
try {
|
||||
|
||||
const res = await request<{ imported: number }>('/admin/system-config/import-env', {
|
||||
|
||||
method: 'POST',
|
||||
|
||||
});
|
||||
|
||||
message.success(`已从当前进程环境导入 ${res.imported} 项`);
|
||||
|
||||
await load();
|
||||
|
||||
} catch (e) {
|
||||
|
||||
message.error(e instanceof Error ? e.message : '导入失败');
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div>
|
||||
|
||||
<div style={{ paddingBottom: 88 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
||||
|
||||
<div>
|
||||
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
|
||||
系统设置
|
||||
|
||||
</Typography.Title>
|
||||
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 8 }}>
|
||||
|
||||
配置存于 <code>system_config</code> 表;保存后写入进程环境。可同步到{' '}
|
||||
|
||||
<code>{meta?.envFilePath ?? '.env'}</code> 以便部署持久化。
|
||||
|
||||
</Typography.Paragraph>
|
||||
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
|
||||
<Button onClick={() => void onImportEnv()}>从环境导入</Button>
|
||||
|
||||
<Button loading={syncing} onClick={() => void onSyncEnv()}>
|
||||
|
||||
同步到 env 文件
|
||||
|
||||
</Button>
|
||||
|
||||
<Button type="primary" loading={saving} onClick={() => void onSave()}>
|
||||
|
||||
保存
|
||||
|
||||
</Button>
|
||||
|
||||
{profile?.adminRole === 'SUPER_ADMIN' ? (
|
||||
<>
|
||||
<Button onClick={() => void onImportEnv()}>从环境导入</Button>
|
||||
<Button loading={syncing} onClick={() => void onSyncEnv()}>
|
||||
同步到 env 文件
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<Alert
|
||||
|
||||
type="info"
|
||||
|
||||
showIcon
|
||||
|
||||
style={{ marginBottom: 16 }}
|
||||
|
||||
message="生效说明"
|
||||
|
||||
description={
|
||||
|
||||
<ul style={{ margin: '8px 0 0', paddingLeft: 20 }}>
|
||||
|
||||
<li>
|
||||
|
||||
<Tag color="green">即时</Tag>:保存后写入 <code>process.env</code>,Mock 开关、短信模板等可立即生效。
|
||||
|
||||
</li>
|
||||
|
||||
<li>
|
||||
|
||||
<Tag color="orange">需重启</Tag>:微信/OSS 密钥等集成凭证变更后,<strong>建议重启 API 进程</strong>。
|
||||
|
||||
</li>
|
||||
|
||||
<li>OSS 始终走阿里云配置;凭证缺失时上传接口将直接报错。</li>
|
||||
|
||||
<li>修改后请点击右下角「保存」;未保存离开页面将提示确认。</li>
|
||||
</ul>
|
||||
|
||||
}
|
||||
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<Card loading={loading}>
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
|
||||
<Collapse defaultActiveKey={meta?.groups.map((g) => g.key)} items={collapseItems} />
|
||||
|
||||
<Form form={form} layout="vertical" onValuesChange={() => setDirty(true)}>
|
||||
<Collapse defaultActiveKey={[]} items={collapseItems} />
|
||||
</Form>
|
||||
|
||||
{meta?.updatedAt ? (
|
||||
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
|
||||
|
||||
最近更新:{new Date(meta.updatedAt).toLocaleString()}
|
||||
|
||||
</Typography.Text>
|
||||
|
||||
) : null}
|
||||
|
||||
</Card>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 32,
|
||||
bottom: 32,
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{dirty ? <Tag color="orange">有未保存更改</Tag> : null}
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
loading={saving}
|
||||
onClick={() => void onSave()}
|
||||
style={{
|
||||
minWidth: 120,
|
||||
boxShadow: '0 6px 16px rgba(0,0,0,0.18)',
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Image, Input, Select, Table, Typography, message } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
@@ -32,6 +45,13 @@ export default function TicketsPage() {
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createForm] = Form.useForm<{
|
||||
ticketType: TicketTypeDto;
|
||||
orderNo: string;
|
||||
remark?: string;
|
||||
}>();
|
||||
|
||||
async function approve(id: string) {
|
||||
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
|
||||
@@ -50,6 +70,29 @@ export default function TicketsPage() {
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const values = await createForm.validateFields();
|
||||
setCreating(true);
|
||||
try {
|
||||
await request('/admin/tickets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ticketType: values.ticketType,
|
||||
orderNo: values.orderNo.trim(),
|
||||
remark: values.remark?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('工单已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||
{
|
||||
@@ -84,7 +127,21 @@ export default function TicketsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>工单中心</Typography.Title>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
工单中心
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
创建工单
|
||||
</Button>
|
||||
</div>
|
||||
<Form
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
@@ -137,14 +194,14 @@ export default function TicketsPage() {
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
|
||||
<>
|
||||
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}>
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => approve(String(detail.id))}>
|
||||
通过
|
||||
</Button>
|
||||
<Button danger onClick={() => reject(String(detail.id))}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
@@ -173,6 +230,44 @@ export default function TicketsPage() {
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="创建工单"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => void submitCreate()}
|
||||
confirmLoading={creating}
|
||||
destroyOnClose
|
||||
okText="提交"
|
||||
>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'REFUND' }}>
|
||||
<Form.Item
|
||||
name="ticketType"
|
||||
label="工单类型"
|
||||
rules={[{ required: true, message: '请选择类型' }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'REFUND', label: '仅退款' },
|
||||
{ value: 'RESHIPMENT', label: '破损补发' },
|
||||
{ value: 'DAMAGE_RETURN', label: '破损退货' },
|
||||
{ value: 'RETURN_REFUND', label: '退货退款' },
|
||||
{ value: 'ALERT', label: '异常' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="orderNo"
|
||||
label="订单号"
|
||||
rules={[{ required: true, message: '请填写订单号' }]}
|
||||
>
|
||||
<Input placeholder="关联订单号" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={3} placeholder="可选" maxLength={512} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
WECOM_BOT_PERMISSIONS,
|
||||
WECOM_BOT_PERMISSION_LABELS,
|
||||
WECOM_BOT_ROLE_DEFAULT_PERMISSIONS,
|
||||
WECOM_BOT_ROLE_LABELS,
|
||||
WECOM_BOT_ROLES,
|
||||
type LlmApiConfigOptionDto,
|
||||
type KnowledgeBaseOptionDto,
|
||||
type WecomBotDto,
|
||||
type WecomBotPermission,
|
||||
type WecomBotRole,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
|
||||
type ListRes = {
|
||||
items: WecomBotDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
runtime?: {
|
||||
masterEnabled: boolean;
|
||||
bots: Array<{ id: string; connected: boolean; lastError: string | null }>;
|
||||
};
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
role: WecomBotRole;
|
||||
botId: string;
|
||||
secret?: string;
|
||||
avatarUrl?: string;
|
||||
welcome?: string;
|
||||
permissions: WecomBotPermission[];
|
||||
aiEnabled: boolean;
|
||||
llmConfigId?: string | null;
|
||||
knowledgeBaseId?: string | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export default function WecomBotsPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<WecomBotDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [detail, setDetail] = useState<WecomBotDto | null>(null);
|
||||
const [runtime, setRuntime] = useState<ListRes['runtime']>();
|
||||
const [llmOptions, setLlmOptions] = useState<LlmApiConfigOptionDto[]>([]);
|
||||
const [kbOptions, setKbOptions] = useState<KnowledgeBaseOptionDto[]>([]);
|
||||
const roleWatch = Form.useWatch('role', form);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<WecomBotDto>(
|
||||
'/admin/wecom-bots',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.role) qs.set('role', filters.role);
|
||||
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// useAdminList returns items; runtime comes from same API — fetch once for banner
|
||||
void request<ListRes>('/admin/wecom-bots?page=1&pageSize=1')
|
||||
.then((res) => setRuntime(res.runtime))
|
||||
.catch(() => {});
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
void request<{
|
||||
llmConfigs: LlmApiConfigOptionDto[];
|
||||
knowledgeBases: KnowledgeBaseOptionDto[];
|
||||
}>('/admin/wecom-bots/ai-options')
|
||||
.then((res) => {
|
||||
setLlmOptions(res.llmConfigs);
|
||||
setKbOptions(res.knowledgeBases);
|
||||
})
|
||||
.catch(() => {
|
||||
setLlmOptions([]);
|
||||
setKbOptions([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
role: 'CUSTOMER_SERVICE',
|
||||
botId: '',
|
||||
secret: '',
|
||||
avatarUrl: '',
|
||||
welcome: '',
|
||||
permissions: [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS.CUSTOMER_SERVICE],
|
||||
aiEnabled: false,
|
||||
llmConfigId: null,
|
||||
knowledgeBaseId: null,
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: WecomBotDto) {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
role: row.role,
|
||||
botId: row.botId,
|
||||
secret: '',
|
||||
avatarUrl: row.avatarUrl || '',
|
||||
welcome: row.welcome || '',
|
||||
permissions: row.permissions,
|
||||
aiEnabled: row.aiEnabled,
|
||||
llmConfigId: row.llmConfigId,
|
||||
knowledgeBaseId: row.knowledgeBaseId,
|
||||
enabled: row.enabled,
|
||||
sortOrder: row.sortOrder,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: values.name.trim(),
|
||||
role: values.role,
|
||||
botId: values.botId.trim(),
|
||||
avatarUrl: values.avatarUrl?.trim() || null,
|
||||
welcome: values.welcome?.trim() || null,
|
||||
permissions: values.permissions,
|
||||
aiEnabled: values.aiEnabled,
|
||||
llmConfigId: values.llmConfigId || null,
|
||||
knowledgeBaseId: values.knowledgeBaseId || null,
|
||||
enabled: values.enabled,
|
||||
sortOrder: values.sortOrder,
|
||||
};
|
||||
if (editing) {
|
||||
await request(`/admin/wecom-bots/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
secret: values.secret?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已更新');
|
||||
} else {
|
||||
if (!values.secret?.trim()) {
|
||||
message.error('请填写 Secret');
|
||||
return;
|
||||
}
|
||||
await request('/admin/wecom-bots', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
secret: values.secret.trim(),
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
await request(`/admin/wecom-bots/${id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadConnections() {
|
||||
try {
|
||||
const st = await request<{
|
||||
masterEnabled: boolean;
|
||||
bots: Array<{ id: string; connected: boolean; lastError: string | null }>;
|
||||
}>('/admin/wecom-bots/reload', { method: 'POST', body: '{}' });
|
||||
message.success('已重载长连接');
|
||||
setRuntime(st);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '重载失败');
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b]));
|
||||
|
||||
const columns: ColumnsType<WecomBotDto> = [
|
||||
{
|
||||
title: '头像',
|
||||
dataIndex: 'avatarUrl',
|
||||
width: 64,
|
||||
render: (url: string | null, row) => (
|
||||
<Avatar src={url || undefined} shape="square" size={40}>
|
||||
{row.name.slice(0, 1)}
|
||||
</Avatar>
|
||||
),
|
||||
},
|
||||
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 120,
|
||||
render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r,
|
||||
},
|
||||
{ title: 'BotID', dataIndex: 'botId', width: 160, ellipsis: true },
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
ellipsis: true,
|
||||
render: (perms: WecomBotPermission[]) =>
|
||||
perms.map((p) => (
|
||||
<Tag key={p} style={{ marginBottom: 2 }}>
|
||||
{WECOM_BOT_PERMISSION_LABELS[p] || p}
|
||||
</Tag>
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: 'AI',
|
||||
width: 100,
|
||||
render: (_, row) =>
|
||||
row.aiEnabled ? (
|
||||
<Tag color="blue">{row.llmConfigName || '已开'}</Tag>
|
||||
) : (
|
||||
<Tag>关</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
width: 70,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '是' : '否'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '连接',
|
||||
width: 80,
|
||||
render: (_, row) => {
|
||||
const rt = runtimeMap.get(row.id);
|
||||
if (!runtime?.masterEnabled) return <Tag>总开关关</Tag>;
|
||||
if (!row.enabled) return <Tag>未启用</Tag>;
|
||||
return (
|
||||
<Tag color={rt?.connected ? 'green' : 'orange'}>{rt?.connected ? '已连接' : '未连接'}</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request<WecomBotDto>(`/admin/wecom-bots/${row.id}`));
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Popconfirm title="确认删除该机器人?" onConfirm={() => remove(row.id)}>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
企微机器人
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
并列创建多个智能机器人,配置 BotID / Secret / 权限 / 角色 / 头像。总开关在「系统设置 → 功能开关」。
|
||||
{runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button onClick={() => void reloadConnections()}>重载连接</Button>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
创建机器人
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input allowClear placeholder="名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="角色">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
options={[
|
||||
{ value: 'true', label: '是' },
|
||||
{ value: 'false', label: '否' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑企微机器人' : '创建企微机器人'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void submit()}
|
||||
confirmLoading={saving}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||
<Input placeholder="如:客服机器人" maxLength={64} />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
|
||||
onChange={(role: WecomBotRole) => {
|
||||
form.setFieldValue('permissions', [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[role]]);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="avatarUrl" label="头像">
|
||||
<OssUpload bizType="WECOM_BOT_AVATAR" />
|
||||
</Form.Item>
|
||||
<Form.Item name="botId" label="BotID" rules={[{ required: true, message: '请填写 BotID' }]}>
|
||||
<Input placeholder="企业微信后台长连接 BotID" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="secret"
|
||||
label="Secret"
|
||||
rules={editing ? [] : [{ required: true, message: '请填写 Secret' }]}
|
||||
extra={editing ? '留空表示不修改' : undefined}
|
||||
>
|
||||
<Input.Password placeholder={editing ? '留空不修改' : '长连接专用 Secret'} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="permissions"
|
||||
label="权限"
|
||||
rules={[{ required: true, message: '请至少选择一项权限' }]}
|
||||
extra={
|
||||
roleWatch
|
||||
? `角色默认:${WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[roleWatch as WecomBotRole]?.map((p) => WECOM_BOT_PERMISSION_LABELS[p]).join('、') || '无'}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Checkbox.Group
|
||||
options={WECOM_BOT_PERMISSIONS.map((p) => ({
|
||||
value: p,
|
||||
label: WECOM_BOT_PERMISSION_LABELS[p],
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="welcome" label="欢迎语">
|
||||
<Input.TextArea rows={3} placeholder="进入会话时的欢迎语,可空" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="aiEnabled"
|
||||
label="启用 AI 问答"
|
||||
valuePropName="checked"
|
||||
extra="开启后,未匹配指令的自然语言将调用绑定的语言模型(可挂知识库)"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="llmConfigId" label="语言模型">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="选择已生效的配置"
|
||||
options={llmOptions.map((o) => ({
|
||||
value: o.id,
|
||||
label: `${o.name}(${o.modelName})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="knowledgeBaseId" label="知识库">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="可选,供 AI 检索"
|
||||
options={kbOptions.map((o) => ({
|
||||
value: o.id,
|
||||
label: `${o.name}(${o.documentCount} 篇)`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Space size="large">
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序">
|
||||
<InputNumber style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer title="机器人详情" width={480} open={!!detail} onClose={() => setDetail(null)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="头像">
|
||||
<Avatar src={detail.avatarUrl || undefined} size={64} shape="square">
|
||||
{detail.name.slice(0, 1)}
|
||||
</Avatar>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="角色">
|
||||
{WECOM_BOT_ROLE_LABELS[detail.role] || detail.role}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="BotID">{detail.botId}</Descriptions.Item>
|
||||
<Descriptions.Item label="Secret">
|
||||
{detail.secretConfigured ? '已配置' : '未配置'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="权限">
|
||||
{detail.permissions.map((p) => WECOM_BOT_PERMISSION_LABELS[p] || p).join('、')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="欢迎语">{detail.welcome || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="AI 问答">{detail.aiEnabled ? '开' : '关'}</Descriptions.Item>
|
||||
<Descriptions.Item label="语言模型">{detail.llmConfigName || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="知识库">{detail.knowledgeBaseName || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="启用">{detail.enabled ? '是' : '否'}</Descriptions.Item>
|
||||
<Descriptions.Item label="排序">{detail.sortOrder}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="更新">{fmtTime(detail.updatedAt)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
@@ -14,24 +18,44 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
|
||||
import {
|
||||
WINERY_SETTLEMENT_RATE,
|
||||
type SystemConfigFormResponse,
|
||||
} from '@dukang/shared-types';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { request } from '../lib/api';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
orderId: string;
|
||||
orderNo: string;
|
||||
deliveryType: string;
|
||||
cityName?: string;
|
||||
receiverCity?: string;
|
||||
payAmount: number;
|
||||
id: string;
|
||||
billNo: string;
|
||||
billDate: string;
|
||||
orderCount: number;
|
||||
orderAmount: number;
|
||||
wineryRate: number;
|
||||
wineryAmount: number;
|
||||
status: string;
|
||||
paidAt?: string | null;
|
||||
};
|
||||
|
||||
type BillItem = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
deliveryType: string;
|
||||
payAmount: number;
|
||||
wineryAmount: number;
|
||||
paidAt: string;
|
||||
productName?: string;
|
||||
quantity?: number;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
UNPAID: 'red',
|
||||
PAID: 'green',
|
||||
};
|
||||
|
||||
const DELIVERY_LABELS: Record<string, string> = {
|
||||
@@ -39,57 +63,146 @@ const DELIVERY_LABELS: Record<string, string> = {
|
||||
CROSS_CITY: '跨城',
|
||||
};
|
||||
|
||||
const WINERY_BANK_KEYS = [
|
||||
'WINERY_BANK_ACCOUNT_NAME',
|
||||
'WINERY_BANK_NAME',
|
||||
'WINERY_BANK_BRANCH',
|
||||
'WINERY_BANK_ACCOUNT_NO',
|
||||
] as const;
|
||||
|
||||
export default function WineryBillsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [bankForm] = Form.useForm<Record<string, string>>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/winery-bills',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
|
||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const [detail, setDetail] = useState<(Row & { items?: BillItem[] }) | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [bankOpen, setBankOpen] = useState(false);
|
||||
const [bankLoading, setBankLoading] = useState(false);
|
||||
const [bankSaving, setBankSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const canEditWineryBank =
|
||||
profile?.adminRole === 'SUPER_ADMIN' ||
|
||||
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
|
||||
|
||||
function confirmPay(ids: string[], amountHint?: number) {
|
||||
Modal.confirm({
|
||||
title: '确认打款?',
|
||||
content: `将确认 ${ids.length} 笔酒厂对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
||||
okText: '确认打款',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (ids.length === 1) {
|
||||
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||
} else {
|
||||
await request('/admin/winery-bills/batch-confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
message.success('已确认打款');
|
||||
setSelectedKeys([]);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const d = await request<Row & { items?: BillItem[] }>(`/admin/winery-bills/${id}`);
|
||||
setDetail(d);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
setExporting(true);
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
|
||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||
const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`);
|
||||
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
||||
downloadExcelCsv(result.csv, `酒厂账单_${suffix}.csv`);
|
||||
downloadExcelCsv(result.csv, `酒厂对账单_${suffix}.csv`);
|
||||
message.success(`已导出 ${result.count} 条`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openBankModal() {
|
||||
setBankOpen(true);
|
||||
setBankLoading(true);
|
||||
try {
|
||||
const cfg = await request<SystemConfigFormResponse>('/admin/system-config');
|
||||
const values: Record<string, string> = {};
|
||||
for (const key of WINERY_BANK_KEYS) {
|
||||
values[key] = cfg.values[key] ?? '';
|
||||
}
|
||||
bankForm.setFieldsValue(values);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载失败');
|
||||
setBankOpen(false);
|
||||
} finally {
|
||||
setBankLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBank() {
|
||||
const values = await bankForm.validateFields();
|
||||
setBankSaving(true);
|
||||
try {
|
||||
await request('/admin/system-config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ values }),
|
||||
});
|
||||
message.success('酒厂银行账户已保存');
|
||||
setBankOpen(false);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setBankSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = data?.summary;
|
||||
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
|
||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 180, ellipsis: true },
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||
{
|
||||
title: '配送类型',
|
||||
dataIndex: 'deliveryType',
|
||||
width: 90,
|
||||
render: (v) => <Tag>{DELIVERY_LABELS[v] || v}</Tag>,
|
||||
},
|
||||
{ title: '开城城市', dataIndex: 'cityName', width: 100, render: (v) => v || '—' },
|
||||
{ title: '收货城市', dataIndex: 'receiverCity', width: 100, render: (v) => v || '—' },
|
||||
{ title: '商品', dataIndex: 'productName', width: 140, ellipsis: true },
|
||||
{ title: '数量', dataIndex: 'quantity', width: 70 },
|
||||
{
|
||||
title: '酒单实付',
|
||||
dataIndex: 'payAmount',
|
||||
title: '账单日',
|
||||
dataIndex: 'billDate',
|
||||
width: 110,
|
||||
render: (v) => String(v || '').slice(0, 10),
|
||||
},
|
||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||
{
|
||||
title: '酒单实付合计',
|
||||
dataIndex: 'orderAmount',
|
||||
width: 120,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
@@ -105,30 +218,62 @@ export default function WineryBillsPage() {
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '支付时间',
|
||||
dataIndex: 'paidAt',
|
||||
width: 170,
|
||||
render: (v) => (v ? fmtTime(v) : '—'),
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
明细
|
||||
</Button>
|
||||
{row.status === 'UNPAID' && (
|
||||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
|
||||
确认打款
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
酒厂账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
T+30 结算:按自然月列出同城/跨城已付酒单,应付 = 酒单实付 × {ratePct}%(暂定)
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
酒厂对账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{canEditWineryBank ? (
|
||||
<Button type="default" onClick={() => void openBankModal()}>
|
||||
酒厂银行账户信息配置
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space size="large" wrap>
|
||||
<Statistic title="酒单数" value={summary.count} />
|
||||
<Statistic title="账单数" value={summary.count} />
|
||||
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? summary.totalAmount} prefix="¥" precision={2} />
|
||||
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
@@ -137,28 +282,30 @@ export default function WineryBillsPage() {
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v: { month?: Dayjs; deliveryType?: string }) => {
|
||||
onFinish={(v: { status?: string; month?: Dayjs; range?: [Dayjs, Dayjs] }) => {
|
||||
setFilters({
|
||||
status: v.status || '',
|
||||
year: v.month ? String(v.month.year()) : '',
|
||||
month: v.month ? String(v.month.month() + 1) : '',
|
||||
deliveryType: v.deliveryType || '',
|
||||
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
|
||||
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="month" label="账期月">
|
||||
<DatePicker picker="month" />
|
||||
</Form.Item>
|
||||
<Form.Item name="deliveryType" label="配送类型">
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'LOCAL', label: '同城' },
|
||||
{ value: 'CROSS_CITY', label: '跨城' },
|
||||
]}
|
||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="month" label="账期月">
|
||||
<DatePicker picker="month" />
|
||||
</Form.Item>
|
||||
<Form.Item name="range" label="账单日">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
@@ -180,15 +327,29 @@ export default function WineryBillsPage() {
|
||||
导出 Excel
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!selectedKeys.length}
|
||||
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
|
||||
>
|
||||
批量确认打款 ({selectedKeys.length})
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="orderId"
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -200,6 +361,93 @@ export default function WineryBillsPage() {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer title="酒厂对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={640}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
|
||||
<Descriptions.Item label="酒厂应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
|
||||
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
订单明细
|
||||
</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={detail.items ?? []}
|
||||
columns={[
|
||||
{ title: '订单号', dataIndex: 'orderNo', ellipsis: true },
|
||||
{
|
||||
title: '配送',
|
||||
dataIndex: 'deliveryType',
|
||||
width: 70,
|
||||
render: (v) => DELIVERY_LABELS[v] || v,
|
||||
},
|
||||
{
|
||||
title: '实付',
|
||||
dataIndex: 'payAmount',
|
||||
width: 90,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '酒厂应付',
|
||||
dataIndex: 'wineryAmount',
|
||||
width: 90,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '支付时间',
|
||||
dataIndex: 'paidAt',
|
||||
width: 150,
|
||||
render: (v) => (v ? fmtTime(v) : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="酒厂银行账户信息配置"
|
||||
open={bankOpen}
|
||||
onCancel={() => setBankOpen(false)}
|
||||
onOk={() => void saveBank()}
|
||||
confirmLoading={bankSaving}
|
||||
destroyOnClose
|
||||
okText="保存"
|
||||
>
|
||||
<Form form={bankForm} layout="vertical" disabled={bankLoading}>
|
||||
<Form.Item
|
||||
name="WINERY_BANK_ACCOUNT_NAME"
|
||||
label="户名"
|
||||
rules={[{ required: true, message: '请填写户名' }]}
|
||||
>
|
||||
<Input placeholder="收款账户户名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="WINERY_BANK_NAME"
|
||||
label="开户银行"
|
||||
rules={[{ required: true, message: '请填写开户银行' }]}
|
||||
>
|
||||
<Input placeholder="如:中国工商银行" />
|
||||
</Form.Item>
|
||||
<Form.Item name="WINERY_BANK_BRANCH" label="开户支行">
|
||||
<Input placeholder="可选" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="WINERY_BANK_ACCOUNT_NO"
|
||||
label="银行账号"
|
||||
rules={[{ required: true, message: '请填写银行账号' }]}
|
||||
>
|
||||
<Input placeholder="银行卡号" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
@@ -25,6 +25,15 @@ import { request } from '../../lib/api';
|
||||
import { fmtTime } from '../../lib/constants';
|
||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||
|
||||
const descLabelStyle: CSSProperties = {
|
||||
whiteSpace: 'nowrap',
|
||||
width: 108,
|
||||
};
|
||||
|
||||
const descContentStyle: CSSProperties = {
|
||||
wordBreak: 'break-all',
|
||||
};
|
||||
|
||||
async function downloadQrcode(url: string, filename: string) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
@@ -72,46 +81,25 @@ export default function PromoCodeDetailPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="推广二维码" size="small">
|
||||
{detail.qrcodeUrl ? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img
|
||||
src={detail.qrcodeUrl}
|
||||
alt="推广二维码"
|
||||
style={{ width: 200, height: 200, marginBottom: 12 }}
|
||||
/>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Button
|
||||
block
|
||||
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-qrcode.png`)}
|
||||
>
|
||||
下载二维码
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无二维码</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={16}>
|
||||
<Row gutter={[16, 16]} align="top">
|
||||
<Col flex="1 1 480px" style={{ minWidth: 0 }}>
|
||||
<Card
|
||||
title="基础信息"
|
||||
size="small"
|
||||
styles={{ body: { paddingTop: 12 } }}
|
||||
extra={(
|
||||
<Space>
|
||||
<Button size="small" onClick={() => {
|
||||
editForm.setFieldsValue({
|
||||
name: detail.name,
|
||||
scene: detail.scene,
|
||||
remark: detail.remark,
|
||||
ownerUserId: detail.ownerUser?.id,
|
||||
});
|
||||
setEditOpen(true);
|
||||
}}
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
editForm.setFieldsValue({
|
||||
name: detail.name,
|
||||
scene: detail.scene,
|
||||
remark: detail.remark,
|
||||
ownerUserId: detail.ownerUser?.id,
|
||||
});
|
||||
setEditOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
@@ -135,7 +123,18 @@ export default function PromoCodeDetailPage() {
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Descriptions column={{ xs: 1, sm: 2 }} bordered size="small">
|
||||
<Descriptions
|
||||
column={2}
|
||||
bordered
|
||||
size="small"
|
||||
layout="horizontal"
|
||||
labelStyle={descLabelStyle}
|
||||
contentStyle={descContentStyle}
|
||||
styles={{
|
||||
label: descLabelStyle,
|
||||
content: descContentStyle,
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="码值">{detail.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="场景">
|
||||
@@ -144,34 +143,81 @@ export default function PromoCodeDetailPage() {
|
||||
<Descriptions.Item label="状态">
|
||||
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="二维码 ID" span={2}>
|
||||
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text>
|
||||
<Descriptions.Item label="活动 ID">
|
||||
<Typography.Text copyable={{ text: String(detail.id) }} style={{ whiteSpace: 'nowrap' }}>
|
||||
{detail.id}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="落地链接" span={2}>
|
||||
<Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</Typography.Text>
|
||||
<Descriptions.Item label="二维码 ID">
|
||||
<Typography.Text copyable={{ text: detail.qrcodeId }} style={{ whiteSpace: 'nowrap' }}>
|
||||
{detail.qrcodeId}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="OSS 地址" span={2}>
|
||||
<Descriptions.Item label="小程序码 OSS" span={2}>
|
||||
{detail.qrcodeUrl ? (
|
||||
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis>
|
||||
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis style={{ maxWidth: '100%' }}>
|
||||
{detail.qrcodeUrl}
|
||||
</Typography.Text>
|
||||
) : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="渠道负责人">
|
||||
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">{fmtTime(detail.updatedAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.createdAt)}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.updatedAt)}</span>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col flex="0 0 220px">
|
||||
<Card title="小程序码" size="small" styles={{ body: { textAlign: 'center', padding: 12 } }}>
|
||||
{detail.qrcodeUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={detail.qrcodeUrl}
|
||||
alt="推广小程序码"
|
||||
style={{ width: 168, height: 168, display: 'block', margin: '0 auto 8px' }}
|
||||
/>
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
style={{ marginBottom: 8, fontSize: 12, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
scene={detail.id}
|
||||
</Typography.Paragraph>
|
||||
<Button
|
||||
block
|
||||
size="small"
|
||||
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-wxacode.png`)}
|
||||
>
|
||||
下载小程序码
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无小程序码</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="扫码次数" value={stats?.scanCount ?? detail.scanCount} />
|
||||
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="扫码注册用户数"
|
||||
value={stats?.registerCount ?? stats?.sourceMarkedCount ?? 0}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
@@ -187,11 +233,6 @@ export default function PromoCodeDetailPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="来源标记用户" value={stats?.sourceMarkedCount ?? '—'} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -8,6 +8,19 @@ export type StoreDraftForm = {
|
||||
phone: string;
|
||||
storeSmsCode: 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;
|
||||
coverUrl: string;
|
||||
envPhotoUrls: string[];
|
||||
@@ -37,6 +50,16 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
phone: '',
|
||||
storeSmsCode: '',
|
||||
address: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
openTime: '10:00',
|
||||
closeTime: '22:00',
|
||||
dualHours: false,
|
||||
openTime2: '17:00',
|
||||
closeTime2: '21:00',
|
||||
avgPrice: '',
|
||||
categoryParentId: '',
|
||||
categoryId: '',
|
||||
intro: '',
|
||||
coverUrl: '',
|
||||
envPhotoUrls: ['', '', ''],
|
||||
@@ -46,35 +69,38 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
bankBranch: '',
|
||||
});
|
||||
|
||||
export function normalizeStringArray(raw: unknown, length: number): string[] {
|
||||
if (!Array.isArray(raw)) return Array.from({ length }, () => '');
|
||||
const items = raw.map((item) => String(item ?? ''));
|
||||
while (items.length < length) items.push('');
|
||||
return items.slice(0, length);
|
||||
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;
|
||||
}
|
||||
|
||||
function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
|
||||
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 normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | undefined): StoreDraftForm {
|
||||
const base = defaultStoreForm();
|
||||
const regionCodes = Array.isArray(raw.regionCodes) && raw.regionCodes.length >= 3
|
||||
? raw.regionCodes.map((code) => String(code))
|
||||
: base.regionCodes;
|
||||
if (!raw) return base;
|
||||
return {
|
||||
regionCodes,
|
||||
...base,
|
||||
...raw,
|
||||
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
|
||||
cityId: String(raw.cityId ?? base.cityId),
|
||||
province: String(raw.province ?? base.province),
|
||||
city: String(raw.city ?? base.city),
|
||||
district: String(raw.district ?? base.district),
|
||||
name: String(raw.name ?? base.name),
|
||||
phone: String(raw.phone ?? base.phone),
|
||||
storeSmsCode: String(raw.storeSmsCode ?? base.storeSmsCode),
|
||||
address: String(raw.address ?? base.address),
|
||||
intro: String(raw.intro ?? base.intro),
|
||||
coverUrl: String(raw.coverUrl ?? base.coverUrl),
|
||||
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, 3),
|
||||
contractUrl: String(raw.contractUrl ?? base.contractUrl),
|
||||
bankAccountName: String(raw.bankAccountName ?? base.bankAccountName),
|
||||
bankAccountNo: String(raw.bankAccountNo ?? base.bankAccountNo),
|
||||
bankBranch: String(raw.bankBranch ?? base.bankBranch),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,14 +116,11 @@ export function loadStoreDraft(accountId?: string): StoreDraft | null {
|
||||
}
|
||||
}
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (parsed.form && typeof parsed.form === 'object') {
|
||||
return {
|
||||
step: Math.min(3, Math.max(1, Number(parsed.step) || 1)),
|
||||
form: normalizeForm(parsed.form as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
return { step: 1, form: normalizeForm(parsed) };
|
||||
const parsed = JSON.parse(raw) as StoreDraft;
|
||||
return {
|
||||
step: Number(parsed.step) || 0,
|
||||
form: normalizeStoreDraftForm(parsed.form),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -117,27 +140,55 @@ export function clearAllStoreDrafts(accountId?: string) {
|
||||
localStorage.removeItem(STORE_DRAFT_KEY);
|
||||
}
|
||||
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
const BANK_RE = /^\d{16,19}$/;
|
||||
|
||||
export function validateStoreStep1(
|
||||
form: Pick<
|
||||
StoreDraftForm,
|
||||
'regionCodes' | 'cityId' | 'name' | 'phone' | 'storeSmsCode' | 'address' | 'intro'
|
||||
| 'regionCodes'
|
||||
| 'cityId'
|
||||
| 'name'
|
||||
| 'address'
|
||||
| 'openTime'
|
||||
| 'closeTime'
|
||||
| 'dualHours'
|
||||
| 'openTime2'
|
||||
| 'closeTime2'
|
||||
| 'avgPrice'
|
||||
| 'categoryId'
|
||||
| 'intro'
|
||||
>,
|
||||
): string | null {
|
||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||
if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划';
|
||||
if (!form.name.trim()) return '请填写门店名称';
|
||||
if (!form.phone.trim()) return '请填写联系电话';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
||||
if (!form.storeSmsCode.trim()) return '请输入门店手机号验证码';
|
||||
if (!/^\d{4,6}$/.test(form.storeSmsCode.trim())) return '验证码格式不正确';
|
||||
if (!form.address.trim()) return '请填写详细地址';
|
||||
if (form.intro.trim()) {
|
||||
const len = form.intro.trim().length;
|
||||
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
|
||||
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 字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -158,11 +209,18 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
|
||||
}
|
||||
|
||||
export function validateStoreStep3(
|
||||
form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>,
|
||||
form: Pick<
|
||||
StoreDraftForm,
|
||||
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'storeSmsCode'
|
||||
>,
|
||||
): 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位手机号';
|
||||
if (!form.storeSmsCode.trim()) return '请输入门店手机号验证码';
|
||||
if (!/^\d{4,6}$/.test(form.storeSmsCode.trim())) return '验证码格式不正确';
|
||||
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)}`;
|
||||
}
|
||||
@@ -20,8 +20,8 @@ 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 '关闭';
|
||||
if (s === 'PAUSED') return '临时闭店';
|
||||
if (s === 'CLOSED') return '永久关闭';
|
||||
return status;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -11,16 +12,16 @@ function fmtMoney(n: number) {
|
||||
|
||||
function billStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'DRAFT': return '待确认';
|
||||
case 'CONFIRMED': return '审核中';
|
||||
case 'PAID': return '已结算';
|
||||
case 'AWAITING_CONFIRM': return '待确认';
|
||||
case 'UNPAID': return '未打款';
|
||||
case 'PAID': return '已打款';
|
||||
case 'REJECTED': return '已驳回';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
function canApplyPayment(status: string) {
|
||||
return status === 'DRAFT' || status === 'REJECTED';
|
||||
function canConfirm(status: string) {
|
||||
return status === 'AWAITING_CONFIRM';
|
||||
}
|
||||
|
||||
export default function BillsPage() {
|
||||
@@ -30,6 +31,7 @@ export default function BillsPage() {
|
||||
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);
|
||||
@@ -50,7 +52,7 @@ export default function BillsPage() {
|
||||
|
||||
const billId = searchParams.get('id');
|
||||
const actionable = useMemo(
|
||||
() => bills.filter((b) => canApplyPayment(b.status)),
|
||||
() => bills.filter((b) => canConfirm(b.status)),
|
||||
[bills],
|
||||
);
|
||||
const bill = billId
|
||||
@@ -58,20 +60,32 @@ export default function BillsPage() {
|
||||
: actionable[0] ?? bills[0];
|
||||
|
||||
const status = String(bill?.status || '');
|
||||
const showApplyForm = !!bill && canApplyPayment(status);
|
||||
const isReviewing = status === 'CONFIRMED';
|
||||
const showApplyForm = !!bill && canConfirm(status);
|
||||
const isUnpaid = status === 'UNPAID';
|
||||
const isRejected = status === 'REJECTED';
|
||||
const isPaid = status === 'PAID';
|
||||
|
||||
async function confirmBill() {
|
||||
if (!bill || !confirmed || !canApplyPayment(String(bill.status))) return;
|
||||
function askConfirm(ids: string[]) {
|
||||
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
|
||||
return;
|
||||
}
|
||||
void doConfirm(ids);
|
||||
}
|
||||
|
||||
async function doConfirm(ids: string[]) {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request('PARTNER_H5', `/partner/settlement/bills/${bill.id}/confirm`, {
|
||||
method: 'POST',
|
||||
});
|
||||
toastSuccess('申请已提交,请耐心等待总部打款审核');
|
||||
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 : '提交失败');
|
||||
@@ -80,8 +94,12 @@ export default function BillsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="partner-bills-page">
|
||||
<PullToRefresh onRefresh={loadBills} className="partner-bills-page">
|
||||
<PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
|
||||
|
||||
<div className="partner-bill-stepper">
|
||||
@@ -89,7 +107,7 @@ export default function BillsPage() {
|
||||
<div className="partner-stepper-line" aria-hidden>
|
||||
<div
|
||||
className="partner-stepper-line-fill"
|
||||
style={{ width: isPaid ? '100%' : isReviewing || isRejected ? '75%' : '50%' }}
|
||||
style={{ width: isPaid ? '100%' : isUnpaid || isRejected ? '75%' : '50%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="partner-step">
|
||||
@@ -99,7 +117,7 @@ export default function BillsPage() {
|
||||
<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 || isReviewing || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
|
||||
<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>
|
||||
)}
|
||||
@@ -107,20 +125,20 @@ export default function BillsPage() {
|
||||
<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' : isReviewing || isRejected ? ' partner-step-circle--active' : ''}`}>
|
||||
<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${isReviewing || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
|
||||
{isRejected ? '已驳回' : isPaid ? '已打款' : '申请打款'}
|
||||
<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>}
|
||||
{!loading && !bill && <div className="empty">暂无待确认账单</div>}
|
||||
|
||||
{bill && (
|
||||
<section className="partner-bill-card">
|
||||
@@ -131,7 +149,7 @@ export default function BillsPage() {
|
||||
<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' : isReviewing ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
|
||||
<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>
|
||||
@@ -140,17 +158,17 @@ export default function BillsPage() {
|
||||
<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-primary" style={{ fontWeight: 600, marginBottom: 4 }}>账单已驳回</p>
|
||||
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>{bill.rejectReason}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isReviewing && (
|
||||
{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>
|
||||
)}
|
||||
@@ -177,29 +195,60 @@ export default function BillsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{bills.length > 1 && (
|
||||
{bills.length > 0 && (
|
||||
<div style={{ padding: '0 20px' }}>
|
||||
<h3 className="headline-md" style={{ marginBottom: 12 }}>全部账单</h3>
|
||||
{bills.map((b) => (
|
||||
<button
|
||||
key={String(b.id)}
|
||||
type="button"
|
||||
className="partner-store-card"
|
||||
style={{ margin: '0 0 12px', width: '100%', textAlign: 'left', cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/center/bills?id=${b.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 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>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -208,9 +257,7 @@ export default function BillsPage() {
|
||||
<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 }}>
|
||||
{isRejected
|
||||
? '请根据驳回理由核对后重新确认并申请打款。'
|
||||
: '账单确认后将正式进入打款审核。如有异议,请在确认前联系城市运营经理核实数据。'}
|
||||
确认后账单变为「未打款」,由总部完成打款。如有异议请先联系城市运营经理。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -224,14 +271,14 @@ export default function BillsPage() {
|
||||
className="partner-btn-primary"
|
||||
disabled={!confirmed || submitting}
|
||||
style={{ opacity: confirmed ? 1 : 0.5 }}
|
||||
onClick={() => void confirmBill()}
|
||||
onClick={() => askConfirm([String(bill!.id)])}
|
||||
>
|
||||
{submitting ? '正在提交...' : isRejected ? '重新申请打款' : '确认并申请打款'}
|
||||
{submitting ? '正在提交...' : '确认账单'}
|
||||
{!submitting && <span className="material-symbols-outlined">payments</span>}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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';
|
||||
@@ -36,18 +37,27 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
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(() => {
|
||||
if (!isPrimary) return;
|
||||
void request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
|
||||
.then(setBills)
|
||||
.catch(() => setBills([]));
|
||||
void listPartnerStaff()
|
||||
.then((list) => setStaffCount(list.length))
|
||||
.catch(() => setStaffCount(0));
|
||||
}, [isPrimary]);
|
||||
void loadCenter();
|
||||
}, [loadCenter]);
|
||||
|
||||
const finance = useMemo(() => {
|
||||
const pending = bills.filter((b) => b.status === 'DRAFT' || b.status === 'REJECTED');
|
||||
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;
|
||||
@@ -55,7 +65,7 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
pendingBillCount: pending.length,
|
||||
rejectedCount,
|
||||
pendingTotal: bills
|
||||
.filter((b) => b.status === 'DRAFT' || b.status === 'CONFIRMED' || b.status === 'REJECTED')
|
||||
.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),
|
||||
@@ -109,7 +119,7 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员');
|
||||
|
||||
return (
|
||||
<div className="page partner-center-page partner-home--flush-top">
|
||||
<PullToRefresh onRefresh={loadCenter} className="page partner-center-page partner-home--flush-top">
|
||||
<section className="partner-profile-card">
|
||||
<button
|
||||
type="button"
|
||||
@@ -286,6 +296,6 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
<div className="partner-center-footer">
|
||||
<p className="label-md text-muted">传承千年 · 杜康好客</p>
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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 { isLoggedIn, request } from '../lib/api';
|
||||
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||
import {
|
||||
@@ -155,45 +156,62 @@ export default function HomePage() {
|
||||
document.title = '工作台';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
// 等 session 带上账号后再按权限发请求,避免无权限接口弹错
|
||||
if (!account) return;
|
||||
const loadHome = useCallback(() => {
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!account) return Promise.resolve();
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
|
||||
if (canOrders) {
|
||||
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
|
||||
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
|
||||
.catch(() => setOrders([]));
|
||||
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) {
|
||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
|
||||
.then(setDash)
|
||||
.catch(() => setDash(null));
|
||||
tasks.push(
|
||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
|
||||
.then(setDash)
|
||||
.catch(() => setDash(null)),
|
||||
);
|
||||
} else {
|
||||
setDash(null);
|
||||
}
|
||||
|
||||
if (canStores) {
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
|
||||
.then((data) => setStores(Array.isArray(data) ? data : []))
|
||||
.catch(() => setStores([]));
|
||||
tasks.push(
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
|
||||
.then((data) => setStores(Array.isArray(data) ? data : []))
|
||||
.catch(() => setStores([])),
|
||||
);
|
||||
} else {
|
||||
setStores([]);
|
||||
}
|
||||
|
||||
// 主账号与全部子账号均可查看同团队贡献榜(后端不校验业务权限点)
|
||||
fetchPartnerLeaderboard('month')
|
||||
.then((data) => {
|
||||
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
|
||||
})
|
||||
.catch(() => {
|
||||
setLeaderboardEntries([]);
|
||||
});
|
||||
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);
|
||||
@@ -219,7 +237,7 @@ export default function HomePage() {
|
||||
const profit = revenue * 0.25;
|
||||
|
||||
return (
|
||||
<div className="page partner-home partner-home--flush-top">
|
||||
<PullToRefresh onRefresh={loadHome} className="page partner-home partner-home--flush-top">
|
||||
<main className="partner-home-body">
|
||||
{isPrimary && (
|
||||
<>
|
||||
@@ -346,6 +364,6 @@ export default function HomePage() {
|
||||
|
||||
<LeaderboardPreview entries={leaderboardEntries} />
|
||||
</main>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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';
|
||||
@@ -33,10 +34,10 @@ export default function LeaderboardPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const loadLeaderboard = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
fetchPartnerLeaderboard(period)
|
||||
return fetchPartnerLeaderboard(period)
|
||||
.then(setData)
|
||||
.catch((e) => {
|
||||
setData(null);
|
||||
@@ -45,8 +46,12 @@ export default function LeaderboardPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [period]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadLeaderboard();
|
||||
}, [loadLeaderboard]);
|
||||
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PullToRefresh onRefresh={loadLeaderboard} className="page-no-tab">
|
||||
<PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
|
||||
|
||||
<div className="partner-leaderboard-tabs">
|
||||
@@ -124,6 +129,6 @@ export default function LeaderboardPage() {
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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';
|
||||
@@ -59,14 +60,17 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
document.title = '订单管理';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
const loadOrders = useCallback(() => {
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!warehouseOk) {
|
||||
setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' });
|
||||
setLoaded(true);
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
request<OrdersResponse>('PARTNER_H5', '/partner/orders')
|
||||
return request<OrdersResponse>('PARTNER_H5', '/partner/orders')
|
||||
.then((res) => {
|
||||
setData({
|
||||
list: Array.isArray(res.list) ? res.list : [],
|
||||
@@ -78,6 +82,10 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
.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();
|
||||
@@ -142,7 +150,7 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
|
||||
<PullToRefresh onRefresh={loadOrders} className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
|
||||
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
|
||||
|
||||
<div className="partner-segment">
|
||||
@@ -265,6 +273,6 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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';
|
||||
|
||||
@@ -12,9 +13,9 @@ function fmtMoney(n: number) {
|
||||
|
||||
function billStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'DRAFT': return '待结算';
|
||||
case 'CONFIRMED': return '审核中';
|
||||
case 'PAID': return '已结算';
|
||||
case 'AWAITING_CONFIRM': return '待确认';
|
||||
case 'UNPAID': return '未打款';
|
||||
case 'PAID': return '已打款';
|
||||
case 'REJECTED': return '已驳回';
|
||||
default: return status;
|
||||
}
|
||||
@@ -22,8 +23,8 @@ function billStatusLabel(status: string) {
|
||||
|
||||
function billStatusClass(status: string) {
|
||||
switch (status) {
|
||||
case 'DRAFT': return 'partner-settlement-status--pending';
|
||||
case 'CONFIRMED': return 'partner-settlement-status--reviewing';
|
||||
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 '';
|
||||
@@ -47,13 +48,17 @@ export default function SettlementPage() {
|
||||
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; }
|
||||
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
||||
}, [navigate]);
|
||||
void loadBills();
|
||||
}, [navigate, loadBills]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const pending = bills.filter((b) => b.status === 'DRAFT');
|
||||
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);
|
||||
@@ -72,9 +77,9 @@ export default function SettlementPage() {
|
||||
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' || b.status === 'REJECTED';
|
||||
if (statusFilter === 'pending') return b.status === 'AWAITING_CONFIRM';
|
||||
if (statusFilter === 'settled') return b.status === 'PAID';
|
||||
if (statusFilter === 'reviewing') return b.status === 'CONFIRMED';
|
||||
if (statusFilter === 'reviewing') return b.status === 'UNPAID';
|
||||
if (statusFilter === 'rejected') return b.status === 'REJECTED';
|
||||
return true;
|
||||
});
|
||||
@@ -95,14 +100,14 @@ export default function SettlementPage() {
|
||||
|
||||
const statusTabs: Array<{ key: StatusFilter; label: string }> = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'pending', label: '待结算' },
|
||||
{ key: 'reviewing', label: '审核中' },
|
||||
{ key: 'pending', label: '待确认' },
|
||||
{ key: 'reviewing', label: '未打款' },
|
||||
{ key: 'rejected', label: '已驳回' },
|
||||
{ key: 'settled', label: '已结算' },
|
||||
{ key: 'settled', label: '已打款' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page-no-tab partner-settlement-page">
|
||||
<PullToRefresh onRefresh={loadBills} className="page-no-tab partner-settlement-page">
|
||||
<PageHeader title="财务对账中心" onBack={() => navigate('/')} />
|
||||
|
||||
<main className="partner-settlement-body">
|
||||
@@ -197,7 +202,7 @@ export default function SettlementPage() {
|
||||
驳回:{bill.rejectReason}
|
||||
</p>
|
||||
) : null}
|
||||
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
|
||||
<p className={`partner-settlement-item-amount${bill.status === 'AWAITING_CONFIRM' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
|
||||
¥{fmtMoney(Number(bill.totalAmount || 0))}
|
||||
</p>
|
||||
</div>
|
||||
@@ -211,6 +216,6 @@ export default function SettlementPage() {
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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,
|
||||
@@ -86,7 +87,7 @@ export default function StaffListPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-no-tab partner-staff-page">
|
||||
<PullToRefresh onRefresh={loadStaff} className="page-no-tab partner-staff-page">
|
||||
<PageHeader title="子账号管理" onBack={() => navigate('/center')} />
|
||||
|
||||
<div style={{ padding: '0 20px 16px' }}>
|
||||
@@ -151,6 +152,6 @@ export default function StaffListPage() {
|
||||
添加子账号
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useEffect, useMemo, useRef, 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';
|
||||
@@ -15,6 +13,8 @@ import { resolveRegionBinding } from '../lib/china-region';
|
||||
|
||||
import { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone';
|
||||
|
||||
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
|
||||
|
||||
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
||||
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
@@ -45,14 +45,15 @@ import {
|
||||
|
||||
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
||||
|
||||
|
||||
type StoreCategoryNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: StoreCategoryNode[];
|
||||
};
|
||||
|
||||
type FieldErrors = {
|
||||
|
||||
phone?: string;
|
||||
|
||||
storeSmsCode?: string;
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -93,8 +94,6 @@ export default function StoreCreatePage() {
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [checkingPhone, setCheckingPhone] = useState(false);
|
||||
|
||||
const [cities, setCities] = useState<OpenCityOption[]>([]);
|
||||
|
||||
const [citiesError, setCitiesError] = useState('');
|
||||
@@ -103,6 +102,10 @@ export default function StoreCreatePage() {
|
||||
|
||||
const [smsHint, setSmsHint] = useState('');
|
||||
|
||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||
|
||||
const [locating, setLocating] = useState(false);
|
||||
|
||||
const draftSaveDisabledRef = useRef(false);
|
||||
|
||||
function reportFormError(message: string) {
|
||||
@@ -153,6 +156,26 @@ export default function StoreCreatePage() {
|
||||
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void request<StoreCategoryNode[]>('PARTNER_H5', '/partner/store-categories')
|
||||
.then((list) => {
|
||||
const tree = Array.isArray(list) ? list : [];
|
||||
setCategoryTree(tree);
|
||||
if (form.categoryId && !form.categoryParentId) {
|
||||
const parent = tree.find((root) =>
|
||||
(root.children ?? []).some((child) => child.id === form.categoryId),
|
||||
);
|
||||
if (parent) patchForm({ categoryParentId: parent.id });
|
||||
}
|
||||
})
|
||||
.catch(() => setCategoryTree([]));
|
||||
}, []);
|
||||
|
||||
const categoryChildren = useMemo(() => {
|
||||
const parent = categoryTree.find((item) => item.id === form.categoryParentId);
|
||||
return Array.isArray(parent?.children) ? parent!.children! : [];
|
||||
}, [categoryTree, form.categoryParentId]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -354,54 +377,10 @@ export default function StoreCreatePage() {
|
||||
const msg = validateStoreStep1(form);
|
||||
|
||||
if (msg) {
|
||||
if (isPhoneValidationMessage(msg)) {
|
||||
if (msg.includes('验证码')) {
|
||||
setFieldErrors({ storeSmsCode: msg });
|
||||
} else {
|
||||
setFieldErrors({ phone: msg });
|
||||
}
|
||||
} else {
|
||||
reportFormError(msg);
|
||||
}
|
||||
reportFormError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingPhone(true);
|
||||
|
||||
setSubmitError('');
|
||||
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
|
||||
const phoneCheck = await checkStorePhoneAvailable(form.phone.trim());
|
||||
|
||||
if (!phoneCheck.available) {
|
||||
const phoneMsg = phoneCheck.message ?? '该手机号已绑定门店,请更换';
|
||||
setFieldErrors({ phone: phoneMsg });
|
||||
return;
|
||||
}
|
||||
if (phoneCheck.needConfirm) {
|
||||
const ok = window.confirm(
|
||||
phoneCheck.message ??
|
||||
`该手机号已是门店主账号(已绑 ${phoneCheck.existingStoreCount ?? 0} 家店),确认后将追加绑定新店。是否继续?`,
|
||||
);
|
||||
if (!ok) {
|
||||
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||
return;
|
||||
|
||||
} finally {
|
||||
|
||||
setCheckingPhone(false);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
@@ -426,6 +405,14 @@ export default function StoreCreatePage() {
|
||||
const msg = validateStoreStep3(form);
|
||||
|
||||
if (msg) {
|
||||
if (isPhoneValidationMessage(msg)) {
|
||||
if (msg.includes('验证码')) {
|
||||
setFieldErrors({ storeSmsCode: msg });
|
||||
} else {
|
||||
setFieldErrors({ phone: msg });
|
||||
}
|
||||
return;
|
||||
}
|
||||
reportFormError(msg);
|
||||
return;
|
||||
}
|
||||
@@ -433,15 +420,6 @@ export default function StoreCreatePage() {
|
||||
const step1Msg = validateStoreStep1(form);
|
||||
|
||||
if (step1Msg) {
|
||||
if (isPhoneValidationMessage(step1Msg)) {
|
||||
if (step1Msg.includes('验证码')) {
|
||||
setFieldErrors({ storeSmsCode: step1Msg });
|
||||
} else {
|
||||
setFieldErrors({ phone: step1Msg });
|
||||
}
|
||||
goStep(1);
|
||||
return;
|
||||
}
|
||||
reportFormError(step1Msg);
|
||||
goStep(1);
|
||||
return;
|
||||
@@ -481,6 +459,8 @@ export default function StoreCreatePage() {
|
||||
|
||||
});
|
||||
|
||||
setSubmitting(false);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
@@ -492,6 +472,7 @@ export default function StoreCreatePage() {
|
||||
);
|
||||
if (!ok) {
|
||||
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
confirmBindExisting = true;
|
||||
@@ -499,6 +480,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
} catch (e) {
|
||||
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -530,6 +512,25 @@ export default function StoreCreatePage() {
|
||||
|
||||
address: form.address.trim(),
|
||||
|
||||
...(form.latitude.trim() && form.longitude.trim()
|
||||
? {
|
||||
latitude: Number(form.latitude),
|
||||
longitude: Number(form.longitude),
|
||||
}
|
||||
: {}),
|
||||
|
||||
openTime: form.openTime.trim(),
|
||||
|
||||
closeTime: form.closeTime.trim(),
|
||||
|
||||
...(form.dualHours
|
||||
? { openTime2: form.openTime2.trim(), closeTime2: form.closeTime2.trim() }
|
||||
: {}),
|
||||
|
||||
...(form.avgPrice.trim() ? { avgPrice: Number(form.avgPrice) } : {}),
|
||||
|
||||
categoryId: form.categoryId.trim(),
|
||||
|
||||
intro: form.intro.trim() || undefined,
|
||||
|
||||
coverUrl: form.coverUrl.trim() || undefined,
|
||||
@@ -567,7 +568,7 @@ export default function StoreCreatePage() {
|
||||
}
|
||||
if (/验证码/.test(message)) {
|
||||
setFieldErrors({ storeSmsCode: message });
|
||||
goStep(1);
|
||||
goStep(3);
|
||||
return;
|
||||
}
|
||||
setSubmitError(message);
|
||||
@@ -584,17 +585,13 @@ export default function StoreCreatePage() {
|
||||
|
||||
const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
|
||||
|
||||
const nextDisabled = submitting || checkingPhone;
|
||||
const nextDisabled = submitting;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div className="partner-page-sticky">
|
||||
|
||||
<PageHeader title="录入新门店" onBack={() => navigate('/stores')} />
|
||||
|
||||
|
||||
<div className="partner-page-sticky partner-home--flush-top">
|
||||
|
||||
<nav className="partner-stepper">
|
||||
|
||||
@@ -694,100 +691,58 @@ export default function StoreCreatePage() {
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>联系电话(门店登录账号) <span className="text-primary">*</span></label>
|
||||
<label>店铺类型 <span className="text-primary">*</span></label>
|
||||
|
||||
<div className="partner-field-input">
|
||||
<div className="partner-input-row" style={{ gap: 8 }}>
|
||||
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<select
|
||||
|
||||
<input
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
type="tel"
|
||||
value={form.categoryParentId}
|
||||
|
||||
placeholder="请输入11位手机号"
|
||||
onChange={(e) => patchForm({ categoryParentId: e.target.value, categoryId: '' })}
|
||||
|
||||
value={form.phone}
|
||||
|
||||
onChange={(e) => patchForm({ phone: e.target.value })}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{fieldErrors.phone && (
|
||||
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
|
||||
|
||||
)}
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
|
||||
验证码将发送至该手机号,需门店负责人确认后方可录入
|
||||
|
||||
</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={form.storeSmsCode}
|
||||
|
||||
onChange={(e) => patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="partner-code-btn"
|
||||
|
||||
disabled={smsCooldown > 0 || checkingPhone}
|
||||
|
||||
onClick={() => void sendStorePhoneCode()}
|
||||
aria-label="一级店铺类型"
|
||||
|
||||
>
|
||||
|
||||
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
||||
<option value="">选择大类</option>
|
||||
|
||||
</button>
|
||||
{categoryTree.map((item) => (
|
||||
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
|
||||
))}
|
||||
|
||||
</select>
|
||||
|
||||
<select
|
||||
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
value={form.categoryId}
|
||||
|
||||
onChange={(e) => patchForm({ categoryId: e.target.value })}
|
||||
|
||||
disabled={!form.categoryParentId}
|
||||
|
||||
aria-label="二级店铺类型"
|
||||
|
||||
>
|
||||
|
||||
<option value="">{form.categoryParentId ? '选择细类' : '请先选大类'}</option>
|
||||
|
||||
{categoryChildren.map((item) => (
|
||||
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
|
||||
))}
|
||||
|
||||
</select>
|
||||
|
||||
</div>
|
||||
|
||||
{smsHint && (
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
|
||||
|
||||
)}
|
||||
|
||||
{fieldErrors.storeSmsCode && (
|
||||
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.storeSmsCode}</p>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
@@ -796,13 +751,170 @@ export default function StoreCreatePage() {
|
||||
|
||||
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
|
||||
|
||||
<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 }}
|
||||
disabled={locating}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setLocating(true);
|
||||
try {
|
||||
const pos = await locateStorePosition();
|
||||
patchForm({
|
||||
latitude: String(pos.latitude),
|
||||
longitude: String(pos.longitude),
|
||||
});
|
||||
toastSuccess('已获取当前坐标');
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '定位失败');
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{locating ? '定位中…' : '获取当前位置'}
|
||||
</button>
|
||||
<span className="label-md text-muted">
|
||||
{formatStoreCoords(form.latitude, form.longitude)
|
||||
? `坐标:${formatStoreCoords(form.latitude, form.longitude)}`
|
||||
: '未定位(提交后可按地址自动解析)'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>营业时间 <span className="text-primary">*</span></label>
|
||||
|
||||
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
|
||||
<input
|
||||
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
type="time"
|
||||
|
||||
value={form.openTime}
|
||||
|
||||
onChange={(e) => patchForm({ openTime: e.target.value })}
|
||||
|
||||
aria-label="营业开始时间"
|
||||
|
||||
/>
|
||||
|
||||
<span className="label-md text-muted">至</span>
|
||||
|
||||
<input
|
||||
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
type="time"
|
||||
|
||||
value={form.closeTime}
|
||||
|
||||
onChange={(e) => patchForm({ closeTime: e.target.value })}
|
||||
|
||||
aria-label="营业结束时间"
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<label className="partner-checkbox-row" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
|
||||
<input
|
||||
|
||||
type="checkbox"
|
||||
|
||||
checked={form.dualHours}
|
||||
|
||||
onChange={(e) => patchForm({ dualHours: e.target.checked })}
|
||||
|
||||
/>
|
||||
|
||||
<span className="label-md">启用第二段营业时间(如午市/晚市)</span>
|
||||
|
||||
</label>
|
||||
|
||||
{form.dualHours ? (
|
||||
|
||||
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8 }}>
|
||||
|
||||
<input
|
||||
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
type="time"
|
||||
|
||||
value={form.openTime2}
|
||||
|
||||
onChange={(e) => patchForm({ openTime2: e.target.value })}
|
||||
|
||||
aria-label="第二段开始"
|
||||
|
||||
/>
|
||||
|
||||
<span className="label-md text-muted">至</span>
|
||||
|
||||
<input
|
||||
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
type="time"
|
||||
|
||||
value={form.closeTime2}
|
||||
|
||||
onChange={(e) => patchForm({ closeTime2: e.target.value })}
|
||||
|
||||
aria-label="第二段结束"
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
) : null}
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
|
||||
支持 1 段(如 09:00-22:00)或 2 段(如 09:00-14:00,17:00-21:00)。
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>人均费用(选填)</label>
|
||||
|
||||
<input
|
||||
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
type="number"
|
||||
|
||||
min={0}
|
||||
|
||||
step={1}
|
||||
|
||||
placeholder="如 88,用户端门店页展示"
|
||||
|
||||
value={form.avgPrice}
|
||||
|
||||
onChange={(e) => patchForm({ avgPrice: 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 })} />
|
||||
<textarea rows={4} placeholder="请输入门店简介 (2-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} />
|
||||
|
||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||
|
||||
@@ -976,6 +1088,54 @@ export default function StoreCreatePage() {
|
||||
|
||||
</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"
|
||||
inputMode="numeric"
|
||||
autoComplete="cc-number"
|
||||
maxLength={19}
|
||||
placeholder="请输入16-19位银行卡号"
|
||||
value={form.bankAccountNo}
|
||||
onChange={(e) => patchForm({ bankAccountNo: e.target.value.replace(/\D/g, '').slice(0, 19) })}
|
||||
/>
|
||||
|
||||
</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>
|
||||
|
||||
<section className="partner-form-card">
|
||||
|
||||
<div className="partner-field">
|
||||
@@ -1008,54 +1168,76 @@ export default function StoreCreatePage() {
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
|
||||
该手机号将作为门店端登录账号,提交前会再次校验是否已被占用。
|
||||
该手机号将作为门店端登录账号,验证码发送至该号确认后方可提交。
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card">
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>户主姓名 *</label>
|
||||
<label>门店账号验证码 <span className="text-primary">*</span></label>
|
||||
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
|
||||
<div className="partner-input-row">
|
||||
|
||||
</div>
|
||||
<div className="partner-input-wrap" style={{ flex: 1 }}>
|
||||
|
||||
<div className="partner-field">
|
||||
<span className="material-symbols-outlined partner-input-icon">shield</span>
|
||||
|
||||
<label>银行卡号 *</label>
|
||||
<input
|
||||
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => patchForm({ bankAccountNo: e.target.value })} />
|
||||
className="partner-input"
|
||||
|
||||
</div>
|
||||
type="text"
|
||||
|
||||
<div className="partner-field">
|
||||
inputMode="numeric"
|
||||
|
||||
<label>开户支行 *</label>
|
||||
maxLength={6}
|
||||
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
|
||||
placeholder="请输入短信验证码"
|
||||
|
||||
value={form.storeSmsCode}
|
||||
|
||||
onChange={(e) => patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="partner-code-btn"
|
||||
|
||||
disabled={smsCooldown > 0 || submitting}
|
||||
|
||||
onClick={() => void sendStorePhoneCode()}
|
||||
|
||||
>
|
||||
|
||||
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
||||
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
{smsHint && (
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
|
||||
|
||||
)}
|
||||
|
||||
{fieldErrors.storeSmsCode && (
|
||||
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.storeSmsCode}</p>
|
||||
|
||||
)}
|
||||
|
||||
</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>
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
@@ -1064,7 +1246,11 @@ export default function StoreCreatePage() {
|
||||
|
||||
<footer className="partner-sticky-footer">
|
||||
|
||||
{step > 1 && (
|
||||
{step === 1 ? (
|
||||
|
||||
<button type="button" className="partner-btn-outline" onClick={() => navigate('/stores')} disabled={nextDisabled}>返回</button>
|
||||
|
||||
) : (
|
||||
|
||||
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)} disabled={nextDisabled}>上一步</button>
|
||||
|
||||
@@ -1074,7 +1260,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}>
|
||||
|
||||
<span>{checkingPhone ? '校验中…' : '下一步'}</span>
|
||||
<span>下一步</span>
|
||||
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { toastSuccess } from '../lib/toast';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { canManagePartnerStore } from '../lib/partnerAccess';
|
||||
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
|
||||
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
|
||||
import OssUploadField from '../components/OssUploadField';
|
||||
import {
|
||||
canPartnerOpenStore,
|
||||
@@ -39,14 +39,23 @@ export default function StoreDetailPage() {
|
||||
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: '' });
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
intro: '',
|
||||
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 [locating, setLocating] = useState(false);
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
||||
|
||||
function applyStore(data: Record<string, unknown>) {
|
||||
setStore(data);
|
||||
@@ -55,6 +64,8 @@ export default function StoreDetailPage() {
|
||||
phone: String(data.phone || ''),
|
||||
address: String(data.address || ''),
|
||||
intro: String(data.intro || ''),
|
||||
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 || ''));
|
||||
@@ -92,8 +103,8 @@ export default function StoreDetailPage() {
|
||||
return;
|
||||
}
|
||||
if (next === 'CLOSED') {
|
||||
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
|
||||
if (!ok) return;
|
||||
setCloseConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
setStatusSaving(true);
|
||||
setActionError('');
|
||||
@@ -104,7 +115,27 @@ export default function StoreDetailPage() {
|
||||
});
|
||||
setStatus(next);
|
||||
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
|
||||
toastSuccess('状态已更新');
|
||||
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 {
|
||||
@@ -129,6 +160,12 @@ export default function StoreDetailPage() {
|
||||
phone: form.phone.trim(),
|
||||
address: form.address.trim(),
|
||||
intro: form.intro.trim(),
|
||||
...(form.latitude.trim() && form.longitude.trim()
|
||||
? {
|
||||
latitude: Number(form.latitude),
|
||||
longitude: Number(form.longitude),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
applyStore(data);
|
||||
@@ -178,8 +215,7 @@ export default function StoreDetailPage() {
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="partner-detail-page">
|
||||
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
|
||||
<div className="partner-detail-page partner-home--flush-top">
|
||||
<div className="empty">{loadError}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -201,10 +237,8 @@ export default function StoreDetailPage() {
|
||||
const canOpen = canPartnerOpenStore(auditStatus);
|
||||
|
||||
return (
|
||||
<div className="partner-detail-page">
|
||||
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
|
||||
|
||||
<main style={{ padding: '16px 20px' }}>
|
||||
<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' }}>
|
||||
@@ -302,10 +336,52 @@ export default function StoreDetailPage() {
|
||||
<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 }}
|
||||
disabled={locating}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setLocating(true);
|
||||
setActionError('');
|
||||
try {
|
||||
const pos = await locateStorePosition();
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
latitude: String(pos.latitude),
|
||||
longitude: String(pos.longitude),
|
||||
}));
|
||||
toastSuccess('已获取当前坐标');
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '定位失败';
|
||||
setActionError(msg);
|
||||
toastError(msg);
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{locating ? '定位中…' : '获取当前位置'}
|
||||
</button>
|
||||
<span className="label-md text-muted">
|
||||
{formatStoreCoords(form.latitude, form.longitude)
|
||||
? `坐标:${formatStoreCoords(form.latitude, form.longitude)}`
|
||||
: '未定位'}
|
||||
</span>
|
||||
</div>
|
||||
) : formatStoreCoords(form.latitude, form.longitude) ? (
|
||||
<p className="label-md text-muted" style={{ marginTop: 6 }}>
|
||||
坐标:{formatStoreCoords(form.latitude, form.longitude)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店简介</label>
|
||||
<textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
|
||||
<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>
|
||||
@@ -385,6 +461,25 @@ export default function StoreDetailPage() {
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 {
|
||||
@@ -35,6 +37,7 @@ export default function StoreListPage() {
|
||||
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);
|
||||
@@ -66,8 +69,8 @@ export default function StoreListPage() {
|
||||
return;
|
||||
}
|
||||
if (next === 'CLOSED') {
|
||||
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
|
||||
if (!ok) return;
|
||||
setCloseTarget(storeId);
|
||||
return;
|
||||
}
|
||||
setUpdatingId(storeId);
|
||||
setError('');
|
||||
@@ -77,6 +80,26 @@ export default function StoreListPage() {
|
||||
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 {
|
||||
@@ -85,7 +108,7 @@ export default function StoreListPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page partner-store-page partner-home--flush-top">
|
||||
<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">
|
||||
@@ -188,6 +211,24 @@ export default function StoreListPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
@@ -20,10 +21,10 @@ export default function WeeklyReportPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const loadWeeklyReport = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
fetchPartnerWeeklyReport(selectedStart)
|
||||
return fetchPartnerWeeklyReport(selectedStart)
|
||||
.then(setData)
|
||||
.catch((e) => {
|
||||
setData(null);
|
||||
@@ -32,6 +33,10 @@ export default function WeeklyReportPage() {
|
||||
.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));
|
||||
@@ -42,7 +47,7 @@ export default function WeeklyReportPage() {
|
||||
const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0;
|
||||
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PullToRefresh onRefresh={loadWeeklyReport} className="page-no-tab">
|
||||
<PageHeader title="数据周报" onBack={() => navigate('/')} />
|
||||
|
||||
<main className="partner-weekly-page">
|
||||
@@ -211,6 +216,6 @@ export default function WeeklyReportPage() {
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3329,10 +3329,16 @@ body {
|
||||
}
|
||||
|
||||
.partner-home--flush-top.partner-store-page,
|
||||
.partner-home--flush-top.partner-center-page {
|
||||
.partner-home--flush-top.partner-center-page,
|
||||
.partner-home--flush-top.partner-detail-page,
|
||||
.partner-home--flush-top.partner-page-sticky {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.partner-home--flush-top.partner-page-sticky .partner-stepper {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.partner-home--flush-top .partner-sticky-filter {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
function formatMoney(n: number) {
|
||||
@@ -9,13 +10,11 @@ function formatMoney(n: number) {
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const loadDashboard = useCallback(() => {
|
||||
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
|
||||
.then((d) => {
|
||||
setDash(d);
|
||||
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
@@ -43,11 +42,17 @@ export default function HomePage() {
|
||||
|
||||
const store = dash?.store as Record<string, unknown> | undefined;
|
||||
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
||||
const openTime = String(store?.openTime || '10:00');
|
||||
const closeTime = String(store?.closeTime || '22:00');
|
||||
const status = String(store?.status || '');
|
||||
const open = status === 'OPEN';
|
||||
const hoursParts: string[] = [];
|
||||
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
|
||||
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
|
||||
const hoursText = hoursParts.length ? hoursParts.join(',') : '10:00 - 22:00';
|
||||
const statusText =
|
||||
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
|
||||
|
||||
return (
|
||||
<div className="shop-home-page">
|
||||
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
|
||||
<header className="shop-home-header">
|
||||
<h1 className="app-page-title">门店管理中心</h1>
|
||||
</header>
|
||||
@@ -90,12 +95,12 @@ export default function HomePage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-home-status-title">营业状态</p>
|
||||
<p className="shop-home-status-sub">{open ? '当前正在营业中' : '当前已停止营业'}</p>
|
||||
<p className="shop-home-status-sub">营业时间: {openTime} - {closeTime}</p>
|
||||
<p className="shop-home-status-sub">{statusText}</p>
|
||||
<p className="shop-home-status-sub">营业时间: {hoursText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="shop-home-switch" onClick={() => navigate('/status')}>
|
||||
<input type="checkbox" checked={open} readOnly tabIndex={-1} />
|
||||
<input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
|
||||
<span className="shop-home-switch-track" />
|
||||
</label>
|
||||
</section>
|
||||
@@ -127,6 +132,6 @@ export default function HomePage() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { getStoreProfile, request } from '../lib/api';
|
||||
@@ -24,21 +25,21 @@ export default function MinePage() {
|
||||
const [binding, setBinding] = useState(false);
|
||||
const [bindMsg, setBindMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null));
|
||||
const loadMine = useCallback(() => {
|
||||
return Promise.all([
|
||||
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null)),
|
||||
fetchClientConfig()
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(false)),
|
||||
fetchShopAccount()
|
||||
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
|
||||
.catch(() => setHasWechat(null)),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClientConfig()
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchShopAccount()
|
||||
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
|
||||
.catch(() => setHasWechat(null));
|
||||
}, []);
|
||||
void loadMine();
|
||||
}, [loadMine]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
|
||||
@@ -57,8 +58,10 @@ export default function MinePage() {
|
||||
.catch((e) => setBindMsg(e instanceof Error ? e.message : '微信绑定失败'));
|
||||
}, [applySession, searchParams, setSearchParams, wxAuthorize]);
|
||||
|
||||
const openTime = String(store?.openTime || '09:30');
|
||||
const closeTime = String(store?.closeTime || '22:00');
|
||||
const hoursParts: string[] = [];
|
||||
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
|
||||
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
|
||||
const hoursText = hoursParts.length ? hoursParts.join(',') : '09:30 - 22:00';
|
||||
const multiStore = (profile?.stores?.length ?? 0) > 1;
|
||||
const showBindWechat = wxAuthorize && hasWechat === false;
|
||||
|
||||
@@ -92,7 +95,7 @@ export default function MinePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shop-mine-page">
|
||||
<PullToRefresh onRefresh={loadMine} className="shop-mine-page">
|
||||
<header className="shop-mine-header">
|
||||
<h1 className="app-page-title">我的</h1>
|
||||
</header>
|
||||
@@ -125,7 +128,7 @@ export default function MinePage() {
|
||||
<div className="shop-mine-info-row">
|
||||
<div>
|
||||
<p className="shop-mine-info-label">营业时间</p>
|
||||
<p className="shop-mine-info-value">{openTime} - {closeTime}</p>
|
||||
<p className="shop-mine-info-value">{hoursText}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
||||
</div>
|
||||
@@ -174,6 +177,6 @@ export default function MinePage() {
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type RangeKey = 'today' | '7d' | '30d';
|
||||
@@ -28,15 +29,21 @@ export default function RecordsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [storeName, setStoreName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
|
||||
setRecords(d.list || []);
|
||||
});
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => setStoreName(String(s.name || '')))
|
||||
.catch(() => {});
|
||||
const loadRecords = useCallback(() => {
|
||||
return Promise.all([
|
||||
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
|
||||
setRecords(d.list || []);
|
||||
}),
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => setStoreName(String(s.name || '')))
|
||||
.catch(() => {}),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRecords();
|
||||
}, [loadRecords]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return records.filter((r) => {
|
||||
if (!inRange(String(r.createdAt), range)) return false;
|
||||
@@ -55,7 +62,7 @@ export default function RecordsPage() {
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div className="shop-records-page">
|
||||
<PullToRefresh onRefresh={loadRecords} className="shop-records-page">
|
||||
<header className="shop-records-header">
|
||||
<h1 className="app-page-title">核销记录</h1>
|
||||
</header>
|
||||
@@ -176,6 +183,6 @@ export default function RecordsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import {
|
||||
needsStoreSelection,
|
||||
@@ -19,15 +20,19 @@ export default function SelectStorePage() {
|
||||
const currentStoreId = store?.storeId || '';
|
||||
const canGoBack = Boolean(currentStoreId);
|
||||
|
||||
const loadStores = useCallback(() => {
|
||||
return request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
|
||||
.then((list) => setStores(list))
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) {
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
void request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
|
||||
.then((list) => setStores(list))
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
|
||||
}, [authenticated, navigate]);
|
||||
void loadStores();
|
||||
}, [authenticated, navigate, loadStores]);
|
||||
|
||||
async function onSelect(storeId: string) {
|
||||
if (loadingId) return;
|
||||
@@ -58,12 +63,13 @@ export default function SelectStorePage() {
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'OPEN') return '营业中';
|
||||
if (status === 'PAUSED') return '暂停营业';
|
||||
if (status === 'PAUSED') return '临时闭店';
|
||||
if (status === 'CLOSED') return '永久关闭';
|
||||
return status || '门店';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shop-select-store-page">
|
||||
<PullToRefresh onRefresh={loadStores} className="shop-select-store-page">
|
||||
<header className="shop-subpage-header">
|
||||
{canGoBack ? (
|
||||
<button
|
||||
@@ -151,7 +157,7 @@ export default function SelectStorePage() {
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { STORE_STAFF_ROLE_LABELS, type StoreStaffRole } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { getStoreProfile, request } from '../lib/api';
|
||||
|
||||
@@ -116,7 +117,7 @@ export default function StaffPage() {
|
||||
form.storeIds.length === 0 ? ownedStores.length : form.storeIds.length;
|
||||
|
||||
return (
|
||||
<div className="shop-staff-page">
|
||||
<PullToRefresh onRefresh={reload} className="shop-staff-page">
|
||||
<header className="shop-subpage-header">
|
||||
<button
|
||||
type="button"
|
||||
@@ -322,6 +323,6 @@ export default function StaffPage() {
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,20 +3,35 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
function formatShopHours(store: Record<string, unknown> | null) {
|
||||
const parts: string[] = [];
|
||||
const openTime = store?.openTime ? String(store.openTime) : '';
|
||||
const closeTime = store?.closeTime ? String(store.closeTime) : '';
|
||||
const openTime2 = store?.openTime2 ? String(store.openTime2) : '';
|
||||
const closeTime2 = store?.closeTime2 ? String(store.closeTime2) : '';
|
||||
if (openTime && closeTime) parts.push(`${openTime} - ${closeTime}`);
|
||||
if (openTime2 && closeTime2) parts.push(`${openTime2} - ${closeTime2}`);
|
||||
return parts.length ? parts.join(',') : '09:30 - 22:00';
|
||||
}
|
||||
|
||||
export default function StatusPage() {
|
||||
const navigate = useNavigate();
|
||||
const { resetSession } = useStoreSession();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [permanentlyClosed, setPermanentlyClosed] = useState(false);
|
||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [pendingOpen, setPendingOpen] = useState<boolean | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request('SHOP_H5', '/shop/dashboard').then((d) => {
|
||||
const s = d.store as Record<string, unknown>;
|
||||
setStore(s);
|
||||
setOpen(String(s?.status) === 'OPEN');
|
||||
const status = String(s?.status || '');
|
||||
setPermanentlyClosed(status === 'CLOSED');
|
||||
setOpen(status === 'OPEN');
|
||||
if (s?.updatedAt) {
|
||||
setLastUpdate(new Date(String(s.updatedAt)).toLocaleString('zh-CN'));
|
||||
}
|
||||
@@ -24,31 +39,33 @@ export default function StatusPage() {
|
||||
}, []);
|
||||
|
||||
function requestToggle(next: boolean) {
|
||||
if (permanentlyClosed) return;
|
||||
if (next === open) return;
|
||||
setPendingOpen(next);
|
||||
setShowModal(true);
|
||||
}
|
||||
|
||||
async function confirmToggle() {
|
||||
if (pendingOpen === null) return;
|
||||
if (pendingOpen === null || permanentlyClosed) return;
|
||||
const next = pendingOpen ? 'OPEN' : 'PAUSED';
|
||||
try {
|
||||
setError('');
|
||||
await request('SHOP_H5', '/shop/store/status', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
setOpen(pendingOpen);
|
||||
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
||||
} catch {
|
||||
/* keep current state */
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '状态切换失败');
|
||||
} finally {
|
||||
setShowModal(false);
|
||||
setPendingOpen(null);
|
||||
}
|
||||
}
|
||||
|
||||
const openTime = String(store?.openTime || '09:30');
|
||||
const closeTime = String(store?.closeTime || '22:00');
|
||||
const hoursText = formatShopHours(store);
|
||||
const statusLabel = permanentlyClosed ? '永久关闭' : open ? '营业中' : '临时闭店';
|
||||
|
||||
return (
|
||||
<div className="shop-status-page">
|
||||
@@ -70,46 +87,61 @@ export default function StatusPage() {
|
||||
<div className="shop-status-content">
|
||||
<section className="shop-status-card">
|
||||
<div className="shop-status-icon-wrap">
|
||||
<div className={`shop-status-icon-outer${open ? ' open' : ' closed'}`}>
|
||||
<div className={`shop-status-icon-inner${open ? ' open' : ' closed'}`}>
|
||||
<div className={`shop-status-icon-outer${open && !permanentlyClosed ? ' open' : ' closed'}`}>
|
||||
<div className={`shop-status-icon-inner${open && !permanentlyClosed ? ' open' : ' closed'}`}>
|
||||
<span className="material-symbols-outlined">storefront</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shop-status-check">
|
||||
<span className={`material-symbols-outlined shop-fill-icon${open ? '' : ''}`} style={{ fontSize: 14, color: open ? 'var(--color-success-green)' : 'var(--color-subtle-gray)' }}>
|
||||
{open ? 'check_circle' : 'cancel'}
|
||||
<span
|
||||
className="material-symbols-outlined shop-fill-icon"
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: open && !permanentlyClosed ? 'var(--color-success-green)' : 'var(--color-subtle-gray)',
|
||||
}}
|
||||
>
|
||||
{open && !permanentlyClosed ? 'check_circle' : 'cancel'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className={`shop-status-label${open ? ' open' : ' closed'}`}>
|
||||
{open ? '营业中' : '临时闭店'}
|
||||
<h2 className={`shop-status-label${open && !permanentlyClosed ? ' open' : ' closed'}`}>
|
||||
{statusLabel}
|
||||
</h2>
|
||||
|
||||
<label className={`shop-status-switch${open ? ' open' : ' closed'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={open}
|
||||
onChange={(e) => requestToggle(e.target.checked)}
|
||||
aria-label={open ? '切换为临时闭店' : '切换为营业中'}
|
||||
/>
|
||||
<span className="shop-status-switch-track" />
|
||||
<span className="shop-status-switch-caption">
|
||||
{open ? '点击可临时闭店' : '点击恢复营业'}
|
||||
</span>
|
||||
</label>
|
||||
{permanentlyClosed ? (
|
||||
<p className="shop-status-switch-caption" style={{ marginTop: 12 }}>
|
||||
总部已永久关闭本店,门店端无法自行恢复营业
|
||||
</p>
|
||||
) : (
|
||||
<label className={`shop-status-switch${open ? ' open' : ' closed'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={open}
|
||||
onChange={(e) => requestToggle(e.target.checked)}
|
||||
aria-label={open ? '切换为临时闭店' : '切换为营业中'}
|
||||
/>
|
||||
<span className="shop-status-switch-track" />
|
||||
<span className="shop-status-switch-caption">
|
||||
{open ? '点击可临时闭店' : '点击恢复营业'}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<p className="shop-status-hours-label">营业时间</p>
|
||||
<p className="shop-status-hours">{openTime} - {closeTime}</p>
|
||||
<p className="shop-status-hours">{hoursText}</p>
|
||||
{lastUpdate && <p className="shop-status-updated">最后修改于 {lastUpdate}</p>}
|
||||
{error ? <p className="shop-status-updated" style={{ color: 'var(--color-error, #c62828)' }}>{error}</p> : null}
|
||||
</section>
|
||||
|
||||
<div className={`shop-status-hint${open ? ' open' : ' closed'}`}>
|
||||
<div className={`shop-status-hint${open && !permanentlyClosed ? ' open' : ' closed'}`}>
|
||||
<span className="material-symbols-outlined">info</span>
|
||||
<p>
|
||||
{open
|
||||
? '当前处于营业状态,用户可在您的门店核销餐券。'
|
||||
: '当前处于休息状态,用户将无法看到您的门店或进行核销。'}
|
||||
{permanentlyClosed
|
||||
? '门店已永久关闭。如需重新营业,请联系总部或合伙人处理。'
|
||||
: open
|
||||
? '当前处于营业状态,用户可在您的门店核销餐券。'
|
||||
: '当前处于临时闭店状态,用户将无法看到您的门店或进行核销。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,11 +14,14 @@ const TABS = [
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '已付款',
|
||||
OUT_WAREHOUSE: '已付款',
|
||||
SHIPPING: '已付款',
|
||||
PENDING_RECEIVE: '已付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
OUT_WAREHOUSE: '已出库',
|
||||
SHIPPING: '配送中',
|
||||
PENDING_RECEIVE: '待收货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
function isReshipOrder(order: Record<string, unknown>) {
|
||||
|
||||
@@ -24,6 +24,9 @@ type StoreDetail = {
|
||||
status: string;
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
openTime2?: string | null;
|
||||
closeTime2?: string | null;
|
||||
avgPrice?: number | null;
|
||||
category?: { name: string } | null;
|
||||
media?: StoreMedia[];
|
||||
};
|
||||
@@ -41,8 +44,10 @@ const DEFAULT_INTRO =
|
||||
'作为本地优质餐饮合作伙伴,门店融合地域饮食文化与高端社交场景,设有杜康文化体验区,让宾客在用餐之余领略中华酒祖的千年传承。主打精品地方菜与创意融合菜,氛围庄重而不失亲和力,是商务宴请、亲友小聚以及文化交流的理想场所。';
|
||||
|
||||
function formatHours(store: StoreDetail) {
|
||||
if (store.openTime && store.closeTime) return `${store.openTime} - ${store.closeTime}`;
|
||||
return '09:30 - 22:00';
|
||||
const parts: string[] = [];
|
||||
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||
return parts.length ? parts.join(',') : '09:30-22:00';
|
||||
}
|
||||
|
||||
function fullAddress(store: StoreDetail) {
|
||||
@@ -135,6 +140,9 @@ export default function StoreDetailPage() {
|
||||
<div>
|
||||
<h2 className="store-detail-name">{store.name}</h2>
|
||||
<p className="store-detail-hours">营业时间:{formatHours(store)}</p>
|
||||
{store.avgPrice != null && Number(store.avgPrice) > 0 ? (
|
||||
<p className="store-detail-hours">人均约 ¥{Number(store.avgPrice).toFixed(0)}</p>
|
||||
) : null}
|
||||
{store.category?.name && (
|
||||
<span className="store-detail-category">{store.category.name}</span>
|
||||
)}
|
||||
|
||||
@@ -32,6 +32,9 @@ type StoreItem = {
|
||||
status: string;
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
openTime2?: string | null;
|
||||
closeTime2?: string | null;
|
||||
avgPrice?: number | null;
|
||||
category?: { name: string } | null;
|
||||
};
|
||||
|
||||
@@ -50,10 +53,10 @@ function storeCover(store: StoreItem, index: number) {
|
||||
}
|
||||
|
||||
function formatHours(store: StoreItem) {
|
||||
if (store.openTime && store.closeTime) {
|
||||
return `营业时间: ${store.openTime}-${store.closeTime}`;
|
||||
}
|
||||
return '营业时间: 10:00-22:00';
|
||||
const parts: string[] = [];
|
||||
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||
return parts.length ? `营业时间: ${parts.join(',')}` : '营业时间: 10:00-22:00';
|
||||
}
|
||||
|
||||
export default function StoreListPage() {
|
||||
@@ -238,6 +241,9 @@ export default function StoreListPage() {
|
||||
<div className="store-card-meta">
|
||||
<span className="status-open">营业中</span>
|
||||
<span className="store-card-hours">{formatHours(s)}</span>
|
||||
{s.avgPrice != null && Number(s.avgPrice) > 0 ? (
|
||||
<span className="store-card-hours">人均¥{Number(s.avgPrice).toFixed(0)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="store-card-bottom">
|
||||
|
||||
@@ -7,9 +7,11 @@ export default defineAppConfig({
|
||||
'pages/product-detail/index',
|
||||
'pages/store-detail/index',
|
||||
'pages/order-confirm/index',
|
||||
'pages/order-confirm-pickup/index',
|
||||
'pages/pay/index',
|
||||
'pages/orders/index',
|
||||
'pages/order-detail/index',
|
||||
'pages/pickup-receive/index',
|
||||
'pages/addresses/index',
|
||||
'pages/address-edit/index',
|
||||
'pages/customer-service/index',
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 518 KiB |
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
|
||||
export type StoreCategoryNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: StoreCategoryNode[];
|
||||
};
|
||||
|
||||
export type CategorySelection = {
|
||||
parentId: string;
|
||||
parentName: string;
|
||||
childId: string;
|
||||
childName: string;
|
||||
};
|
||||
|
||||
export const EMPTY_CATEGORY: CategorySelection = {
|
||||
parentId: '',
|
||||
parentName: '',
|
||||
childId: '',
|
||||
childName: '',
|
||||
};
|
||||
|
||||
export function formatCategoryLabel(sel: CategorySelection): string {
|
||||
if (sel.childName) return sel.childName;
|
||||
if (sel.parentName) return sel.parentName;
|
||||
return '全部分类';
|
||||
}
|
||||
|
||||
type CategoryPickerProps = {
|
||||
open: boolean;
|
||||
tree: StoreCategoryNode[];
|
||||
value: CategorySelection;
|
||||
onClose: () => void;
|
||||
onConfirm: (next: CategorySelection) => void;
|
||||
};
|
||||
|
||||
type TabKey = 'parent' | 'child';
|
||||
|
||||
export default function CategoryPicker({
|
||||
open,
|
||||
tree,
|
||||
value,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: CategoryPickerProps) {
|
||||
const [draft, setDraft] = useState<CategorySelection>(value);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('parent');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(value);
|
||||
setActiveTab(value.parentId ? 'child' : 'parent');
|
||||
}, [open, value]);
|
||||
|
||||
const children = useMemo(() => {
|
||||
const parent = tree.find((n) => n.id === draft.parentId);
|
||||
return parent?.children ?? [];
|
||||
}, [tree, draft.parentId]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function selectParent(node: StoreCategoryNode | null) {
|
||||
if (!node) {
|
||||
setDraft(EMPTY_CATEGORY);
|
||||
return;
|
||||
}
|
||||
setDraft({
|
||||
parentId: node.id,
|
||||
parentName: node.name,
|
||||
childId: '',
|
||||
childName: '',
|
||||
});
|
||||
setActiveTab('child');
|
||||
}
|
||||
|
||||
function selectChild(node: StoreCategoryNode | null) {
|
||||
if (!node) {
|
||||
setDraft((prev) => ({ ...prev, childId: '', childName: '' }));
|
||||
return;
|
||||
}
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
childId: node.id,
|
||||
childName: node.name,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
onConfirm(draft);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="region-picker-overlay" onClick={onClose}>
|
||||
<View className="region-picker-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<View className="region-picker-toolbar">
|
||||
<View className="region-picker-tabs">
|
||||
<Text
|
||||
className={`region-picker-tab${activeTab === 'parent' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('parent')}
|
||||
>
|
||||
{draft.parentName || '大类'}
|
||||
</Text>
|
||||
<Text
|
||||
className={`region-picker-tab${activeTab === 'child' ? ' active' : ''}${!draft.parentId ? ' disabled' : ''}`}
|
||||
onClick={() => draft.parentId && setActiveTab('child')}
|
||||
>
|
||||
{draft.childName || '细类'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="region-picker-confirm ready" onClick={handleConfirm}>
|
||||
确定
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView className="region-picker-list" scrollY showScrollbar={false}>
|
||||
{activeTab === 'parent' ? (
|
||||
<>
|
||||
<View
|
||||
className={`region-picker-option${!draft.parentId ? ' selected' : ''} region-picker-option--all`}
|
||||
onClick={() => selectParent(null)}
|
||||
>
|
||||
<Text>全部分类</Text>
|
||||
</View>
|
||||
{tree.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className={`region-picker-option${draft.parentId === item.id ? ' selected' : ''}`}
|
||||
onClick={() => selectParent(item)}
|
||||
>
|
||||
<Text>{item.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
className={`region-picker-option${!draft.childId ? ' selected' : ''} region-picker-option--all`}
|
||||
onClick={() => selectChild(null)}
|
||||
>
|
||||
<Text>全部细类</Text>
|
||||
</View>
|
||||
{children.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className={`region-picker-option${draft.childId === item.id ? ' selected' : ''}`}
|
||||
onClick={() => selectChild(item)}
|
||||
>
|
||||
<Text>{item.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,11 @@ type CouponBadgeProps = {
|
||||
|
||||
/** Taro 友好版权益角标(对齐 shared-ui CouponBadge) */
|
||||
export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) {
|
||||
const n = Number(amount);
|
||||
const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount);
|
||||
return (
|
||||
<Text className="coupon-badge">
|
||||
¥{amount}
|
||||
{label}
|
||||
享 ¥{display} {label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function PageShell({
|
||||
'page-shell',
|
||||
`page-shell--${variant}`,
|
||||
hasFixedFooter ? 'page-shell--fixed-footer' : '',
|
||||
variant === 'tab' && process.env.TARO_ENV === 'weapp' ? 'page-shell--native-tabbar' : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
|
||||
import { toast } from '../lib/api';
|
||||
import { capturePromoSceneAndTouchScan } from '../lib/promo';
|
||||
import { saveWechatLoginResult } from '../lib/pay-wechat';
|
||||
import { applyWechatShare } from '../lib/wechat-share';
|
||||
import { handleWechatAuthCallback } from '../lib/wechat-auth';
|
||||
@@ -29,13 +30,16 @@ function currentPagePathWithQuery(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 App 根节点专用:不可用 useDidShow(App 无页面 Context)。
|
||||
* - 首次进页:捕获 iOS 签名 URL + 默认分享 + OAuth code 回调
|
||||
* - 路由/回前台:刷新默认分享卡片
|
||||
* H5 App 根节点:iOS 签名 URL + 默认分享 + OAuth code 回调。
|
||||
* 小程序:冷启动时捕获推广码 scene 并回传扫码埋点。
|
||||
*/
|
||||
export default function WechatShareBootstrap() {
|
||||
const handlingCode = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
void capturePromoSceneAndTouchScan();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.TARO_ENV !== 'h5') return;
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 球面距离(米) */
|
||||
export function haversineMeters(
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number,
|
||||
): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const R = 6371000;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
export function formatDistanceMeters(meters: number | null | undefined): string {
|
||||
if (meters == null || !Number.isFinite(meters) || meters < 0) return '—';
|
||||
if (meters < 1000) return `${Math.max(1, Math.round(meters))}m`;
|
||||
const km = meters / 1000;
|
||||
return `${km < 10 ? km.toFixed(1) : Math.round(km)}km`;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { request } from './api';
|
||||
|
||||
const PROMO_ID_KEY = 'dukang_promo_id';
|
||||
|
||||
/** 同一次进入只 touch 一次扫码计数,避免首页反复 onShow 刷量 */
|
||||
let lastScanTouchKey = '';
|
||||
|
||||
function safeDecode(raw: string): string {
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePromoId(raw: unknown): string | null {
|
||||
if (raw == null || raw === '') return null;
|
||||
const s = safeDecode(String(raw)).trim();
|
||||
// 小程序码 scene 写入的是推广活动数字 ID
|
||||
if (!/^\d+$/.test(s)) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
type EnterOptionsLike = {
|
||||
scene?: string | number;
|
||||
query?: Record<string, string | undefined>;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
/** 从启动/进入参数解析推广活动 ID(优先 query.scene,与 getwxacodeunlimit 一致) */
|
||||
export function extractPromoIdFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
|
||||
if (!opts) return null;
|
||||
const q = opts.query ?? {};
|
||||
return (
|
||||
normalizePromoId(q.scene) ||
|
||||
normalizePromoId(q.promoId) ||
|
||||
normalizePromoId(q.pid) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function getStoredPromoId(): string | null {
|
||||
try {
|
||||
const v = Taro.getStorageSync(PROMO_ID_KEY);
|
||||
return normalizePromoId(v);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredPromoId(promoId: string) {
|
||||
const id = normalizePromoId(promoId);
|
||||
if (!id) return;
|
||||
try {
|
||||
Taro.setStorageSync(PROMO_ID_KEY, id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function readEnterOptions(): EnterOptionsLike | null {
|
||||
try {
|
||||
if (typeof Taro.getEnterOptionsSync === 'function') {
|
||||
return Taro.getEnterOptionsSync() as EnterOptionsLike;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
if (typeof Taro.getLaunchOptionsSync === 'function') {
|
||||
return Taro.getLaunchOptionsSync() as EnterOptionsLike;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// H5:从 URL query 读取
|
||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
query: {
|
||||
scene: params.get('scene') || undefined,
|
||||
promoId: params.get('promoId') || undefined,
|
||||
pid: params.get('pid') || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主页面进入时:取出 scene(活动 ID)本地缓存,并回传 /promo/touch 累加扫码次数。
|
||||
* 同一进入会话只计一次扫码。
|
||||
*/
|
||||
export async function capturePromoSceneAndTouchScan(): Promise<void> {
|
||||
const opts = readEnterOptions();
|
||||
const fromEnter = extractPromoIdFromEnterOptions(opts);
|
||||
if (fromEnter) {
|
||||
setStoredPromoId(fromEnter);
|
||||
const touchKey = `${fromEnter}|${opts?.path || ''}|${JSON.stringify(opts?.query || {})}|${String(opts?.scene ?? '')}`;
|
||||
if (touchKey === lastScanTouchKey) return;
|
||||
lastScanTouchKey = touchKey;
|
||||
await touchPromo({ promoId: fromEnter, countScan: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// 无新 scene 时不重复扫码计数
|
||||
}
|
||||
|
||||
/** 登录成功后:用已缓存的活动 ID 做归因(不重复加扫码次数) */
|
||||
export async function touchStoredPromoAfterLogin(): Promise<void> {
|
||||
const promoId = getStoredPromoId();
|
||||
if (!promoId) return;
|
||||
await touchPromo({ promoId, countScan: false });
|
||||
}
|
||||
|
||||
async function touchPromo(input: { promoId: string; countScan: boolean }): Promise<void> {
|
||||
try {
|
||||
await request('/promo/touch', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
promoId: input.promoId,
|
||||
countScan: input.countScan,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* 静默失败,不阻断浏览 */
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export function normalizeRegionSelection(selection: RegionSelection): RegionSele
|
||||
export const DEFAULT_REGION: RegionSelection = {
|
||||
province: '河南省',
|
||||
city: '郑州市',
|
||||
district: '金水区',
|
||||
district: REGION_ALL,
|
||||
};
|
||||
|
||||
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
|
||||
import { API_BASE, CLIENT_APP, getToken, request } from './api';
|
||||
import { DEFAULT_REGION, regionFromGeo, type RegionSelection } from './region-data';
|
||||
import { DEFAULT_REGION, REGION_ALL, regionFromGeo, type RegionSelection } from './region-data';
|
||||
import { FALLBACK_CITY_CODE } from './product-images';
|
||||
|
||||
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
|
||||
const USER_COORDS_KEY = 'dukang_user_coords';
|
||||
/** 用户拒绝定位后持久化,避免首页/门店每次 useDidShow 再弹授权 */
|
||||
const LOCATION_DENIED_KEY = 'dukang_location_denied';
|
||||
|
||||
@@ -19,12 +20,14 @@ export type ResolvedUserCity = {
|
||||
displayCity: string;
|
||||
};
|
||||
|
||||
export type UserCoords = { latitude: number; longitude: number };
|
||||
|
||||
type GpsCityCache = ResolvedUserCity & { timestamp: number };
|
||||
|
||||
const FALLBACK_CITY: ResolvedUserCity = {
|
||||
province: DEFAULT_REGION.province,
|
||||
city: DEFAULT_REGION.city,
|
||||
district: DEFAULT_REGION.district,
|
||||
district: REGION_ALL,
|
||||
cityCode: FALLBACK_CITY_CODE,
|
||||
cityName: '郑州市',
|
||||
openCity: true,
|
||||
@@ -85,6 +88,39 @@ function writeCache(data: ResolvedUserCity) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeUserCoords(latitude: number, longitude: number) {
|
||||
try {
|
||||
Taro.setStorageSync(
|
||||
USER_COORDS_KEY,
|
||||
JSON.stringify({ latitude, longitude, timestamp: Date.now() }),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function readCachedUserCoords(): UserCoords | null {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(USER_COORDS_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(String(raw)) as UserCoords & { timestamp?: number };
|
||||
if (parsed.timestamp && Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
|
||||
if (!Number.isFinite(parsed.latitude) || !Number.isFinite(parsed.longitude)) return null;
|
||||
return { latitude: parsed.latitude, longitude: parsed.longitude };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 门店列表默认用市级全市筛选 */
|
||||
export function toCityWideRegion(region: RegionSelection): RegionSelection {
|
||||
return {
|
||||
province: region.province,
|
||||
city: region.city,
|
||||
district: REGION_ALL,
|
||||
};
|
||||
}
|
||||
|
||||
/** 拒绝或失败后写入兜底城市,避免短时间内反复调起定位 */
|
||||
function cacheFallbackAndMaybeDeny(denied: boolean) {
|
||||
if (denied) markLocationDenied();
|
||||
@@ -167,12 +203,12 @@ async function resolveViaH5Jssdk(): Promise<ResolvedUserCity | null> {
|
||||
status: 'fail',
|
||||
errMsg: outcome.errMsg,
|
||||
}).catch(() => {});
|
||||
// 失败一律缓存兜底,避免首页/门店每次进入再次调起微信定位弹窗
|
||||
cacheFallbackAndMaybeDeny(denied);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
writeUserCoords(outcome.location.latitude, outcome.location.longitude);
|
||||
const data = await reportLocationToServer({
|
||||
latitude: outcome.location.latitude,
|
||||
longitude: outcome.location.longitude,
|
||||
@@ -213,6 +249,7 @@ export async function resolveUserCity(force = false): Promise<ResolvedUserCity>
|
||||
|
||||
try {
|
||||
const loc = await getMiniLocation();
|
||||
writeUserCoords(loc.latitude, loc.longitude);
|
||||
const data = await reportLocationToServer({
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
|
||||
@@ -5,9 +5,15 @@ import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import RegionPicker from '../../components/RegionPicker';
|
||||
import { buildAddressListUrl, readCheckoutContext } from '../../lib/checkout-nav';
|
||||
import { DEFAULT_REGION, formatRegion, type RegionSelection } from '../../lib/region-data';
|
||||
import {
|
||||
DEFAULT_REGION,
|
||||
REGION_ALL,
|
||||
formatRegion,
|
||||
type RegionSelection,
|
||||
} from '../../lib/region-data';
|
||||
import { normalizePhoneInput, validateMobilePhone } from '../../lib/phone';
|
||||
import { getStoredUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
|
||||
import { resolveUserCity } from '../../lib/user-location';
|
||||
import { request, toast, type UserProfile } from '../../lib/api';
|
||||
|
||||
type AddressForm = {
|
||||
@@ -27,6 +33,7 @@ export default function AddressEditPage() {
|
||||
const checkoutCtx = readCheckoutContext(router.params);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<AddressForm>(() => ({
|
||||
receiverName: '',
|
||||
@@ -49,6 +56,35 @@ export default function AddressEditPage() {
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) return;
|
||||
let cancelled = false;
|
||||
setLocating(true);
|
||||
void resolveUserCity(true)
|
||||
.then((resolved) => {
|
||||
if (cancelled) return;
|
||||
const district =
|
||||
resolved.region.district && resolved.region.district !== REGION_ALL
|
||||
? resolved.region.district
|
||||
: resolved.district && resolved.district !== REGION_ALL
|
||||
? resolved.district
|
||||
: DEFAULT_REGION.district;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
province: resolved.region.province || prev.province,
|
||||
city: resolved.region.city || prev.city,
|
||||
district: district || prev.district,
|
||||
}));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLocating(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
request<Array<Record<string, unknown>>>('/user/addresses').then((list) => {
|
||||
@@ -161,7 +197,11 @@ export default function AddressEditPage() {
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<Text>{regionText || '请选择省市区'}</Text>
|
||||
<Text>
|
||||
{locating && !isEdit
|
||||
? '定位中…'
|
||||
: regionText || '请选择省市区'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="address-form-field">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '地址管理',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import {
|
||||
@@ -44,6 +44,10 @@ export default function AddressesPage() {
|
||||
loadList();
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void Promise.resolve(loadList()).finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
function selectAddress(addr: Address) {
|
||||
if (!selectMode) return;
|
||||
Taro.redirectTo({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '好客权益',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
@@ -35,18 +35,19 @@ function usagePercent(coupon: CouponItem) {
|
||||
|
||||
export default function BenefitPage() {
|
||||
const metrics = useNavBarMetrics();
|
||||
const loggedIn = isLoggedIn();
|
||||
const [loggedIn, setLoggedIn] = useState(() => isLoggedIn());
|
||||
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
||||
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
||||
const [tab, setTab] = useState<'available' | 'history'>('available');
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(2);
|
||||
});
|
||||
const resetGuestState = useCallback(() => {
|
||||
setSummary(null);
|
||||
setCoupons([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) return;
|
||||
Promise.all([
|
||||
const loadBenefit = useCallback(() => {
|
||||
if (!isLoggedIn()) return Promise.resolve();
|
||||
return Promise.all([
|
||||
request<BenefitSummary>('/benefit/summary'),
|
||||
request<CouponItem[]>('/benefit/coupons'),
|
||||
])
|
||||
@@ -55,7 +56,29 @@ export default function BenefitPage() {
|
||||
setCoupons(Array.isArray(list) ? list : []);
|
||||
})
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [loggedIn]);
|
||||
}, []);
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(2);
|
||||
const loggedInNow = isLoggedIn();
|
||||
setLoggedIn(loggedInNow);
|
||||
if (loggedInNow) {
|
||||
void loadBenefit();
|
||||
} else {
|
||||
resetGuestState();
|
||||
}
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
const loggedInNow = isLoggedIn();
|
||||
setLoggedIn(loggedInNow);
|
||||
if (!loggedInNow) {
|
||||
resetGuestState();
|
||||
Taro.stopPullDownRefresh();
|
||||
return;
|
||||
}
|
||||
void loadBenefit().finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
|
||||
const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '杜康好客',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,29 +1,39 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import CouponBadge from '../../components/CouponBadge';
|
||||
import ProductCarousel from '../../components/ProductCarousel';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { getProductImages } from '../../lib/product-images';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
spec?: string;
|
||||
price: number;
|
||||
benefitDisplay?: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
aromaType: string;
|
||||
allowOnSitePickup?: boolean;
|
||||
};
|
||||
|
||||
type MiniHomeConfig = {
|
||||
banners: string[];
|
||||
footerUrl: string | null;
|
||||
};
|
||||
|
||||
const AROMA_TABS = [
|
||||
{ key: 'QINGXIANG', label: '清香型' },
|
||||
{ key: 'JIANGXIANG', label: '酱香型' },
|
||||
{ key: 'NONGXIANG', label: '浓香型' },
|
||||
{ key: 'JIANGXIANG', label: '酱香型' },
|
||||
] as const;
|
||||
|
||||
export default function HomePage() {
|
||||
@@ -32,23 +42,69 @@ export default function HomePage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [displayCity, setDisplayCity] = useState('郑州市');
|
||||
const [cityCode, setCityCode] = useState('410100');
|
||||
const [miniHome, setMiniHome] = useState<MiniHomeConfig>({ banners: [], footerUrl: null });
|
||||
|
||||
const loadMiniHome = useCallback(() => {
|
||||
return request<{ miniHome?: MiniHomeConfig }>('/common/client-config')
|
||||
.then((cfg) => {
|
||||
const banners = Array.isArray(cfg.miniHome?.banners)
|
||||
? cfg.miniHome!.banners.filter((u) => typeof u === 'string' && !!u.trim())
|
||||
: [];
|
||||
const footerUrl =
|
||||
typeof cfg.miniHome?.footerUrl === 'string' && cfg.miniHome.footerUrl.trim()
|
||||
? cfg.miniHome.footerUrl.trim()
|
||||
: null;
|
||||
setMiniHome({ banners, footerUrl });
|
||||
})
|
||||
.catch(() => {
|
||||
/* 首页装饰图失败不阻断商品列表 */
|
||||
});
|
||||
}, []);
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(0);
|
||||
void capturePromoSceneAndTouchScan();
|
||||
void loadMiniHome();
|
||||
void resolveUserCity().then((resolved) => {
|
||||
setDisplayCity(resolved.displayCity);
|
||||
setCityCode(getCityCodeForCatalog(resolved));
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadProducts = useCallback(() => {
|
||||
setLoading(true);
|
||||
request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`)
|
||||
return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`)
|
||||
.then((list) => setProducts(Array.isArray(list) ? list : []))
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [cityCode]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProducts();
|
||||
}, [loadProducts]);
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await resolveUserCity();
|
||||
setDisplayCity(resolved.displayCity);
|
||||
const nextCode = getCityCodeForCatalog(resolved);
|
||||
setCityCode(nextCode);
|
||||
setLoading(true);
|
||||
const [list] = await Promise.all([
|
||||
request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`),
|
||||
loadMiniHome(),
|
||||
]);
|
||||
setProducts(Array.isArray(list) ? list : []);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
Taro.stopPullDownRefresh();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
const availableAromas = useMemo(
|
||||
() =>
|
||||
AROMA_TABS.filter((item) =>
|
||||
@@ -68,12 +124,43 @@ export default function HomePage() {
|
||||
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
||||
}
|
||||
|
||||
async function goOnSitePickup(productId: string) {
|
||||
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
|
||||
if (!isLoggedIn()) {
|
||||
goLogin(returnPath);
|
||||
return;
|
||||
}
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
Taro.navigateTo({ url: returnPath });
|
||||
}
|
||||
|
||||
const filtered = products.filter((p) => p.aromaType === tab);
|
||||
const banners = miniHome.banners;
|
||||
const footerUrl = miniHome.footerUrl;
|
||||
|
||||
return (
|
||||
<PageShell variant="tab" className="home-page no-tab-header">
|
||||
<TabMainHeader title="杜康好客" />
|
||||
|
||||
{banners.length > 0 ? (
|
||||
<View className="home-promo-banner">
|
||||
<Swiper
|
||||
className="home-promo-banner-swiper"
|
||||
indicatorDots={banners.length > 1}
|
||||
autoplay={banners.length > 1}
|
||||
circular={banners.length > 1}
|
||||
interval={2500}
|
||||
>
|
||||
{banners.map((url) => (
|
||||
<SwiperItem key={url}>
|
||||
<Image className="home-promo-banner-img" src={url} mode="aspectFill" />
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="home-aroma-nav">
|
||||
<View className="home-aroma-tabs">
|
||||
{availableAromas.map((t) => (
|
||||
@@ -96,32 +183,65 @@ export default function HomePage() {
|
||||
) : null}
|
||||
{!loading &&
|
||||
filtered.map((p) => {
|
||||
const images = getProductImages(p);
|
||||
const thumb = getProductMainImage(p);
|
||||
const spec = p.subtitle || p.spec || '';
|
||||
return (
|
||||
<View key={p.id} className="home-product-card">
|
||||
<View onClick={() => openProductDetail(p.id)}>
|
||||
<ProductCarousel images={images} alt={p.name} variant="home" />
|
||||
<View className="home-product-body">
|
||||
<View
|
||||
className="home-product-card-inner"
|
||||
onClick={() => openProductDetail(p.id)}
|
||||
>
|
||||
<View className="home-product-thumb-wrap">
|
||||
{thumb ? (
|
||||
<Image className="home-product-thumb" src={thumb} mode="aspectFill" />
|
||||
) : (
|
||||
<View className="home-product-thumb home-product-thumb--empty" />
|
||||
)}
|
||||
</View>
|
||||
<View className="home-product-main">
|
||||
<View className="home-product-row">
|
||||
<Text className="home-product-name">{p.name}</Text>
|
||||
<Text className="home-product-price">¥{Number(p.price).toFixed(2)}</Text>
|
||||
<Text className="home-product-price">¥{Number(p.price).toFixed(0)}</Text>
|
||||
</View>
|
||||
{p.subtitle ? <Text className="home-product-sub">{p.subtitle}</Text> : null}
|
||||
{spec ? <Text className="home-product-sub">{spec}</Text> : null}
|
||||
<View className="home-product-footer">
|
||||
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
|
||||
</View>
|
||||
<View className="home-product-actions">
|
||||
{p.allowOnSitePickup ? (
|
||||
<Text
|
||||
className="home-pickup-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
void goOnSitePickup(p.id);
|
||||
}}
|
||||
>
|
||||
现场取货
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
className="home-buy-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
openProductDetail(p.id);
|
||||
}}
|
||||
>
|
||||
立即购买
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className="home-product-actions">
|
||||
<Text className="home-buy-btn" onClick={() => openProductDetail(p.id)}>
|
||||
立即购买
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{footerUrl ? (
|
||||
<View className="home-promo-footer">
|
||||
<Image className="home-promo-footer-img" src={footerUrl} mode="aspectFill" />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null}
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type MiniWechatProfile,
|
||||
} from '../../lib/mini-wechat-profile';
|
||||
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
|
||||
import { touchStoredPromoAfterLogin } from '../../lib/promo';
|
||||
|
||||
const IS_WEAPP = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
@@ -176,6 +177,7 @@ export default function LoginPage() {
|
||||
refreshToken: data.refreshToken,
|
||||
});
|
||||
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
|
||||
void touchStoredPromoAfterLogin();
|
||||
if (!phoneValue) {
|
||||
void fetchUserProfile()
|
||||
.then((me) => resolveDefaultUserPhone(me))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '我的',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Image, Button, Input } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { View, Text, Image, Button, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../lib/mini-wechat-profile';
|
||||
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
|
||||
import { isWechatEnv } from '../../lib/weixin';
|
||||
import qualificationDisclosureImg from '../../assets/qualification-disclosure.png';
|
||||
|
||||
const ORDER_SHORTCUTS = [
|
||||
{ tab: 'pending_pay', icon: '付', label: '待付款' },
|
||||
@@ -29,6 +30,7 @@ const SERVICES = [
|
||||
{ icon: '址', label: '地址管理', url: '/pages/addresses/index' },
|
||||
{ icon: '店', label: '可用门店', tab: '/pages/stores/index' },
|
||||
{ icon: '服', label: '联系客服', url: '/pages/customer-service/index' },
|
||||
{ icon: '资', label: '资质公示', action: 'qualification' as const },
|
||||
{ icon: '关', label: '关于我们', action: 'about' as const },
|
||||
] as const;
|
||||
|
||||
@@ -51,6 +53,7 @@ export default function MinePage() {
|
||||
const [draftNickname, setDraftNickname] = useState('');
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [profileLoadError, setProfileLoadError] = useState('');
|
||||
const [qualificationOpen, setQualificationOpen] = useState(false);
|
||||
|
||||
function resetGuestState() {
|
||||
setProfile(null);
|
||||
@@ -64,9 +67,9 @@ export default function MinePage() {
|
||||
}
|
||||
|
||||
function loadProfile() {
|
||||
if (!isLoggedIn()) return;
|
||||
if (!isLoggedIn()) return Promise.resolve();
|
||||
setProfileLoadError('');
|
||||
Promise.all([
|
||||
return Promise.all([
|
||||
request<UserProfile>('/auth/me'),
|
||||
request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []),
|
||||
...ORDER_SHORTCUTS.map((s) =>
|
||||
@@ -109,6 +112,17 @@ export default function MinePage() {
|
||||
}
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
const loggedInNow = isLoggedIn();
|
||||
setAuthed(loggedInNow);
|
||||
if (!loggedInNow) {
|
||||
resetGuestState();
|
||||
Taro.stopPullDownRefresh();
|
||||
return;
|
||||
}
|
||||
void loadProfile().finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
request<ClientRuntimeConfig>('/common/client-config')
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
@@ -243,6 +257,10 @@ export default function MinePage() {
|
||||
Taro.switchTab({ url: item.tab });
|
||||
return;
|
||||
}
|
||||
if ('action' in item && item.action === 'qualification') {
|
||||
setQualificationOpen(true);
|
||||
return;
|
||||
}
|
||||
if ('action' in item && item.action === 'about') {
|
||||
toast('杜康好客 · 传承千年酒文化');
|
||||
}
|
||||
@@ -496,14 +514,32 @@ export default function MinePage() {
|
||||
</View>
|
||||
<View
|
||||
className={`mine-profile-sheet-save${savingProfile ? ' is-disabled' : ''}`}
|
||||
onClick={savingProfile ? undefined : () => void saveWxProfile()}
|
||||
onClick={() => {
|
||||
if (!savingProfile) void saveWxProfile();
|
||||
}}
|
||||
>
|
||||
<Text>{savingProfile ? '保存中...' : '保存'}</Text>
|
||||
<Text>{savingProfile ? '保存中…' : '保存'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{qualificationOpen ? (
|
||||
<View
|
||||
className="mine-qualification-mask"
|
||||
onClick={() => setQualificationOpen(false)}
|
||||
>
|
||||
<ScrollView scrollY className="mine-qualification-scroll" enhanced showScrollbar>
|
||||
<Image
|
||||
className="mine-qualification-img"
|
||||
src={qualificationDisclosureImg}
|
||||
mode="widthFix"
|
||||
/>
|
||||
</ScrollView>
|
||||
<Text className="mine-qualification-hint">点击任意处关闭</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '现场取货确认',
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { request } from '../../lib/api';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
|
||||
type PreviewProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
spec?: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
};
|
||||
|
||||
type OrderPreview = {
|
||||
product: PreviewProduct;
|
||||
quantity: number;
|
||||
deliveryType: string;
|
||||
productAmount: number;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
quantityOk?: boolean;
|
||||
quantityMessage?: string | null;
|
||||
minQty?: number;
|
||||
};
|
||||
|
||||
export default function OrderConfirmPickupPage() {
|
||||
const router = useRouter();
|
||||
const productId = router.params.productId ?? '';
|
||||
const [quantity, setQuantity] = useState(Math.max(2, Number(router.params.qty || 2)));
|
||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const phonePromptSkipped = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) return;
|
||||
let cancelled = false;
|
||||
setPreviewLoading(true);
|
||||
request<OrderPreview>('/trade/orders/preview', {
|
||||
method: 'POST',
|
||||
data: { productId, quantity, onSitePickup: true },
|
||||
})
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setPreview(data);
|
||||
setMsg(data.quantityOk === false ? data.quantityMessage || '' : '');
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '加载失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPreviewLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [productId, quantity]);
|
||||
|
||||
const minQty = preview?.minQty ?? 2;
|
||||
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
|
||||
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
|
||||
|
||||
function updateQuantity(next: number) {
|
||||
if (next < minQty) return;
|
||||
setQuantity(next);
|
||||
}
|
||||
|
||||
async function doSubmit() {
|
||||
const order = await request<{ id: string }>('/trade/orders', {
|
||||
method: 'POST',
|
||||
data: { productId, quantity, onSitePickup: true },
|
||||
});
|
||||
Taro.redirectTo({
|
||||
url: buildPayUrl({
|
||||
orderId: order.id,
|
||||
productId,
|
||||
qty: String(quantity),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit) {
|
||||
if (!quantityOk) setMsg(`现场提货至少购买 ${minQty} 瓶`);
|
||||
return;
|
||||
}
|
||||
|
||||
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=${quantity}`;
|
||||
|
||||
if (!phonePromptSkipped.current) {
|
||||
try {
|
||||
const profile = await fetchUserProfile();
|
||||
const phoneBound =
|
||||
!!profile.phoneVerified ||
|
||||
(!!profile.phone && /^1[3-9]\d{9}$/.test(String(profile.phone)));
|
||||
if (!phoneBound) {
|
||||
const { confirm, cancel } = await Taro.showModal({
|
||||
title: '建议绑定手机号',
|
||||
content: '绑定后便于订单通知与售后联系;也可跳过,不绑定也能继续下单。',
|
||||
confirmText: '去绑定',
|
||||
cancelText: '暂不绑定',
|
||||
});
|
||||
if (confirm) {
|
||||
goLogin(returnPath, { needPhone: '1' });
|
||||
return;
|
||||
}
|
||||
if (cancel) {
|
||||
phonePromptSkipped.current = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 拉取档案失败不阻塞下单 */
|
||||
}
|
||||
}
|
||||
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await doSubmit();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
|
||||
const submitLabel = loading
|
||||
? '提交中…'
|
||||
: !quantityOk
|
||||
? `至少购买 ${minQty} 瓶`
|
||||
: '提交订单';
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
|
||||
<SubPageHeader title="现场取货确认" />
|
||||
<View className="sub-page-body">
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">取货方式</Text>
|
||||
<Text className="u-muted">现场取货 · 无需填写收货地址 · 免运费</Text>
|
||||
</View>
|
||||
|
||||
{preview ? (
|
||||
<>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">商品信息</Text>
|
||||
<View className="order-product-row">
|
||||
<View className="order-product-thumb">
|
||||
{productImage ? (
|
||||
<Image
|
||||
className="order-product-thumb-img"
|
||||
src={productImage}
|
||||
mode="aspectFill"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text className="order-product-name">{preview.product.name}</Text>
|
||||
{preview.product.spec ? (
|
||||
<Text className="u-muted">{preview.product.spec}</Text>
|
||||
) : null}
|
||||
<Text className="order-product-price">
|
||||
¥{Number(preview.product.price).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-qty-row">
|
||||
<Text>购买数量</Text>
|
||||
<View className="order-qty-controls">
|
||||
<View
|
||||
className={`order-qty-btn${quantity <= minQty ? ' order-qty-btn--disabled' : ''}`}
|
||||
onClick={() => updateQuantity(Math.max(minQty, quantity - 1))}
|
||||
>
|
||||
<Text>−</Text>
|
||||
</View>
|
||||
<Text className="order-qty-value">{quantity}</Text>
|
||||
<View className="order-qty-btn" onClick={() => updateQuantity(quantity + 1)}>
|
||||
<Text>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">费用明细</Text>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">商品金额</Text>
|
||||
<Text className="order-row-value">¥{Number(preview.productAmount).toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">好客权益</Text>
|
||||
<Text className="order-row-value--price">¥{Number(preview.benefitAmount).toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">运费</Text>
|
||||
<Text className="order-row-value">免运费</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : previewLoading ? (
|
||||
<View className="u-empty">加载订单信息…</View>
|
||||
) : null}
|
||||
|
||||
{msg ? (
|
||||
<Text className="u-muted" style={{ display: 'block', marginTop: 8 }}>
|
||||
{msg}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View className="order-confirm-bar">
|
||||
<View className="order-confirm-total">
|
||||
<Text className="order-confirm-total-label">应付合计</Text>
|
||||
<Text className="order-confirm-total-value">
|
||||
¥{preview ? Number(preview.payAmount).toFixed(2) : '—'}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
|
||||
onClick={() => {
|
||||
if (!canSubmit) return;
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<Text>{submitLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -333,7 +333,11 @@ export default function OrderConfirmPage() {
|
||||
{!previewLoading && !preview && productId ? (
|
||||
<View className="u-empty">无法加载商品信息</View>
|
||||
) : null}
|
||||
{msg ? <Text className="u-muted" style={{ display: 'block', marginTop: 8 }}>{msg}</Text> : null}
|
||||
{msg ? (
|
||||
<Text className="u-muted" style={{ display: 'block', marginTop: 8, textAlign: 'center' }}>
|
||||
{msg}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className="order-confirm-bar">
|
||||
|
||||
@@ -47,7 +47,9 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_SHIP: '待发货',
|
||||
OUT_WAREHOUSE: '出库中',
|
||||
SHIPPING: '配送中',
|
||||
SHIPPED: '配送中',
|
||||
PENDING_RECEIVE: '待签收',
|
||||
DELIVERED: '待签收',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
@@ -68,6 +70,7 @@ export default function OrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const orderId = router.params.id ?? '';
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return;
|
||||
@@ -76,7 +79,11 @@ export default function OrderDetailPage() {
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [orderId]);
|
||||
|
||||
const canPay = !!order && order.status === 'PENDING_PAY' && !order.originOrderId;
|
||||
const isReship = !!order?.originOrderId;
|
||||
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
|
||||
const canConfirmReceive =
|
||||
!!order && !isReship && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
|
||||
|
||||
const item = order?.items?.[0];
|
||||
const productName = item?.productName || order?.productName || '杜康商品';
|
||||
const quantity = item?.quantity ?? order?.quantity ?? order?.qty ?? 1;
|
||||
@@ -111,10 +118,36 @@ export default function OrderDetailPage() {
|
||||
Taro.navigateTo({ url: '/pages/customer-service/index' });
|
||||
}
|
||||
|
||||
async function confirmReceive() {
|
||||
if (!order || !canConfirmReceive || confirming) return;
|
||||
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认收货?',
|
||||
content: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
|
||||
confirmText: '确认收货',
|
||||
cancelText: '再想想',
|
||||
});
|
||||
if (!confirm) return;
|
||||
|
||||
setConfirming(true);
|
||||
try {
|
||||
const updated = await request<OrderDetail>(`/trade/orders/${order.id}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: {},
|
||||
});
|
||||
setOrder(updated);
|
||||
toast('已确认收货');
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '确认收货失败');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}
|
||||
|
||||
const pageClass = [
|
||||
'order-detail-page',
|
||||
order ? 'order-detail-page--with-actions' : '',
|
||||
canPay ? 'order-detail-page--with-pay' : '',
|
||||
canPay || canConfirmReceive ? 'order-detail-page--with-pay' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
@@ -189,7 +222,11 @@ export default function OrderDetailPage() {
|
||||
</View>
|
||||
|
||||
{order ? (
|
||||
<View className={`order-detail-actionbar${canPay ? ' order-detail-actionbar--with-pay' : ''}`}>
|
||||
<View
|
||||
className={`order-detail-actionbar${
|
||||
canPay || canConfirmReceive ? ' order-detail-actionbar--with-pay' : ''
|
||||
}`}
|
||||
>
|
||||
{isWeapp ? (
|
||||
<ContactCsButton
|
||||
className="order-detail-cs-btn"
|
||||
@@ -219,6 +256,14 @@ export default function OrderDetailPage() {
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
{canConfirmReceive ? (
|
||||
<View
|
||||
className={`order-confirm-submit${confirming ? ' order-confirm-submit--disabled' : ''}`}
|
||||
onClick={confirming ? undefined : () => void confirmReceive()}
|
||||
>
|
||||
{confirming ? '提交中…' : '确认收货'}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</PageShell>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '我的订单',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { usePullDownRefresh, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
|
||||
import { ORDER_STATUS_LABELS } from '@dukang/shared-types';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'all', label: '全部订单' },
|
||||
@@ -12,24 +15,21 @@ const TABS = [
|
||||
{ key: 'completed', label: '已完成' },
|
||||
] as const;
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '已付款',
|
||||
OUT_WAREHOUSE: '已付款',
|
||||
SHIPPED: '已付款',
|
||||
DELIVERED: '已付款',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
};
|
||||
|
||||
function orderStatusLabel(tab: string, status?: string): string {
|
||||
if (tab !== 'all') {
|
||||
return TABS.find((t) => t.key === tab)?.label || status || '';
|
||||
}
|
||||
if (!status) return '';
|
||||
return STATUS_LABELS[status] || status;
|
||||
return ORDER_STATUS_LABELS[status] || status;
|
||||
}
|
||||
|
||||
type OrderItem = {
|
||||
productName?: string;
|
||||
productImage?: string;
|
||||
unitPrice?: number;
|
||||
quantity?: number;
|
||||
};
|
||||
|
||||
type OrderRow = {
|
||||
id: string;
|
||||
orderNo?: string;
|
||||
@@ -37,7 +37,9 @@ type OrderRow = {
|
||||
payAmount?: number;
|
||||
productName?: string;
|
||||
qty?: number;
|
||||
createdAt?: string;
|
||||
quantity?: number;
|
||||
originOrderId?: string | null;
|
||||
items?: OrderItem[];
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
@@ -47,9 +49,9 @@ export default function OrdersPage() {
|
||||
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadOrders = useCallback(() => {
|
||||
setLoading(true);
|
||||
request<{ list?: OrderRow[]; items?: OrderRow[]; total?: number } | OrderRow[]>(
|
||||
return request<{ list?: OrderRow[]; items?: OrderRow[]; total?: number } | OrderRow[]>(
|
||||
`/trade/orders?tab=${encodeURIComponent(tab)}&pageSize=20`,
|
||||
)
|
||||
.then((data) => {
|
||||
@@ -63,6 +65,18 @@ export default function OrdersPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOrders();
|
||||
}, [loadOrders]);
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void loadOrders().finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
function goPay(orderId: string) {
|
||||
Taro.navigateTo({ url: buildPayUrl({ orderId }) });
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="orders-page">
|
||||
<SubPageHeader title="我的订单" />
|
||||
@@ -81,35 +95,62 @@ export default function OrdersPage() {
|
||||
{loading ? <View className="u-empty">加载中…</View> : null}
|
||||
{!loading && orders.length === 0 ? <View className="u-empty">暂无订单</View> : null}
|
||||
{!loading &&
|
||||
orders.map((o) => (
|
||||
<View
|
||||
key={o.id}
|
||||
className="order-list-item"
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })}
|
||||
>
|
||||
<View className="order-list-head">
|
||||
<Text className="order-list-no">{o.orderNo || o.id}</Text>
|
||||
<Text className="order-list-status">
|
||||
{orderStatusLabel(tab, o.status)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="order-list-body">
|
||||
<View className="order-list-thumb" />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text className="order-list-name">{o.productName || '杜康商品'}</Text>
|
||||
<Text className="order-list-meta">
|
||||
数量 {o.qty ?? 1} · {o.createdAt ? String(o.createdAt).slice(0, 10) : ''}
|
||||
orders.map((o) => {
|
||||
const item = o.items?.[0];
|
||||
const productName = item?.productName || o.productName || '杜康商品';
|
||||
const productImage = (item?.productImage || '').trim();
|
||||
const qty = item?.quantity ?? o.quantity ?? o.qty ?? 1;
|
||||
const unitPrice = Number(item?.unitPrice ?? 0);
|
||||
const canPay = o.status === 'PENDING_PAY' && !o.originOrderId;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={o.id}
|
||||
className="order-list-item"
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })}
|
||||
>
|
||||
<View className="order-list-head">
|
||||
<Text className="order-list-no">{o.orderNo || o.id}</Text>
|
||||
<Text className="order-list-status">
|
||||
{orderStatusLabel(tab, o.status)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="order-list-body">
|
||||
<View className="order-list-thumb">
|
||||
{productImage ? (
|
||||
<Image className="order-list-thumb-img" src={productImage} mode="aspectFill" />
|
||||
) : null}
|
||||
</View>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text className="order-list-name">{productName}</Text>
|
||||
<View className="order-list-meta-row">
|
||||
<Text className="order-list-meta">数量 {qty}</Text>
|
||||
<Text className="order-list-meta">单价 ¥{unitPrice.toFixed(2)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-list-footer">
|
||||
<View className="order-list-pay-amount">
|
||||
<Text className="order-list-meta">实付</Text>
|
||||
<Text className="order-product-price">
|
||||
¥{Number(o.payAmount ?? 0).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
{canPay ? (
|
||||
<View
|
||||
className="order-list-pay-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goPay(o.id);
|
||||
}}
|
||||
>
|
||||
付款
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-list-footer">
|
||||
<Text className="order-list-meta">实付</Text>
|
||||
<Text className="order-product-price">
|
||||
¥{Number(o.payAmount ?? 0).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function PayPage() {
|
||||
const [msg, setMsg] = useState('');
|
||||
const [orderNo, setOrderNo] = useState('');
|
||||
const [payAmount, setPayAmount] = useState('—');
|
||||
const [deliveryType, setDeliveryType] = useState('');
|
||||
|
||||
const returnPath = orderId
|
||||
? `/pages/pay/index?orderId=${orderId}`
|
||||
@@ -62,11 +63,15 @@ export default function PayPage() {
|
||||
setPayAmount('—');
|
||||
return;
|
||||
}
|
||||
request<{ orderNo?: string; payAmount?: number | string; totalAmount?: number | string }>(
|
||||
`/trade/orders/${orderId}`,
|
||||
)
|
||||
request<{
|
||||
orderNo?: string;
|
||||
payAmount?: number | string;
|
||||
totalAmount?: number | string;
|
||||
deliveryType?: string;
|
||||
}>(`/trade/orders/${orderId}`)
|
||||
.then((order) => {
|
||||
setOrderNo(order.orderNo || '');
|
||||
setDeliveryType(order.deliveryType || '');
|
||||
const amount = Number(order.payAmount ?? order.totalAmount ?? 0);
|
||||
if (Number.isFinite(amount) && amount > 0) {
|
||||
setPayAmount(amount.toFixed(2));
|
||||
@@ -74,6 +79,7 @@ export default function PayPage() {
|
||||
})
|
||||
.catch((e) => {
|
||||
setOrderNo('');
|
||||
setDeliveryType('');
|
||||
toast(e instanceof Error ? e.message : '加载订单失败');
|
||||
});
|
||||
}, [orderId]);
|
||||
@@ -137,7 +143,11 @@ export default function PayPage() {
|
||||
} else {
|
||||
toast('支付成功', 'success');
|
||||
}
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=paid' });
|
||||
if (deliveryType === 'ON_SITE_PICKUP') {
|
||||
Taro.redirectTo({ url: `/pages/pickup-receive/index?id=${orderId}` });
|
||||
} else {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=paid' });
|
||||
}
|
||||
} catch (e) {
|
||||
if (isWechatAuthRequiredError(e)) {
|
||||
setNeedsWechatAuth(true);
|
||||
@@ -200,7 +210,7 @@ export default function PayPage() {
|
||||
</View>
|
||||
</View>
|
||||
{msg ? (
|
||||
<Text className="pay-wechat-auth-msg" style={{ display: 'block', marginTop: 12 }}>
|
||||
<Text className="pay-wechat-auth-msg" style={{ marginTop: 12 }}>
|
||||
{msg}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '确认收货',
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
|
||||
type OrderDetail = {
|
||||
id: string;
|
||||
orderNo?: string;
|
||||
status?: string;
|
||||
payAmount?: number | string;
|
||||
productName?: string;
|
||||
productSpec?: string;
|
||||
quantity?: number;
|
||||
product?: {
|
||||
name?: string;
|
||||
spec?: string;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
};
|
||||
imageUrl?: string | null;
|
||||
mainImageUrl?: string | null;
|
||||
};
|
||||
|
||||
export default function PickupReceivePage() {
|
||||
const router = useRouter();
|
||||
const orderId = router.params.id ?? router.params.orderId ?? '';
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!orderId) return;
|
||||
setLoading(true);
|
||||
request<OrderDetail>(`/trade/orders/${orderId}`)
|
||||
.then((data) => setOrder(data))
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [orderId]);
|
||||
|
||||
useDidShow(() => {
|
||||
load();
|
||||
});
|
||||
|
||||
async function confirmReceive() {
|
||||
if (!orderId || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request(`/trade/orders/${orderId}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: {},
|
||||
});
|
||||
toast('确认收货成功', 'success');
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=done' });
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '确认失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const name = order?.productName || order?.product?.name || '商品';
|
||||
const spec = order?.productSpec || order?.product?.spec;
|
||||
const image =
|
||||
order?.mainImageUrl ||
|
||||
order?.imageUrl ||
|
||||
(order?.product ? getProductMainImage(order.product) : '') ||
|
||||
'';
|
||||
const amount = Number(order?.payAmount ?? 0);
|
||||
const canConfirm = order?.status === 'PENDING_RECEIVE' || order?.status === 'DELIVERED';
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
|
||||
<SubPageHeader title="确认收货" />
|
||||
<View className="sub-page-body">
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">现场取货</Text>
|
||||
<Text className="u-muted">请确认已在现场拿到商品后再点击确认收货</Text>
|
||||
</View>
|
||||
|
||||
{loading && !order ? (
|
||||
<View className="order-card">
|
||||
<Text className="u-muted">加载中…</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{order ? (
|
||||
<>
|
||||
<View className="order-card">
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">订单号</Text>
|
||||
<Text className="order-row-value">{order.orderNo || '—'}</Text>
|
||||
</View>
|
||||
<View className="order-product-row" style={{ marginTop: 12 }}>
|
||||
<View className="order-product-thumb">
|
||||
{image ? (
|
||||
<Image className="order-product-thumb-img" src={image} mode="aspectFill" />
|
||||
) : null}
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text className="order-product-name">{name}</Text>
|
||||
{spec ? <Text className="u-muted">{spec}</Text> : null}
|
||||
<Text className="u-muted">×{order.quantity ?? 1}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="order-card">
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">实付金额</Text>
|
||||
<Text className="order-row-value order-pay-amount">
|
||||
¥{Number.isFinite(amount) ? amount.toFixed(2) : '—'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
<View className="pay-bar">
|
||||
<View
|
||||
className="order-confirm-submit"
|
||||
style={{ flex: 1, opacity: canConfirm && !submitting ? 1 : 0.6 }}
|
||||
onClick={() => {
|
||||
if (!canConfirm || submitting) return;
|
||||
void confirmReceive();
|
||||
}}
|
||||
>
|
||||
<Text>{submitting ? '提交中…' : canConfirm ? '确认收货' : '订单状态不可确认'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -11,17 +11,51 @@ type BenefitSummary = {
|
||||
maxRedeemAmount: number;
|
||||
};
|
||||
|
||||
const MIN_REDEEM_AMOUNT = 0.01;
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 核销金额输入清洗:
|
||||
* - 只保留数字与一个小数点
|
||||
* - 小数最多 2 位(再输入会被截断)
|
||||
* - 去掉多余前导 0
|
||||
*/
|
||||
function sanitizeRedeemAmountInput(raw: string): string {
|
||||
let next = String(raw ?? '').replace(/[^\d.]/g, '');
|
||||
if (!next) return '';
|
||||
|
||||
const firstDot = next.indexOf('.');
|
||||
if (firstDot >= 0) {
|
||||
const intRaw = next.slice(0, firstDot).replace(/\D/g, '');
|
||||
const decRaw = next
|
||||
.slice(firstDot + 1)
|
||||
.replace(/\D/g, '')
|
||||
.slice(0, 2);
|
||||
const intPart = intRaw.replace(/^0+(?=\d)/, '') || '0';
|
||||
if (next.endsWith('.') && decRaw.length === 0) {
|
||||
return `${intPart}.`;
|
||||
}
|
||||
if (decRaw.length > 0) {
|
||||
return `${intPart}.${decRaw}`;
|
||||
}
|
||||
return intPart;
|
||||
}
|
||||
|
||||
return next.replace(/^0+(?=\d)/, '');
|
||||
}
|
||||
|
||||
export default function RedeemPage() {
|
||||
const router = useRouter();
|
||||
const couponId = router.params.couponId;
|
||||
const initialAmount = router.params.amount ?? '';
|
||||
const [balance, setBalance] = useState(0);
|
||||
const [couponBalance, setCouponBalance] = useState<number | null>(null);
|
||||
const [amount, setAmount] = useState(initialAmount);
|
||||
const [amount, setAmount] = useState(() => sanitizeRedeemAmountInput(initialAmount));
|
||||
/** 原生 input 在截断小数后偶发不同步,强制 remount 对齐受控值 */
|
||||
const [inputKey, setInputKey] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const redeemableMax = couponId ? (couponBalance ?? 0) : balance;
|
||||
@@ -50,14 +84,24 @@ export default function RedeemPage() {
|
||||
}, [couponId]);
|
||||
|
||||
function fillMaxAmount() {
|
||||
if (redeemableMax <= 0) return;
|
||||
setAmount(String(redeemableMax));
|
||||
if (redeemableMax < MIN_REDEEM_AMOUNT) return;
|
||||
setAmount(sanitizeRedeemAmountInput(redeemableMax.toFixed(2)));
|
||||
setInputKey((k) => k + 1);
|
||||
}
|
||||
|
||||
function onAmountChange(raw: string) {
|
||||
const next = sanitizeRedeemAmountInput(raw);
|
||||
setAmount(next);
|
||||
// 用户试图输入超过两位小数 / 非法字符时,强制刷新原生框显示
|
||||
if (next !== raw) {
|
||||
setInputKey((k) => k + 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const value = Math.round(Number(amount) * 100) / 100;
|
||||
if (!(value > 0)) {
|
||||
toast('请输入核销金额');
|
||||
if (!Number.isFinite(value) || value < MIN_REDEEM_AMOUNT) {
|
||||
toast(`核销金额不能低于 ${MIN_REDEEM_AMOUNT.toFixed(2)} 元`);
|
||||
return;
|
||||
}
|
||||
if (value > redeemableMax) {
|
||||
@@ -98,29 +142,34 @@ export default function RedeemPage() {
|
||||
</View>
|
||||
<View className="redeem-input-wrap">
|
||||
<Input
|
||||
key={inputKey}
|
||||
className="redeem-input"
|
||||
type="digit"
|
||||
placeholder="输入核销金额"
|
||||
placeholderClass="redeem-input-placeholder"
|
||||
value={amount}
|
||||
onInput={(e) => setAmount(e.detail.value)}
|
||||
maxlength={12}
|
||||
onInput={(e) => onAmountChange(e.detail.value)}
|
||||
onBlur={(e) => onAmountChange(e.detail.value)}
|
||||
style={{ textAlign: 'center' }}
|
||||
/>
|
||||
</View>
|
||||
<View className="redeem-amount-foot">
|
||||
<Text className="redeem-tips" style={{ margin: 0 }}>
|
||||
<Text className="redeem-amount-hint">
|
||||
最高可核销 ¥{formatMoney(redeemableMax)}
|
||||
</Text>
|
||||
<Text className="redeem-fill-max" onClick={fillMaxAmount}>
|
||||
全部核销
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="redeem-tips">
|
||||
直接核销:金额须大于 0 且不超过全部可用权益余额。核销码有效期 3 分钟,请到店出示。
|
||||
</Text>
|
||||
<View className="redeem-tips">
|
||||
<Text className="redeem-tips-text">
|
||||
核销金额最低 0.01 元,小数最多两位。不超过可用权益余额。核销码有效期 3 分钟,请到店出示给收银员扫码。
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`redeem-submit${loading || redeemableMax <= 0 ? ' redeem-submit--disabled' : ''}`}
|
||||
onClick={loading || redeemableMax <= 0 ? undefined : submit}
|
||||
className={`redeem-submit${loading || redeemableMax < MIN_REDEEM_AMOUNT ? ' redeem-submit--disabled' : ''}`}
|
||||
onClick={loading || redeemableMax < MIN_REDEEM_AMOUNT ? undefined : submit}
|
||||
>
|
||||
<Text>{loading ? '生成中...' : '生成核销码'}</Text>
|
||||
</View>
|
||||
|
||||
@@ -17,15 +17,29 @@ type Store = {
|
||||
id: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
province?: string;
|
||||
cityName?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
phone?: string;
|
||||
intro?: string | null;
|
||||
coverUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
openTime2?: string | null;
|
||||
closeTime2?: string | null;
|
||||
avgPrice?: number | null;
|
||||
latitude?: number | string | null;
|
||||
longitude?: number | string | null;
|
||||
category?: { name: string } | null;
|
||||
};
|
||||
|
||||
function fullAddress(store: Store) {
|
||||
const city = store.cityName || store.city || '';
|
||||
return `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
|
||||
}
|
||||
|
||||
export default function StoreDetailPage() {
|
||||
const router = useRouter();
|
||||
const storeId = router.params.id ?? '';
|
||||
@@ -54,7 +68,7 @@ export default function StoreDetailPage() {
|
||||
const sharePayload = useMemo(
|
||||
() => ({
|
||||
title: store?.name || DEFAULT_SHARE_TITLE,
|
||||
desc: store?.address || DEFAULT_SHARE_DESC,
|
||||
desc: store?.intro?.trim() || store?.address || DEFAULT_SHARE_DESC,
|
||||
path: `/pages/store-detail/index?id=${storeId}`,
|
||||
imgUrl: store?.coverUrl || store?.carouselUrls?.[0] || undefined,
|
||||
}),
|
||||
@@ -74,6 +88,44 @@ export default function StoreDetailPage() {
|
||||
else Taro.switchTab({ url: '/pages/stores/index' });
|
||||
}
|
||||
|
||||
function callStore() {
|
||||
if (!store?.phone) {
|
||||
toast('暂无门店电话');
|
||||
return;
|
||||
}
|
||||
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
|
||||
}
|
||||
|
||||
function openMap() {
|
||||
if (!store) return;
|
||||
const lat = store.latitude != null ? Number(store.latitude) : NaN;
|
||||
const lng = store.longitude != null ? Number(store.longitude) : NaN;
|
||||
const address = fullAddress(store) || store.address || store.name;
|
||||
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
Taro.openLocation({
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
name: store.name,
|
||||
address,
|
||||
scale: 16,
|
||||
}).catch(() => {
|
||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
|
||||
window.location.href = `https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(store.name)}&address=${encodeURIComponent(address)}`;
|
||||
return;
|
||||
}
|
||||
toast('无法打开地图导航');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' && address) {
|
||||
window.location.href = `https://uri.amap.com/search?keyword=${encodeURIComponent(address)}&src=dukang`;
|
||||
return;
|
||||
}
|
||||
toast('门店位置待完善,暂无法导航');
|
||||
}
|
||||
|
||||
if (!store) {
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-detail-page">
|
||||
@@ -90,6 +142,8 @@ export default function StoreDetailPage() {
|
||||
? [store.coverUrl]
|
||||
: []) as string[];
|
||||
|
||||
const intro = store.intro?.trim() || '';
|
||||
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
@@ -107,14 +161,39 @@ export default function StoreDetailPage() {
|
||||
|
||||
<View className="store-detail-info-card">
|
||||
<Text className="store-detail-name">{store.name}</Text>
|
||||
|
||||
<View className="store-detail-row">
|
||||
<Text className="store-detail-meta store-detail-meta--flex">
|
||||
{store.district ? `${store.district} · ` : ''}
|
||||
{store.address || '地址待完善'}
|
||||
</Text>
|
||||
<Text className="store-detail-action" onClick={openMap}>
|
||||
导航
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className="store-detail-meta">
|
||||
{store.district ? `${store.district} · ` : ''}
|
||||
{store.address || '地址待完善'}
|
||||
营业时间:{' '}
|
||||
{(() => {
|
||||
const parts: string[] = [];
|
||||
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||
return parts.length ? parts.join(',') : '10:00-22:00';
|
||||
})()}
|
||||
</Text>
|
||||
<Text className="store-detail-meta">
|
||||
营业时间: {store.openTime && store.closeTime ? `${store.openTime}-${store.closeTime}` : '10:00-22:00'}
|
||||
</Text>
|
||||
{store.phone ? <Text className="store-detail-meta">电话: {store.phone}</Text> : null}
|
||||
{store.avgPrice != null && Number(store.avgPrice) > 0 ? (
|
||||
<Text className="store-detail-meta">人均约 ¥{Number(store.avgPrice).toFixed(0)}</Text>
|
||||
) : null}
|
||||
|
||||
{store.phone ? (
|
||||
<View className="store-detail-row">
|
||||
<Text className="store-detail-meta store-detail-meta--flex">电话: {store.phone}</Text>
|
||||
<Text className="store-detail-action" onClick={callStore}>
|
||||
拨打
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="store-detail-tags">
|
||||
{store.category?.name ? (
|
||||
<Text className="store-detail-tag">{store.category.name}</Text>
|
||||
@@ -124,8 +203,18 @@ export default function StoreDetailPage() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{intro ? (
|
||||
<View className="store-detail-section">
|
||||
<Text className="store-detail-section-title">门店详情</Text>
|
||||
<Text className="store-detail-intro">{intro}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="store-detail-bar">
|
||||
<View className="u-btn u-btn--block" onClick={() => toast('核销请前往「好客权益」')}>
|
||||
<View
|
||||
className="u-btn u-btn--block"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||
>
|
||||
<Text>到店核销</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '门店',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, Image, Input } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import RegionPicker from '../../components/RegionPicker';
|
||||
import CategoryPicker, {
|
||||
EMPTY_CATEGORY,
|
||||
formatCategoryLabel,
|
||||
type CategorySelection,
|
||||
type StoreCategoryNode,
|
||||
} from '../../components/CategoryPicker';
|
||||
import {
|
||||
DEFAULT_REGION,
|
||||
formatRegionLabel,
|
||||
@@ -11,8 +17,15 @@ import {
|
||||
type RegionSelection,
|
||||
} from '../../lib/region-data';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
||||
import {
|
||||
getCityCodeForCatalog,
|
||||
readCachedUserCoords,
|
||||
resolveUserCity,
|
||||
toCityWideRegion,
|
||||
type UserCoords,
|
||||
} from '../../lib/user-location';
|
||||
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
|
||||
import { formatDistanceMeters } from '../../lib/geo';
|
||||
import { request, toast } from '../../lib/api';
|
||||
|
||||
type Store = {
|
||||
@@ -25,86 +38,183 @@ type Store = {
|
||||
coverUrl?: string | null;
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
openTime2?: string | null;
|
||||
closeTime2?: string | null;
|
||||
avgPrice?: number | null;
|
||||
status?: string;
|
||||
categoryId?: string | null;
|
||||
category?: { id?: string; name?: string; parentId?: string | null } | null;
|
||||
latitude?: number | string | null;
|
||||
longitude?: number | string | null;
|
||||
distanceMeters?: number | null;
|
||||
};
|
||||
|
||||
const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
|
||||
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
|
||||
|
||||
export default function StoresPage() {
|
||||
const [stores, setStores] = useState<Store[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [categoryTab, setCategoryTab] = useState<string>('全部');
|
||||
const [keywordInput, setKeywordInput] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
|
||||
const [regionOpen, setRegionOpen] = useState(false);
|
||||
const [category, setCategory] = useState<CategorySelection>(EMPTY_CATEGORY);
|
||||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||
const [cityCode, setCityCode] = useState<string>(FALLBACK_CITY_CODE);
|
||||
const [userCoords, setUserCoords] = useState<UserCoords | null>(() => readCachedUserCoords());
|
||||
const regionLabel = formatRegionLabel(region);
|
||||
const categoryLabel = formatCategoryLabel(category);
|
||||
|
||||
const childIdsByParent = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const root of categoryTree) {
|
||||
map.set(
|
||||
root.id,
|
||||
(root.children ?? []).map((c) => c.id),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [categoryTree]);
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(1);
|
||||
void resolveUserCity().then((resolved) => {
|
||||
setRegion(resolved.region);
|
||||
setRegion(toCityWideRegion(resolved.region));
|
||||
setCityCode(getCityCodeForCatalog(resolved));
|
||||
setUserCoords(readCachedUserCoords());
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void request<StoreCategoryNode[]>('/store-categories')
|
||||
.then((tree) => setCategoryTree(Array.isArray(tree) ? tree : []))
|
||||
.catch(() => setCategoryTree([]));
|
||||
}, []);
|
||||
|
||||
const loadStores = useCallback(() => {
|
||||
setLoading(true);
|
||||
const path = cityCode ? `/stores?cityCode=${encodeURIComponent(cityCode)}` : '/stores';
|
||||
request<Store[]>(path)
|
||||
const qs = new URLSearchParams();
|
||||
if (cityCode) qs.set('cityCode', cityCode);
|
||||
const coords = userCoords ?? readCachedUserCoords();
|
||||
if (coords) {
|
||||
qs.set('lat', String(coords.latitude));
|
||||
qs.set('lng', String(coords.longitude));
|
||||
}
|
||||
const path = qs.toString() ? `/stores?${qs}` : '/stores';
|
||||
return request<Store[]>(path)
|
||||
.then((list) => setStores(Array.isArray(list) ? list : []))
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [cityCode]);
|
||||
}, [cityCode, userCoords]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStores();
|
||||
}, [loadStores]);
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await resolveUserCity(true);
|
||||
setRegion(toCityWideRegion(resolved.region));
|
||||
const nextCode = getCityCodeForCatalog(resolved);
|
||||
setCityCode(nextCode);
|
||||
const coords = readCachedUserCoords();
|
||||
setUserCoords(coords);
|
||||
setLoading(true);
|
||||
const qs = new URLSearchParams();
|
||||
if (nextCode) qs.set('cityCode', nextCode);
|
||||
if (coords) {
|
||||
qs.set('lat', String(coords.latitude));
|
||||
qs.set('lng', String(coords.longitude));
|
||||
}
|
||||
const path = qs.toString() ? `/stores?${qs}` : '/stores';
|
||||
const list = await request<Store[]>(path);
|
||||
setStores(Array.isArray(list) ? list : []);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
Taro.stopPullDownRefresh();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
function matchesCategory(store: Store): boolean {
|
||||
if (!category.parentId) return true;
|
||||
const storeCatId = String(store.categoryId || store.category?.id || '');
|
||||
const storeParentId = String(store.category?.parentId || '');
|
||||
if (category.childId) {
|
||||
return storeCatId === category.childId;
|
||||
}
|
||||
if (storeParentId && storeParentId === category.parentId) return true;
|
||||
const siblings = childIdsByParent.get(category.parentId) ?? [];
|
||||
return siblings.includes(storeCatId);
|
||||
}
|
||||
|
||||
const filtered = stores.filter((s) => {
|
||||
if (!matchesRegionFilter(s, region)) return false;
|
||||
if (!matchesCategory(s)) return false;
|
||||
if (!keyword.trim()) return true;
|
||||
const q = keyword.trim();
|
||||
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
|
||||
});
|
||||
|
||||
function applySearch() {
|
||||
setKeyword(keywordInput.trim());
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setKeywordInput('');
|
||||
setKeyword('');
|
||||
setCategory(EMPTY_CATEGORY);
|
||||
setRegion(DEFAULT_REGION);
|
||||
}
|
||||
|
||||
function formatHours(store: Store) {
|
||||
if (store.openTime && store.closeTime) {
|
||||
return `营业时间: ${store.openTime}-${store.closeTime}`;
|
||||
}
|
||||
return '营业时间: 10:00-22:00';
|
||||
const parts: string[] = [];
|
||||
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||
return parts.length ? `营业时间: ${parts.join(',')}` : '营业时间: 10:00-22:00';
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="tab" className="store-page no-tab-header">
|
||||
<TabMainHeader title="门店" />
|
||||
<View className="store-toolbar">
|
||||
<View className="store-location" onClick={() => setRegionOpen(true)}>
|
||||
<View className="store-location-pin" />
|
||||
<Text className="store-location-text">{regionLabel} ▾</Text>
|
||||
</View>
|
||||
<Input
|
||||
className="store-search"
|
||||
placeholder="搜索门店"
|
||||
value={keyword}
|
||||
onInput={(e) => setKeyword(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="store-category-tabs">
|
||||
{CATEGORY_TABS.map((tab) => (
|
||||
<Text
|
||||
key={tab}
|
||||
className={`store-category-tab${categoryTab === tab ? ' store-category-tab--active' : ''}`}
|
||||
onClick={() => setCategoryTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
<View className="store-filter">
|
||||
<View className="store-search-row">
|
||||
<Input
|
||||
className="store-search-input"
|
||||
placeholder="搜索门店名称/地址"
|
||||
value={keywordInput}
|
||||
confirmType="search"
|
||||
onInput={(e) => setKeywordInput(e.detail.value)}
|
||||
onConfirm={applySearch}
|
||||
/>
|
||||
<View className="store-search-btn" onClick={applySearch} aria-label="搜索">
|
||||
<View className="store-search-icon" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="store-filter-row">
|
||||
<View className="store-filter-chip" onClick={() => setRegionOpen(true)}>
|
||||
<Text className="store-filter-chip-text">{regionLabel}</Text>
|
||||
<Text className="store-filter-chip-arrow">▾</Text>
|
||||
</View>
|
||||
<View className="store-filter-chip" onClick={() => setCategoryOpen(true)}>
|
||||
<Text className="store-filter-chip-text">{categoryLabel}</Text>
|
||||
<Text className="store-filter-chip-arrow">▾</Text>
|
||||
</View>
|
||||
<Text className="store-filter-reset" onClick={resetFilters}>
|
||||
重置
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="store-list">
|
||||
{loading ? <View className="u-empty">加载中…</View> : null}
|
||||
{!loading && filtered.length === 0 ? <View className="u-empty">暂无营业中门店</View> : null}
|
||||
{!loading &&
|
||||
filtered.map((s, index) => (
|
||||
filtered.map((s) => (
|
||||
<View
|
||||
key={s.id}
|
||||
className="store-card"
|
||||
@@ -122,8 +232,13 @@ export default function StoresPage() {
|
||||
{s.address || '地址待完善'}
|
||||
</Text>
|
||||
<Text className="store-card-meta">{formatHours(s)}</Text>
|
||||
{s.avgPrice != null && Number(s.avgPrice) > 0 ? (
|
||||
<Text className="store-card-meta">人均¥{Number(s.avgPrice).toFixed(0)}</Text>
|
||||
) : null}
|
||||
<View className="store-card-footer">
|
||||
<Text className="store-card-distance">{MOCK_DISTANCES[index % MOCK_DISTANCES.length]}</Text>
|
||||
<Text className="store-card-distance">
|
||||
{formatDistanceMeters(s.distanceMeters)}
|
||||
</Text>
|
||||
<Text
|
||||
className="store-card-cta"
|
||||
onClick={(e) => {
|
||||
@@ -147,6 +262,13 @@ export default function StoresPage() {
|
||||
onClose={() => setRegionOpen(false)}
|
||||
onConfirm={(next) => setRegion(next)}
|
||||
/>
|
||||
<CategoryPicker
|
||||
open={categoryOpen}
|
||||
tree={categoryTree}
|
||||
value={category}
|
||||
onClose={() => setCategoryOpen(false)}
|
||||
onConfirm={(next) => setCategory(next)}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,23 +31,45 @@
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.home-promo-banner {
|
||||
margin: 12px var(--space-page) 0;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--color-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
aspect-ratio: 15 / 8;
|
||||
}
|
||||
|
||||
.home-promo-banner-swiper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.home-promo-banner-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.home-aroma-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px var(--space-page);
|
||||
background: rgba(250, 249, 247, 0.95);
|
||||
border-bottom: 1px solid var(--color-surface-container);
|
||||
margin-top: 4px;
|
||||
padding: 6px var(--space-page) 4px;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
position: sticky;
|
||||
top: var(--nav-bar-height, 56px);
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
background: rgba(250, 249, 247, 0.96);
|
||||
}
|
||||
|
||||
.home-aroma-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -66,11 +88,11 @@
|
||||
.home-aroma-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 6px 12px;
|
||||
padding: 4px 10px;
|
||||
font-family: var(--font-headline);
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
line-height: 22px;
|
||||
color: var(--color-subtle-gray);
|
||||
border-bottom: 2px solid transparent;
|
||||
white-space: nowrap;
|
||||
@@ -84,68 +106,49 @@
|
||||
.home-product-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px var(--space-page);
|
||||
gap: 10px;
|
||||
padding: 8px var(--space-page) 12px;
|
||||
}
|
||||
|
||||
.home-product-card {
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.home-carousel-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background: var(--color-surface-container);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-carousel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.home-carousel-item,
|
||||
.home-carousel-image,
|
||||
.home-carousel-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.home-carousel-placeholder {
|
||||
background: var(--color-surface-container);
|
||||
}
|
||||
|
||||
.home-carousel-dots {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
.home-product-card-inner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.home-carousel-dot {
|
||||
height: 4px;
|
||||
width: 6px;
|
||||
margin: 0 3px;
|
||||
border-radius: 999px;
|
||||
background: rgba(153, 153, 153, 0.35);
|
||||
.home-product-thumb-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-container);
|
||||
}
|
||||
|
||||
.home-carousel-dot--active {
|
||||
width: 20px;
|
||||
background: var(--color-heritage-red);
|
||||
.home-product-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.home-product-body {
|
||||
padding: var(--space-gutter);
|
||||
.home-product-thumb--empty {
|
||||
background: var(--color-surface-container);
|
||||
}
|
||||
|
||||
.home-product-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.home-product-row {
|
||||
@@ -157,9 +160,9 @@
|
||||
|
||||
.home-product-name {
|
||||
font-family: var(--font-headline);
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
line-height: 20px;
|
||||
color: var(--color-on-surface);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
@@ -168,61 +171,96 @@
|
||||
}
|
||||
|
||||
.home-product-price {
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
color: var(--color-heritage-red);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-product-sub {
|
||||
font-size: 13px;
|
||||
font-size: 11px;
|
||||
color: var(--color-subtle-gray);
|
||||
line-height: 18px;
|
||||
line-height: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-product-footer {
|
||||
margin-top: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.home-product-actions {
|
||||
padding: 0 var(--space-gutter) var(--space-gutter);
|
||||
margin-top: auto;
|
||||
padding-top: 4px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.home-pickup-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-aged-amber);
|
||||
color: #5c4500;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.home-buy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-heritage-red);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.home-promo-footer {
|
||||
margin: 0 var(--space-page) 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--color-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
aspect-ratio: 15 / 4;
|
||||
}
|
||||
|
||||
.home-promo-footer-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.home-empty {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
padding: 32px 24px;
|
||||
color: var(--color-subtle-gray);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.coupon-badge {
|
||||
.home-page .coupon-badge {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--color-aged-amber);
|
||||
color: var(--color-on-secondary-container);
|
||||
padding: 4px 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 2px;
|
||||
font-family: var(--font-label);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
@@ -498,3 +498,38 @@
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mine-qualification-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 24px 16px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mine-qualification-scroll {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: calc(86vh - 36px);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mine-qualification-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mine-qualification-hint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* 小程序原生 tabBar 页面窗口已扣除底栏,避免滑到底多余空白 */
|
||||
.page-shell--tab.page-shell--native-tabbar {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-shell--scroll,
|
||||
.page-shell--sub,
|
||||
.page-shell--plain {
|
||||
|
||||
@@ -68,6 +68,11 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.order-confirm-submit--disabled {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -164,6 +169,7 @@
|
||||
font-size: 13px;
|
||||
color: var(--color-heritage-red);
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.order-row {
|
||||
@@ -314,6 +320,13 @@
|
||||
background: var(--color-surface-container);
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.order-list-thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.order-list-name {
|
||||
@@ -323,6 +336,12 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.order-list-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.order-list-meta {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
@@ -336,6 +355,28 @@
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-surface-container);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.order-list-pay-amount {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.order-list-pay-btn {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-heritage-red);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pay-status {
|
||||
@@ -398,7 +439,9 @@
|
||||
}
|
||||
|
||||
.pay-wechat-auth-msg {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--color-heritage-red, #a02d30);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -75,10 +75,26 @@
|
||||
}
|
||||
|
||||
.redeem-tips {
|
||||
margin: 0 var(--space-page);
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
margin: 8px var(--space-page) 0;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.redeem-tips-text {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--color-subtle-gray);
|
||||
line-height: 1.6;
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.redeem-amount-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-subtle-gray);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.redeem-amount-foot {
|
||||
|
||||
@@ -79,6 +79,38 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.store-detail-meta--flex {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.store-detail-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.store-detail-action {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(166, 29, 36, 0.08);
|
||||
color: var(--color-heritage-red);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.store-detail-intro {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--color-on-surface);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.store-detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -3,56 +3,34 @@
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.store-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.store-filter {
|
||||
padding: 0 var(--space-page) 12px;
|
||||
}
|
||||
|
||||
.store-location {
|
||||
.store-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
max-width: 42%;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-location-pin {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-heritage-red);
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.store-location-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-search {
|
||||
.store-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-container-low);
|
||||
font-size: 13px;
|
||||
line-height: 40px;
|
||||
color: var(--color-on-surface);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.store-search input,
|
||||
.store-search .taro-input,
|
||||
.store-search .weui-input {
|
||||
.store-search-input input,
|
||||
.store-search-input .taro-input,
|
||||
.store-search-input .weui-input {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
@@ -66,32 +44,92 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.store-category-tabs {
|
||||
.store-search-btn {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-heritage-red);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
padding: 12px var(--space-page);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid rgba(226, 190, 188, 0.1);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.store-category-tab {
|
||||
flex-shrink: 0;
|
||||
margin-right: 24px;
|
||||
.store-search-icon {
|
||||
position: relative;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-search-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
bottom: -4px;
|
||||
width: 7px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
transform: rotate(45deg);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.store-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.store-filter-chip {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-container-low);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-filter-chip-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-on-surface-variant);
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.store-category-tab--active {
|
||||
.store-filter-chip-arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: var(--color-subtle-gray);
|
||||
}
|
||||
|
||||
.store-filter-reset {
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-heritage-red);
|
||||
border-bottom-color: var(--color-heritage-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.store-list {
|
||||
padding: 16px var(--space-page);
|
||||
padding: 4px var(--space-page) 16px;
|
||||
}
|
||||
|
||||
.store-card {
|
||||
|
||||
@@ -61,12 +61,20 @@ describe('validatePartnerCityBinding', () => {
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects overlapping district codes', () => {
|
||||
it('allows overlapping district codes as labels', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }],
|
||||
[
|
||||
{
|
||||
id: '1',
|
||||
partnerAccountId: '10',
|
||||
scopeType: 'DISTRICT',
|
||||
districtCodes: ['410105'],
|
||||
companyName: '甲公司',
|
||||
},
|
||||
],
|
||||
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: ['410105'] },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('allows valid district binding', () => {
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface PartnerCityBindingInput {
|
||||
scopeType: CityPartnerScopeType;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: CityPartnerStatus;
|
||||
/** 仅用于重合报错文案 */
|
||||
companyName?: string | null;
|
||||
}
|
||||
|
||||
export interface PartnerCityResolveRef {
|
||||
@@ -98,20 +100,7 @@ export function validatePartnerCityBinding(
|
||||
return { ok: false, message: '区域合伙人须至少选择一个区县' };
|
||||
}
|
||||
|
||||
const occupied = new Set<string>();
|
||||
for (const row of others) {
|
||||
if (row.scopeType !== 'DISTRICT') continue;
|
||||
for (const code of normalizeDistrictCodes(row.districtCodes)) {
|
||||
occupied.add(code);
|
||||
}
|
||||
}
|
||||
|
||||
for (const code of districts) {
|
||||
if (occupied.has(code)) {
|
||||
return { ok: false, message: `区县 ${code} 已被其他区域合伙人占用` };
|
||||
}
|
||||
}
|
||||
|
||||
// 区县仅为标识,允许多个区域合伙人选择相同区县
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calcBenefitAmount,
|
||||
calcRedeemSettleAmount,
|
||||
calcLogisticsFeeByBottles,
|
||||
calcOrderBoxCount,
|
||||
shouldHoldAutoCourierDispatch,
|
||||
validateMinPurchase,
|
||||
validateBusinessHours,
|
||||
formatBusinessHours,
|
||||
validateRedeemAmount,
|
||||
allocateBenefitCoupons,
|
||||
calcBenefitSummary,
|
||||
@@ -32,6 +37,50 @@ describe('validateMinPurchase', () => {
|
||||
expect(validateMinPurchase('CROSS_CITY', 5, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('on-site pickup requires at least local min (2 bottles)', () => {
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 0, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBusinessHours', () => {
|
||||
it('accepts one segment', () => {
|
||||
expect(validateBusinessHours([{ open: '09:00', close: '22:00' }]).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts two non-overlapping segments', () => {
|
||||
expect(
|
||||
validateBusinessHours([
|
||||
{ open: '09:00', close: '14:00' },
|
||||
{ open: '17:00', close: '21:00' },
|
||||
]).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects overlapping second segment', () => {
|
||||
expect(
|
||||
validateBusinessHours([
|
||||
{ open: '09:00', close: '14:00' },
|
||||
{ open: '13:00', close: '21:00' },
|
||||
]).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBusinessHours', () => {
|
||||
it('formats one or two segments', () => {
|
||||
expect(formatBusinessHours({ openTime: '9:00', closeTime: '22:00' })).toBe('9:00-22:00');
|
||||
expect(
|
||||
formatBusinessHours({
|
||||
openTime: '09:00',
|
||||
closeTime: '14:00',
|
||||
openTime2: '17:00',
|
||||
closeTime2: '21:00',
|
||||
}),
|
||||
).toBe('09:00-14:00,17:00-21:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRedeemAmount', () => {
|
||||
@@ -62,6 +111,46 @@ describe('calcRedeemSettleAmount', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcLogisticsFeeByBottles', () => {
|
||||
const xfx = {
|
||||
baseBottles: 2,
|
||||
baseFee: 6,
|
||||
extraBottleFee: 2,
|
||||
boxBottles: 6,
|
||||
boxFee: 14,
|
||||
};
|
||||
|
||||
it('charges base for 1~2 bottles', () => {
|
||||
expect(calcLogisticsFeeByBottles(1, xfx)).toBe(6);
|
||||
expect(calcLogisticsFeeByBottles(2, xfx)).toBe(6);
|
||||
});
|
||||
|
||||
it('adds extra bottle fee', () => {
|
||||
expect(calcLogisticsFeeByBottles(3, xfx)).toBe(8);
|
||||
expect(calcLogisticsFeeByBottles(5, xfx)).toBe(12);
|
||||
});
|
||||
|
||||
it('uses box fee for full boxes', () => {
|
||||
expect(calcLogisticsFeeByBottles(6, xfx)).toBe(14);
|
||||
expect(calcLogisticsFeeByBottles(12, xfx)).toBe(28);
|
||||
});
|
||||
|
||||
it('combines boxes with remainder ladder', () => {
|
||||
expect(calcLogisticsFeeByBottles(7, xfx)).toBe(20);
|
||||
expect(calcLogisticsFeeByBottles(8, xfx)).toBe(20);
|
||||
expect(calcLogisticsFeeByBottles(9, xfx)).toBe(22);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldHoldAutoCourierDispatch', () => {
|
||||
it('holds at 10 full boxes (60 bottles)', () => {
|
||||
expect(shouldHoldAutoCourierDispatch(59)).toBe(false);
|
||||
expect(shouldHoldAutoCourierDispatch(60)).toBe(true);
|
||||
expect(shouldHoldAutoCourierDispatch(66)).toBe(true);
|
||||
expect(calcOrderBoxCount(60)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('allocateBenefitCoupons', () => {
|
||||
const coupons = [
|
||||
{ id: '1', balance: 300, createdAt: 1 },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user