1 Commits

Author SHA1 Message Date
jacy 81a6e3674b Merge pull request 'Dev' (#9) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/9
2026-07-17 10:50:17 +08:00
307 changed files with 4700 additions and 26252 deletions
+1 -1
View File
@@ -51,7 +51,7 @@
|-----|------|----------| |-----|------|----------|
| ACC-010 | 工单 | 四类型;总部决策;补发/退款流转 | | ACC-010 | 工单 | 四类型;总部决策;补发/退款流转 |
| ACC-011 | 发票 | 四组合申请;2 工作日回传;可下载 | | ACC-011 | 发票 | 四组合申请;2 工作日回传;可下载 |
| ACC-012 | 代下单 | W3客户+合伙人双短信;手机号建用户(来源 PARTNER_PROXY);线下确认后已付款已完成并发权益;C 端展示代下单人 | | ACC-012 | 代下单 | W3手机号建用户;权益自动发 |
| ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 | | ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 |
| ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 | | ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 |
| ACC-018 | 话术 | OPT-012 3 日内对齐 | | ACC-018 | 话术 | OPT-012 3 日内对齐 |
-1
View File
@@ -15,4 +15,3 @@ coverage/
server/dukang-api/prisma/migrations/ server/dukang-api/prisma/migrations/
debug_v3.xlsx debug_v3.xlsx
~$debug_v3.xlsx ~$debug_v3.xlsx
deploy/auto-release.env
-14
View File
@@ -20,7 +20,6 @@ import CityPartnersPage from './pages/CityPartnersPage';
import CityWarehousesPage from './pages/CityWarehousesPage'; import CityWarehousesPage from './pages/CityWarehousesPage';
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage'; import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
import StoreMediaPage from './pages/StoreMediaPage'; import StoreMediaPage from './pages/StoreMediaPage';
import StoreCategoriesPage from './pages/StoreCategoriesPage';
import PromoCodesPage from './pages/PromoCodesPage'; import PromoCodesPage from './pages/PromoCodesPage';
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout'; import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage'; import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
@@ -31,10 +30,7 @@ import ResourcesPage from './pages/ResourcesPage';
import StoreBillsPage from './pages/StoreBillsPage'; import StoreBillsPage from './pages/StoreBillsPage';
import PartnerBillsPage from './pages/PartnerBillsPage'; import PartnerBillsPage from './pages/PartnerBillsPage';
import WineryBillsPage from './pages/WineryBillsPage'; import WineryBillsPage from './pages/WineryBillsPage';
import LogisticsBillsPage from './pages/LogisticsBillsPage';
import TicketsPage from './pages/TicketsPage'; import TicketsPage from './pages/TicketsPage';
import SupportTicketsPage from './pages/SupportTicketsPage';
import InvoicesPage from './pages/InvoicesPage';
import UserLogsPage from './pages/UserLogsPage'; import UserLogsPage from './pages/UserLogsPage';
import HqLogsPage from './pages/HqLogsPage'; import HqLogsPage from './pages/HqLogsPage';
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage'; import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
@@ -43,9 +39,6 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage'; import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage'; import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage'; 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 }) { function RequireAuth({ children }: { children: React.ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />; if (!getToken()) return <Navigate to="/login" replace />;
@@ -72,13 +65,9 @@ export default function App() {
<Route index element={<PromoCodeDetailPage />} /> <Route index element={<PromoCodeDetailPage />} />
<Route path="users" element={<PromoCodeUsersPage />} /> <Route path="users" element={<PromoCodeUsersPage />} />
</Route> </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="/products" element={<ProductsPage />} />
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} /> <Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
<Route path="/stores" element={<StoresPage />} /> <Route path="/stores" element={<StoresPage />} />
<Route path="/store-categories" element={<StoreCategoriesPage />} />
<Route path="/store-accounts" element={<StoreAccountsPage />} /> <Route path="/store-accounts" element={<StoreAccountsPage />} />
<Route path="/store-media" element={<StoreMediaPage />} /> <Route path="/store-media" element={<StoreMediaPage />} />
<Route path="/resources" element={<ResourcesPage />} /> <Route path="/resources" element={<ResourcesPage />} />
@@ -96,13 +85,10 @@ export default function App() {
<Route path="/finance/store-bills" element={<StoreBillsPage />} /> <Route path="/finance/store-bills" element={<StoreBillsPage />} />
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} /> <Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
<Route path="/finance/winery-bills" element={<WineryBillsPage />} /> <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-bills" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/store-payouts" 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="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
<Route path="/tickets" element={<TicketsPage />} /> <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/users" element={<UserLogsPage />} />
<Route path="/logs/stores" element={<StoreLogsPage />} /> <Route path="/logs/stores" element={<StoreLogsPage />} />
<Route path="/logs/partners" element={<PartnerLogsPage />} /> <Route path="/logs/partners" element={<PartnerLogsPage />} />
@@ -1,42 +0,0 @@
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,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
Button, Button,
Cascader,
Checkbox, Checkbox,
Drawer, Drawer,
Form, Form,
@@ -26,9 +27,8 @@ import {
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; import { CHINA_REGION_OPTIONS } from '../lib/china-region';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
import PartnerSubAccountList from './PartnerSubAccountList'; import PartnerSubAccountList from './PartnerSubAccountList';
type PartnerRow = { type PartnerRow = {
@@ -37,7 +37,6 @@ type PartnerRow = {
phone: string; phone: string;
name: string; name: string;
scopeType?: string; scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number; orderCommissionRate?: number;
redeemCommissionRate?: number; redeemCommissionRate?: number;
accountCount: number; accountCount: number;
@@ -66,7 +65,6 @@ type PartnerDetail = PartnerRow & {
type Props = { type Props = {
cityId: string; cityId: string;
cityCode?: string;
maxPartnerCommissionRate?: number; maxPartnerCommissionRate?: number;
onChanged?: () => void; onChanged?: () => void;
}; };
@@ -94,31 +92,14 @@ function commissionSumError(
redeemPercent: number, redeemPercent: number,
maxRate: number, maxRate: number,
): string | null { ): string | null {
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限 const sum = orderPercent / 100 + redeemPercent / 100;
const max = Number(maxRate); if (sum > maxRate + 1e-9) {
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100; return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
if (sum > max + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
} }
return null; return null;
} }
function formatApiError(err: unknown): string | null { export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0.05, onChanged }: Props) {
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 [editForm] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [subForm] = Form.useForm(); const [subForm] = Form.useForm();
@@ -172,34 +153,29 @@ export default function CityPartnersPanel({
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
try { const v = await editForm.validateFields();
const v = await editForm.validateFields(); const err = commissionSumError(
const err = commissionSumError( Number(v.orderCommissionRate ?? 0),
Number(v.orderCommissionRate ?? 0), Number(v.redeemCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0), maxPartnerCommissionRate,
maxPartnerCommissionRate, );
); if (err) {
if (err) { message.error(err);
message.error(err); return;
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) { async function deleteSubAccount(subId: string) {
@@ -211,14 +187,6 @@ export default function CityPartnersPanel({
} }
const columns: ColumnsType<PartnerRow> = [ 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: 'companyName', ellipsis: true },
{ title: '主账号', dataIndex: 'phone', width: 120 }, { title: '主账号', dataIndex: 'phone', width: 120 },
{ {
@@ -281,11 +249,11 @@ export default function CityPartnersPanel({
label: '主账号', label: '主账号',
children: ( children: (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item> <Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item> <Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item> <Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}> <Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} /> <Select options={BINDING_OPTIONS} />
</Form.Item> </Form.Item>
@@ -293,12 +261,8 @@ export default function CityPartnersPanel({
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item> </Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && ( {editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item <Form.Item name="districtCodes" label="区县">
name="districtCodes" <Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -352,51 +316,42 @@ export default function CityPartnersPanel({
</Drawer> </Drawer>
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => { <Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
try { const v = await createForm.validateFields();
const v = await createForm.validateFields(); const err = commissionSumError(
const err = commissionSumError( Number(v.orderCommissionRate ?? 0),
Number(v.orderCommissionRate ?? 0), Number(v.redeemCommissionRate ?? 3),
Number(v.redeemCommissionRate ?? 3), maxPartnerCommissionRate,
maxPartnerCommissionRate, );
); if (err) {
if (err) { message.error(err);
message.error(err); return;
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 form={createForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item> <Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item> <Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && ( {createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item <Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
name="districtCodes" <Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -1,138 +0,0 @@
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,230 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Empty, Input, List, Modal, Space, Spin, Typography, message } from 'antd';
import { request } from '../lib/api';
import {
placeToPicked,
type LbsPlaceItem,
type TencentPickedLocation,
} from '../lib/tencentLocPicker';
type Props = {
open: boolean;
onClose: () => void;
onPick: (loc: TencentPickedLocation) => void;
latitude?: number | null;
longitude?: number | null;
/** 城市名,提升搜索相关性 */
region?: string | null;
};
function hasCoords(lat: unknown, lng: unknown): lat is number {
const a = typeof lat === 'number' ? lat : Number(lat);
const b = typeof lng === 'number' ? lng : Number(lng);
return Number.isFinite(a) && Number.isFinite(b) && !(a === 0 && b === 0);
}
export default function TencentLocPickerModal({
open,
onClose,
onPick,
latitude,
longitude,
region,
}: Props) {
const [keyword, setKeyword] = useState('');
const [items, setItems] = useState<LbsPlaceItem[]>([]);
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
const [hint, setHint] = useState('输入地点名称搜索,或加载附近地点');
const seqRef = useRef(0);
useEffect(() => {
if (!open) {
setKeyword('');
setItems([]);
setPending(null);
setHint('输入地点名称搜索,或加载附近地点');
return;
}
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (hasCoords(lat, lng)) {
void loadNearby(lat, lng);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
async function loadNearby(lat: number, lng: number) {
const seq = ++seqRef.current;
setLoading(true);
setHint('正在加载附近地点…');
try {
const res = await request<{ items: LbsPlaceItem[] }>(
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `附近 ${res.items.length} 个地点,点击选择` : '附近暂无地点,请搜索');
} catch (e) {
if (seq !== seqRef.current) return;
const msg = e instanceof Error ? e.message : '加载附近地点失败';
setItems([]);
setHint(msg);
message.error(msg);
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
async function runSearch(q: string) {
const trimmed = q.trim();
if (!trimmed) {
message.warning('请输入搜索关键词');
return;
}
const seq = ++seqRef.current;
setLoading(true);
setHint('搜索中…');
try {
const params = new URLSearchParams({ keyword: trimmed });
if (region?.trim()) params.set('region', region.trim());
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (Number.isFinite(lat) && Number.isFinite(lng)) {
params.set('lat', String(lat));
params.set('lng', String(lng));
}
const res = await request<{ items: LbsPlaceItem[] }>(`/common/lbs/suggest?${params.toString()}`);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `找到 ${res.items.length} 个结果,点击选择` : '无匹配结果,换个关键词试试');
} catch (e) {
if (seq !== seqRef.current) return;
const msg = e instanceof Error ? e.message : '搜索失败';
setItems([]);
setHint(msg);
message.error(msg);
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
function useBrowserLocation() {
if (!navigator.geolocation) {
message.error('当前浏览器不支持定位');
return;
}
setLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
setPending({ latitude: lat, longitude: lng, name: '当前位置' });
void loadNearby(lat, lng);
},
() => {
setLoading(false);
message.error('定位失败,请检查浏览器定位权限');
},
{ enableHighAccuracy: true, timeout: 12000 },
);
}
function confirmPick() {
if (!pending) {
message.warning('请先从列表中选择一个地点');
return;
}
onPick(pending);
onClose();
}
return (
<Modal
title="地图选点(腾讯位置服务)"
open={open}
onCancel={onClose}
width={720}
destroyOnClose
footer={
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary" style={{ maxWidth: 420 }} ellipsis>
{pending
? `${pending.latitude.toFixed(6)}, ${pending.longitude.toFixed(6)}${
pending.name ? ` · ${pending.name}` : ''
}`
: '搜索或选择附近地点后确认'}
</Typography.Text>
<Space>
<Button onClick={onClose}></Button>
<Button type="primary" disabled={!pending} onClick={confirmPick}>
</Button>
</Space>
</Space>
}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space.Compact style={{ width: '100%' }}>
<Input
allowClear
placeholder="输入小区 / 写字楼 / 门店名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => void runSearch(keyword)}
/>
<Button type="primary" loading={loading} onClick={() => void runSearch(keyword)}>
</Button>
</Space.Compact>
<Space wrap>
<Button onClick={useBrowserLocation} disabled={loading}>
</Button>
<Typography.Text type="secondary">{hint}</Typography.Text>
</Space>
<div style={{ height: 420, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 8 }}>
{loading && !items.length ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin tip="加载中…" />
</div>
) : items.length ? (
<List
size="small"
dataSource={items}
renderItem={(item) => {
const active =
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
return (
<List.Item
style={{
cursor: 'pointer',
background: active ? 'rgba(22, 119, 255, 0.08)' : undefined,
paddingInline: 12,
}}
onClick={() => setPending(placeToPicked(item))}
>
<List.Item.Meta
title={item.title}
description={
<span>
{item.address}
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
</Typography.Text>
</span>
}
/>
</List.Item>
);
}}
/>
) : (
<Empty style={{ marginTop: 80 }} description={hint} />
)}
</div>
</Space>
</Modal>
);
}
+4 -105
View File
@@ -1,9 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Layout, Menu, Typography, Button, Space } from 'antd'; import { Layout, Menu, Typography, Button, Space } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { import {
RobotOutlined,
DashboardOutlined, DashboardOutlined,
UserOutlined, UserOutlined,
ShoppingOutlined, ShoppingOutlined,
@@ -19,14 +18,11 @@ import {
SettingOutlined, SettingOutlined,
AccountBookOutlined, AccountBookOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
import { clearAuth, request, type HqProfile } from '../lib/api'; import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
type MenuItem = NonNullable<MenuProps['items']>[number];
const MENU_ITEMS: MenuProps['items'] = [ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/', icon: <DashboardOutlined />, label: '概览' }, { key: '/', icon: <DashboardOutlined />, label: '概览' },
{ key: '/users', icon: <UserOutlined />, label: '用户' }, { key: '/users', icon: <UserOutlined />, label: '用户' },
@@ -42,16 +38,12 @@ const MENU_ITEMS: MenuProps['items'] = [
}, },
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' }, { key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
{ key: '/promo-codes', icon: <GiftOutlined />, 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', key: 'stores-group',
icon: <ShopOutlined />, icon: <ShopOutlined />,
label: '门店', label: '门店',
children: [ children: [
{ key: '/stores', label: '门店列表' }, { key: '/stores', label: '门店列表' },
{ key: '/store-categories', label: '门店分类' },
{ key: '/store-accounts', label: '门店账户' }, { key: '/store-accounts', label: '门店账户' },
{ key: '/store-media', label: '门店资源' }, { key: '/store-media', label: '门店资源' },
], ],
@@ -75,7 +67,6 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/finance/store-bills', label: '门店账单' }, { key: '/finance/store-bills', label: '门店账单' },
{ key: '/finance/partner-bills', label: '合伙人账单' }, { key: '/finance/partner-bills', label: '合伙人账单' },
{ key: '/finance/winery-bills', label: '酒厂账单' }, { key: '/finance/winery-bills', label: '酒厂账单' },
{ key: '/finance/logistics-bills', label: '物流对账' },
], ],
}, },
{ {
@@ -99,16 +90,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' }, { 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 资源库' }, { key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
{ {
key: 'logs-group', key: 'logs-group',
@@ -127,83 +109,6 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' }, { 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() { export default function AdminLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -231,12 +136,6 @@ export default function AdminLayout() {
? '/promo-codes' ? '/promo-codes'
: location.pathname; : 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 ( return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}> <Layout style={{ height: '100vh', overflow: 'hidden' }}>
<Sider <Sider
@@ -255,8 +154,8 @@ export default function AdminLayout() {
theme="dark" theme="dark"
mode="inline" mode="inline"
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
defaultOpenKeys={[]} defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'finance-group', 'benefit-group', 'logs-group', 'deliveries-group']}
items={menuItems} items={MENU_ITEMS}
onClick={({ key }) => { onClick={({ key }) => {
if (key.startsWith('/')) navigate(key); if (key.startsWith('/')) navigate(key);
}} }}
-3
View File
@@ -7,7 +7,6 @@ export type HqProfile = {
name: string; name: string;
adminRole: string; adminRole: string;
status: string; status: string;
permissionKeys?: string[];
}; };
export function getToken() { export function getToken() {
@@ -146,8 +145,6 @@ export type AdminOrderRow = {
city?: { id: string; name: string; code: string }; city?: { id: string; name: string; code: string };
fulfillmentWarehouseId?: string | null; fulfillmentWarehouseId?: string | null;
fulfillmentWarehouse?: { id: string; name: string } | null; fulfillmentWarehouse?: { id: string; name: string } | null;
fulfillmentHold?: boolean;
fulfillmentHoldReason?: string | null;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: { delivery?: {
provider: string; provider: string;
-33
View File
@@ -79,39 +79,6 @@ export function districtCodeToCityCode(districtCode: string): string {
return `${districtCode.slice(0, 4)}00`; 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 { export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null {
if (!codes || codes.length < 3) return null; if (!codes || codes.length < 3) return null;
const [provinceCode, cityCode, districtCode] = codes; const [provinceCode, cityCode, districtCode] = codes;
+5 -22
View File
@@ -34,8 +34,8 @@ export const ORDER_STATUS_COLORS: Record<string, string> = {
export const STORE_STATUS_LABELS: Record<string, string> = { export const STORE_STATUS_LABELS: Record<string, string> = {
OPEN: '营业中', OPEN: '营业中',
PAUSED: '临时闭店', PAUSED: '暂停',
CLOSED: '永久关闭', CLOSED: '关闭',
}; };
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = { export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
@@ -62,11 +62,9 @@ export const CITY_STATUS_LABELS: Record<string, string> = {
}; };
export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = { export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
PENDING_REVIEW: '待审核', DRAFT: '草稿',
AWAITING_CONFIRM: '待合伙人确认', CONFIRMED: '确认',
UNPAID: '未打款', PAID: '已结算',
PAID: '已打款',
REJECTED: '已驳回',
}; };
export const MEDIA_TYPE_LABELS: Record<string, string> = { export const MEDIA_TYPE_LABELS: Record<string, string> = {
@@ -128,18 +126,3 @@ export const LEDGER_TYPE_LABELS: Record<string, string> = {
export function fmtTime(v?: string | null) { export function fmtTime(v?: string | null) {
return v ? new Date(v).toLocaleString('zh-CN') : '—'; return v ? new Date(v).toLocaleString('zh-CN') : '—';
} }
/** 手机号脱敏展示:138****5678;空值返回 — */
export function maskPhone(phone?: string | null): string {
const raw = String(phone ?? '').trim();
if (!raw) return '—';
const digits = raw.replace(/\D/g, '');
if (digits.length >= 11) {
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
if (digits.length >= 7) {
return `${digits.slice(0, 3)}****${digits.slice(-2)}`;
}
if (digits.length > 0) return `${digits.slice(0, 1)}****`;
return '****';
}
-21
View File
@@ -27,11 +27,6 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'STORE_AUDIT', label: '门店审核' }, { value: 'STORE_AUDIT', label: '门店审核' },
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' }, { value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' }, { value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
{ value: 'STORE_ACCOUNT_STAFF_DELETE', 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_CREATE', label: '新增商品' },
{ value: 'PRODUCT_UPDATE', label: '编辑商品' }, { value: 'PRODUCT_UPDATE', label: '编辑商品' },
{ value: 'PRODUCT_DELETE', label: '删除商品' }, { value: 'PRODUCT_DELETE', label: '删除商品' },
@@ -39,27 +34,11 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'DELIVERY_UPDATE', label: '编辑配送单' }, { value: 'DELIVERY_UPDATE', label: '编辑配送单' },
{ value: 'TICKET_APPROVE', label: '工单通过' }, { value: 'TICKET_APPROVE', label: '工单通过' },
{ value: 'TICKET_REJECT', 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_CONFIRM', label: '门店打款确认' },
{ value: 'STORE_PAYOUT_BATCH_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_GENERATE', label: '生成合伙人账单' },
{ value: 'PARTNER_BILL_SEND', label: '发送合伙人账单' },
{ value: 'PARTNER_BILL_BATCH_SEND', label: '批量发送合伙人账单' },
{ value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' }, { value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' },
{ value: 'PARTNER_BILL_MARK_PAID', 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; ] as const;
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries( export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
+2 -59
View File
@@ -6,20 +6,10 @@ export type StoreCreateForm = {
city?: string; city?: string;
district: string; district: string;
districtCode?: string; districtCode?: string;
categoryParentId?: string;
categoryId: string;
name: string; name: string;
phone: string; phone: string;
address: string; address: string;
latitude?: number | null;
longitude?: number | null;
intro?: string; intro?: string;
benefitUsageRule?: string;
openTime?: string;
closeTime?: string;
openTime2?: string;
closeTime2?: string;
avgPrice?: number | null;
coverUrl?: string; coverUrl?: string;
envPhotoUrls?: string[]; envPhotoUrls?: string[];
contractUrl?: string; contractUrl?: string;
@@ -31,67 +21,20 @@ export type StoreCreateForm = {
const PHONE_RE = /^1\d{10}$/; const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/; 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( export function validateStoreCreateStep1(
form: Pick< form: Pick<StoreCreateForm, 'partnerAccountId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
StoreCreateForm,
| 'partnerAccountId'
| 'cityId'
| 'regionCodes'
| 'categoryId'
| 'name'
| 'phone'
| 'address'
| 'intro'
| 'benefitUsageRule'
| 'openTime'
| 'closeTime'
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
>,
): string | null { ): string | null {
if (!form.partnerAccountId) return '请选择开城合伙人'; if (!form.partnerAccountId) return '请选择开城合伙人';
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划'; if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
if (!form.categoryId?.trim()) return '请选择门店分类(细类)';
if (!form.name?.trim()) return '请填写门店名称'; if (!form.name?.trim()) return '请填写门店名称';
if (!form.phone?.trim()) return '请填写门店手机号'; if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号'; if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
if (!form.address?.trim()) return '请填写详细地址'; 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()) { if (form.intro?.trim()) {
const len = form.intro.trim().length; const len = form.intro.trim().length;
if (len < 2 || len > 500) return '门店简介须为 2~500 字'; if (len < 10 || len > 500) return '门店简介须为 10~500 字';
}
if (form.benefitUsageRule?.trim() && form.benefitUsageRule.trim().length > 1000) {
return '好客权益券使用规则最多 1000 字';
} }
return null; return null;
} }
@@ -1,26 +0,0 @@
export type TencentPickedLocation = {
latitude: number;
longitude: number;
address?: string;
name?: string;
cityname?: string;
};
export type LbsPlaceItem = {
id: string;
title: string;
address: string;
latitude: number;
longitude: number;
city?: string;
};
export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
return {
latitude: item.latitude,
longitude: item.longitude,
address: item.address || undefined,
name: item.title || undefined,
cityname: item.city || undefined,
};
}
+14 -236
View File
@@ -33,34 +33,6 @@ type Row = {
order?: { orderNo: string } | null; 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() { export default function BenefitCouponsPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>(); const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
@@ -78,25 +50,8 @@ export default function BenefitCouponsPage() {
}, },
[filters], [filters],
); );
const [detail, setDetail] = useState<CouponDetail | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); 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> = [ const columns: ColumnsType<Row> = [
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false }, { title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
@@ -242,7 +197,7 @@ export default function BenefitCouponsPage() {
<Drawer <Drawer
title="权益券详情" title="权益券详情"
width={720} width={560}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={ extra={
@@ -263,202 +218,25 @@ export default function BenefitCouponsPage() {
} }
> >
{detail && ( {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 column={1} bordered size="small">
<Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item> <Descriptions.Item label="号">{String(detail.couponNo)}</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="用户"> <Descriptions.Item label="用户">
{String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')} {String((detail.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>
<Descriptions.Item label="门店"> <Descriptions.Item label="手机号">
{String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')} {String((detail.user as { phone?: string } | undefined)?.phone ?? '—')}
{(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>
<Descriptions.Item label="关联订单"> <Descriptions.Item label="关联订单">
{String( {String((detail.order as { orderNo?: string } | null | undefined)?.orderNo ?? '—')}
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
'—',
)}
</Descriptions.Item> </Descriptions.Item>
{Array.isArray(redeemDetail.allocations) && <Descriptions.Item label="总额">¥{String(detail.totalAmount)}</Descriptions.Item>
(redeemDetail.allocations as unknown[]).length > 0 ? ( <Descriptions.Item label="余额">¥{String(detail.balance)}</Descriptions.Item>
<Descriptions.Item label="券分摊"> <Descriptions.Item label="状态">
{( {COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)}
redeemDetail.allocations as Array<{ </Descriptions.Item>
couponNo?: string; <Descriptions.Item label="来源">{String(detail.sourceProduct)}</Descriptions.Item>
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> </Descriptions>
) : null} )}
</Drawer> </Drawer>
</div> </div>
); );
+6 -271
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { import {
Button, Button,
Descriptions, Descriptions,
@@ -18,7 +17,7 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types'; import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types';
import { request, type HqProfile, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region'; import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
@@ -51,40 +50,6 @@ type Row = {
type PartnerOption = { id: string; companyName: string; cityId?: string | null }; type PartnerOption = { id: string; companyName: string; cityId?: string | null };
type CityDeletePreview = {
city: { id: string; code: string; name: string; province: string; status: string };
canDelete: boolean;
blockers: string[];
warnings: string[];
summary: {
primaryPartnerCount: number;
staffCount: number;
storeCount: number;
warehouseCount: number;
orderCount: number;
redeemCount: number;
partnerBillCount: number;
};
partners: Array<{
id: string;
phone: string;
name: string;
companyName?: string | null;
status: string;
staff: Array<{ id: string; phone: string; name: string; staffRole?: string | null }>;
}>;
orphanStaff: Array<{ id: string; phone: string; name: string }>;
stores: Array<{
id: string;
name: string;
phone: string;
status: string;
address: string;
partnerAccount?: { companyName?: string | null; phone?: string } | null;
}>;
warehouses: Array<{ id: string; name: string; status: string; address: string }>;
};
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label })); const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
export default function CitiesPage() { export default function CitiesPage() {
@@ -117,18 +82,6 @@ export default function CitiesPage() {
const createRegionCodes = Form.useWatch('regionCodes', createForm); const createRegionCodes = Form.useWatch('regionCodes', createForm);
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ); const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ); const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Row | null>(null);
const [deletePreview, setDeletePreview] = useState<CityDeletePreview | null>(null);
const [confirmName, setConfirmName] = useState('');
const [profile, setProfile] = useState<HqProfile | null>(null);
const canDeleteCities = (profile?.permissionKeys ?? []).includes('cities_delete');
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const loadPartners = useCallback(async (cityId: string) => { const loadPartners = useCallback(async (cityId: string) => {
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`); const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
@@ -176,85 +129,22 @@ export default function CitiesPage() {
setDrawerOpen(true); setDrawerOpen(true);
}; };
const openDelete = async (row: Row) => {
setDeleteTarget(row);
setDeletePreview(null);
setConfirmName('');
setDeleteOpen(true);
setDeleteLoading(true);
try {
const preview = await request<CityDeletePreview>(`/admin/cities/${row.id}/delete-preview`);
setDeletePreview(preview);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载删除预览失败');
setDeleteOpen(false);
} finally {
setDeleteLoading(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget || !deletePreview) return;
if (!deletePreview.canDelete) {
message.error(deletePreview.blockers.join('') || '当前城市不可删除');
return;
}
if (confirmName.trim() !== deletePreview.city.name) {
message.warning(`请输入城市名称「${deletePreview.city.name}」确认删除`);
return;
}
setDeleteSubmitting(true);
try {
await request(`/admin/cities/${deleteTarget.id}`, {
method: 'DELETE',
body: JSON.stringify({ confirmName: confirmName.trim() }),
});
message.success(`已删除城市「${deletePreview.city.name}`);
setDeleteOpen(false);
setDeleteTarget(null);
setDeletePreview(null);
setConfirmName('');
if (detail?.id === deleteTarget.id) setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeleteSubmitting(false);
}
};
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 90 }, { title: '编码', dataIndex: 'code', width: 90 },
{ title: '城市', dataIndex: 'name', width: 100 }, { title: '城市', dataIndex: 'name', width: 100 },
{ title: '省份', dataIndex: 'province', width: 90 }, { title: '省份', dataIndex: 'province', width: 90 },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 }, { title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
{ { title: '门店', dataIndex: 'storeCount', width: 70 },
title: '门店',
dataIndex: 'storeCount',
width: 70,
render: (n: number, row) => (
<Link to={`/stores?cityId=${row.id}`} title={`查看「${row.name}」门店`}>
{n ?? 0}
</Link>
),
},
{ title: '订单', dataIndex: 'orderCount', width: 70 }, { title: '订单', dataIndex: 'orderCount', width: 70 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 140, width: 80,
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Button type="link" size="small" onClick={() => void openDetail(row)}>
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
</Button>
{canDeleteCities ? (
<Button type="link" size="small" danger onClick={() => void openDelete(row)}>
</Button>
) : null}
</Space>
), ),
}, },
]; ];
@@ -374,26 +264,6 @@ export default function CitiesPage() {
setDetail(refreshed); setDetail(refreshed);
void reload(); void reload();
}}></Button> }}></Button>
{canDeleteCities ? (
<Button
danger
style={{ marginLeft: 8 }}
onClick={() =>
void openDelete({
id: String(detail.id),
code: String(detail.code),
name: String(detail.name),
province: String(detail.province),
status: String(detail.status),
storeCount: Number(detail.storeCount ?? 0),
orderCount: Number(detail.orderCount ?? 0),
createdAt: String(detail.createdAt ?? ''),
})
}
>
</Button>
) : null}
</Form> </Form>
</> </>
), ),
@@ -404,7 +274,6 @@ export default function CitiesPage() {
children: detail?.id ? ( children: detail?.id ? (
<CityPartnersPanel <CityPartnersPanel
cityId={String(detail.id)} cityId={String(detail.id)}
cityCode={detail.code != null ? String(detail.code) : undefined}
maxPartnerCommissionRate={ maxPartnerCommissionRate={
detail.maxPartnerCommissionRate != null detail.maxPartnerCommissionRate != null
? Number(detail.maxPartnerCommissionRate) ? Number(detail.maxPartnerCommissionRate)
@@ -537,140 +406,6 @@ export default function CitiesPage() {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal
title={deleteTarget ? `删除城市「${deleteTarget.name}` : '删除城市'}
open={deleteOpen}
onCancel={() => {
if (deleteSubmitting) return;
setDeleteOpen(false);
}}
okText="确认删除"
okButtonProps={{
danger: true,
disabled:
!deletePreview?.canDelete ||
!deletePreview ||
confirmName.trim() !== (deletePreview?.city.name ?? ''),
loading: deleteSubmitting,
}}
confirmLoading={deleteSubmitting}
onOk={() => void confirmDelete()}
width={720}
destroyOnClose
>
{deleteLoading || !deletePreview ? (
<Typography.Text type="secondary"></Typography.Text>
) : (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Descriptions size="small" bordered column={2}>
<Descriptions.Item label="编码">{deletePreview.city.code}</Descriptions.Item>
<Descriptions.Item label="省份">{deletePreview.city.province}</Descriptions.Item>
<Descriptions.Item label="合伙人主账号">{deletePreview.summary.primaryPartnerCount}</Descriptions.Item>
<Descriptions.Item label="子账号">{deletePreview.summary.staffCount}</Descriptions.Item>
<Descriptions.Item label="门店">{deletePreview.summary.storeCount}</Descriptions.Item>
<Descriptions.Item label="仓库">{deletePreview.summary.warehouseCount}</Descriptions.Item>
<Descriptions.Item label="订单">{deletePreview.summary.orderCount}</Descriptions.Item>
<Descriptions.Item label="核销">{deletePreview.summary.redeemCount}</Descriptions.Item>
</Descriptions>
{deletePreview.blockers.length > 0 && (
<Typography.Paragraph type="danger" style={{ marginBottom: 0 }}>
{deletePreview.blockers.map((b) => (
<div key={b}> {b}</div>
))}
</Typography.Paragraph>
)}
{deletePreview.warnings.length > 0 && (
<Typography.Paragraph type="warning" style={{ marginBottom: 0 }}>
{deletePreview.warnings.map((w) => (
<div key={w}> {w}</div>
))}
</Typography.Paragraph>
)}
<div>
<Typography.Text strong></Typography.Text>
<Table
size="small"
style={{ marginTop: 8 }}
pagination={false}
rowKey="id"
locale={{ emptyText: '无合伙人' }}
dataSource={deletePreview.partners.flatMap((p) => [
{
id: p.id,
kind: '主账号',
name: p.companyName || p.name,
phone: p.phone,
parent: '—',
},
...p.staff.map((s) => ({
id: s.id,
kind: '子账号',
name: s.name,
phone: s.phone,
parent: p.companyName || p.name,
})),
]).concat(
deletePreview.orphanStaff.map((s) => ({
id: s.id,
kind: '子账号',
name: s.name,
phone: s.phone,
parent: '(无主账号)',
})),
)}
columns={[
{ title: '类型', dataIndex: 'kind', width: 80 },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{ title: '手机号', dataIndex: 'phone', width: 120 },
{ title: '归属', dataIndex: 'parent', ellipsis: true },
]}
/>
</div>
<div>
<Typography.Text strong></Typography.Text>
<Table
size="small"
style={{ marginTop: 8 }}
pagination={false}
rowKey="id"
locale={{ emptyText: '无门店' }}
dataSource={deletePreview.stores}
columns={[
{ title: '门店名', dataIndex: 'name', ellipsis: true },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '合伙人',
width: 140,
ellipsis: true,
render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
},
]}
/>
</div>
{deletePreview.canDelete ? (
<Form.Item
label={`请输入城市名称「${deletePreview.city.name}」确认删除`}
style={{ marginBottom: 0 }}
>
<Input
value={confirmName}
placeholder={deletePreview.city.name}
onChange={(e) => setConfirmName(e.target.value)}
disabled={deleteSubmitting}
/>
</Form.Item>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Space>
)}
</Modal>
</div> </div>
); );
} }
+71 -132
View File
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
Cascader,
Checkbox, Checkbox,
Descriptions, Descriptions,
Drawer, Drawer,
@@ -31,10 +32,9 @@ import {
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; import { CHINA_REGION_OPTIONS } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList'; import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
type SubRow = PartnerSubAccountRow; type SubRow = PartnerSubAccountRow;
@@ -48,7 +48,6 @@ type Row = {
cityId?: string | null; cityId?: string | null;
cityName?: string | null; cityName?: string | null;
scopeType?: string; scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number; orderCommissionRate?: number;
redeemCommissionRate?: number; redeemCommissionRate?: number;
bindingStatus?: string; bindingStatus?: string;
@@ -99,25 +98,13 @@ function flattenDistrictCodes(values: string[] | string[][] | undefined): string
} }
function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null { function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null {
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限 const sum = orderPercent / 100 + redeemPercent / 100;
const max = Number(maxRate); if (sum > maxRate + 1e-9) {
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100; return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
if (sum > max + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
} }
return null; 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() { export default function CityPartnersPage() {
const [filterForm] = Form.useForm(); const [filterForm] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
@@ -148,7 +135,6 @@ export default function CityPartnersPage() {
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE); const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05); const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
const [createMaxRate, setCreateMaxRate] = useState(0.05); const [createMaxRate, setCreateMaxRate] = useState(0.05);
const [createCityCode, setCreateCityCode] = useState<string | undefined>();
const loadCities = useCallback(async () => { const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
@@ -159,15 +145,11 @@ export default function CityPartnersPage() {
void loadCities(); void loadCities();
}, [loadCities]); }, [loadCities]);
const editCityCode = detail?.cityId
? cities.find((c) => c.id === detail.cityId)?.code
: undefined;
async function openPartner(id: string) { async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`); const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d); setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE); setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
setMaxCommissionRate(Number(d.maxPartnerCommissionRate ?? 0.05)); setMaxCommissionRate(d.maxPartnerCommissionRate ?? 0.05);
editForm.setFieldsValue({ editForm.setFieldsValue({
name: d.name, name: d.name,
phone: d.phone, phone: d.phone,
@@ -189,33 +171,28 @@ export default function CityPartnersPage() {
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
try { const v = await editForm.validateFields();
const v = await editForm.validateFields(); const err = commissionSumError(
const err = commissionSumError( Number(v.orderCommissionRate ?? 0),
Number(v.orderCommissionRate ?? 0), Number(v.redeemCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0), maxCommissionRate,
maxCommissionRate, );
); if (err) {
if (err) { message.error(err);
message.error(err); return;
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) { async function refreshPartnerContext(parentId: string) {
@@ -252,24 +229,13 @@ export default function CityPartnersPage() {
} }
async function onCreateCityChange(cityId: string) { 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}`); const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05)); setCreateMaxRate(cityRes.maxPartnerCommissionRate ?? 0.05);
} }
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 90 }, { 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: 'name', width: 100, ellipsis: true },
{ title: '登录手机', dataIndex: 'phone', width: 120 }, { title: '登录手机', dataIndex: 'phone', width: 120 },
{ {
@@ -301,16 +267,7 @@ export default function CityPartnersPage() {
ellipsis: true, ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ { title: '门店', dataIndex: 'storeCount', width: 60 },
title: '门店',
dataIndex: 'storeCount',
width: 60,
render: (n: number, row) => (
<Link to={`/stores?partnerId=${row.id}`} title={`查看「${row.companyName || row.phone}」门店`}>
{n ?? 0}
</Link>
),
},
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) }, { title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{ {
@@ -342,7 +299,6 @@ export default function CityPartnersPage() {
}); });
setCreateScopeType(CityPartnerScopeType.CITY_WIDE); setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateMaxRate(0.05); setCreateMaxRate(0.05);
setCreateCityCode(undefined);
setCreateOpen(true); setCreateOpen(true);
}} }}
> >
@@ -371,6 +327,12 @@ export default function CityPartnersPage() {
setPage(1); 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="城市"> <Form.Item name="cityId" label="城市">
<Select <Select
allowClear allowClear
@@ -381,12 +343,6 @@ export default function CityPartnersPage() {
options={cities.map((c) => ({ value: c.id, label: c.name }))} options={cities.map((c) => ({ value: c.id, label: c.name }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="companyName" label="公司">
<Input allowClear placeholder="公司名" />
</Form.Item>
<Form.Item name="phone" label="手机">
<Input allowClear placeholder="登录手机" />
</Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
@@ -453,11 +409,7 @@ export default function CityPartnersPage() {
<> <>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}> <Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item> <Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="门店数"> <Descriptions.Item label="门店数">{detail.storeCount ?? 0}</Descriptions.Item>
<Link to={`/stores?partnerId=${detail.id}`} title="查看该城市合伙人门店">
{detail.storeCount ?? 0}
</Link>
</Descriptions.Item>
<Descriptions.Item label="管仓仓库" span={2}> <Descriptions.Item label="管仓仓库" span={2}>
{detail.managedWarehouseName ?? ( {detail.managedWarehouseName ?? (
<Typography.Text type="secondary"> <Typography.Text type="secondary">
@@ -473,8 +425,8 @@ export default function CityPartnersPage() {
label: '主账号', label: '主账号',
children: ( children: (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名"> <Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
<Input placeholder="选填" /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input /> <Input />
@@ -485,8 +437,8 @@ export default function CityPartnersPage() {
<Form.Item name="contactPhone" label="业务联系电话"> <Form.Item name="contactPhone" label="业务联系电话">
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="address" label="地址"> <Form.Item name="address" label="地址" rules={[{ required: true }]}>
<Input placeholder="选填" /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}> <Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} /> <Select options={BINDING_OPTIONS} />
@@ -495,12 +447,8 @@ export default function CityPartnersPage() {
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item> </Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && ( {editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item <Form.Item name="districtCodes" label="区县">
name="districtCodes" <Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={editCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -554,34 +502,29 @@ export default function CityPartnersPage() {
width={560} width={560}
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
onOk={async () => { onOk={async () => {
try { const v = await createForm.validateFields();
const v = await createForm.validateFields(); const err = commissionSumError(
const err = commissionSumError( Number(v.orderCommissionRate ?? 0),
Number(v.orderCommissionRate ?? 0), Number(v.redeemCommissionRate ?? 3),
Number(v.redeemCommissionRate ?? 3), createMaxRate,
createMaxRate, );
); if (err) {
if (err) { message.error(err);
message.error(err); return;
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"> <Form form={createForm} layout="vertical">
@@ -593,8 +536,8 @@ export default function CityPartnersPage() {
onChange={(id) => void onCreateCityChange(id)} onChange={(id) => void onCreateCityChange(id)}
/> />
</Form.Item> </Form.Item>
<Form.Item name="companyName" label="公司名"> <Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
<Input placeholder="选填" /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input /> <Input />
@@ -602,19 +545,15 @@ export default function CityPartnersPage() {
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="address" label="地址"> <Form.Item name="address" label="地址" rules={[{ required: true }]}>
<Input placeholder="选填" /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && ( {createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item <Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
name="districtCodes" <Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
+12 -22
View File
@@ -52,18 +52,8 @@ export default function DashboardPage() {
request<DashboardStats>('/admin/dashboard/stats') request<DashboardStats>('/admin/dashboard/stats')
.then(setStats) .then(setStats)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
request<HqProfile>('/admin/auth/me') void loadVersion();
.then((p) => { request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
setProfile(p);
if (p.adminRole === 'SUPER_ADMIN') {
void loadVersion();
} else {
setVersionLoading(false);
}
})
.catch(() => {
setVersionLoading(false);
});
}, [loadVersion]); }, [loadVersion]);
function handleDeploy() { function handleDeploy() {
@@ -93,7 +83,6 @@ export default function DashboardPage() {
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
{isSuperAdmin ? (
<Card <Card
title="系统版本" title="系统版本"
loading={versionLoading} loading={versionLoading}
@@ -103,14 +92,16 @@ export default function DashboardPage() {
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}> <Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
</Button> </Button>
<Button {isSuperAdmin && (
type="primary" <Button
icon={<CloudUploadOutlined />} type="primary"
loading={deploying} icon={<CloudUploadOutlined />}
onClick={handleDeploy} loading={deploying}
> onClick={handleDeploy}
>
</Button>
</Button>
)}
</Space> </Space>
} }
> >
@@ -131,7 +122,6 @@ export default function DashboardPage() {
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
)} )}
</Card> </Card>
) : null}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} lg={6}>
@@ -2,10 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Alert, Alert,
Button, Button,
Divider,
Form, Form,
Input, Input,
InputNumber,
Modal, Modal,
Select, Select,
Space, Space,
@@ -16,13 +14,10 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
DEFAULT_XFX_LOGISTICS_PRICING,
FULFILLMENT_PROVIDER_STATUS_LABELS, FULFILLMENT_PROVIDER_STATUS_LABELS,
FULFILLMENT_PROVIDER_TYPE_LABELS, FULFILLMENT_PROVIDER_TYPE_LABELS,
FulfillmentProviderStatus, FulfillmentProviderStatus,
FulfillmentProviderType, FulfillmentProviderType,
LOGISTICS_SETTLEMENT_METHOD_LABELS,
LogisticsSettlementMethod,
isXfxProviderCode, isXfxProviderCode,
type FulfillmentProviderDto, type FulfillmentProviderDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
@@ -37,9 +32,6 @@ const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([
value, value,
label, label,
})); }));
const SETTLEMENT_OPTIONS = Object.entries(LOGISTICS_SETTLEMENT_METHOD_LABELS).map(
([value, label]) => ({ value, label }),
);
const SIGN_OPTIONS = [ const SIGN_OPTIONS = [
{ value: 'MD5', label: 'MD5' }, { value: 'MD5', label: 'MD5' },
{ value: 'HMAC-SHA256', label: 'HMAC-SHA256' }, { value: 'HMAC-SHA256', label: 'HMAC-SHA256' },
@@ -85,12 +77,6 @@ export default function FulfillmentProvidersPage() {
status: FulfillmentProviderStatus.ACTIVE, status: FulfillmentProviderStatus.ACTIVE,
apiUrl: DEFAULT_XFX_API_URL, apiUrl: DEFAULT_XFX_API_URL,
signType: 'MD5', 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); setOpen(true);
} }
@@ -98,7 +84,6 @@ export default function FulfillmentProvidersPage() {
function openEdit(row: FulfillmentProviderDto) { function openEdit(row: FulfillmentProviderDto) {
setEditRow(row); setEditRow(row);
const xfx = row.xiaofeixiaConfig; const xfx = row.xiaofeixiaConfig;
const pricing = row.pricingRules;
form.setFieldsValue({ form.setFieldsValue({
code: row.code, code: row.code,
name: row.name, name: row.name,
@@ -109,16 +94,6 @@ export default function FulfillmentProvidersPage() {
apiKey: '', apiKey: '',
signType: xfx?.signType || 'MD5', signType: xfx?.signType || 'MD5',
appId: xfx?.appId || '', 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); setOpen(true);
} }
@@ -129,18 +104,6 @@ export default function FulfillmentProvidersPage() {
name: v.name, name: v.name,
type: v.type, type: v.type,
status: v.status, 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) { if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
@@ -181,19 +144,6 @@ export default function FulfillmentProvidersPage() {
dataIndex: 'type', dataIndex: 'type',
render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v, 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: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -238,7 +188,7 @@ export default function FulfillmentProvidersPage() {
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
使 API
</Typography.Text> </Typography.Text>
</div> </div>
<Button type="primary" onClick={openCreate}> <Button type="primary" onClick={openCreate}>
@@ -250,7 +200,7 @@ export default function FulfillmentProvidersPage() {
type="info" type="info"
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。" message="小飞侠配置已从「系统设置 → 同城配送」迁至本页。寄件地址以仓库联系人/地址为准。"
/> />
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} /> <Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
@@ -260,7 +210,7 @@ export default function FulfillmentProvidersPage() {
open={open} open={open}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={() => void submit()} onOk={() => void submit()}
width={640} width={560}
destroyOnClose destroyOnClose
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
@@ -277,58 +227,11 @@ export default function FulfillmentProvidersPage() {
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </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 && ( {showXfxFields && (
<> <>
<Divider orientation="left"></Divider> <Typography.Title level={5} style={{ marginTop: 8 }}>
</Typography.Title>
<Form.Item <Form.Item
name="apiUrl" name="apiUrl"
label="API 地址" label="API 地址"
+34 -113
View File
@@ -33,15 +33,6 @@ type AccountPermRes = {
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label])); 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 groupColSpan(itemCount: number): number {
if (itemCount <= 2) return 12;
if (itemCount === 3) return 8;
if (itemCount === 4) return 6;
return 8;
}
function PermissionChecklist({ function PermissionChecklist({
value, value,
onChange, onChange,
@@ -58,44 +49,13 @@ function PermissionChecklist({
disabled={disabled} disabled={disabled}
onChange={(checked) => onChange(checked as string[])} onChange={(checked) => onChange(checked as string[])}
> >
{CATALOG_GROUPS.map((group) => { <Row gutter={[8, 8]}>
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group); {HQ_PERMISSION_CATALOG.map((item) => (
const span = groupColSpan(items.length); <Col key={item.key} span={8}>
const compact = items.length <= 3; <Checkbox value={item.key}>{item.label}</Checkbox>
return ( </Col>
<div ))}
key={group} </Row>
style={{
marginBottom: 16,
padding: '12px 16px',
background: '#fafafa',
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<Typography.Text strong style={{ display: 'block', marginBottom: 10 }}>
{group}
</Typography.Text>
{compact ? (
<Space size={[24, 8]} wrap>
{items.map((item) => (
<Checkbox key={item.key} value={item.key}>
{item.label}
</Checkbox>
))}
</Space>
) : (
<Row gutter={[12, 10]}>
{items.map((item) => (
<Col key={item.key} xs={24} sm={12} md={span}>
<Checkbox value={item.key}>{item.label}</Checkbox>
</Col>
))}
</Row>
)}
</div>
);
})}
</Checkbox.Group> </Checkbox.Group>
); );
} }
@@ -114,12 +74,6 @@ export default function HqPermissionsPage() {
const [accountLoading, setAccountLoading] = useState(false); const [accountLoading, setAccountLoading] = useState(false);
const [accountSaving, setAccountSaving] = useState(false); const [accountSaving, setAccountSaving] = useState(false);
const selectedAccount = useMemo(
() => accounts.find((a) => a.id === accountId),
[accounts, accountId],
);
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
const previewEffectiveKeys = useMemo( const previewEffectiveKeys = useMemo(
() => [...new Set([...roleInheritedKeys, ...accountKeys])], () => [...new Set([...roleInheritedKeys, ...accountKeys])],
[roleInheritedKeys, accountKeys], [roleInheritedKeys, accountKeys],
@@ -204,9 +158,7 @@ export default function HqPermissionsPage() {
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
= =
//
//
</Typography.Paragraph> </Typography.Paragraph>
<Tabs <Tabs
@@ -231,11 +183,7 @@ export default function HqPermissionsPage() {
</Form.Item> </Form.Item>
</Form> </Form>
{role === 'SUPER_ADMIN' ? ( {role === 'SUPER_ADMIN' ? (
<Alert <Alert type="info" showIcon message="超级管理员拥有全部权限,无需配置" />
type="info"
showIcon
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
/>
) : ( ) : (
<> <>
<PermissionChecklist value={roleKeys} onChange={setRoleKeys} /> <PermissionChecklist value={roleKeys} onChange={setRoleKeys} />
@@ -264,10 +212,12 @@ export default function HqPermissionsPage() {
value={accountId} value={accountId}
onChange={setAccountId} onChange={setAccountId}
optionFilterProp="label" optionFilterProp="label"
options={accounts.map((a) => ({ options={accounts
value: a.id, .filter((a) => a.adminRole !== 'SUPER_ADMIN')
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`, .map((a) => ({
}))} value: a.id,
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
}))}
/> />
</Form.Item> </Form.Item>
</Form> </Form>
@@ -275,57 +225,28 @@ export default function HqPermissionsPage() {
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" /> <Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
) : ( ) : (
<> <>
<div style={{ marginBottom: 12 }}> <Space wrap style={{ marginBottom: 12 }}>
<span style={{ marginRight: 8 }}></span> <span></span>
{selectedIsSuperAdmin ? ( {roleInheritedKeys.map((key) => {
<Tag color="blue"></Tag> const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
) : ( return (
<Space wrap size={[4, 4]}> <Tag key={key} color="blue">
{roleInheritedKeys.map((key) => { {item?.label || key}
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key); </Tag>
return ( );
<Tag key={key} color="blue"> })}
{item?.label || key} </Space>
</Tag>
);
})}
{!roleInheritedKeys.length ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
</Space>
)}
</div>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
//
</Typography.Paragraph> </Typography.Paragraph>
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} /> <PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
<div style={{ marginTop: 12 }}> <Space wrap style={{ marginTop: 12 }}>
<span style={{ marginRight: 8 }}></span> <span></span>
{selectedIsSuperAdmin ? ( {previewEffectiveKeys.map((key) => {
<Space wrap size={[4, 4]}> const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
<Tag></Tag> return <Tag key={key}>{item?.label || key}</Tag>;
{accountKeys.map((key) => { })}
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); </Space>
return (
<Tag key={key} color="orange">
{item?.label || key}
</Tag>
);
})}
</Space>
) : (
<Space wrap size={[4, 4]}>
{previewEffectiveKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
return (
<Tag key={key}>
{item?.label || key}
</Tag>
);
})}
</Space>
)}
</div>
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}> <Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
-424
View File
@@ -1,424 +0,0 @@
import { useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Form,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
Upload,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
INVOICE_KIND_LABELS,
INVOICE_STATUS_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceKind,
type InvoiceStatus,
type InvoiceTitleType,
} 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 Row = {
id: string;
invoiceNo: string;
orderNo?: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
titleName: string;
status: InvoiceStatus;
overdue?: boolean;
createdAt: string;
fileUrl?: string;
email?: string;
phone?: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
payAmount?: string;
userPhone?: string;
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>(
'/admin/invoices',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
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}`));
setDrawerOpen(true);
}
async function issueWithFile(file: File) {
if (!detail) return false;
setUploading(true);
try {
const uploaded = await uploadFileToOss(file, { bizType: 'invoice' });
await request(`/admin/invoices/${detail.id}/issue`, {
method: 'POST',
body: JSON.stringify({ fileUrl: uploaded.url }),
});
message.success('已开票回传');
reload();
setDrawerOpen(false);
} catch (e) {
message.error(e instanceof Error ? e.message : '开票失败');
} finally {
setUploading(false);
}
return false;
}
async function reject() {
if (!detail) return;
await request(`/admin/invoices/${detail.id}/reject`, {
method: 'POST',
body: JSON.stringify({ remark: '驳回' }),
});
message.success('已驳回');
reload();
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 },
{
title: '抬头',
width: 100,
render: (_, r) => INVOICE_TITLE_TYPE_LABELS[r.titleType] ?? r.titleType,
},
{
title: '票种',
width: 120,
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
},
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
{
title: '状态',
width: 110,
render: (_, r) => (
<>
<Tag color={r.status === 'ISSUED' ? 'green' : r.status === 'REJECTED' ? 'red' : 'orange'}>
{INVOICE_STATUS_LABELS[r.status] ?? r.status}
</Tag>
{r.overdue ? <Tag color="red"></Tag> : null}
</>
),
},
{ title: '申请时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
</Button>
),
},
];
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={() => {
createForm.setFieldsValue({
titleType: 'PERSONAL',
invoiceKind: 'NORMAL',
});
setCreateOpen(true);
}}
>
</Button>
</div>
<Form
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'PENDING', label: '待开票' },
{ value: 'ISSUED', label: '已开票' },
{ value: 'REJECTED', label: '已驳回' },
]}
/>
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1000 }}
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
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={
detail?.status === 'PENDING' ? (
<Space>
<Upload
accept="image/*,.pdf"
showUploadList={false}
beforeUpload={(file) => {
void issueWithFile(file);
return false;
}}
>
<Button type="primary" loading={uploading}>
</Button>
</Upload>
<Button danger onClick={() => void reject()}>
</Button>
</Space>
) : null
}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="申请单号">{detail.invoiceNo}</Descriptions.Item>
<Descriptions.Item label="订单号">{detail.orderNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="金额">{detail.payAmount ?? '—'}</Descriptions.Item>
<Descriptions.Item label="用户手机">{detail.userPhone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="抬头类型">
{INVOICE_TITLE_TYPE_LABELS[detail.titleType]}
</Descriptions.Item>
<Descriptions.Item label="发票类型">
{INVOICE_KIND_LABELS[detail.invoiceKind]}
</Descriptions.Item>
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="开户行账号">{detail.bankAccount ?? '—'}</Descriptions.Item>
<Descriptions.Item label="邮箱">{detail.email}</Descriptions.Item>
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
<Descriptions.Item label="状态">
{INVOICE_STATUS_LABELS[detail.status]}
{detail.overdue ? '(超 2 工作日)' : ''}
</Descriptions.Item>
<Descriptions.Item label="发票文件">
{detail.fileUrl ? (
<a href={detail.fileUrl} target="_blank" rel="noreferrer">
/
</a>
) : (
'—'
)}
</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="提交"
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>
);
}
@@ -1,403 +0,0 @@
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>
);
}
-380
View File
@@ -1,380 +0,0 @@
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>
);
}
@@ -1,625 +0,0 @@
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>
);
}
+17 -280
View File
@@ -18,7 +18,7 @@ import {
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api'; import { request, type AdminOrderItem, type AdminOrderRow, type Paginated } from '../lib/api';
import { import {
ADMIN_OPTIONS_PAGE_SIZE, ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS, DELIVERY_PROVIDER_LABELS,
@@ -26,7 +26,6 @@ import {
ORDER_STATUS_LABELS, ORDER_STATUS_LABELS,
fmtTime, fmtTime,
} from '../lib/constants'; } from '../lib/constants';
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
type ShipDefaults = { type ShipDefaults = {
provider: string; provider: string;
@@ -56,28 +55,6 @@ type WarehouseOption = {
lat?: number | null; 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 & { type OrderDetail = AdminOrderRow & {
receiverAddress?: string; receiverAddress?: string;
receiverProvince?: string; receiverProvince?: string;
@@ -104,8 +81,6 @@ type OrderDetail = AdminOrderRow & {
payment?: Record<string, unknown> | null; payment?: Record<string, unknown> | null;
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>; statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
benefitCoupons?: Array<Record<string, unknown>>; benefitCoupons?: Array<Record<string, unknown>>;
redeemSummary?: OrderRedeemSummary | null;
redeemRecords?: OrderRedeemRecord[];
fulfillmentWarehouse?: { fulfillmentWarehouse?: {
id: string; id: string;
name: string; name: string;
@@ -154,16 +129,12 @@ export default function OrdersPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [shipForm] = Form.useForm(); const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm(); const [logisticsForm] = Form.useForm();
const [profile, setProfile] = useState<HqProfile | null>(null);
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null); const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20); const [pageSize, setPageSize] = useState(20);
const [detail, setDetail] = useState<OrderDetail | null>(null); const [detail, setDetail] = useState<OrderDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); 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 [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false); const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
const [batchDeleting, setBatchDeleting] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false);
@@ -175,31 +146,12 @@ export default function OrdersPage() {
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null); const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS'); const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]); const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const selectedOrders = useMemo( const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), () => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
[data?.items, selectedRowKeys], [data?.items, selectedRowKeys],
); );
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
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 () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -209,7 +161,6 @@ export default function OrdersPage() {
if (values.status) qs.set('status', values.status); if (values.status) qs.set('status', values.status);
if (values.cityId) qs.set('cityId', values.cityId); if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`); const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res); setData(res);
} finally { } finally {
@@ -403,12 +354,9 @@ export default function OrdersPage() {
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 140, width: 100,
render: (s, row) => ( render: (s) => (
<Space size={4} wrap> <Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
</Space>
), ),
}, },
{ {
@@ -460,15 +408,13 @@ export default function OrdersPage() {
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{canDeleteOrders ? ( <Button
<Button danger
danger disabled={!selectedRowKeys.length}
disabled={!selectedRowKeys.length} onClick={() => setBatchDeleteOpen(true)}
onClick={() => setBatchDeleteOpen(true)} >
> {selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''} </Button>
</Button>
) : null}
</Space> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="orderNo" label="订单号"> <Form.Item name="orderNo" label="订单号">
@@ -490,14 +436,6 @@ export default function OrdersPage() {
<Form.Item name="receiverPhone" label="收货手机"> <Form.Item name="receiverPhone" label="收货手机">
<Input allowClear /> <Input allowClear />
</Form.Item> </Form.Item>
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked">
<Select
allowClear
style={{ width: 140 }}
placeholder="全部"
options={[{ value: true, label: '仅待确认大单' }]}
/>
</Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit"></Button> <Button type="primary" htmlType="submit"></Button>
@@ -512,10 +450,10 @@ export default function OrdersPage() {
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1500 }} scroll={{ x: 1500 }}
rowSelection={canDeleteOrders ? { rowSelection={{
selectedRowKeys, selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]), onChange: (keys) => setSelectedRowKeys(keys as string[]),
} : undefined} }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -530,7 +468,7 @@ export default function OrdersPage() {
<Drawer <Drawer
title="订单详情" title="订单详情"
width={720} width={640}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={detail && ( extra={detail && (
@@ -571,17 +509,9 @@ export default function OrdersPage() {
{detail.fulfillmentWarehouse?.name || '未分配'} {detail.fulfillmentWarehouse?.name || '未分配'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
<Space> <Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}> {ORDER_STATUS_LABELS[detail.status] || detail.status}
{ORDER_STATUS_LABELS[detail.status] || detail.status} </Tag>
</Tag>
{detail.fulfillmentHold ? (
<Tag color="orange">
{FULFILLMENT_HOLD_REASON_LABELS[detail.fulfillmentHoldReason || ''] ||
'大单待确认'}
</Tag>
) : null}
</Space>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item> <Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item> <Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
@@ -640,98 +570,6 @@ export default function OrdersPage() {
<Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item> <Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item>
</Descriptions> </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 column={1} bordered size="small" title="位置快照(方案C" style={{ marginTop: 16 }}>
<Descriptions.Item label="clientIp">{detail.clientIp || '—'}</Descriptions.Item> <Descriptions.Item label="clientIp">{detail.clientIp || '—'}</Descriptions.Item>
<Descriptions.Item label="IP解析">{[detail.ipProvince, detail.ipCity, detail.ipDistrict].filter(Boolean).join(' / ') || '—'}</Descriptions.Item> <Descriptions.Item label="IP解析">{[detail.ipProvince, detail.ipCity, detail.ipDistrict].filter(Boolean).join(' / ') || '—'}</Descriptions.Item>
@@ -780,95 +618,6 @@ export default function OrdersPage() {
)} )}
</Drawer> </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 <Modal
title={shipTarget ? `配送发货 · ${shipTarget.orderNo}` : '配送发货'} title={shipTarget ? `配送发货 · ${shipTarget.orderNo}` : '配送发货'}
open={shipModalOpen} open={shipModalOpen}
@@ -893,18 +642,6 @@ export default function OrdersPage() {
> >
{shipTarget && ( {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 size="small" column={1} style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市">{shipTarget.city?.name || '—'}</Descriptions.Item> <Descriptions.Item label="城市">{shipTarget.city?.name || '—'}</Descriptions.Item>
<Descriptions.Item label="收货"> <Descriptions.Item label="收货">
+35 -103
View File
@@ -38,17 +38,15 @@ type Row = {
type PartnerOption = { id: string; companyName: string; phone?: string }; type PartnerOption = { id: string; companyName: string; phone?: string };
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
PENDING_REVIEW: '待审核', DRAFT: '待合伙人确认',
AWAITING_CONFIRM: '待合伙人确认', CONFIRMED: '待打款审核',
UNPAID: '未打款',
PAID: '已打款', PAID: '已打款',
REJECTED: '已驳回', REJECTED: '已驳回',
}; };
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
PENDING_REVIEW: 'gold', DRAFT: 'default',
AWAITING_CONFIRM: 'blue', CONFIRMED: 'orange',
UNPAID: 'red',
PAID: 'green', PAID: 'green',
REJECTED: 'red', REJECTED: 'red',
}; };
@@ -76,7 +74,6 @@ export default function PartnerBillsPage() {
const [rejectTarget, setRejectTarget] = useState<Row | null>(null); const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
const [rejectForm] = Form.useForm(); const [rejectForm] = Form.useForm();
const [rejecting, setRejecting] = useState(false); const [rejecting, setRejecting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
useEffect(() => { useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -84,10 +81,10 @@ export default function PartnerBillsPage() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
async function generateBill(values: { partnerId?: string; month: Dayjs }) { async function generateBill(values: { partnerId?: string; month: Dayjs; all?: boolean }) {
const year = values.month.year(); const year = values.month.year();
const month = values.month.month() + 1; const month = values.month.month() + 1;
if (!values.partnerId) { if (values.all || !values.partnerId) {
const result = await request<{ success: number; failed: number; total: number }>( const result = await request<{ success: number; failed: number; total: number }>(
'/admin/partner-bills/generate-all', '/admin/partner-bills/generate-all',
{ method: 'POST', body: JSON.stringify({ year, month }) }, { method: 'POST', body: JSON.stringify({ year, month }) },
@@ -98,71 +95,31 @@ export default function PartnerBillsPage() {
method: 'POST', method: 'POST',
body: JSON.stringify({ partnerId: values.partnerId, year, month }), body: JSON.stringify({ partnerId: values.partnerId, year, month }),
}); });
message.success('账单已生成(待审核)'); message.success('账单已生成');
} }
setGenOpen(false); setGenOpen(false);
reload(); reload();
} }
function sendBills(ids: string[]) { async function confirmBill(id: string) {
Modal.confirm({ await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
title: '发送给合伙人?', message.success('已代确认,进入待打款审核');
content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`, reload();
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();
},
});
} }
function markPaid(ids: string[], amountHint?: number) { async function markPaid(id: string) {
Modal.confirm({ await request(`/admin/partner-bills/${id}/mark-paid`, {
title: '确认打款?', method: 'POST',
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}`, body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
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) { function openReject(row: Row) {
Modal.confirm({ setRejectTarget(row);
title: '驳回该账单?', rejectForm.resetFields();
content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。', setRejectOpen(true);
okText: '继续填写理由',
cancelText: '取消',
onOk: () => {
setRejectTarget(row);
rejectForm.resetFields();
setRejectOpen(true);
},
});
} }
async function submitReject(values: { reason: string }) { async function submitReject(values: { reason: string }) {
@@ -173,7 +130,7 @@ export default function PartnerBillsPage() {
method: 'POST', method: 'POST',
body: JSON.stringify({ reason: values.reason.trim() }), body: JSON.stringify({ reason: values.reason.trim() }),
}); });
message.success('已驳回'); message.success('已驳回打款申请');
setRejectOpen(false); setRejectOpen(false);
setRejectTarget(null); setRejectTarget(null);
reload(); reload();
@@ -201,10 +158,6 @@ export default function PartnerBillsPage() {
const summary = data?.summary; const summary = data?.summary;
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—'; 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> = [ const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true }, { title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
@@ -241,7 +194,7 @@ export default function PartnerBillsPage() {
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 130, width: 120,
render: (s, row) => ( render: (s, row) => (
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag> <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
@@ -259,26 +212,21 @@ export default function PartnerBillsPage() {
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size={0} wrap> <Space size={0} wrap>
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && ( {row.status === 'DRAFT' && (
<Button type="link" size="small" onClick={() => sendBills([row.id])}> <Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
</Button> </Button>
)} )}
{row.status === 'UNPAID' && ( {row.status === 'CONFIRMED' && (
<> <>
<Button type="link" size="small" onClick={() => markPaid([row.id], Number(row.totalAmount))}> <Button type="link" size="small" onClick={() => void markPaid(row.id)}>
</Button> </Button>
<Button type="link" size="small" danger onClick={() => openReject(row)}> <Button type="link" size="small" danger onClick={() => openReject(row)}>
</Button> </Button>
</> </>
)} )}
{row.status === 'AWAITING_CONFIRM' && (
<Button type="link" size="small" danger onClick={() => openReject(row)}>
</Button>
)}
</Space> </Space>
), ),
}, },
@@ -291,7 +239,7 @@ export default function PartnerBillsPage() {
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
1 8:00 T+30
</Typography.Text> </Typography.Text>
</Space> </Space>
@@ -339,7 +287,7 @@ export default function PartnerBillsPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select <Select
allowClear allowClear
style={{ width: 150 }} style={{ width: 140 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
@@ -369,20 +317,6 @@ export default function PartnerBillsPage() {
Excel Excel
</Button> </Button>
</Form.Item> </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> </Form>
<Table <Table
@@ -391,10 +325,6 @@ export default function PartnerBillsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
}}
scroll={{ x: 1280 }} scroll={{ x: 1280 }}
pagination={{ pagination={{
current: page, current: page,
@@ -408,7 +338,7 @@ export default function PartnerBillsPage() {
}} }}
/> />
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}> <Modal title="生成合伙人账单T+30" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
<Form <Form
form={genForm} form={genForm}
layout="vertical" layout="vertical"
@@ -431,7 +361,7 @@ export default function PartnerBillsPage() {
/> />
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit" block> <Button type="primary" htmlType="submit" block>
</Button> </Button>
</Form> </Form>
</Modal> </Modal>
@@ -448,6 +378,8 @@ export default function PartnerBillsPage() {
> >
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}> <Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''} {rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''}
<br />
H5
</Typography.Paragraph> </Typography.Paragraph>
<Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}> <Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}>
<Form.Item <Form.Item
@@ -458,7 +390,7 @@ export default function PartnerBillsPage() {
{ max: 500, message: '不超过 500 字' }, { max: 500, message: '不超过 500 字' },
]} ]}
> >
<Input.TextArea rows={4} placeholder="请说明驳回原因" maxLength={500} showCount /> <Input.TextArea rows={4} placeholder="请说明驳回原因,便于合伙人核对后重新申请" maxLength={500} showCount />
</Form.Item> </Form.Item>
<Button type="primary" danger htmlType="submit" block loading={rejecting}> <Button type="primary" danger htmlType="submit" block loading={rejecting}>
+37 -80
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
Button, Button,
Cascader,
Drawer, Drawer,
Form, Form,
Input, Input,
@@ -23,10 +24,9 @@ import {
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; import { CHINA_REGION_OPTIONS } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
type Row = { type Row = {
id: string; id: string;
@@ -37,7 +37,6 @@ type Row = {
cityId?: string | null; cityId?: string | null;
cityName?: string | null; cityName?: string | null;
scopeType?: string; scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number; orderCommissionRate?: number;
redeemCommissionRate?: number; redeemCommissionRate?: number;
storeCount: number; storeCount: number;
@@ -103,18 +102,6 @@ export default function PartnersPage() {
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE); const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [createCityId, setCreateCityId] = useState<string | undefined>(); 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 loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items); setCities(res.items);
@@ -155,35 +142,22 @@ export default function PartnersPage() {
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
try { const v = await editForm.validateFields();
const v = await editForm.validateFields(); const body = {
const body = { ...v,
...v, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, };
}; await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) }); message.success('已保存');
message.success('已保存'); setDrawerOpen(false);
setDrawerOpen(false); void reload();
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
} }
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ 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: 'companyName', ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 100 },
{ title: '主账号', dataIndex: 'phone', width: 130 }, { title: '主账号', dataIndex: 'phone', width: 130 },
{ {
title: '管辖', title: '管辖',
@@ -248,21 +222,17 @@ export default function PartnersPage() {
label: '基本信息', label: '基本信息',
children: ( children: (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item> <Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item> <Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item> <Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item> </Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && ( {editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item <Form.Item name="districtCodes" label="区县">
name="districtCodes" <Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={editCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -316,52 +286,39 @@ export default function PartnersPage() {
width={560} width={560}
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
onOk={async () => { onOk={async () => {
try { const v = await createForm.validateFields();
const v = await createForm.validateFields(); await request('/admin/partners', {
await request('/admin/partners', { method: 'POST',
method: 'POST', body: JSON.stringify({
body: JSON.stringify({ ...v,
...v, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, }),
}), });
}); message.success('已创建');
message.success('已创建'); setCreateOpen(false);
setCreateOpen(false); createForm.resetFields();
createForm.resetFields(); void reload();
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 form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}> <Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select <Select
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))} options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => { onChange={(id) => { setCreateCityId(id); void loadWarehouses(id); }}
setCreateCityId(id);
createForm.setFieldsValue({ districtCodes: undefined });
void loadWarehouses(id);
}}
/> />
</Form.Item> </Form.Item>
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item> <Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item> <Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && ( {createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item <Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
name="districtCodes" <Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
+3 -17
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
Switch, Table, Tabs, Tag, Typography, message, Table, Tabs, Tag, Typography, message,
} from 'antd'; } from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
@@ -31,7 +31,6 @@ type Row = {
benefitAmount: number; benefitAmount: number;
status: string; status: string;
sortOrder: number; sortOrder: number;
allowOnSitePickup?: boolean;
mainImageUrl?: string | null; mainImageUrl?: string | null;
carouselUrls?: string[]; carouselUrls?: string[];
detailImageUrls?: string[]; detailImageUrls?: string[];
@@ -50,7 +49,6 @@ type ProductFormValues = {
benefitAmount?: number; benefitAmount?: number;
status?: string; status?: string;
sortOrder?: number; sortOrder?: number;
allowOnSitePickup?: boolean;
coverUrl?: string; coverUrl?: string;
carouselUrls?: string[]; carouselUrls?: string[];
detailImageUrls?: string[]; detailImageUrls?: string[];
@@ -102,7 +100,6 @@ function buildProductPayload(v: ProductFormValues) {
benefitAmount: v.benefitAmount, benefitAmount: v.benefitAmount,
status: v.status, status: v.status,
sortOrder: v.sortOrder, sortOrder: v.sortOrder,
allowOnSitePickup: !!v.allowOnSitePickup,
coverUrl: v.coverUrl, coverUrl: v.coverUrl,
carouselUrls, carouselUrls,
detailImageUrls, detailImageUrls,
@@ -220,9 +217,6 @@ function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
<Form.Item name="sortOrder" label="排序"> <Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} /> <InputNumber min={0} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item name="coverUrl" label="封面"> <Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" /> <OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item> </Form.Item>
@@ -271,15 +265,7 @@ export default function ProductsPage() {
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true }, { title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` }, { title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` }, { title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => ( { title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{PRODUCT_STATUS_LABELS[s] || s}</Tag> },
<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: 'sortOrder', width: 60 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
@@ -371,7 +357,7 @@ export default function ProductsPage() {
void reload(); void reload();
}} width={720}> }} width={720}>
<Form form={createForm} layout="vertical" initialValues={{ <Form form={createForm} layout="vertical" initialValues={{
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, allowOnSitePickup: false, aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
carouselUrls: [''], detailImageUrls: [''], carouselUrls: [''], detailImageUrls: [''],
features: [{ icon: 'water_drop', title: '', desc: '' }], features: [{ icon: 'water_drop', title: '', desc: '' }],
}}> }}>
+3 -11
View File
@@ -121,10 +121,10 @@ export default function PromoCodesPage() {
[filters], [filters],
); );
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
async function loadScenes() {
try { try {
@@ -145,7 +145,6 @@ export default function PromoCodesPage() {
useEffect(() => { useEffect(() => {
void loadScenes(); void loadScenes();
page: values.page?.trim() || undefined,
}, []); }, []);
@@ -221,13 +220,6 @@ export default function PromoCodesPage() {
width: 220, width: 220,
<Form.Item
name="page"
label="小程序落地页(选填)"
extra="如 pages/home/index;留空则使用服务端环境变量 WX_MINI_PROMO_PAGE"
>
<Input placeholder="pages/home/index" />
</Form.Item>
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
@@ -235,7 +227,7 @@ export default function PromoCodesPage() {
<Space size="small" wrap> <Space size="small" wrap>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}> <Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
</Button> </Button>
+2 -45
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Button, Descriptions, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
@@ -45,33 +45,12 @@ export default function StoreAccountsPage() {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [stores, setStores] = useState<StoreOption[]>([]); const [stores, setStores] = useState<StoreOption[]>([]);
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
async function loadStores() { async function loadStores() {
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setStores(res.items); setStores(res.items);
} }
async function refreshDetail(accountId: string) {
const d = await request<Row>(`/admin/store-accounts/${accountId}`);
setDetail(d);
void reload();
}
async function deleteStaff(staffId: string) {
if (!detail) return;
setDeletingStaffId(staffId);
try {
await request(`/admin/store-accounts/${detail.id}/staff/${staffId}`, { method: 'DELETE' });
message.success('子账号已删除');
await refreshDetail(detail.id);
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeletingStaffId(null);
}
}
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name', width: 100 }, { title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 }, { title: '手机', dataIndex: 'phone', width: 120 },
@@ -239,32 +218,10 @@ export default function StoreAccountsPage() {
dataIndex: 'status', dataIndex: 'status',
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
}, },
{
title: '操作',
width: 80,
render: (_, staff) => (
<Popconfirm
title="确认删除该子账号?"
description={`${staff.name}${staff.phone})删除后将无法登录门店端`}
okText="删除"
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
cancelText="取消"
onConfirm={() => void deleteStaff(staff.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
},
]} ]}
/> />
</> </>
) : ( ) : null}
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
</Typography.Paragraph>
)}
</> </>
)} )}
</Drawer> </Drawer>
+92 -134
View File
@@ -6,7 +6,6 @@ import {
Descriptions, Descriptions,
Drawer, Drawer,
Form, Form,
Modal,
Select, Select,
Space, Space,
Statistic, Statistic,
@@ -24,26 +23,26 @@ import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
id: string; id: string;
billNo: string;
billDate: string;
redeemCount: number;
redeemAmount: number; redeemAmount: number;
settlementRate: number;
payoutAmount: number; payoutAmount: number;
settlementRate: number;
status: string; status: string;
paidAt?: string | null; expectedPayAt: string;
paidAt?: string;
createdAt: string;
store?: { id: string; name: string; cityName: string; phone?: string }; store?: { id: string; name: string; cityName: string; phone?: string };
redeemRecord?: { redeemNo: string; amount?: number; createdAt?: string };
}; };
type StoreOption = { id: string; name: string; phone: string }; type StoreOption = { id: string; name: string; phone: string };
const STATUS_LABELS: Record<string, string> = { const PAYOUT_STATUS_LABELS: Record<string, string> = {
UNPAID: '打款', PENDING: '打款',
PAID: '已打款', PAID: '已打款',
}; };
const STATUS_COLORS: Record<string, string> = { const PAYOUT_STATUS_COLORS: Record<string, string> = {
UNPAID: 'red', PENDING: 'orange',
PAID: 'green', PAID: 'green',
}; };
@@ -52,7 +51,7 @@ export default function StoreBillsPage() {
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const [stores, setStores] = useState<StoreOption[]>([]); const [stores, setStores] = useState<StoreOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-bills', '/admin/store-payouts',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', filters.status);
@@ -66,8 +65,6 @@ export default function StoreBillsPage() {
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [batchLoading, setBatchLoading] = useState(false);
useEffect(() => { useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -75,37 +72,13 @@ export default function StoreBillsPage() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
function confirmPay(ids: string[], amountHint?: number) { async function confirmPayout(id: string) {
Modal.confirm({ await request(`/admin/store-payouts/${id}/confirm`, {
title: '确认打款?', method: 'POST',
content: `将确认 ${ids.length} 笔门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`, body: JSON.stringify({ remark: '财务确认打款' }),
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() { async function exportExcel() {
@@ -116,8 +89,10 @@ export default function StoreBillsPage() {
if (filters.storeId) qs.set('storeId', filters.storeId); if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo); if (filters.dateTo) qs.set('dateTo', filters.dateTo);
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`); const result = await request<{ csv: string; count: number }>(
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`); `/admin/store-payouts/export?${qs}`,
);
downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
message.success(`已导出 ${result.count}`); message.success(`已导出 ${result.count}`);
} finally { } finally {
setExporting(false); setExporting(false);
@@ -125,56 +100,62 @@ export default function StoreBillsPage() {
} }
const summary = data?.summary; 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> = [ const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
{ {
title: '账单日', title: '核销日期',
dataIndex: 'billDate',
width: 110, width: 110,
render: (v) => String(v || '').slice(0, 10), render: (_, r) =>
(r.redeemRecord?.createdAt || r.createdAt || '').toString().slice(0, 10) || '—',
}, },
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true }, { title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' }, { title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 }, { title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
{ title: '核销笔数', dataIndex: 'redeemCount', width: 90 },
{ {
title: '核销金额', title: '核销单号',
dataIndex: 'redeemAmount', dataIndex: ['redeemRecord', 'redeemNo'],
width: 110, width: 160,
render: (v) => `¥${Number(v).toFixed(2)}`, ellipsis: true,
render: (v) => v || '—',
}, },
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
{ {
title: '结算比例', title: '核销比例',
dataIndex: 'settlementRate', dataIndex: 'settlementRate',
width: 90, width: 90,
render: (v) => `${Math.round(Number(v) * 100)}%`, render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'),
}, },
{ {
title: '应付金额', title: '应付门店',
dataIndex: 'payoutAmount', dataIndex: 'payoutAmount',
width: 110, width: 100,
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${v}`,
}, },
{ {
title: '状态', title: '打款状态',
dataIndex: 'status', dataIndex: 'status',
width: 90, width: 100,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>, render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>,
}, },
{ title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 160, width: 140,
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}> <Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/store-payouts/${row.id}`));
setDrawerOpen(true);
}}
>
</Button> </Button>
{row.status === 'UNPAID' && ( {row.status === 'PENDING' && (
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.payoutAmount))}> <Button type="link" size="small" onClick={() => void confirmPayout(row.id)}>
</Button> </Button>
)} )}
@@ -187,19 +168,19 @@ export default function StoreBillsPage() {
<div> <div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}> <Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
8:00 绿 T+1 = ×
</Typography.Text> </Typography.Text>
</Space> </Space>
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap> <Space size="large" wrap>
<Statistic title="账单数" value={summary.count} /> <Statistic title="明细笔数" value={summary.count} />
<Statistic title="核销额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} /> <Statistic title="核销额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.payoutAmount ?? 0} prefix="¥" precision={2} /> <Statistic title="应付门店合计" value={summary.payoutAmount ?? summary.totalAmount} prefix="¥" precision={2} />
</Space> </Space>
</Card> </Card>
)} )}
@@ -208,12 +189,16 @@ export default function StoreBillsPage() {
form={form} form={form}
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
onFinish={(v: { storeId?: string; status?: string; range?: [Dayjs, Dayjs] }) => { onFinish={(v: {
storeId?: string;
status?: string;
dateRange?: [Dayjs, Dayjs];
}) => {
setFilters({ setFilters({
storeId: v.storeId || '', storeId: v.storeId || '',
status: v.status || '', status: v.status || '',
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '', dateFrom: v.dateRange?.[0]?.format('YYYY-MM-DD') || '',
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '', dateTo: v.dateRange?.[1]?.format('YYYY-MM-DD') || '',
}); });
setPage(1); setPage(1);
}} }}
@@ -225,17 +210,21 @@ export default function StoreBillsPage() {
placeholder="全部门店" placeholder="全部门店"
style={{ width: 200 }} style={{ width: 200 }}
optionFilterProp="label" optionFilterProp="label"
options={stores.map((s) => ({ value: s.id, label: s.name || s.phone || s.id }))} options={stores.map((s) => ({
value: s.id,
label: `${s.name}${s.phone}`,
}))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label="打款状态">
<Select <Select
allowClear allowClear
style={{ width: 120 }} style={{ width: 120 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))} placeholder="全部"
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="range" label="账单日"> <Form.Item name="dateRange" label="核销日期">
<DatePicker.RangePicker /> <DatePicker.RangePicker />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
@@ -259,16 +248,6 @@ export default function StoreBillsPage() {
Excel Excel
</Button> </Button>
</Form.Item> </Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!selectedKeys.length}
loading={batchLoading}
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
>
({selectedKeys.length})
</Button>
</Form.Item>
</Form> </Form>
<Table <Table
@@ -277,12 +256,7 @@ export default function StoreBillsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
rowSelection={{ scroll={{ x: 1300 }}
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
}}
scroll={{ x: 1200 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -295,44 +269,28 @@ export default function StoreBillsPage() {
}} }}
/> />
<Drawer title="门店对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}> <Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && ( {detail && (
<> <Descriptions column={1} bordered size="small">
<Descriptions column={1} size="small" bordered> <Descriptions.Item label="门店">
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item> {String((detail.store as { name?: string })?.name ?? '—')}
<Descriptions.Item label="账单日">{String(detail.billDate || '').slice(0, 10)}</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="应付">¥{Number(detail.payoutAmount ?? 0).toFixed(2)}</Descriptions.Item> <Descriptions.Item label="核销单号">
<Descriptions.Item label="状态">{STATUS_LABELS[String(detail.status)] || String(detail.status)}</Descriptions.Item> {String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')}
<Descriptions.Item label="打款时间"> </Descriptions.Item>
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} <Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item>
</Descriptions.Item> <Descriptions.Item label="应付金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
</Descriptions> <Descriptions.Item label="结算比例">
<Typography.Title level={5} style={{ marginTop: 16 }}> {detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'}
</Descriptions.Item>
</Typography.Title> <Descriptions.Item label="状态">
<Table {PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)}
size="small" </Descriptions.Item>
rowKey="id" <Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
pagination={false} <Descriptions.Item label="实际打款">
dataSource={(detail.payouts as Array<Record<string, unknown>>) || []} {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
columns={[ </Descriptions.Item>
{ </Descriptions>
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> </Drawer>
</div> </div>
@@ -1,233 +0,0 @@
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>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,384 +0,0 @@
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>
);
}
+300 -125
View File
@@ -1,182 +1,283 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
Card, Card,
Collapse, Collapse,
Form, Form,
Input, Input,
InputNumber, InputNumber,
Modal,
Space, Space,
Switch, Switch,
Table, Table,
Tag, Tag,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types'; import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types';
import { request, type HqProfile } from '../lib/api';
import { ConfigImageField, ConfigImageListField } from '../components/ConfigMediaFields'; import { request } from '../lib/api';
const { TextArea } = Input; const { TextArea } = Input;
function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loading?: boolean }) { function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loading?: boolean }) {
return ( return (
<div style={{ marginTop: -8, marginBottom: 16, marginLeft: 0 }}> <div style={{ marginTop: -8, marginBottom: 16, marginLeft: 0 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Mock 50 Mock 50
</Typography.Text> </Typography.Text>
<Table<MockSmsCodeItem> <Table<MockSmsCodeItem>
size="small" size="small"
rowKey="id" rowKey="id"
loading={loading} loading={loading}
pagination={false} pagination={false}
scroll={{ y: 240 }} scroll={{ y: 240 }}
locale={{ emptyText: '暂无记录,触发短信发送后将显示在此' }} locale={{ emptyText: '暂无记录,触发短信发送后将显示在此' }}
columns={[ columns={[
{ {
title: '时间', title: '时间',
dataIndex: 'createdAt', dataIndex: 'createdAt',
width: 168, width: 168,
render: (v: string) => new Date(v).toLocaleString(), render: (v: string) => new Date(v).toLocaleString(),
}, },
{ title: '手机号', dataIndex: 'phone', width: 120 }, { title: '手机号', dataIndex: 'phone', width: 120 },
{ title: '场景', dataIndex: 'scene', width: 160 }, { title: '场景', dataIndex: 'scene', width: 160 },
{ {
title: '验证码', title: '验证码',
dataIndex: 'code', dataIndex: 'code',
width: 88, width: 88,
render: (code: string) => ( render: (code: string) => (
<Typography.Text copyable strong> <Typography.Text copyable strong>
{code} {code}
</Typography.Text> </Typography.Text>
), ),
}, },
]} ]}
dataSource={codes} dataSource={codes}
/> />
</div> </div>
); );
} }
function renderField( function renderField(
field: SystemConfigFieldMeta, field: SystemConfigFieldMeta,
configuredSecrets: string[], configuredSecrets: string[],
extra?: ReactNode, extra?: ReactNode,
) { ) {
const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key); const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key);
if (field.type === 'boolean') { if (field.type === 'boolean') {
return ( return (
<div key={field.key}> <div key={field.key}>
<Form.Item <Form.Item
name={field.key} name={field.key}
label={ label={
<Space size={4}> <Space size={4}>
<span>{field.label}</span> <span>{field.label}</span>
<Typography.Text type="secondary" code style={{ fontSize: 11 }}> <Typography.Text type="secondary" code style={{ fontSize: 11 }}>
{field.key} {field.key}
</Typography.Text> </Typography.Text>
{field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>} {field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>}
</Space> </Space>
} }
tooltip={field.description} tooltip={field.description}
valuePropName="checked" valuePropName="checked"
getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')} getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')}
getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })} getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })}
> >
<Switch /> <Switch />
</Form.Item> </Form.Item>
{extra} {extra}
</div> </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 = const input =
field.type === 'textarea' ? ( field.type === 'textarea' ? (
<TextArea rows={3} placeholder={field.placeholder} /> <TextArea rows={3} placeholder={field.placeholder} />
) : field.type === 'number' ? ( ) : field.type === 'number' ? (
<InputNumber style={{ width: '100%' }} placeholder={field.placeholder} /> <InputNumber style={{ width: '100%' }} placeholder={field.placeholder} />
) : field.secret ? ( ) : field.secret ? (
<Input.Password <Input.Password
placeholder={isConfiguredSecret ? '已配置,留空则不修改' : field.placeholder} placeholder={isConfiguredSecret ? '已配置,留空则不修改' : field.placeholder}
autoComplete="new-password" autoComplete="new-password"
/> />
) : ( ) : (
<Input placeholder={field.placeholder} /> <Input placeholder={field.placeholder} />
); );
return ( return (
<Form.Item <Form.Item
key={field.key} key={field.key}
name={field.key} name={field.key}
label={ label={
<Space size={4} wrap> <Space size={4} wrap>
<span>{field.label}</span> <span>{field.label}</span>
<Typography.Text type="secondary" code style={{ fontSize: 11 }}> <Typography.Text type="secondary" code style={{ fontSize: 11 }}>
{field.key} {field.key}
</Typography.Text> </Typography.Text>
{field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>} {field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>}
{isConfiguredSecret ? <Tag></Tag> : null} {isConfiguredSecret ? <Tag></Tag> : null}
</Space> </Space>
} }
tooltip={field.description} tooltip={field.description}
> >
{input} {input}
</Form.Item> </Form.Item>
); );
} }
export default function SystemSettingsPage() { export default function SystemSettingsPage() {
const navigate = useNavigate();
const [form] = Form.useForm<Record<string, string>>(); const [form] = Form.useForm<Record<string, string>>();
const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null); const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null);
const [profile, setProfile] = useState<HqProfile | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = 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'; const mockSmsEnabled = Form.useWatch('MOCK_SMS', form) === 'true';
dirtyRef.current = dirty;
async function load(silent = false) { async function load(silent = false) {
if (!silent) setLoading(true); if (!silent) setLoading(true);
try { try {
const data = await request<SystemConfigFormResponse>('/admin/system-config'); const data = await request<SystemConfigFormResponse>('/admin/system-config');
if (silent) { if (silent) {
// 仅刷新 Mock 验证码列表,避免轮询用服务端值覆盖未保存的表单(含 MOCK_SMS 开关)
setMeta((prev) => setMeta((prev) =>
prev prev
? { ...prev, mockSmsCodes: data.mockSmsCodes, updatedAt: data.updatedAt } ? { ...prev, mockSmsCodes: data.mockSmsCodes, updatedAt: data.updatedAt }
@@ -184,226 +285,300 @@ export default function SystemSettingsPage() {
); );
return; return;
} }
setMeta(data); setMeta(data);
form.setFieldsValue(data.values); form.setFieldsValue(data.values);
setDirty(false);
} catch (e) { } catch (e) {
if (!silent) message.error(e instanceof Error ? e.message : '加载失败'); if (!silent) message.error(e instanceof Error ? e.message : '加载失败');
} finally { } finally {
if (!silent) setLoading(false); if (!silent) setLoading(false);
} }
} }
useEffect(() => {
void load();
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
void load();
}, []);
useEffect(() => {
// 以表单开关为准展示验证码;轮询只刷列表,不回写表单
if (!mockSmsEnabled) return; if (!mockSmsEnabled) return;
const timer = window.setInterval(() => void load(true), 5000); const timer = window.setInterval(() => void load(true), 5000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [mockSmsEnabled]); }, [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(() => { const collapseItems = useMemo(() => {
if (!meta) return []; if (!meta) return [];
return meta.groups.map((group) => ({ return meta.groups.map((group) => ({
key: group.key, key: group.key,
label: group.label, label: group.label,
forceRender: true,
children: ( children: (
<div style={{ maxWidth: 720 }}> <div style={{ maxWidth: 720 }}>
{meta.fields {meta.fields
.filter((f) => f.group === group.key) .filter((f) => f.group === group.key)
.map((f) => .map((f) =>
renderField( renderField(
f, f,
meta.configuredSecrets, meta.configuredSecrets,
f.key === 'MOCK_SMS' && mockSmsEnabled ? ( f.key === 'MOCK_SMS' && mockSmsEnabled ? (
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} /> <MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
) : undefined, ) : undefined,
), ),
)} )}
</div> </div>
), ),
})); }));
}, [meta, mockSmsEnabled, loading]); }, [meta, mockSmsEnabled, loading]);
async function onSave() { async function onSave() {
await form.validateFields();
const values = form.getFieldsValue(true); const values = await form.validateFields();
const payload: Record<string, string> = {}; const payload: Record<string, string> = {};
for (const [k, v] of Object.entries(values)) { for (const [k, v] of Object.entries(values)) {
payload[k] = v === undefined || v === null ? '' : String(v); payload[k] = v === undefined || v === null ? '' : String(v);
} }
setSaving(true); setSaving(true);
try { try {
const res = await request<{ updatedKeys: string[]; requiresRestartKeys: string[] }>( const res = await request<{ updatedKeys: string[]; requiresRestartKeys: string[] }>(
'/admin/system-config', '/admin/system-config',
{ method: 'PUT', body: JSON.stringify({ values: payload }) }, { method: 'PUT', body: JSON.stringify({ values: payload }) },
); );
message.success(`已保存 ${res.updatedKeys.length}`); message.success(`已保存 ${res.updatedKeys.length}`);
if (res.requiresRestartKeys.length) { if (res.requiresRestartKeys.length) {
message.warning(`以下配置需重启 API 后生效:${res.requiresRestartKeys.join(', ')}`); message.warning(`以下配置需重启 API 后生效:${res.requiresRestartKeys.join(', ')}`);
} }
setDirty(false);
await load(); await load();
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '保存失败'); message.error(e instanceof Error ? e.message : '保存失败');
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
async function onSyncEnv() { async function onSyncEnv() {
setSyncing(true); setSyncing(true);
try { try {
const res = await request<{ message: string; envFilePath: string }>( const res = await request<{ message: string; envFilePath: string }>(
'/admin/system-config/sync-env', '/admin/system-config/sync-env',
{ method: 'POST' }, { method: 'POST' },
); );
message.success(res.message || '已同步到 env 文件'); message.success(res.message || '已同步到 env 文件');
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '同步失败'); message.error(e instanceof Error ? e.message : '同步失败');
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
} }
async function onImportEnv() { async function onImportEnv() {
try { try {
const res = await request<{ imported: number }>('/admin/system-config/import-env', { const res = await request<{ imported: number }>('/admin/system-config/import-env', {
method: 'POST', method: 'POST',
}); });
message.success(`已从当前进程环境导入 ${res.imported}`); message.success(`已从当前进程环境导入 ${res.imported}`);
await load(); await load();
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '导入失败'); message.error(e instanceof Error ? e.message : '导入失败');
} }
} }
return ( return (
<div style={{ paddingBottom: 88 }}>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 8 }}> <Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 8 }}>
<code>system_config</code> {' '} <code>system_config</code> {' '}
<code>{meta?.envFilePath ?? '.env'}</code> 便 <code>{meta?.envFilePath ?? '.env'}</code> 便
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
<Space> <Space>
{profile?.adminRole === 'SUPER_ADMIN' ? (
<> <Button onClick={() => void onImportEnv()}></Button>
<Button onClick={() => void onImportEnv()}></Button>
<Button loading={syncing} onClick={() => void onSyncEnv()}> <Button loading={syncing} onClick={() => void onSyncEnv()}>
env
</Button> env
</>
) : null} </Button>
<Button type="primary" loading={saving} onClick={() => void onSave()}>
</Button>
</Space> </Space>
</div> </div>
<Alert <Alert
type="info" type="info"
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
message="生效说明" message="生效说明"
description={ description={
<ul style={{ margin: '8px 0 0', paddingLeft: 20 }}> <ul style={{ margin: '8px 0 0', paddingLeft: 20 }}>
<li> <li>
<Tag color="green"></Tag> <code>process.env</code>Mock <Tag color="green"></Tag> <code>process.env</code>Mock
</li> </li>
<li> <li>
<Tag color="orange"></Tag>/OSS <strong> API </strong> <Tag color="orange"></Tag>/OSS <strong> API </strong>
</li> </li>
<li>OSS </li> <li>OSS </li>
<li></li>
</ul> </ul>
} }
/> />
<Card loading={loading}> <Card loading={loading}>
<Form form={form} layout="vertical" onValuesChange={() => setDirty(true)}>
<Collapse defaultActiveKey={[]} items={collapseItems} /> <Form form={form} layout="vertical">
<Collapse defaultActiveKey={meta?.groups.map((g) => g.key)} items={collapseItems} />
</Form> </Form>
{meta?.updatedAt ? ( {meta?.updatedAt ? (
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}> <Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
{new Date(meta.updatedAt).toLocaleString()} {new Date(meta.updatedAt).toLocaleString()}
</Typography.Text> </Typography.Text>
) : null} ) : null}
</Card> </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> </div>
); );
} }
+29 -198
View File
@@ -1,20 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { import { Button, Descriptions, Drawer, Form, Select, Table, Typography, message } from 'antd';
Button,
Descriptions,
Drawer,
Form,
Image,
Input,
Modal,
Select,
Space,
Table,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
@@ -22,12 +8,11 @@ import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
id: string; id: string;
ticketNo: string; ticketNo: string;
ticketType: TicketTypeDto; ticketType: string;
status: string; status: string;
refType: string; refType: string;
refId: string; refId: string;
remark?: string; remark?: string;
extraJson?: { evidenceUrls?: string[] };
createdAt: string; createdAt: string;
}; };
@@ -43,15 +28,8 @@ export default function TicketsPage() {
}, },
[filters], [filters],
); );
const [detail, setDetail] = useState<Row | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); 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) { async function approve(id: string) {
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }); await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
@@ -70,204 +48,57 @@ export default function TicketsPage() {
setDrawerOpen(false); 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> = [ const columns: ColumnsType<Row> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 }, { title: '工单号', dataIndex: 'ticketNo', width: 180 },
{ { title: '类型', dataIndex: 'ticketType', width: 100 },
title: '类型',
dataIndex: 'ticketType',
width: 110,
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t,
},
{ title: '状态', dataIndex: 'status', width: 90 }, { title: '状态', dataIndex: 'status', width: 90 },
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` }, { title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
{ title: '备注', dataIndex: 'remark', ellipsis: true }, { title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作', width: 80,
width: 80,
render: (_, row) => ( render: (_, row) => (
<Button <Button type="link" size="small" onClick={async () => {
type="link" setDetail(await request(`/admin/tickets/${row.id}`));
size="small" setDrawerOpen(true);
onClick={async () => { }}></Button>
setDetail(await request(`/admin/tickets/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
), ),
}, },
]; ];
const evidenceUrls = detail?.extraJson?.evidenceUrls ?? [];
return ( return (
<div> <div>
<div <Typography.Title level={4}></Typography.Title>
style={{ <Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
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="类型"> <Form.Item name="ticketType" label="类型">
<Select <Select allowClear style={{ width: 120 }} options={[
allowClear { value: 'REFUND', label: '退款' },
style={{ width: 140 }} { value: 'RESHIPMENT', label: '补发' },
options={[ { value: 'ALERT', label: '异常' },
{ value: 'REFUND', label: '仅退款' }, ]} />
{ value: 'RESHIPMENT', label: '破损补发' },
{ value: 'DAMAGE_RETURN', label: '破损退货' },
{ value: 'RETURN_REFUND', label: '退货退款' },
{ value: 'ALERT', label: '异常' },
]}
/>
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态"><Input allowClear placeholder="PENDING" /></Form.Item>
<Input allowClear placeholder="PENDING" /> <Button type="primary" htmlType="submit"></Button>
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form> </Form>
<Table <Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
rowKey="id" pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
loading={loading} <Drawer title="工单详情" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
columns={columns} extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
dataSource={data?.items ?? []} <>
scroll={{ x: 900 }} <Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}></Button>
pagination={{ <Button danger onClick={() => reject(String(detail.id))}></Button>
current: page, </>
pageSize, ) : null}>
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="工单详情"
width={480}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
<Space>
<Button type="primary" onClick={() => approve(String(detail.id))}>
</Button>
<Button danger onClick={() => reject(String(detail.id))}>
</Button>
</Space>
) : null
}
>
{detail && ( {detail && (
<Descriptions column={1} bordered size="small"> <Descriptions column={1} bordered size="small">
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item> <Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
<Descriptions.Item label="类型"> <Descriptions.Item label="类型">{String(detail.ticketType)}</Descriptions.Item>
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
</Descriptions.Item>
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item> <Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
<Descriptions.Item label="关联"> <Descriptions.Item label="关联">{String(detail.refType)} #{String(detail.refId)}</Descriptions.Item>
{String(detail.refType)} #{String(detail.refId)}
</Descriptions.Item>
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item> <Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item>
<Descriptions.Item label="凭证">
{evidenceUrls.length ? (
<Image.PreviewGroup>
{evidenceUrls.map((url) => (
<Image key={url} src={url} width={72} style={{ marginRight: 8 }} />
))}
</Image.PreviewGroup>
) : (
'—'
)}
</Descriptions.Item>
</Descriptions> </Descriptions>
)} )}
</Drawer> </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> </div>
); );
} }
+17 -25
View File
@@ -18,8 +18,8 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types'; import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api'; import { request, type AdminUserRow, type Paginated } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants'; import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
type UserOrderRow = { type UserOrderRow = {
id: string; id: string;
@@ -66,7 +66,6 @@ export default function UsersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [profile, setProfile] = useState<HqProfile | null>(null);
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null); const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -83,11 +82,6 @@ export default function UsersPage() {
const [batchPreviewLoading, setBatchPreviewLoading] = useState(false); const [batchPreviewLoading, setBatchPreviewLoading] = useState(false);
const [batchDeleting, setBatchDeleting] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false);
const [batchRiskAck, setBatchRiskAck] = useState(false); const [batchRiskAck, setBatchRiskAck] = useState(false);
const canDeleteUsers = (profile?.permissionKeys ?? []).includes('users_delete');
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -228,7 +222,7 @@ export default function UsersPage() {
title: '手机', title: '手机',
dataIndex: 'phone', dataIndex: 'phone',
width: 120, width: 120,
render: (v) => maskPhone(v), render: (v) => v || '—',
}, },
{ {
title: '验手机', title: '验手机',
@@ -315,15 +309,13 @@ export default function UsersPage() {
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{canDeleteUsers ? ( <Button
<Button danger
danger disabled={!selectedRowKeys.length}
disabled={!selectedRowKeys.length} onClick={() => void openBatchDeleteModal()}
onClick={() => void openBatchDeleteModal()} >
> {selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''} </Button>
</Button>
) : null}
</Space> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="phone" label="手机号"> <Form.Item name="phone" label="手机号">
@@ -361,11 +353,11 @@ export default function UsersPage() {
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1500 }} scroll={{ x: 1500 }}
rowSelection={canDeleteUsers ? { rowSelection={{
selectedRowKeys, selectedRowKeys,
preserveSelectedRowKeys: true, preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys as string[]), onChange: (keys) => setSelectedRowKeys(keys as string[]),
} : undefined} }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -383,9 +375,9 @@ export default function UsersPage() {
width={640} width={640}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={detail && canDeleteUsers ? ( extra={detail && (
<Button danger onClick={openDeleteModal}></Button> <Button danger onClick={openDeleteModal}></Button>
) : undefined} )}
> >
{detail && ( {detail && (
<> <>
@@ -393,7 +385,7 @@ export default function UsersPage() {
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item> <Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item> <Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item> <Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
<Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item> <Descriptions.Item label="手机号">{detail.phone || '—'}</Descriptions.Item>
<Descriptions.Item label="验手机时间"> <Descriptions.Item label="验手机时间">
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'} {detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
</Descriptions.Item> </Descriptions.Item>
@@ -427,7 +419,7 @@ export default function UsersPage() {
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item> <Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
<Descriptions.Item label="合并至"> <Descriptions.Item label="合并至">
{detail.mergedInto {detail.mergedInto
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone ? maskPhone(detail.mergedInto.phone) : '无手机'})` ? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})`
: '—'} : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item> <Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
@@ -601,7 +593,7 @@ export default function UsersPage() {
columns={[ columns={[
{ title: '用户编号', dataIndex: 'userNo', width: 120 }, { title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' }, { title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => maskPhone(v) }, { title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
{ {
title: '风险', title: '风险',
width: 200, width: 200,
@@ -5,7 +5,7 @@ import {
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime, maskPhone } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ'; type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
@@ -119,7 +119,7 @@ export default function WechatBindingsPage() {
title: '手机号', title: '手机号',
dataIndex: 'primaryPhone', dataIndex: 'primaryPhone',
width: 140, width: 140,
render: (v) => maskPhone(v), render: (v) => v || '—',
}, },
{ {
title: '身份摘要', title: '身份摘要',
@@ -128,7 +128,7 @@ export default function WechatBindingsPage() {
<AdminCellLine <AdminCellLine
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')} primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
secondary={r.identities secondary={r.identities
.map((i) => i.refLabel || i.name || (i.phone ? maskPhone(i.phone) : '')) .map((i) => i.refLabel || i.name || i.phone)
.filter(Boolean) .filter(Boolean)
.join(' · ')} .join(' · ')}
/> />
@@ -164,7 +164,7 @@ export default function WechatBindingsPage() {
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.name || '—'} primary={r.name || '—'}
secondary={[r.phone ? maskPhone(r.phone) : null, `#${r.actorId}`].filter(Boolean).join(' ')} secondary={[r.phone, `#${r.actorId}`].filter(Boolean).join(' ')}
/> />
), ),
}, },
-512
View File
@@ -1,512 +0,0 @@
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>
);
}
+52 -300
View File
@@ -1,13 +1,9 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { import {
Button, Button,
Card, Card,
DatePicker, DatePicker,
Descriptions,
Drawer,
Form, Form,
Input,
Modal,
Select, Select,
Space, Space,
Statistic, Statistic,
@@ -18,44 +14,24 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import { import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
WINERY_SETTLEMENT_RATE,
type SystemConfigFormResponse,
} from '@dukang/shared-types';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel'; import { downloadExcelCsv } from '../lib/exportExcel';
import { request, type HqProfile } from '../lib/api'; import { request } from '../lib/api';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
id: string; orderId: string;
billNo: string;
billDate: string;
orderCount: number;
orderAmount: number;
wineryRate: number;
wineryAmount: number;
status: string;
paidAt?: string | null;
};
type BillItem = {
id: string;
orderNo: string; orderNo: string;
deliveryType: string; deliveryType: string;
cityName?: string;
receiverCity?: string;
payAmount: number; payAmount: number;
wineryRate: number;
wineryAmount: number; wineryAmount: number;
paidAt: string; 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> = { const DELIVERY_LABELS: Record<string, string> = {
@@ -63,146 +39,57 @@ const DELIVERY_LABELS: Record<string, string> = {
CROSS_CITY: '跨城', 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() { export default function WineryBillsPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [bankForm] = Form.useForm<Record<string, string>>();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/winery-bills', '/admin/winery-bills',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.year) qs.set('year', filters.year); if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month); if (filters.month) qs.set('month', filters.month);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
return qs; return qs;
}, },
[filters], [filters],
); );
const [exporting, setExporting] = useState(false); 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() { async function exportExcel() {
setExporting(true); setExporting(true);
try { try {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.year) qs.set('year', filters.year); if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month); if (filters.month) qs.set('month', filters.month);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`); const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all'; 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}`); message.success(`已导出 ${result.count}`);
} finally { } finally {
setExporting(false); 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 summary = data?.summary;
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100); 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> = [ const columns: ColumnsType<Row> = [
{ title: '单号', dataIndex: 'billNo', width: 170, ellipsis: true }, { title: '单号', dataIndex: 'orderNo', width: 180, ellipsis: true },
{ {
title: '账单日', title: '配送类型',
dataIndex: 'billDate', dataIndex: 'deliveryType',
width: 110, width: 90,
render: (v) => String(v || '').slice(0, 10), render: (v) => <Tag>{DELIVERY_LABELS[v] || v}</Tag>,
}, },
{ title: '订单数', dataIndex: 'orderCount', width: 80 }, { 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: '酒单实付合计', title: '酒单实付',
dataIndex: 'orderAmount', dataIndex: 'payAmount',
width: 120, width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
@@ -218,62 +105,30 @@ export default function WineryBillsPage() {
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
title: '状态', title: '支付时间',
dataIndex: 'status', dataIndex: 'paidAt',
width: 90, width: 170,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>, render: (v) => (v ? fmtTime(v) : '—'),
},
{
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 ( return (
<div> <div>
<div <Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
style={{ <Typography.Title level={4} style={{ margin: 0 }}>
display: 'flex',
justifyContent: 'space-between', </Typography.Title>
alignItems: 'flex-start', <Typography.Text type="secondary">
marginBottom: 16, T+30 / = × {ratePct}%
gap: 16, </Typography.Text>
}} </Space>
>
<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 && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap> <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.orderAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} /> <Statistic title="酒厂应付合计" value={summary.wineryAmount ?? summary.totalAmount} prefix="¥" precision={2} />
</Space> </Space>
</Card> </Card>
)} )}
@@ -282,29 +137,27 @@ export default function WineryBillsPage() {
form={form} form={form}
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
onFinish={(v: { status?: string; month?: Dayjs; range?: [Dayjs, Dayjs] }) => { onFinish={(v: { month?: Dayjs; deliveryType?: string }) => {
setFilters({ setFilters({
status: v.status || '',
year: v.month ? String(v.month.year()) : '', year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '', month: v.month ? String(v.month.month() + 1) : '',
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '', deliveryType: v.deliveryType || '',
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
}); });
setPage(1); setPage(1);
}} }}
> >
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="month" label="账期月"> <Form.Item name="month" label="账期月">
<DatePicker picker="month" /> <DatePicker picker="month" />
</Form.Item> </Form.Item>
<Form.Item name="range" label="账单日"> <Form.Item name="deliveryType" label="配送类型">
<DatePicker.RangePicker /> <Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'LOCAL', label: '同城' },
{ value: 'CROSS_CITY', label: '跨城' },
]}
/>
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
@@ -327,29 +180,15 @@ export default function WineryBillsPage() {
Excel Excel
</Button> </Button>
</Form.Item> </Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!selectedKeys.length}
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
>
({selectedKeys.length})
</Button>
</Form.Item>
</Form> </Form>
<Table <Table
rowKey="id" rowKey="orderId"
className="admin-table-nowrap" className="admin-table-nowrap"
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
rowSelection={{ scroll={{ x: 1200 }}
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
}}
scroll={{ x: 1100 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -361,93 +200,6 @@ 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> </div>
); );
} }
@@ -1,4 +1,4 @@
import { useState, type CSSProperties } from 'react'; import { useState } from 'react';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import { import {
Button, Button,
@@ -25,15 +25,6 @@ import { request } from '../../lib/api';
import { fmtTime } from '../../lib/constants'; import { fmtTime } from '../../lib/constants';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout'; 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) { async function downloadQrcode(url: string, filename: string) {
try { try {
const res = await fetch(url); const res = await fetch(url);
@@ -81,25 +72,46 @@ export default function PromoCodeDetailPage() {
return ( return (
<> <>
<Row gutter={[16, 16]} align="top"> <Row gutter={[16, 16]}>
<Col flex="1 1 480px" style={{ minWidth: 0 }}> <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}>
<Card <Card
title="基础信息" title="基础信息"
size="small" size="small"
styles={{ body: { paddingTop: 12 } }}
extra={( extra={(
<Space> <Space>
<Button <Button size="small" onClick={() => {
size="small" editForm.setFieldsValue({
onClick={() => { name: detail.name,
editForm.setFieldsValue({ scene: detail.scene,
name: detail.name, remark: detail.remark,
scene: detail.scene, ownerUserId: detail.ownerUser?.id,
remark: detail.remark, });
ownerUserId: detail.ownerUser?.id, setEditOpen(true);
}); }}
setEditOpen(true);
}}
> >
</Button> </Button>
@@ -123,18 +135,7 @@ export default function PromoCodeDetailPage() {
</Space> </Space>
)} )}
> >
<Descriptions <Descriptions column={{ xs: 1, sm: 2 }} bordered size="small">
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.name}</Descriptions.Item>
<Descriptions.Item label="码值">{detail.code}</Descriptions.Item> <Descriptions.Item label="码值">{detail.code}</Descriptions.Item>
<Descriptions.Item label="场景"> <Descriptions.Item label="场景">
@@ -143,81 +144,34 @@ export default function PromoCodeDetailPage() {
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status} {PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="活动 ID"> <Descriptions.Item label="二维码 ID" span={2}>
<Typography.Text copyable={{ text: String(detail.id) }} style={{ whiteSpace: 'nowrap' }}> <Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text>
{detail.id}
</Typography.Text>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="二维码 ID"> <Descriptions.Item label="落地链接" span={2}>
<Typography.Text copyable={{ text: detail.qrcodeId }} style={{ whiteSpace: 'nowrap' }}> <Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</Typography.Text>
{detail.qrcodeId}
</Typography.Text>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="小程序码 OSS" span={2}> <Descriptions.Item label="OSS 地址" span={2}>
{detail.qrcodeUrl ? ( {detail.qrcodeUrl ? (
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis style={{ maxWidth: '100%' }}> <Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis>
{detail.qrcodeUrl} {detail.qrcodeUrl}
</Typography.Text> </Typography.Text>
) : '—'} ) : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="渠道负责人"> <Descriptions.Item label="渠道负责人">
<span style={{ whiteSpace: 'nowrap' }}> {detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
</span>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item> <Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
<Descriptions.Item label="创建时间"> <Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.createdAt)}</span> <Descriptions.Item label="更新时间">{fmtTime(detail.updatedAt)}</Descriptions.Item>
</Descriptions.Item>
<Descriptions.Item label="更新时间">
<span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.updatedAt)}</span>
</Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
</Col> </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>
<Row gutter={16} style={{ marginTop: 16 }}> <Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <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> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
@@ -233,6 +187,11 @@ export default function PromoCodeDetailPage() {
/> />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="来源标记用户" value={stats?.sourceMarkedCount ?? '—'} />
</Card>
</Col>
</Row> </Row>
<Modal <Modal
-2
View File
@@ -15,7 +15,6 @@ import BillsPage from './pages/BillsPage';
import SettlementPage from './pages/SettlementPage'; import SettlementPage from './pages/SettlementPage';
import WeeklyReportPage from './pages/WeeklyReportPage'; import WeeklyReportPage from './pages/WeeklyReportPage';
import ProxyOrderPage from './pages/ProxyOrderPage'; import ProxyOrderPage from './pages/ProxyOrderPage';
import ProxyOrderProductsPage from './pages/ProxyOrderProductsPage';
import StaffListPage from './pages/StaffListPage'; import StaffListPage from './pages/StaffListPage';
import StaffCreatePage from './pages/StaffCreatePage'; import StaffCreatePage from './pages/StaffCreatePage';
import LeaderboardPage from './pages/LeaderboardPage'; import LeaderboardPage from './pages/LeaderboardPage';
@@ -34,7 +33,6 @@ function PrimaryRoutes() {
<Route path="/center/staff" element={<StaffListPage />} /> <Route path="/center/staff" element={<StaffListPage />} />
<Route path="/center/staff/new" element={<StaffCreatePage />} /> <Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/proxy-order" element={<ProxyOrderPage />} /> <Route path="/proxy-order" element={<ProxyOrderPage />} />
<Route path="/proxy-order/products" element={<ProxyOrderProductsPage />} />
<Route path="/reports/weekly" element={<WeeklyReportPage />} /> <Route path="/reports/weekly" element={<WeeklyReportPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} /> <Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} /> <Route path="/stores/:id" element={<StoreDetailPage />} />
+5 -1
View File
@@ -1,5 +1,5 @@
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { getPartnerProfile } from '../lib/api'; import { getPartnerProfile, hasPartnerWxSession } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { partnerHomePath } from '../lib/partnerAccess'; import { partnerHomePath } from '../lib/partnerAccess';
@@ -18,6 +18,10 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
} }
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) { if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
const profile = getPartnerProfile();
if (profile && hasPartnerWxSession() && profile.hasWechat) {
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
}
return <Navigate to="/login" replace state={{ from: location }} />; return <Navigate to="/login" replace state={{ from: location }} />;
} }
+157 -22
View File
@@ -1,7 +1,17 @@
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { uploadFileToOss, type OssMediaType } from '../lib/upload'; import { uploadFileToOss, type OssMediaType } from '../lib/upload';
import { enqueueUpload } from '../lib/upload-lock'; import { enqueueUpload } from '../lib/upload-lock';
import {
authorizePartnerWechat,
fetchClientConfig,
fetchPartnerProfile,
needsWechatAuth,
type PartnerProfile,
} from '../lib/wechat-auth';
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { toastError } from '../lib/toast'; import { toastError } from '../lib/toast';
import type { ClientRuntimeConfig } from '@dukang/shared-types';
type OssUploadFieldProps = { type OssUploadFieldProps = {
value?: string; value?: string;
@@ -12,10 +22,27 @@ type OssUploadFieldProps = {
wide?: boolean; wide?: boolean;
compact?: boolean; compact?: boolean;
label?: string; label?: string;
/** 父级已确认微信授权时可跳过检查 */
wechatReady?: boolean;
onWechatReadyChange?: (ready: boolean) => void;
}; };
const DEFAULT_MAX_MB = 10; const DEFAULT_MAX_MB = 10;
function formatWechatUploadError(e: unknown): string {
const msg = e instanceof Error ? e.message : '无法打开相册';
const formatted = formatChooseImageFailMessage(msg);
if (formatted) return formatted;
if (/invalid signature/i.test(msg)) {
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
}
return msg;
}
function acceptsImages(accept: string) {
return accept.includes('image');
}
export default function OssUploadField({ export default function OssUploadField({
value, value,
onChange, onChange,
@@ -25,10 +52,15 @@ export default function OssUploadField({
wide, wide,
compact, compact,
label, label,
wechatReady,
onWechatReadyChange,
}: OssUploadFieldProps) { }: OssUploadFieldProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [authorizing, setAuthorizing] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [profile, setProfile] = useState<PartnerProfile | null>(null);
const [clientConfig, setClientConfig] = useState<ClientRuntimeConfig | null>(null);
function showUploadError(text: string) { function showUploadError(text: string) {
setError(text); setError(text);
@@ -37,6 +69,35 @@ export default function OssUploadField({
const resolvedAccept = const resolvedAccept =
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*'); accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
const inWechat = isWechatEnv();
const useWechatPicker =
inWechat && (mediaType === 'IMAGE' || (mediaType === 'FILE' && acceptsImages(resolvedAccept)));
const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true;
const onWechatReadyChangeRef = useRef(onWechatReadyChange);
onWechatReadyChangeRef.current = onWechatReadyChange;
useEffect(() => {
if (!useWechatPicker) return;
void fetchClientConfig()
.then(setClientConfig)
.catch(() => {
/* 未登录等场景由上传接口报错 */
});
if (wechatReady === true) return;
void fetchPartnerProfile()
.then(setProfile)
.catch(() => {
/* 未登录等场景由上传接口报错 */
});
}, [useWechatPicker, wechatReady]);
useEffect(() => {
if (!useWechatPicker || needsAuth) return;
weixinSdk.reset();
void weixinSdk.init().catch(() => {
/* 点击上传时会再次初始化 */
});
}, [useWechatPicker, needsAuth]);
async function persistUpload(file: File) { async function persistUpload(file: File) {
if (!file.size) { if (!file.size) {
@@ -64,38 +125,112 @@ export default function OssUploadField({
} }
} }
function pickFile() { async function startWechatAuth() {
if (uploading) return; setAuthorizing(true);
setError(''); setError('');
try {
const result = await authorizePartnerWechat();
if (result) {
const me = await fetchPartnerProfile();
setProfile(me);
if (me.hasWechat) onWechatReadyChangeRef.current?.(true);
}
} catch (e) {
const text = e instanceof Error ? e.message : '微信授权失败';
showUploadError(text);
} finally {
setAuthorizing(false);
}
}
async function pickWechatImage() {
setUploading(true);
setError('');
try {
weixinSdk.reset();
await weixinSdk.init();
// 选图 + 上传须在同一个队列任务内完成,避免嵌套 enqueueUpload 死锁
await enqueueUpload(async () => {
const files = await weixinSdk.chooseImages({
count: 1,
sourceType: ['album', 'camera'],
});
if (!files?.[0]) return;
await persistUpload(files[0]);
});
} catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册';
if (/cancel/i.test(msg)) return;
showUploadError(formatWechatUploadError(e));
} finally {
setUploading(false);
}
}
async function pickFile() {
if (uploading || authorizing) return;
setError('');
if (useWechatPicker && needsAuth) {
const text = '请先完成微信授权后再上传照片';
showUploadError(text);
return;
}
if (useWechatPicker) {
try {
await pickWechatImage();
} catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册';
if (/cancel/i.test(msg)) return;
showUploadError(formatWechatUploadError(e));
}
return;
}
inputRef.current?.click(); inputRef.current?.click();
} }
const isImage = mediaType === 'IMAGE' && value; const isImage = mediaType === 'IMAGE' && value;
const isFile = mediaType === 'FILE' && value; const isFile = mediaType === 'FILE' && value;
const busy = uploading; const busy = uploading || authorizing;
const pickerLabel = label ?? (mediaType === 'IMAGE' ? '从系统相册选择' : '点击上传'); const pickerLabel = label ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传');
const triggerProps = { const triggerProps = {
type: 'button' as const, type: 'button' as const,
disabled: busy, disabled: busy || needsAuth,
onClick: pickFile, onClick: () => void pickFile(),
}; };
return ( return (
<div className="partner-oss-upload"> <div className="partner-oss-upload">
<input {needsAuth && (
ref={inputRef} <div className="partner-wechat-auth-hint" role="status">
type="file" <p className="body-md"></p>
accept={resolvedAccept} <button
className="partner-oss-upload-input" type="button"
disabled={busy} className="partner-btn-outline"
onChange={(e) => { style={{ marginTop: 8, width: '100%' }}
const file = e.target.files?.[0]; disabled={authorizing}
if (file) { onClick={() => void startWechatAuth()}
void uploadSelectedFile(file); >
} {authorizing ? '跳转授权中…' : '微信授权绑定'}
}} </button>
/> </div>
)}
{!useWechatPicker && (
<input
ref={inputRef}
type="file"
accept={resolvedAccept}
className="partner-oss-upload-input"
disabled={busy}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void uploadSelectedFile(file);
}}
/>
)}
{isImage ? ( {isImage ? (
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}> <button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
<img src={value} alt={label ?? '已上传'} /> <img src={value} alt={label ?? '已上传'} />
@@ -120,10 +255,10 @@ export default function OssUploadField({
className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`} className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`}
> >
<span className="material-symbols-outlined text-primary" style={{ fontSize: compact ? 28 : 36 }}> <span className="material-symbols-outlined text-primary" style={{ fontSize: compact ? 28 : 36 }}>
{busy ? 'hourglass_top' : 'photo_library'} {busy ? 'hourglass_top' : 'add_a_photo'}
</span> </span>
<span className="text-primary" style={{ fontWeight: 500 }}> <span className="text-primary" style={{ fontWeight: 500 }}>
{uploading ? '上传中…' : pickerLabel} {uploading ? '上传中…' : needsAuth ? '请先微信授权' : pickerLabel}
</span> </span>
</button> </button>
)} )}
@@ -1,264 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { request } from '../lib/api';
import {
placeToPicked,
type LbsPlaceItem,
type TencentPickedLocation,
} from '../lib/tencentLocPicker';
type Props = {
open: boolean;
onClose: () => void;
onPick: (loc: TencentPickedLocation) => void;
latitude?: number | null;
longitude?: number | null;
region?: string | null;
};
export default function TencentLocPickerOverlay({
open,
onClose,
onPick,
latitude,
longitude,
region,
}: Props) {
const [keyword, setKeyword] = useState('');
const [items, setItems] = useState<LbsPlaceItem[]>([]);
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
const [hint, setHint] = useState('输入地点名称搜索');
const seqRef = useRef(0);
useEffect(() => {
if (!open) {
setKeyword('');
setItems([]);
setPending(null);
setHint('输入地点名称搜索');
return;
}
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
void loadNearby(lat, lng);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
async function loadNearby(lat: number, lng: number) {
const seq = ++seqRef.current;
setLoading(true);
setHint('正在加载附近地点…');
try {
const res = await request<{ items: LbsPlaceItem[] }>(
'PARTNER_H5',
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
{ silent: true },
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `附近 ${res.items.length} 个地点` : '附近暂无地点,请搜索');
} catch (e) {
if (seq !== seqRef.current) return;
setItems([]);
setHint(e instanceof Error ? e.message : '加载附近地点失败');
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
async function runSearch() {
const trimmed = keyword.trim();
if (!trimmed) {
setHint('请输入搜索关键词');
return;
}
const seq = ++seqRef.current;
setLoading(true);
setHint('搜索中…');
try {
const params = new URLSearchParams({ keyword: trimmed });
if (region?.trim()) params.set('region', region.trim());
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (Number.isFinite(lat) && Number.isFinite(lng)) {
params.set('lat', String(lat));
params.set('lng', String(lng));
}
const res = await request<{ items: LbsPlaceItem[] }>(
'PARTNER_H5',
`/common/lbs/suggest?${params.toString()}`,
{ silent: true },
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `找到 ${res.items.length} 个结果` : '无匹配结果');
} catch (e) {
if (seq !== seqRef.current) return;
setItems([]);
setHint(e instanceof Error ? e.message : '搜索失败');
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
function useBrowserLocation() {
if (!navigator.geolocation) {
setHint('当前浏览器不支持定位');
return;
}
setLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
setPending({ latitude: lat, longitude: lng, name: '当前位置' });
void loadNearby(lat, lng);
},
() => {
setLoading(false);
setHint('定位失败,请检查定位权限');
},
{ enableHighAccuracy: true, timeout: 12000 },
);
}
if (!open) return null;
function confirmPick() {
if (!pending) return;
onPick(pending);
onClose();
}
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: '#fff',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: '1px solid rgba(0,0,0,0.06)',
flexShrink: 0,
gap: 8,
}}
>
<button type="button" className="partner-btn-outline" style={{ padding: '6px 12px' }} onClick={onClose}>
</button>
<span style={{ fontWeight: 600 }}></span>
<button
type="button"
className="partner-btn-primary"
style={{ padding: '6px 12px', opacity: pending ? 1 : 0.45, width: 'auto' }}
disabled={!pending}
onClick={confirmPick}
>
</button>
</div>
<div style={{ padding: '12px 16px', borderBottom: '1px solid rgba(0,0,0,0.04)', flexShrink: 0 }}>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void runSearch();
}}
placeholder="输入小区 / 写字楼 / 门店名称"
style={{
flex: 1,
padding: '10px 12px',
border: '1px solid rgba(0,0,0,0.12)',
borderRadius: 8,
fontSize: 14,
}}
/>
<button
type="button"
className="partner-btn-primary"
style={{ width: 'auto', padding: '8px 14px' }}
disabled={loading}
onClick={() => void runSearch()}
>
</button>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'center' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '6px 12px' }}
disabled={loading}
onClick={useBrowserLocation}
>
</button>
<span className="label-md text-muted">{hint}</span>
</div>
{pending ? (
<p className="label-md text-muted" style={{ margin: '8px 0 0' }}>
{pending.latitude.toFixed(6)}, {pending.longitude.toFixed(6)}
{pending.name ? ` · ${pending.name}` : ''}
</p>
) : null}
</div>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', WebkitOverflowScrolling: 'touch' }}>
{loading && !items.length ? (
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
</p>
) : items.length ? (
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{items.map((item) => {
const active =
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
return (
<li key={`${item.id}-${item.latitude}-${item.longitude}`}>
<button
type="button"
onClick={() => setPending(placeToPicked(item))}
style={{
width: '100%',
textAlign: 'left',
padding: '12px 16px',
border: 0,
borderBottom: '1px solid rgba(0,0,0,0.06)',
background: active ? 'rgba(166, 29, 36, 0.06)' : '#fff',
cursor: 'pointer',
}}
>
<div style={{ fontWeight: 600, fontSize: 15 }}>{item.title}</div>
<div className="label-md text-muted" style={{ marginTop: 4 }}>
{item.address}
</div>
<div className="label-md text-muted" style={{ marginTop: 2, fontSize: 12 }}>
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
</div>
</button>
</li>
);
})}
</ul>
) : (
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
{hint}
</p>
)}
</div>
</div>
);
}
+4 -9
View File
@@ -12,7 +12,7 @@ const PARTNER_PROFILE = 'partnerProfile';
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt'; const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
export const PARTNER_WX_BOUND = 'partnerWxBound'; export const PARTNER_WX_BOUND = 'partnerWxBound';
/** 手机号或微信验证通过后的免登录时长 */ /** 微信验证通过后的免登录时长 */
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const AUTH_RECOVERY_EXEMPT_PATHS = [ const AUTH_RECOVERY_EXEMPT_PATHS = [
@@ -77,7 +77,7 @@ export function isPartnerSessionExpired() {
} }
export function touchPartnerSession() { export function touchPartnerSession() {
if (!localStorage.getItem(REFRESH_TOKEN)) return; if (!hasPartnerWxSession()) return;
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS)); localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
} }
@@ -90,16 +90,11 @@ export function saveAuth(data: PartnerSessionPayload) {
} }
} }
/** 手机号验证成功后写入 7 天免验证码会话 */
export function saveRememberedSession(data: PartnerSessionPayload) {
saveAuth(data);
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
}
/** 微信登录/绑定成功后写入 7 天免登录 session */ /** 微信登录/绑定成功后写入 7 天免登录 session */
export function saveWechatSession(data: PartnerSessionPayload) { export function saveWechatSession(data: PartnerSessionPayload) {
saveRememberedSession(data); saveAuth(data);
localStorage.setItem(PARTNER_WX_BOUND, '1'); localStorage.setItem(PARTNER_WX_BOUND, '1');
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
} }
export function clearAuth(options?: { keepProfile?: boolean }) { export function clearAuth(options?: { keepProfile?: boolean }) {
+43 -104
View File
@@ -6,23 +6,9 @@ export type StoreDraftForm = {
district: string; district: string;
name: string; name: string;
phone: string; phone: string;
storeSmsCode: string;
address: 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; intro: string;
/** 好客权益券使用规则 */
benefitUsageRule: string;
coverUrl: string; coverUrl: string;
envPhotoUrls: string[]; envPhotoUrls: string[];
contractUrl: string; contractUrl: string;
@@ -49,19 +35,9 @@ export const defaultStoreForm = (): StoreDraftForm => ({
cityId: '', cityId: '',
name: '', name: '',
phone: '', phone: '',
storeSmsCode: '',
address: '', address: '',
latitude: '',
longitude: '',
openTime: '10:00',
closeTime: '22:00',
dualHours: false,
openTime2: '17:00',
closeTime2: '21:00',
avgPrice: '',
categoryParentId: '',
categoryId: '',
intro: '', intro: '',
benefitUsageRule: '',
coverUrl: '', coverUrl: '',
envPhotoUrls: ['', '', ''], envPhotoUrls: ['', '', ''],
contractUrl: '', contractUrl: '',
@@ -70,38 +46,35 @@ export const defaultStoreForm = (): StoreDraftForm => ({
bankBranch: '', bankBranch: '',
}); });
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; export function normalizeStringArray(raw: unknown, length: number): string[] {
const PHONE_RE = /^1[3-9]\d{9}$/; if (!Array.isArray(raw)) return Array.from({ length }, () => '');
const BANK_RE = /^\d{16,19}$/; const items = raw.map((item) => String(item ?? ''));
while (items.length < length) items.push('');
function timeToMinutes(hhmm: string): number { return items.slice(0, length);
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
} }
export function normalizeStringArray(urls: unknown, minLen: number): string[] { function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
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 base = defaultStoreForm();
if (!raw) return base; const regionCodes = Array.isArray(raw.regionCodes) && raw.regionCodes.length >= 3
? raw.regionCodes.map((code) => String(code))
: base.regionCodes;
return { return {
...base, regionCodes,
...raw,
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
cityId: String(raw.cityId ?? base.cityId), cityId: String(raw.cityId ?? base.cityId),
latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude, province: String(raw.province ?? base.province),
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude, city: String(raw.city ?? base.city),
openTime: String(raw.openTime ?? base.openTime), district: String(raw.district ?? base.district),
closeTime: String(raw.closeTime ?? base.closeTime), name: String(raw.name ?? base.name),
dualHours: Boolean(raw.dualHours), phone: String(raw.phone ?? base.phone),
openTime2: String(raw.openTime2 ?? base.openTime2), storeSmsCode: String(raw.storeSmsCode ?? base.storeSmsCode),
closeTime2: String(raw.closeTime2 ?? base.closeTime2), address: String(raw.address ?? base.address),
avgPrice: String(raw.avgPrice ?? base.avgPrice), intro: String(raw.intro ?? base.intro),
coverUrl: String(raw.coverUrl ?? base.coverUrl),
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3), 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),
}; };
} }
@@ -117,11 +90,14 @@ export function loadStoreDraft(accountId?: string): StoreDraft | null {
} }
} }
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as StoreDraft; const parsed = JSON.parse(raw) as Record<string, unknown>;
return { if (parsed.form && typeof parsed.form === 'object') {
step: Number(parsed.step) || 0, return {
form: normalizeStoreDraftForm(parsed.form), 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) };
} catch { } catch {
return null; return null;
} }
@@ -141,58 +117,26 @@ export function clearAllStoreDrafts(accountId?: string) {
localStorage.removeItem(STORE_DRAFT_KEY); localStorage.removeItem(STORE_DRAFT_KEY);
} }
const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/;
export function validateStoreStep1( export function validateStoreStep1(
form: Pick< form: Pick<
StoreDraftForm, StoreDraftForm,
| 'regionCodes' 'regionCodes' | 'cityId' | 'name' | 'phone' | 'storeSmsCode' | 'address' | 'intro'
| 'cityId'
| 'name'
| 'address'
| 'openTime'
| 'closeTime'
| 'dualHours'
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
| 'categoryId'
| 'intro'
| 'benefitUsageRule'
>, >,
): string | null { ): string | null {
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划'; if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划';
if (!form.name.trim()) 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.address.trim()) return '请填写详细地址';
if (!form.openTime.trim()) return '请填写营业开始时间';
if (!TIME_RE.test(form.openTime.trim())) return '营业开始时间格式须为 HH:MM';
if (!form.closeTime.trim()) return '请填写营业结束时间';
if (!TIME_RE.test(form.closeTime.trim())) return '营业结束时间格式须为 HH:MM';
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
return '营业结束时间须晚于开始时间';
}
if (form.dualHours) {
if (!form.openTime2.trim() || !form.closeTime2.trim()) return '请完整填写第二段营业时间';
if (!TIME_RE.test(form.openTime2.trim()) || !TIME_RE.test(form.closeTime2.trim())) {
return '第二段时间格式须为 HH:MM';
}
if (timeToMinutes(form.openTime2.trim()) >= timeToMinutes(form.closeTime2.trim())) {
return '第二段结束时间须晚于开始时间';
}
if (timeToMinutes(form.closeTime.trim()) >= timeToMinutes(form.openTime2.trim())) {
return '第二段开始时间须晚于第一段结束时间';
}
}
if (form.avgPrice.trim()) {
const n = Number(form.avgPrice);
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
}
if (!form.categoryId.trim()) return '请选择店铺类型';
if (form.intro.trim()) { if (form.intro.trim()) {
const len = form.intro.trim().length; const len = form.intro.trim().length;
if (len < 2 || len > 500) return '门店简介须为 2~500 字'; if (len < 10 || len > 500) return '门店简介须为 10~500 字';
}
if (form.benefitUsageRule.trim().length > 1000) {
return '好客权益券使用规则最多 1000 字';
} }
return null; return null;
} }
@@ -214,16 +158,11 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
} }
export function validateStoreStep3( export function validateStoreStep3(
form: Pick< form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>,
StoreDraftForm,
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone'
>,
): string | null { ): string | null {
if (!form.bankAccountName.trim()) return '请填写户主姓名'; if (!form.bankAccountName.trim()) return '请填写户主姓名';
if (!form.bankAccountNo.trim()) return '请填写银行卡号'; if (!form.bankAccountNo.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字'; if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch.trim()) return '请填写开户支行'; if (!form.bankBranch.trim()) return '请填写开户支行';
if (!form.phone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
return null; return null;
} }
-26
View File
@@ -1,26 +0,0 @@
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)}`;
}
+8
View File
@@ -8,4 +8,12 @@ export function checkStorePhoneAvailable(phone: string) {
); );
} }
export function sendStorePhoneSms(phone: string) {
return request<{ ok: boolean; maskedPhone: string }>('PARTNER_H5', '/partner/stores/send-phone-sms', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
silent: true,
});
}
export type { PartnerStorePhoneAvailableResponse }; export type { PartnerStorePhoneAvailableResponse };
+2 -2
View File
@@ -20,8 +20,8 @@ export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED';
export function storeStatusLabel(status: string): string { export function storeStatusLabel(status: string): string {
const s = String(status).toUpperCase(); const s = String(status).toUpperCase();
if (s === 'OPEN') return '营业中'; if (s === 'OPEN') return '营业中';
if (s === 'PAUSED') return '时闭店'; if (s === 'PAUSED') return '时闭店';
if (s === 'CLOSED') return '永久关闭'; if (s === 'CLOSED') return '关闭';
return status; return status;
} }
@@ -1,26 +0,0 @@
export type TencentPickedLocation = {
latitude: number;
longitude: number;
address?: string;
name?: string;
cityname?: string;
};
export type LbsPlaceItem = {
id: string;
title: string;
address: string;
latitude: number;
longitude: number;
city?: string;
};
export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
return {
latitude: item.latitude,
longitude: item.longitude,
address: item.address || undefined,
name: item.title || undefined,
cityname: item.city || undefined,
};
}
+52 -99
View File
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerBillDto } from '@dukang/shared-types'; import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast'; import { toastError, toastSuccess } from '../lib/toast';
@@ -12,16 +11,16 @@ function fmtMoney(n: number) {
function billStatusLabel(status: string) { function billStatusLabel(status: string) {
switch (status) { switch (status) {
case 'AWAITING_CONFIRM': return '待确认'; case 'DRAFT': return '待确认';
case 'UNPAID': return '未打款'; case 'CONFIRMED': return '审核中';
case 'PAID': return '已打款'; case 'PAID': return '已结算';
case 'REJECTED': return '已驳回'; case 'REJECTED': return '已驳回';
default: return status; default: return status;
} }
} }
function canConfirm(status: string) { function canApplyPayment(status: string) {
return status === 'AWAITING_CONFIRM'; return status === 'DRAFT' || status === 'REJECTED';
} }
export default function BillsPage() { export default function BillsPage() {
@@ -31,7 +30,6 @@ export default function BillsPage() {
const [confirmed, setConfirmed] = useState(false); const [confirmed, setConfirmed] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
async function loadBills() { async function loadBills() {
setLoading(true); setLoading(true);
@@ -52,7 +50,7 @@ export default function BillsPage() {
const billId = searchParams.get('id'); const billId = searchParams.get('id');
const actionable = useMemo( const actionable = useMemo(
() => bills.filter((b) => canConfirm(b.status)), () => bills.filter((b) => canApplyPayment(b.status)),
[bills], [bills],
); );
const bill = billId const bill = billId
@@ -60,32 +58,20 @@ export default function BillsPage() {
: actionable[0] ?? bills[0]; : actionable[0] ?? bills[0];
const status = String(bill?.status || ''); const status = String(bill?.status || '');
const showApplyForm = !!bill && canConfirm(status); const showApplyForm = !!bill && canApplyPayment(status);
const isUnpaid = status === 'UNPAID'; const isReviewing = status === 'CONFIRMED';
const isRejected = status === 'REJECTED'; const isRejected = status === 'REJECTED';
const isPaid = status === 'PAID'; const isPaid = status === 'PAID';
function askConfirm(ids: string[]) { async function confirmBill() {
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) { if (!bill || !confirmed || !canApplyPayment(String(bill.status))) return;
return;
}
void doConfirm(ids);
}
async function doConfirm(ids: string[]) {
setSubmitting(true); setSubmitting(true);
try { try {
if (ids.length === 1) { await request('PARTNER_H5', `/partner/settlement/bills/${bill.id}/confirm`, {
await request('PARTNER_H5', `/partner/settlement/bills/${ids[0]}/confirm`, { method: 'POST' }); method: 'POST',
} else { });
await request('PARTNER_H5', '/partner/settlement/bills/batch-confirm', { toastSuccess('申请已提交,请耐心等待总部打款审核');
method: 'POST',
body: JSON.stringify({ ids }),
});
}
toastSuccess('已确认,等待总部打款');
setConfirmed(false); setConfirmed(false);
setSelectedIds([]);
await loadBills(); await loadBills();
} catch (e) { } catch (e) {
toastError(e instanceof Error ? e.message : '提交失败'); toastError(e instanceof Error ? e.message : '提交失败');
@@ -94,12 +80,8 @@ export default function BillsPage() {
} }
} }
function toggleSelect(id: string) {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}
return ( return (
<PullToRefresh onRefresh={loadBills} className="partner-bills-page"> <div className="partner-bills-page">
<PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} /> <PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
<div className="partner-bill-stepper"> <div className="partner-bill-stepper">
@@ -107,7 +89,7 @@ export default function BillsPage() {
<div className="partner-stepper-line" aria-hidden> <div className="partner-stepper-line" aria-hidden>
<div <div
className="partner-stepper-line-fill" className="partner-stepper-line-fill"
style={{ width: isPaid ? '100%' : isUnpaid || isRejected ? '75%' : '50%' }} style={{ width: isPaid ? '100%' : isReviewing || isRejected ? '75%' : '50%' }}
/> />
</div> </div>
<div className="partner-step"> <div className="partner-step">
@@ -117,7 +99,7 @@ export default function BillsPage() {
<span className="partner-step-label partner-step-label--active"></span> <span className="partner-step-label partner-step-label--active"></span>
</div> </div>
<div className="partner-step"> <div className="partner-step">
<div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isUnpaid || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}> <div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isReviewing || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
{showApplyForm && !confirmed ? '2' : ( {showApplyForm && !confirmed ? '2' : (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span> <span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
)} )}
@@ -125,20 +107,20 @@ export default function BillsPage() {
<span className="partner-step-label partner-step-label--active"></span> <span className="partner-step-label partner-step-label--active"></span>
</div> </div>
<div className="partner-step"> <div className="partner-step">
<div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isUnpaid || isRejected ? ' partner-step-circle--active' : ''}`}> <div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isReviewing || isRejected ? ' partner-step-circle--active' : ''}`}>
{isPaid ? ( {isPaid ? (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span> <span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
) : '3'} ) : '3'}
</div> </div>
<span className={`partner-step-label${isUnpaid || isRejected || isPaid ? ' partner-step-label--active' : ''}`}> <span className={`partner-step-label${isReviewing || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
{isRejected ? '已驳回' : isPaid ? '已打款' : '总部打款'} {isRejected ? '已驳回' : isPaid ? '已打款' : '申请打款'}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
{loading && <div className="empty"></div>} {loading && <div className="empty"></div>}
{!loading && !bill && <div className="empty"></div>} {!loading && !bill && <div className="empty"></div>}
{bill && ( {bill && (
<section className="partner-bill-card"> <section className="partner-bill-card">
@@ -149,7 +131,7 @@ export default function BillsPage() {
<p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p> <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> <h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2>
</div> </div>
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isUnpaid ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}> <span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isReviewing ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
{billStatusLabel(status)} {billStatusLabel(status)}
</span> </span>
</div> </div>
@@ -158,17 +140,17 @@ export default function BillsPage() {
<div className="partner-info-banner" style={{ marginBottom: 16, background: 'rgba(166,29,36,0.06)' }}> <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> <span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>error</span>
<div> <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> <p className="body-md text-variant" style={{ lineHeight: 1.5 }}>{bill.rejectReason}</p>
</div> </div>
</div> </div>
)} )}
{isUnpaid && ( {isReviewing && (
<div className="partner-info-banner" style={{ marginBottom: 16 }}> <div className="partner-info-banner" style={{ marginBottom: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>hourglass_top</span> <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 className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</p> </p>
</div> </div>
)} )}
@@ -195,60 +177,29 @@ export default function BillsPage() {
</section> </section>
)} )}
{bills.length > 0 && ( {bills.length > 1 && (
<div style={{ padding: '0 20px' }}> <div style={{ padding: '0 20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> <h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
<h3 className="headline-md" style={{ margin: 0 }}></h3> {bills.map((b) => (
{actionable.length > 1 && ( <button
<button key={String(b.id)}
type="button" type="button"
className="partner-fill-max" className="partner-store-card"
style={{ fontSize: 13 }} style={{ margin: '0 0 12px', width: '100%', textAlign: 'left', cursor: 'pointer' }}
disabled={!selectedIds.length || submitting} onClick={() => navigate(`/center/bills?id=${b.id}`)}
onClick={() => askConfirm(selectedIds)} >
> <div className="partner-store-card-header" style={{ marginBottom: 0 }}>
({selectedIds.length}) <div>
</button> <p className="body-md">{b.billNo}</p>
)} <p className="label-md text-muted">{billStatusLabel(b.status)}</p>
</div> {b.status === 'REJECTED' && b.rejectReason ? (
{bills.map((b) => { <p className="label-md text-primary" style={{ marginTop: 4 }}>{b.rejectReason}</p>
const id = String(b.id); ) : null}
const selectable = canConfirm(b.status); </div>
return ( <span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
<div
key={id}
className="partner-store-card"
style={{ margin: '0 0 12px', display: 'flex', gap: 10, alignItems: 'flex-start' }}
>
{selectable ? (
<input
type="checkbox"
checked={selectedIds.includes(id)}
onChange={() => toggleSelect(id)}
style={{ marginTop: 4 }}
/>
) : (
<span style={{ width: 16 }} />
)}
<button
type="button"
style={{ flex: 1, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
onClick={() => navigate(`/center/bills?id=${id}`)}
>
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div>
<p className="body-md">{b.billNo}</p>
<p className="label-md text-muted">{billStatusLabel(b.status)}</p>
{b.status === 'REJECTED' && b.rejectReason ? (
<p className="label-md text-primary" style={{ marginTop: 4 }}>{b.rejectReason}</p>
) : null}
</div>
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
</div>
</button>
</div> </div>
); </button>
})} ))}
</div> </div>
)} )}
@@ -257,7 +208,9 @@ export default function BillsPage() {
<div className="partner-info-banner" style={{ marginTop: 16 }}> <div className="partner-info-banner" style={{ marginTop: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span> <span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}> <p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
{isRejected
? '请根据驳回理由核对后重新确认并申请打款。'
: '账单确认后将正式进入打款审核。如有异议,请在确认前联系城市运营经理核实数据。'}
</p> </p>
</div> </div>
@@ -271,14 +224,14 @@ export default function BillsPage() {
className="partner-btn-primary" className="partner-btn-primary"
disabled={!confirmed || submitting} disabled={!confirmed || submitting}
style={{ opacity: confirmed ? 1 : 0.5 }} style={{ opacity: confirmed ? 1 : 0.5 }}
onClick={() => askConfirm([String(bill!.id)])} onClick={() => void confirmBill()}
> >
{submitting ? '正在提交...' : '确认账单'} {submitting ? '正在提交...' : isRejected ? '重新申请打款' : '确认并申请打款'}
{!submitting && <span className="material-symbols-outlined">payments</span>} {!submitting && <span className="material-symbols-outlined">payments</span>}
</button> </button>
</footer> </footer>
</> </>
)} )}
</PullToRefresh> </div>
); );
} }
+13 -23
View File
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { PartnerBillDto } from '@dukang/shared-types'; import type { PartnerBillDto } from '@dukang/shared-types';
import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { contactSupport } from '../lib/contact'; import { contactSupport } from '../lib/contact';
@@ -37,27 +36,18 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
document.title = isPrimary ? '合伙人中心' : '个人中心'; document.title = isPrimary ? '合伙人中心' : '个人中心';
}, [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(() => { useEffect(() => {
void loadCenter(); if (!isPrimary) return;
}, [loadCenter]); void request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
.then(setBills)
.catch(() => setBills([]));
void listPartnerStaff()
.then((list) => setStaffCount(list.length))
.catch(() => setStaffCount(0));
}, [isPrimary]);
const finance = useMemo(() => { const finance = useMemo(() => {
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM'); const pending = bills.filter((b) => b.status === 'DRAFT' || b.status === 'REJECTED');
const settled = bills.filter((b) => b.status === 'PAID'); const settled = bills.filter((b) => b.status === 'PAID');
const paidCount = settled.length; const paidCount = settled.length;
const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length; const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length;
@@ -65,7 +55,7 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
pendingBillCount: pending.length, pendingBillCount: pending.length,
rejectedCount, rejectedCount,
pendingTotal: bills pendingTotal: bills
.filter((b) => b.status === 'AWAITING_CONFIRM' || b.status === 'UNPAID' || b.status === 'REJECTED') .filter((b) => b.status === 'DRAFT' || b.status === 'CONFIRMED' || b.status === 'REJECTED')
.reduce((s, b) => s + Number(b.totalAmount || 0), 0), .reduce((s, b) => s + Number(b.totalAmount || 0), 0),
settledTotal: settled.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), balance: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
@@ -119,7 +109,7 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员'); const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员');
return ( return (
<PullToRefresh onRefresh={loadCenter} className="page partner-center-page partner-home--flush-top"> <div className="page partner-center-page partner-home--flush-top">
<section className="partner-profile-card"> <section className="partner-profile-card">
<button <button
type="button" type="button"
@@ -296,6 +286,6 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
<div className="partner-center-footer"> <div className="partner-center-footer">
<p className="label-md text-muted"> · </p> <p className="label-md text-muted"> · </p>
</div> </div>
</PullToRefresh> </div>
); );
} }
+24 -42
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import type { PartnerLeaderboardEntry } from '@dukang/shared-types'; import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { fetchPartnerLeaderboard } from '../lib/leaderboard'; import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import { import {
@@ -156,62 +155,45 @@ export default function HomePage() {
document.title = '工作台'; document.title = '工作台';
}, []); }, []);
const loadHome = useCallback(() => { useEffect(() => {
if (!isLoggedIn()) { if (!isLoggedIn()) { navigate('/login'); return; }
navigate('/login'); // 等 session 带上账号后再按权限发请求,避免无权限接口弹错
return Promise.resolve(); if (!account) return;
}
if (!account) return Promise.resolve();
const tasks: Promise<unknown>[] = [];
if (canOrders) { if (canOrders) {
tasks.push( request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true }) .then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
.then((data) => setOrders(Array.isArray(data.list) ? data.list : [])) .catch(() => setOrders([]));
.catch(() => setOrders([])),
);
} else { } else {
setOrders([]); setOrders([]);
} }
if (canDashboard) { if (canDashboard) {
tasks.push( request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true }) .then(setDash)
.then(setDash) .catch(() => setDash(null));
.catch(() => setDash(null)),
);
} else { } else {
setDash(null); setDash(null);
} }
if (canStores) { if (canStores) {
tasks.push( request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true }) .then((data) => setStores(Array.isArray(data) ? data : []))
.then((data) => setStores(Array.isArray(data) ? data : [])) .catch(() => setStores([]));
.catch(() => setStores([])),
);
} else { } else {
setStores([]); setStores([]);
} }
tasks.push( // 主账号与全部子账号均可查看同团队贡献榜(后端不校验业务权限点)
fetchPartnerLeaderboard('month') fetchPartnerLeaderboard('month')
.then((data) => { .then((data) => {
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT)); setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
}) })
.catch(() => { .catch(() => {
setLeaderboardEntries([]); setLeaderboardEntries([]);
}), });
);
return Promise.all(tasks).then(() => undefined);
}, [navigate, account, canOrders, canDashboard, canStores]); }, [navigate, account, canOrders, canDashboard, canStores]);
useEffect(() => {
void loadHome();
}, [loadHome]);
const storeCount = Number(dash?.storeCount || stores.length || 0); const storeCount = Number(dash?.storeCount || stores.length || 0);
const orderStats = useMemo(() => summarizeOrders(orders), [orders]); const orderStats = useMemo(() => summarizeOrders(orders), [orders]);
const pendingAuditCount = Number(dash?.pendingAuditCount || 0); const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
@@ -237,7 +219,7 @@ export default function HomePage() {
const profit = revenue * 0.25; const profit = revenue * 0.25;
return ( return (
<PullToRefresh onRefresh={loadHome} className="page partner-home partner-home--flush-top"> <div className="page partner-home partner-home--flush-top">
<main className="partner-home-body"> <main className="partner-home-body">
{isPrimary && ( {isPrimary && (
<> <>
@@ -364,6 +346,6 @@ export default function HomePage() {
<LeaderboardPreview entries={leaderboardEntries} /> <LeaderboardPreview entries={leaderboardEntries} />
</main> </main>
</PullToRefresh> </div>
); );
} }
+5 -10
View File
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types'; import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { fetchPartnerLeaderboard } from '../lib/leaderboard'; import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isPrimaryAccount } from '../lib/partnerAccess'; import { isPrimaryAccount } from '../lib/partnerAccess';
@@ -34,10 +33,10 @@ export default function LeaderboardPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const loadLeaderboard = useCallback(() => { useEffect(() => {
setLoading(true); setLoading(true);
setError(''); setError('');
return fetchPartnerLeaderboard(period) fetchPartnerLeaderboard(period)
.then(setData) .then(setData)
.catch((e) => { .catch((e) => {
setData(null); setData(null);
@@ -46,12 +45,8 @@ export default function LeaderboardPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [period]); }, [period]);
useEffect(() => {
void loadLeaderboard();
}, [loadLeaderboard]);
return ( return (
<PullToRefresh onRefresh={loadLeaderboard} className="page-no-tab"> <div className="page-no-tab">
<PageHeader title="团队贡献榜" onBack={() => navigate('/')} /> <PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
<div className="partner-leaderboard-tabs"> <div className="partner-leaderboard-tabs">
@@ -129,6 +124,6 @@ export default function LeaderboardPage() {
<span className="material-symbols-outlined">add</span> <span className="material-symbols-outlined">add</span>
</Link> </Link>
)} )}
</PullToRefresh> </div>
); );
} }
+174 -8
View File
@@ -1,14 +1,25 @@
import { useRef, useState, type RefObject } from 'react'; import { useEffect, useRef, useState, type RefObject } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { import {
getLastPhone, getLastPhone,
getPartnerProfile, getPartnerProfile,
hasPartnerWxSession,
request, request,
saveRememberedSession, saveAuth,
type PartnerSessionPayload, type PartnerSessionPayload,
} from '../lib/api'; } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { partnerHomePath } from '../lib/partnerAccess'; import { partnerHomePath } from '../lib/partnerAccess';
import {
bindPartnerWechatAfterSmsLogin,
canPartnerUseWechatLogin,
fetchClientConfig,
loginPartnerWithWechat,
PARTNER_WECHAT_LOGIN_HINT,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
const REMEMBER_PHONE_KEY = 'partner_remember_phone'; const REMEMBER_PHONE_KEY = 'partner_remember_phone';
const REMEMBER_FLAG_KEY = 'partner_remember_account'; const REMEMBER_FLAG_KEY = 'partner_remember_account';
@@ -20,7 +31,7 @@ function AgreementCheckbox({
}: { }: {
agreed: boolean; agreed: boolean;
onChange: (next: boolean) => void; onChange: (next: boolean) => void;
inputRef?: RefObject<HTMLLabelElement>; inputRef?: RefObject<HTMLLabelElement | null>;
}) { }) {
return ( return (
<label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}> <label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}>
@@ -39,6 +50,11 @@ function AgreementCheckbox({
); );
} }
function maskPhone(phone: string) {
if (phone.length < 7) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
}
function loadRememberedPhone(): { phone: string; remember: boolean } { function loadRememberedPhone(): { phone: string; remember: boolean } {
try { try {
const remember = localStorage.getItem(REMEMBER_FLAG_KEY) === '1'; const remember = localStorage.getItem(REMEMBER_FLAG_KEY) === '1';
@@ -60,9 +76,19 @@ function formatPartnerError(e: unknown): string {
return text; return text;
} }
function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
}
return text;
}
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession, refresh, account } = usePartnerSession(); const { applySession, refresh, account } = usePartnerSession();
const [params] = useSearchParams();
const quick = params.get('quick') === '1';
const savedProfile = getPartnerProfile(); const savedProfile = getPartnerProfile();
const remembered = loadRememberedPhone(); const remembered = loadRememberedPhone();
const [phone, setPhone] = useState(remembered.phone || getLastPhone()); const [phone, setPhone] = useState(remembered.phone || getLastPhone());
@@ -70,10 +96,22 @@ export default function LoginPage() {
const [rememberAccount, setRememberAccount] = useState(remembered.remember); const [rememberAccount, setRememberAccount] = useState(remembered.remember);
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0); const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null); const agreementRef = useRef<HTMLLabelElement>(null);
useEffect(() => {
fetchClientConfig()
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false));
}, []);
const quickName = savedProfile?.name ?? '城市合伙人';
const quickCompany = savedProfile?.companyName ?? '';
const quickPhone = savedProfile?.phone || phone;
function ensureAgreed() { function ensureAgreed() {
if (!agreed) { if (!agreed) {
setMsg('请先勾选并同意用户协议'); setMsg('请先勾选并同意用户协议');
@@ -137,9 +175,14 @@ export default function LoginPage() {
body: JSON.stringify({ phone, code }), body: JSON.stringify({ phone, code }),
silent: true, silent: true,
}); });
saveRememberedSession(data); saveAuth(data);
applySession(data); applySession(data);
persistRememberAccount(phone); persistRememberAccount(phone);
if (isWechatEnv() && wxAuthorize) {
setMsg('登录成功,正在关联微信…');
await bindPartnerWechatAfterSmsLogin();
return;
}
await finishLoginNavigate(); await finishLoginNavigate();
} catch (e) { } catch (e) {
setMsg(formatPartnerError(e)); setMsg(formatPartnerError(e));
@@ -148,6 +191,116 @@ export default function LoginPage() {
} }
} }
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
if (!isWechatEnv()) {
setMsg(WECHAT_INAPP_REQUIRED_MSG);
return;
}
const canUseWechat = await canPartnerUseWechatLogin({
profile: savedProfile,
phone,
});
if (!canUseWechat) {
setMsg(PARTNER_WECHAT_LOGIN_HINT);
return;
}
setWxLoading(true);
try {
const session = await loginPartnerWithWechat();
if (session) {
applySession(session);
await finishLoginNavigate();
}
} catch (e) {
setMsg(formatWechatError(e));
} finally {
setWxLoading(false);
}
}
if (quick) {
const canWechatQuick =
wxAuthorize &&
isWechatEnv() &&
hasPartnerWxSession() &&
!!savedProfile &&
savedProfile.hasWechat === true;
return (
<div className="partner-auth-page partner-auth-page--quick">
<header className="partner-auth-brand">
<div className="partner-quick-avatar" style={{ width: 120, height: 120, margin: '0 auto 16px' }}>
<span className="material-symbols-outlined" style={{ fontSize: 48 }}>wine_bar</span>
</div>
<h1 className="partner-auth-title" style={{ fontSize: 20 }}></h1>
<p className="partner-auth-subtitle" style={{ fontSize: 12, letterSpacing: '0.2em', textTransform: 'uppercase' }}></p>
</header>
<section className="partner-glass-card">
<div className="partner-quick-badge"></div>
<div className="partner-quick-avatar">
<span className="material-symbols-outlined">person</span>
</div>
<div style={{ textAlign: 'center' }}>
<h2 className="headline-md">
{quickName}
{quickCompany ? (
<span className="text-muted body-md" style={{ fontWeight: 400 }}> ({quickCompany})</span>
) : null}
</h2>
<p className="text-muted body-md" style={{ letterSpacing: '0.1em', marginTop: 4 }}>
{quickPhone ? maskPhone(quickPhone) : '暂无已保存账号'}
</p>
</div>
</section>
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
<AgreementCheckbox
agreed={agreed}
onChange={setAgreed}
inputRef={agreementRef}
/>
{canWechatQuick ? (
<button
type="button"
className="partner-btn-primary"
style={{ marginTop: 16 }}
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
<span className="material-symbols-outlined">chat</span>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
) : (
<p className="partner-auth-msg" style={{ textAlign: 'center', marginTop: 12, marginBottom: 12 }}>
{wxAuthorize && !isWechatEnv()
? '请在微信内打开以使用一键登录'
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
</p>
)}
{!canWechatQuick && (
<Link to="/login" className="partner-btn-primary" style={{ display: 'block', textAlign: 'center', textDecoration: 'none', marginTop: 12 }}>
</Link>
)}
<Link to="/login" className="partner-btn-ghost" style={{ display: 'block', marginTop: 12 }}></Link>
</nav>
<footer className="partner-auth-footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="material-symbols-outlined">verified_user</span>
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase' }}>
{canWechatQuick ? '微信验证 · 7 天内免登录' : 'Secured by Dukang Heritage'}
</span>
</div>
</footer>
</div>
);
}
return ( return (
<div className="partner-auth-page"> <div className="partner-auth-page">
<div className="partner-auth-brand"> <div className="partner-auth-brand">
@@ -214,12 +367,25 @@ export default function LoginPage() {
<span>{loading ? '登录中...' : '登录'}</span> <span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>} {!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button> </button>
<p className="partner-auth-msg" style={{ textAlign: 'center' }}>
7 {wxAuthorize && (
</p> <>
<div className="partner-auth-divider"><span></span></div>
<button type="button" className="partner-btn-wechat" onClick={() => void wechatLogin()} disabled={wxLoading}>
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
</svg>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
</>
)}
</div> </div>
</main> </main>
{hasPartnerWxSession() && savedProfile?.hasWechat && (
<Link to="/login?quick=1" className="partner-link"></Link>
)}
<footer className="partner-auth-footer"> <footer className="partner-auth-footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="material-symbols-outlined">verified_user</span> <span className="material-symbols-outlined">verified_user</span>
+7 -15
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { hasWarehouseAccess } from '../lib/partnerAccess'; import { hasWarehouseAccess } from '../lib/partnerAccess';
@@ -60,17 +59,14 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
document.title = '订单管理'; document.title = '订单管理';
}, []); }, []);
const loadOrders = useCallback(() => { useEffect(() => {
if (!isLoggedIn()) { if (!isLoggedIn()) { navigate('/login'); return; }
navigate('/login');
return Promise.resolve();
}
if (!warehouseOk) { if (!warehouseOk) {
setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' }); setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' });
setLoaded(true); setLoaded(true);
return Promise.resolve(); return;
} }
return request<OrdersResponse>('PARTNER_H5', '/partner/orders') request<OrdersResponse>('PARTNER_H5', '/partner/orders')
.then((res) => { .then((res) => {
setData({ setData({
list: Array.isArray(res.list) ? res.list : [], list: Array.isArray(res.list) ? res.list : [],
@@ -82,10 +78,6 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
.finally(() => setLoaded(true)); .finally(() => setLoaded(true));
}, [navigate, warehouseOk]); }, [navigate, warehouseOk]);
useEffect(() => {
void loadOrders();
}, [loadOrders]);
const filtered = useMemo(() => data.list.filter((o) => { const filtered = useMemo(() => data.list.filter((o) => {
if (statusFilter === 'ALL') return true; if (statusFilter === 'ALL') return true;
const s = String(o.status).toUpperCase(); const s = String(o.status).toUpperCase();
@@ -150,7 +142,7 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
} }
return ( return (
<PullToRefresh onRefresh={loadOrders} className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}> <div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />} {!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
<div className="partner-segment"> <div className="partner-segment">
@@ -273,6 +265,6 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
</div> </div>
</div> </div>
)} )}
</PullToRefresh> </div>
); );
} }
+112 -286
View File
@@ -1,12 +1,11 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import ChinaRegionPicker from '../components/ChinaRegionPicker'; import ChinaRegionPicker from '../components/ChinaRegionPicker';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region'; import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
import { toastError, toastSuccess } from '../lib/toast'; import { toastError, toastSuccess } from '../lib/toast';
import type { import type {
PartnerProxyDeliveryMode,
PartnerProxyOrderCreateRequest, PartnerProxyOrderCreateRequest,
PartnerProxyOrderOptions, PartnerProxyOrderOptions,
PartnerProxyOrderPreviewResult, PartnerProxyOrderPreviewResult,
@@ -18,25 +17,19 @@ function fmtMoney(n: number) {
export default function ProxyOrderPage() { export default function ProxyOrderPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null); const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(true); const [loadingOptions, setLoadingOptions] = useState(true);
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [customerSmsCode, setCustomerSmsCode] = useState(''); const [smsCode, setSmsCode] = useState('');
const [partnerSmsCode, setPartnerSmsCode] = useState('');
const [receiverName, setReceiverName] = useState(''); const [receiverName, setReceiverName] = useState('');
const [regionCodes, setRegionCodes] = useState<string[]>([]); const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState(''); const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState(''); const [productId, setProductId] = useState('');
const [quantity, setQuantity] = useState(2); const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState(''); const [promoCodeId, setPromoCodeId] = useState('');
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
const [autoReceive, setAutoReceive] = useState(false);
const [confirmStep, setConfirmStep] = useState(false);
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null); const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const [customerCooldown, setCustomerCooldown] = useState(0); const [smsCooldown, setSmsCooldown] = useState(0);
const [partnerCooldown, setPartnerCooldown] = useState(0);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
@@ -46,26 +39,12 @@ export default function ProxyOrderPage() {
useEffect(() => { useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options') request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
.then((data) => { .then((data) => {
setOptions({ setOptions(data);
...data, if (data.products[0]) setProductId(data.products[0].id);
stores: Array.isArray(data.stores) ? data.stores : [],
});
const fromQuery = searchParams.get('productId');
if (fromQuery && data.products.some((p) => p.id === fromQuery)) {
setProductId(fromQuery);
} else if (data.products[0] && !productId) {
setProductId(data.products[0].id);
}
}) })
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败')) .catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoadingOptions(false)); .finally(() => setLoadingOptions(false));
// eslint-disable-next-line react-hooks/exhaustive-deps -- hydrate once; productId from query }, []);
}, [searchParams]);
useEffect(() => {
const fromQuery = searchParams.get('productId');
if (fromQuery) setProductId(fromQuery);
}, [searchParams]);
useEffect(() => { useEffect(() => {
if (!productId || quantity < 1) { if (!productId || quantity < 1) {
@@ -79,9 +58,8 @@ export default function ProxyOrderPage() {
body: JSON.stringify({ body: JSON.stringify({
productId, productId,
quantity, quantity,
deliveryMode, receiverCity: region.city || undefined,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined, receiverDistrict: region.district || undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}), }),
silent: true, silent: true,
}) })
@@ -90,109 +68,67 @@ export default function ProxyOrderPage() {
.finally(() => setPreviewLoading(false)); .finally(() => setPreviewLoading(false));
}, 300); }, 300);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [productId, quantity, deliveryMode, region?.city, region?.district]); }, [productId, quantity, region.city, region.district]);
function startCooldown(setter: (n: number | ((s: number) => number)) => void) { async function sendSms() {
setter(60);
const timer = setInterval(() => {
setter((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
}
async function sendCustomerSms() {
setMsg(''); setMsg('');
if (!/^1\d{10}$/.test(phone.trim())) { if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入有效手机号'); setMsg('请输入有效手机号');
return; return;
} }
try { try {
const res = await request<{ maskedPhone: string }>( const res = await request<{ maskedPhone: string }>('PARTNER_H5', '/partner/proxy-orders/send-sms', {
'PARTNER_H5', method: 'POST',
'/partner/proxy-orders/send-customer-sms', body: JSON.stringify({ phone: phone.trim() }),
{ silent: true,
method: 'POST', });
body: JSON.stringify({ phone: phone.trim() }), setMsg(`验证码已发送至 ${res.maskedPhone}`);
silent: true, setSmsCooldown(60);
}, const timer = setInterval(() => {
); setSmsCooldown((s) => {
setMsg(`客户验证码已发送至 ${res.maskedPhone}`); if (s <= 1) {
startCooldown(setCustomerCooldown); clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败'); setMsg(e instanceof Error ? e.message : '发送失败');
} }
} }
async function sendPartnerSms() {
setMsg('');
try {
const res = await request<{ maskedPhone: string }>(
'PARTNER_H5',
'/partner/proxy-orders/send-partner-sms',
{ method: 'POST', body: '{}', silent: true },
);
setMsg(`确认验证码已发送至合伙人手机 ${res.maskedPhone}`);
startCooldown(setPartnerCooldown);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
}
}
function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!customerSmsCode.trim()) return '请输入客户验证码';
if (!productId) return '请选择商品';
if (deliveryMode !== 'ADDRESS') {
// 现场提货不需要地址与门店
} else {
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
if (!addressDetail.trim()) return '请填写详细地址';
if (!autoReceive) return '配送到址须勾选同意自动收货';
}
if (!preview) return '请等待费用计算完成';
return null;
}
async function openConfirmStep() {
setMsg('');
const err = validateForm();
if (err) {
setMsg(err);
return;
}
setConfirmStep(true);
setPartnerSmsCode('');
await sendPartnerSms();
}
async function submit() { async function submit() {
setMsg(''); setMsg('');
const err = validateForm(); if (!/^1\d{10}$/.test(phone.trim())) {
if (err) { setMsg('请输入有效手机号');
setMsg(err);
return; return;
} }
if (!partnerSmsCode.trim()) { if (!smsCode.trim()) {
setMsg('请输入合伙人确认验证码'); setMsg('请输入验证码');
return;
}
if (!region?.province || !region.city || !region.district) {
setMsg('请选择省市区');
return;
}
if (!addressDetail.trim()) {
setMsg('请填写详细地址');
return;
}
if (!productId) {
setMsg('请选择商品');
return; return;
} }
const payload: PartnerProxyOrderCreateRequest = { const payload: PartnerProxyOrderCreateRequest = {
phone: phone.trim(), phone: phone.trim(),
customerSmsCode: customerSmsCode.trim(), smsCode: smsCode.trim(),
partnerSmsCode: partnerSmsCode.trim(),
deliveryMode,
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
receiverName: receiverName.trim() || undefined, receiverName: receiverName.trim() || undefined,
province: deliveryMode === 'ADDRESS' ? region?.province : undefined, province: region.province,
city: deliveryMode === 'ADDRESS' ? region?.city : undefined, city: region.city,
district: deliveryMode === 'ADDRESS' ? region?.district : undefined, district: region.district,
addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined, addressDetail: addressDetail.trim(),
productId, productId,
quantity, quantity,
promoCodeId: promoCodeId || undefined, promoCodeId: promoCodeId || undefined,
@@ -215,12 +151,6 @@ export default function ProxyOrderPage() {
} }
const selectedProduct = options?.products.find((p) => p.id === productId); const selectedProduct = options?.products.find((p) => p.id === productId);
const deliveryLabel =
preview?.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
: preview?.deliveryType === 'CROSS_CITY'
? '跨城配送'
: '同城配送';
return ( return (
<div className="page partner-proxy-order-page"> <div className="page partner-proxy-order-page">
@@ -228,7 +158,7 @@ export default function ProxyOrderPage() {
<main className="partner-form-card" style={{ margin: '0 16px 24px' }}> <main className="partner-form-card" style={{ margin: '0 16px 24px' }}>
{loadingOptions ? ( {loadingOptions ? (
<p className="label-md text-muted"></p> <p className="label-md text-muted">广</p>
) : ( ) : (
<> <>
<section className="partner-form-section"> <section className="partner-form-section">
@@ -245,49 +175,70 @@ export default function ProxyOrderPage() {
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
disabled={customerCooldown > 0} disabled={smsCooldown > 0}
onClick={() => void sendCustomerSms()} onClick={() => void sendSms()}
> >
{customerCooldown > 0 ? `${customerCooldown}s` : '获取验证码'} {smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
</button> </button>
</div> </div>
</section> </section>
<section className="partner-form-section"> <section className="partner-form-section">
<label className="partner-form-label"></label> <label className="partner-form-label"></label>
<div className="partner-input-wrap"> <div className="partner-input-wrap">
<input <input
className="partner-input" className="partner-input"
placeholder="发至客户手机" placeholder="线下代发货确认码"
value={customerSmsCode} value={smsCode}
onChange={(e) => setCustomerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))} onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/> />
</div> </div>
</section> </section>
<section className="partner-form-section"> <section className="partner-form-section">
<label className="partner-form-label"></label> <label className="partner-form-label"></label>
<button <div className="partner-input-wrap">
type="button" <input
className="partner-proxy-product-picker" className="partner-input"
onClick={() => placeholder="默认:用户+手机尾号"
navigate( value={receiverName}
`/proxy-order/products${productId ? `?selected=${encodeURIComponent(productId)}` : ''}`, onChange={(e) => setReceiverName(e.target.value)}
) />
} </div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
{regionLabel && <p className="label-md text-muted" style={{ marginTop: 8 }}>{regionLabel}</p>}
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-field-input partner-field-input--block">
<textarea
rows={3}
placeholder="街道、门牌号等"
value={addressDetail}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<select
className="partner-input"
value={productId}
onChange={(e) => setProductId(e.target.value)}
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }}
> >
{selectedProduct ? ( {(options?.products ?? []).map((p) => (
<> <option key={p.id} value={p.id}>
<span className="partner-proxy-product-name">{selectedProduct.name}</span> {p.name}{p.spec}¥{fmtMoney(p.price)}
<span className="label-md text-muted"> </option>
{selectedProduct.spec} · ¥{fmtMoney(selectedProduct.price)} ))}
</span> </select>
</>
) : (
<span className="label-md text-muted"></span>
)}
<span className="partner-proxy-product-picker-arrow"></span>
</button>
</section> </section>
<section className="partner-form-section"> <section className="partner-form-section">
@@ -310,12 +261,7 @@ export default function ProxyOrderPage() {
className="partner-input" className="partner-input"
value={promoCodeId} value={promoCodeId}
onChange={(e) => setPromoCodeId(e.target.value)} onChange={(e) => setPromoCodeId(e.target.value)}
style={{ style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }}
width: '100%',
padding: '12px 14px',
borderRadius: 12,
border: '1px solid var(--color-border)',
}}
> >
<option value=""></option> <option value=""></option>
{options!.promoCodes.map((p) => ( {options!.promoCodes.map((p) => (
@@ -327,73 +273,6 @@ export default function ProxyOrderPage() {
</section> </section>
)} )}
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-proxy-mode-tabs">
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ADDRESS' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ADDRESS')}
>
</button>
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ON_SITE_PICKUP' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ON_SITE_PICKUP')}
>
</button>
</div>
</section>
{deliveryMode === 'ADDRESS' ? (
<>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
placeholder="默认:用户+手机尾号"
value={receiverName}
onChange={(e) => setReceiverName(e.target.value)}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
{regionLabel ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{regionLabel}
</p>
) : null}
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-field-input partner-field-input--block">
<textarea
rows={3}
placeholder="街道、门牌号等"
value={addressDetail}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</div>
</section>
<label className="partner-proxy-auto-receive">
<input
type="checkbox"
checked={autoReceive}
onChange={(e) => setAutoReceive(e.target.checked)}
/>
<span>线</span>
</label>
</>
) : null}
<section className="partner-proxy-fee-card"> <section className="partner-proxy-fee-card">
<h3 className="headline-md"></h3> <h3 className="headline-md"></h3>
{previewLoading ? ( {previewLoading ? (
@@ -409,8 +288,8 @@ export default function ProxyOrderPage() {
<span className="body-md">×{quantity}</span> <span className="body-md">×{quantity}</span>
</div> </div>
<div className="partner-proxy-fee-row"> <div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span> <span className="label-md text-muted"></span>
<span className="body-md">{deliveryLabel}</span> <span className="body-md">{preview.deliveryType === 'LOCAL' ? '同城' : '跨城'}</span>
</div> </div>
<div className="partner-proxy-fee-row"> <div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span> <span className="label-md text-muted"></span>
@@ -423,78 +302,25 @@ export default function ProxyOrderPage() {
</> </>
) : ( ) : (
<p className="label-md text-muted"> <p className="label-md text-muted">
{selectedProduct ? '请确认数量与履约信息后查看费用' : '请选择商品'} {selectedProduct ? '请确认数量与地址后查看费用' : '请选择商品'}
</p> </p>
)} )}
</section> </section>
{confirmStep ? ( {msg && <p className="partner-form-error" role="alert">{msg}</p>}
<section className="partner-form-section partner-proxy-confirm">
<label className="partner-form-label"></label>
<p className="label-md text-muted" style={{ marginBottom: 8 }}>
线
</p>
<div className="partner-input-row">
<div className="partner-input-wrap" style={{ flex: 1 }}>
<input
className="partner-input"
placeholder="发至合伙人手机"
value={partnerSmsCode}
onChange={(e) => setPartnerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={partnerCooldown > 0}
onClick={() => void sendPartnerSms()}
>
{partnerCooldown > 0 ? `${partnerCooldown}s` : '重新发送'}
</button>
</div>
</section>
) : null}
{msg ? ( <button
<p className="partner-form-error" role="alert"> type="button"
{msg} className="btn btn-primary btn-block"
</p> disabled={submitting || !preview}
) : null} onClick={() => void submit()}
style={{ marginTop: 16 }}
{!confirmStep ? ( >
<button {submitting ? '提交中…' : '验证码确认并代下单'}
type="button" </button>
className="btn btn-primary btn-block"
disabled={!preview}
onClick={() => void openConfirmStep()}
style={{ marginTop: 16 }}
>
</button>
) : (
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={() => setConfirmStep(false)}
>
</button>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
disabled={submitting || !preview}
onClick={() => void submit()}
>
{submitting ? '提交中…' : '验证并提交'}
</button>
</div>
)}
<p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}> <p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}>
线/ /线
</p> </p>
</> </>
)} )}
@@ -1,69 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import { toastError } from '../lib/toast';
import type { PartnerProxyOrderOptions, PartnerProxyOrderProductOption } from '@dukang/shared-types';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function ProxyOrderProductsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const selectedId = searchParams.get('selected') || '';
const [products, setProducts] = useState<PartnerProxyOrderProductOption[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
.then((data) => setProducts(Array.isArray(data.products) ? data.products : []))
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, []);
function pick(product: PartnerProxyOrderProductOption) {
navigate(`/proxy-order?productId=${encodeURIComponent(product.id)}`, { replace: true });
}
return (
<div className="page partner-proxy-order-page">
<PageHeader title="选择酒品" onBack={() => navigate(-1)} />
<main className="partner-proxy-product-list">
{loading ? <p className="label-md text-muted"></p> : null}
{!loading && products.length === 0 ? (
<p className="label-md text-muted"></p>
) : null}
{products.map((p) => {
const benefit = p.benefitAmount != null ? Number(p.benefitAmount) : Number(p.price);
const active = p.id === selectedId;
return (
<button
key={p.id}
type="button"
className={`partner-proxy-product-card${active ? ' partner-proxy-product-card--active' : ''}`}
onClick={() => pick(p)}
>
<div className="partner-proxy-product-thumb">
{p.coverUrl ? (
<img src={p.coverUrl} alt="" />
) : (
<span className="partner-proxy-product-thumb-empty" />
)}
</div>
<div className="partner-proxy-product-main">
<div className="partner-proxy-product-row">
<span className="partner-proxy-product-name">{p.name}</span>
<span className="partner-proxy-product-price">¥{fmtMoney(p.price)}</span>
</div>
<p className="partner-proxy-product-spec">{p.spec}</p>
<p className="partner-proxy-product-benefit"> ¥{fmtMoney(benefit)}</p>
</div>
</button>
);
})}
</main>
</div>
);
}
+17 -22
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerBillDto } from '@dukang/shared-types'; import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
@@ -13,9 +12,9 @@ function fmtMoney(n: number) {
function billStatusLabel(status: string) { function billStatusLabel(status: string) {
switch (status) { switch (status) {
case 'AWAITING_CONFIRM': return '待确认'; case 'DRAFT': return '待结算';
case 'UNPAID': return '未打款'; case 'CONFIRMED': return '审核中';
case 'PAID': return '已打款'; case 'PAID': return '已结算';
case 'REJECTED': return '已驳回'; case 'REJECTED': return '已驳回';
default: return status; default: return status;
} }
@@ -23,8 +22,8 @@ function billStatusLabel(status: string) {
function billStatusClass(status: string) { function billStatusClass(status: string) {
switch (status) { switch (status) {
case 'AWAITING_CONFIRM': return 'partner-settlement-status--pending'; case 'DRAFT': return 'partner-settlement-status--pending';
case 'UNPAID': return 'partner-settlement-status--reviewing'; case 'CONFIRMED': return 'partner-settlement-status--reviewing';
case 'PAID': return 'partner-settlement-status--settled'; case 'PAID': return 'partner-settlement-status--settled';
case 'REJECTED': return 'partner-settlement-status--rejected'; case 'REJECTED': return 'partner-settlement-status--rejected';
default: return ''; default: return '';
@@ -48,17 +47,13 @@ export default function SettlementPage() {
const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 }); const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 });
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all'); const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const loadBills = useCallback(() => {
return request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
}, []);
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } if (!isLoggedIn()) { navigate('/login'); return; }
void loadBills(); request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
}, [navigate, loadBills]); }, [navigate]);
const summary = useMemo(() => { const summary = useMemo(() => {
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM'); const pending = bills.filter((b) => b.status === 'DRAFT');
const settled = bills.filter((b) => b.status === 'PAID'); const settled = bills.filter((b) => b.status === 'PAID');
const currentMonth = bills.filter((b) => { const currentMonth = bills.filter((b) => {
const d = new Date(b.periodStart); const d = new Date(b.periodStart);
@@ -77,9 +72,9 @@ export default function SettlementPage() {
const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month; const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
if (!matchMonth) return false; if (!matchMonth) return false;
if (statusFilter === 'all') return true; if (statusFilter === 'all') return true;
if (statusFilter === 'pending') return b.status === 'AWAITING_CONFIRM'; if (statusFilter === 'pending') return b.status === 'DRAFT' || b.status === 'REJECTED';
if (statusFilter === 'settled') return b.status === 'PAID'; if (statusFilter === 'settled') return b.status === 'PAID';
if (statusFilter === 'reviewing') return b.status === 'UNPAID'; if (statusFilter === 'reviewing') return b.status === 'CONFIRMED';
if (statusFilter === 'rejected') return b.status === 'REJECTED'; if (statusFilter === 'rejected') return b.status === 'REJECTED';
return true; return true;
}); });
@@ -100,14 +95,14 @@ export default function SettlementPage() {
const statusTabs: Array<{ key: StatusFilter; label: string }> = [ const statusTabs: Array<{ key: StatusFilter; label: string }> = [
{ key: 'all', label: '全部' }, { key: 'all', label: '全部' },
{ key: 'pending', label: '待确认' }, { key: 'pending', label: '待结算' },
{ key: 'reviewing', label: '未打款' }, { key: 'reviewing', label: '审核中' },
{ key: 'rejected', label: '已驳回' }, { key: 'rejected', label: '已驳回' },
{ key: 'settled', label: '已打款' }, { key: 'settled', label: '已结算' },
]; ];
return ( return (
<PullToRefresh onRefresh={loadBills} className="page-no-tab partner-settlement-page"> <div className="page-no-tab partner-settlement-page">
<PageHeader title="财务对账中心" onBack={() => navigate('/')} /> <PageHeader title="财务对账中心" onBack={() => navigate('/')} />
<main className="partner-settlement-body"> <main className="partner-settlement-body">
@@ -202,7 +197,7 @@ export default function SettlementPage() {
{bill.rejectReason} {bill.rejectReason}
</p> </p>
) : null} ) : null}
<p className={`partner-settlement-item-amount${bill.status === 'AWAITING_CONFIRM' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}> <p className={`partner-settlement-item-amount${bill.status === 'DRAFT' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
¥{fmtMoney(Number(bill.totalAmount || 0))} ¥{fmtMoney(Number(bill.totalAmount || 0))}
</p> </p>
</div> </div>
@@ -216,6 +211,6 @@ export default function SettlementPage() {
)} )}
</section> </section>
</main> </main>
</PullToRefresh> </div>
); );
} }
+2 -3
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { import {
AccountStatus, AccountStatus,
PARTNER_STAFF_ROLE_LABELS, PARTNER_STAFF_ROLE_LABELS,
@@ -87,7 +86,7 @@ export default function StaffListPage() {
} }
return ( return (
<PullToRefresh onRefresh={loadStaff} className="page-no-tab partner-staff-page"> <div className="page-no-tab partner-staff-page">
<PageHeader title="子账号管理" onBack={() => navigate('/center')} /> <PageHeader title="子账号管理" onBack={() => navigate('/center')} />
<div style={{ padding: '0 20px 16px' }}> <div style={{ padding: '0 20px 16px' }}>
@@ -152,6 +151,6 @@ export default function StaffListPage() {
</Link> </Link>
</div> </div>
</PullToRefresh> </div>
); );
} }
+324 -371
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import ChinaRegionPicker from '../components/ChinaRegionPicker'; import ChinaRegionPicker from '../components/ChinaRegionPicker';
import OssUploadField from '../components/OssUploadField'; import OssUploadField from '../components/OssUploadField';
@@ -11,15 +13,13 @@ import { toastError, toastSuccess } from '../lib/toast';
import { resolveRegionBinding } from '../lib/china-region'; import { resolveRegionBinding } from '../lib/china-region';
import { checkStorePhoneAvailable } from '../lib/storePhone'; import { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone';
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload'; import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { isWechatEnv, weixinSdk } from '../lib/weixin';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { import {
@@ -47,14 +47,14 @@ import {
const STEPS = ['基本信息', '照片上传', '结算资质'] as const; const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
type StoreCategoryNode = {
id: string;
name: string;
children?: StoreCategoryNode[];
};
type FieldErrors = { type FieldErrors = {
phone?: string; phone?: string;
storeSmsCode?: string;
}; };
@@ -69,7 +69,7 @@ function isPhoneConflictMessage(message: string) {
function isPhoneValidationMessage(message: string) { function isPhoneValidationMessage(message: string) {
return message.includes('联系电话') || message.includes('手机号'); return message.includes('联系电话') || message.includes('手机号') || message.includes('验证码');
} }
@@ -79,10 +79,16 @@ export default function StoreCreatePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { account } = usePartnerSession(); const { account, refresh } = usePartnerSession();
const accountId = account?.id; const accountId = account?.id;
const wechatReady = !!account?.hasWechat;
const handleWechatReadyChange = useCallback(() => {
void refresh();
}, [refresh]);
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const saved = loadStoreDraft(accountId); const saved = loadStoreDraft(accountId);
@@ -95,15 +101,15 @@ export default function StoreCreatePage() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [checkingPhone, setCheckingPhone] = useState(false);
const [cities, setCities] = useState<OpenCityOption[]>([]); const [cities, setCities] = useState<OpenCityOption[]>([]);
const [citiesError, setCitiesError] = useState(''); const [citiesError, setCitiesError] = useState('');
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]); const [smsCooldown, setSmsCooldown] = useState(0);
const [locating, setLocating] = useState(false); const [smsHint, setSmsHint] = useState('');
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const draftSaveDisabledRef = useRef(false); const draftSaveDisabledRef = useRef(false);
@@ -136,6 +142,17 @@ export default function StoreCreatePage() {
useEffect(() => {
if (step !== 2 || !isWechatEnv()) return;
void refresh();
weixinSdk.reset();
void weixinSdk.init().catch(() => {
/* OssUploadField 点击时会再次初始化 */
});
}, [step, refresh]);
useEffect(() => { useEffect(() => {
void fetchPartnerCities() void fetchPartnerCities()
@@ -155,26 +172,6 @@ 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(() => { useEffect(() => {
@@ -225,13 +222,25 @@ export default function StoreCreatePage() {
setSubmitError(''); setSubmitError('');
let nextPatch = patch;
if ('phone' in patch) { if ('phone' in patch) {
setFieldErrors((prev) => ({ ...prev, phone: undefined })); setFieldErrors((prev) => ({ ...prev, phone: undefined, storeSmsCode: undefined }));
setSmsHint('');
nextPatch = { ...patch, storeSmsCode: '' };
} }
setForm((prev) => ({ ...prev, ...patch })); if ('storeSmsCode' in patch) {
setFieldErrors((prev) => ({ ...prev, storeSmsCode: undefined }));
}
setForm((prev) => ({ ...prev, ...nextPatch }));
} }
@@ -291,6 +300,72 @@ export default function StoreCreatePage() {
} }
async function sendStorePhoneCode() {
const phone = form.phone.trim();
if (!/^1\d{10}$/.test(phone)) {
setFieldErrors({ phone: '请先填写正确的11位手机号' });
return;
}
setSmsHint('');
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
try {
const phoneCheck = await checkStorePhoneAvailable(phone);
if (!phoneCheck.available) {
setFieldErrors({ phone: phoneCheck.message ?? '该手机号不可用于门店账号' });
return;
}
const res = await sendStorePhoneSms(phone);
setSmsHint(`验证码已发送至 ${res.maskedPhone}`);
setSmsCooldown(60);
const timer = setInterval(() => {
setSmsCooldown((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
} catch (e) {
const msg = e instanceof Error ? e.message : '验证码发送失败';
setFieldErrors({ phone: msg });
}
}
async function handleNext() { async function handleNext() {
if (step === 1) { if (step === 1) {
@@ -298,10 +373,54 @@ export default function StoreCreatePage() {
const msg = validateStoreStep1(form); const msg = validateStoreStep1(form);
if (msg) { if (msg) {
reportFormError(msg); if (isPhoneValidationMessage(msg)) {
if (msg.includes('验证码')) {
setFieldErrors({ storeSmsCode: msg });
} else {
setFieldErrors({ phone: msg });
}
} else {
reportFormError(msg);
}
return; 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) { if (step === 2) {
@@ -326,10 +445,6 @@ export default function StoreCreatePage() {
const msg = validateStoreStep3(form); const msg = validateStoreStep3(form);
if (msg) { if (msg) {
if (isPhoneValidationMessage(msg)) {
setFieldErrors({ phone: msg });
return;
}
reportFormError(msg); reportFormError(msg);
return; return;
} }
@@ -337,6 +452,15 @@ export default function StoreCreatePage() {
const step1Msg = validateStoreStep1(form); const step1Msg = validateStoreStep1(form);
if (step1Msg) { if (step1Msg) {
if (isPhoneValidationMessage(step1Msg)) {
if (step1Msg.includes('验证码')) {
setFieldErrors({ storeSmsCode: step1Msg });
} else {
setFieldErrors({ phone: step1Msg });
}
goStep(1);
return;
}
reportFormError(step1Msg); reportFormError(step1Msg);
goStep(1); goStep(1);
return; return;
@@ -376,8 +500,6 @@ export default function StoreCreatePage() {
}); });
setSubmitting(false);
return; return;
} }
@@ -389,7 +511,6 @@ export default function StoreCreatePage() {
); );
if (!ok) { if (!ok) {
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' }); setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
setSubmitting(false);
return; return;
} }
confirmBindExisting = true; confirmBindExisting = true;
@@ -397,7 +518,6 @@ export default function StoreCreatePage() {
} catch (e) { } catch (e) {
reportFormError(e instanceof Error ? e.message : '手机号校验失败'); reportFormError(e instanceof Error ? e.message : '手机号校验失败');
setSubmitting(false);
return; return;
} }
@@ -423,31 +543,13 @@ export default function StoreCreatePage() {
phone: form.phone.trim(), phone: form.phone.trim(),
smsCode: form.storeSmsCode.trim(),
district: form.district.trim(), district: form.district.trim(),
address: form.address.trim(), 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, intro: form.intro.trim() || undefined,
benefitUsageRule: form.benefitUsageRule.trim() || undefined,
coverUrl: form.coverUrl.trim() || undefined, coverUrl: form.coverUrl.trim() || undefined,
@@ -472,6 +574,7 @@ export default function StoreCreatePage() {
setForm(defaultStoreForm()); setForm(defaultStoreForm());
setFieldErrors({}); setFieldErrors({});
setSubmitError(''); setSubmitError('');
setSmsHint('');
setParams({ step: '1' }, { replace: true }); setParams({ step: '1' }, { replace: true });
toastSuccess('门店录入成功'); toastSuccess('门店录入成功');
navigate(`/stores/${result.store.id}`); navigate(`/stores/${result.store.id}`);
@@ -481,6 +584,11 @@ export default function StoreCreatePage() {
setFieldErrors({ phone: message }); setFieldErrors({ phone: message });
return; return;
} }
if (/验证码/.test(message)) {
setFieldErrors({ storeSmsCode: message });
goStep(1);
return;
}
setSubmitError(message); setSubmitError(message);
toastError(message); toastError(message);
} finally { } finally {
@@ -495,13 +603,17 @@ export default function StoreCreatePage() {
const progress = step === 1 ? 0 : step === 2 ? 50 : 100; const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
const nextDisabled = submitting; const nextDisabled = submitting || checkingPhone;
return ( return (
<div className="partner-page-sticky partner-home--flush-top"> <div className="partner-page-sticky">
<PageHeader title="录入新门店" onBack={() => navigate('/stores')} />
<nav className="partner-stepper"> <nav className="partner-stepper">
@@ -601,58 +713,100 @@ export default function StoreCreatePage() {
<div className="partner-field"> <div className="partner-field">
<label> <span className="text-primary">*</span></label> <label> <span className="text-primary">*</span></label>
<div className="partner-input-row" style={{ gap: 8 }}> <div className="partner-field-input">
<select <span className="material-symbols-outlined">call</span>
className="partner-field-input partner-field-input--block" <input
value={form.categoryParentId} type="tel"
onChange={(e) => patchForm({ categoryParentId: e.target.value, categoryId: '' })} placeholder="请输入11位手机号"
aria-label="一级店铺类型" value={form.phone}
> onChange={(e) => patchForm({ phone: e.target.value })}
<option value=""></option> />
{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> </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()}
>
{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> </div>
<div className="partner-field"> <div className="partner-field">
@@ -661,178 +815,13 @@ export default function StoreCreatePage() {
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} /> <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>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
onClick={() => setMapPickerOpen(true)}
>
</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:0017: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>
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
<textarea rows={4} placeholder="请输入门店简介 (2-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} /> <textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} />
<div style={{ textAlign: 'right', marginTop: 4 }}> <div style={{ textAlign: 'right', marginTop: 4 }}>
@@ -842,26 +831,6 @@ export default function StoreCreatePage() {
</div> </div>
<div className="partner-field">
<label>使</label>
<textarea
rows={4}
placeholder="选填,展示在用户端门店详情,最多1000字"
maxLength={1000}
value={form.benefitUsageRule}
onChange={(e) => patchForm({ benefitUsageRule: e.target.value })}
/>
<div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.benefitUsageRule.length} / 1000</span>
</div>
</div>
</section> </section>
<div className="partner-info-banner"> <div className="partner-info-banner">
@@ -926,9 +895,13 @@ export default function StoreCreatePage() {
value={form.coverUrl} value={form.coverUrl}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(coverUrl) => patchForm({ coverUrl })} onChange={(coverUrl) => patchForm({ coverUrl })}
label="从系统相册选择" label="点击或拖拽上传"
/> />
@@ -958,6 +931,10 @@ export default function StoreCreatePage() {
value={url} value={url}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)} onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
/> />
@@ -974,7 +951,7 @@ export default function StoreCreatePage() {
<h3 className="headline-md"> <span className="text-primary">*</span></h3> <h3 className="headline-md"> <span className="text-primary">*</span></h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}></p> <p className="label-md text-muted" style={{ margin: '4px 0 12px' }}></p>
<OssUploadField <OssUploadField
@@ -986,6 +963,10 @@ export default function StoreCreatePage() {
value={form.contractUrl} value={form.contractUrl}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(contractUrl) => patchForm({ contractUrl })} onChange={(contractUrl) => patchForm({ contractUrl })}
label="上传合同副本" label="上传合同副本"
@@ -1026,54 +1007,6 @@ export default function StoreCreatePage() {
</div> </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"> <section className="partner-form-card">
<div className="partner-field"> <div className="partner-field">
@@ -1106,13 +1039,54 @@ export default function StoreCreatePage() {
<p className="label-md text-muted" style={{ marginTop: 8 }}> <p className="label-md text-muted" style={{ marginTop: 8 }}>
</p> </p>
</div> </div>
</section> </section>
<section className="partner-form-card">
<div className="partner-field">
<label> *</label>
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
</div>
<div className="partner-field">
<label> *</label>
<input className="partner-field-input partner-field-input--block" placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => patchForm({ bankAccountNo: e.target.value })} />
</div>
<div className="partner-field">
<label> *</label>
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
</div>
</section>
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
<span className="material-symbols-outlined text-secondary">info</span>
<p className="body-md" style={{ color: 'var(--color-on-secondary-container)' }}>
</p>
</div>
</> </>
)} )}
@@ -1121,11 +1095,7 @@ export default function StoreCreatePage() {
<footer className="partner-sticky-footer"> <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> <button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)} disabled={nextDisabled}></button>
@@ -1135,7 +1105,7 @@ export default function StoreCreatePage() {
<button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}> <button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}>
<span></span> <span>{checkingPhone ? '校验中…' : '下一步'}</span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span> <span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
@@ -1153,23 +1123,6 @@ export default function StoreCreatePage() {
</footer> </footer>
<TencentLocPickerOverlay
open={mapPickerOpen}
onClose={() => setMapPickerOpen(false)}
latitude={form.latitude ? Number(form.latitude) : undefined}
longitude={form.longitude ? Number(form.longitude) : undefined}
onPick={(loc) => {
patchForm({
latitude: String(loc.latitude),
longitude: String(loc.longitude),
...(loc.address && !form.address.trim() ? { address: loc.address } : {}),
});
toastSuccess(
`已选点 ${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}${loc.name ? `${loc.name}` : ''}`,
);
}}
/>
</div> </div>
); );
+19 -157
View File
@@ -1,14 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import AppImage from '@dukang/shared-ui/AppImage'; import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast'; import { toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canManagePartnerStore } from '../lib/partnerAccess'; import { canManagePartnerStore } from '../lib/partnerAccess';
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft'; import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
import OssUploadField from '../components/OssUploadField'; import OssUploadField from '../components/OssUploadField';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import { import {
canPartnerOpenStore, canPartnerOpenStore,
storeAuditLabel, storeAuditLabel,
@@ -40,25 +39,15 @@ export default function StoreDetailPage() {
const canMutate = canManagePartnerStore(account); const canMutate = canManagePartnerStore(account);
const [store, setStore] = useState<Record<string, unknown> | null>(null); const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({ const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
name: '',
phone: '',
address: '',
intro: '',
benefitUsageRule: '',
latitude: '',
longitude: '',
});
const [coverUrl, setCoverUrl] = useState(''); const [coverUrl, setCoverUrl] = useState('');
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']); const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
const [status, setStatus] = useState<StoreStatusValue>('OPEN'); const [status, setStatus] = useState<StoreStatusValue>('OPEN');
const [statusSaving, setStatusSaving] = useState(false); const [statusSaving, setStatusSaving] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false); const [mediaSaving, setMediaSaving] = useState(false);
const [locating, setLocating] = useState(false);
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [actionError, setActionError] = useState(''); const [actionError, setActionError] = useState('');
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false); const [wechatReady, setWechatReady] = useState(false);
function applyStore(data: Record<string, unknown>) { function applyStore(data: Record<string, unknown>) {
setStore(data); setStore(data);
@@ -67,12 +56,6 @@ export default function StoreDetailPage() {
phone: String(data.phone || ''), phone: String(data.phone || ''),
address: String(data.address || ''), address: String(data.address || ''),
intro: String(data.intro || ''), intro: String(data.intro || ''),
benefitUsageRule: (() => {
const raw = String(data.benefitUsageRule || '').trim();
return raw && !/^null$/i.test(raw) ? raw : '';
})(),
latitude: data.latitude != null && data.latitude !== '' ? String(data.latitude) : '',
longitude: data.longitude != null && data.longitude !== '' ? String(data.longitude) : '',
}); });
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue); setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
setCoverUrl(String(data.coverUrl || '')); setCoverUrl(String(data.coverUrl || ''));
@@ -110,8 +93,8 @@ export default function StoreDetailPage() {
return; return;
} }
if (next === 'CLOSED') { if (next === 'CLOSED') {
setCloseConfirmOpen(true); const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
return; if (!ok) return;
} }
setStatusSaving(true); setStatusSaving(true);
setActionError(''); setActionError('');
@@ -122,27 +105,7 @@ export default function StoreDetailPage() {
}); });
setStatus(next); setStatus(next);
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev)); setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
toastSuccess(next === 'OPEN' ? '开店成功' : '状态已更新'); toastSuccess('状态已更新');
} 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) { } catch (e) {
setActionError(e instanceof Error ? e.message : '状态更新失败'); setActionError(e instanceof Error ? e.message : '状态更新失败');
} finally { } finally {
@@ -167,13 +130,6 @@ export default function StoreDetailPage() {
phone: form.phone.trim(), phone: form.phone.trim(),
address: form.address.trim(), address: form.address.trim(),
intro: form.intro.trim(), intro: form.intro.trim(),
benefitUsageRule: form.benefitUsageRule.trim() || null,
...(form.latitude.trim() && form.longitude.trim()
? {
latitude: Number(form.latitude),
longitude: Number(form.longitude),
}
: {}),
}), }),
}); });
applyStore(data); applyStore(data);
@@ -223,7 +179,8 @@ export default function StoreDetailPage() {
if (loadError) { if (loadError) {
return ( return (
<div className="partner-detail-page partner-home--flush-top"> <div className="partner-detail-page">
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
<div className="empty">{loadError}</div> <div className="empty">{loadError}</div>
</div> </div>
); );
@@ -245,8 +202,10 @@ export default function StoreDetailPage() {
const canOpen = canPartnerOpenStore(auditStatus); const canOpen = canPartnerOpenStore(auditStatus);
return ( return (
<div className="partner-detail-page partner-home--flush-top"> <div className="partner-detail-page">
<main style={{ padding: '12px 20px 16px' }}> <PageHeader title="门店详情" onBack={() => navigate('/stores')} />
<main style={{ padding: '16px 20px' }}>
{actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>} {actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>}
<section className="partner-form-card" style={{ margin: '0 0 16px' }}> <section className="partner-form-card" style={{ margin: '0 0 16px' }}>
@@ -325,8 +284,10 @@ export default function StoreDetailPage() {
bizType="STORE_TITLE" bizType="STORE_TITLE"
mediaType="IMAGE" mediaType="IMAGE"
value={coverUrl} value={coverUrl}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
onChange={setCoverUrl} onChange={setCoverUrl}
label="从系统相册选择" label="点击更换门头照"
/> />
</div> </div>
)} )}
@@ -344,78 +305,14 @@ export default function StoreDetailPage() {
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
<textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} /> <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>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
onClick={() => setMapPickerOpen(true)}
>
</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>
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
<textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(2-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} /> <textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
<div style={{ textAlign: 'right', marginTop: 4 }}> <div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.intro.length} / 500</span> <span className="label-md text-muted">{form.intro.length} / 500</span>
</div> </div>
</div> </div>
<div className="partner-field">
<label>使</label>
<textarea
disabled={readOnly}
rows={4}
placeholder="选填,展示在用户端门店详情,最多1000字"
maxLength={1000}
value={form.benefitUsageRule}
onChange={(e) => setForm({ ...form, benefitUsageRule: e.target.value })}
/>
<div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.benefitUsageRule.length} / 1000</span>
</div>
</div>
</section> </section>
<section className="partner-form-card" style={{ margin: '0 0 16px' }}> <section className="partner-form-card" style={{ margin: '0 0 16px' }}>
@@ -439,6 +336,8 @@ export default function StoreDetailPage() {
bizType="STORE_ENV" bizType="STORE_ENV"
mediaType="IMAGE" mediaType="IMAGE"
value={url} value={url}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))} onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
/> />
))} ))}
@@ -491,43 +390,6 @@ export default function StoreDetailPage() {
</button> </button>
)} )}
</footer> </footer>
{closeConfirmOpen && (
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseConfirmOpen(false)}>
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
</p>
<div className="partner-ship-actions">
<button type="button" className="partner-btn-secondary" onClick={() => setCloseConfirmOpen(false)}>
</button>
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
</button>
</div>
</div>
</div>
)}
<TencentLocPickerOverlay
open={mapPickerOpen}
onClose={() => setMapPickerOpen(false)}
latitude={form.latitude ? Number(form.latitude) : undefined}
longitude={form.longitude ? Number(form.longitude) : undefined}
onPick={(loc) => {
setForm((prev) => ({
...prev,
latitude: String(loc.latitude),
longitude: String(loc.longitude),
...(loc.address && !prev.address.trim() ? { address: loc.address } : {}),
}));
toastSuccess(
`已选点 ${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}${loc.name ? `${loc.name}` : ''}`,
);
}}
/>
</div> </div>
); );
} }
+4 -45
View File
@@ -1,8 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess'; import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
import { import {
@@ -37,7 +35,6 @@ export default function StoreListPage() {
const [filter, setFilter] = useState<StatusFilter>(initialFilter); const [filter, setFilter] = useState<StatusFilter>(initialFilter);
const [updatingId, setUpdatingId] = useState<string | null>(null); const [updatingId, setUpdatingId] = useState<string | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [closeTarget, setCloseTarget] = useState<string | null>(null);
const loadStores = useCallback(() => { const loadStores = useCallback(() => {
return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores); return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
@@ -69,8 +66,8 @@ export default function StoreListPage() {
return; return;
} }
if (next === 'CLOSED') { if (next === 'CLOSED') {
setCloseTarget(storeId); const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
return; if (!ok) return;
} }
setUpdatingId(storeId); setUpdatingId(storeId);
setError(''); setError('');
@@ -80,26 +77,6 @@ export default function StoreListPage() {
body: JSON.stringify({ status: next }), body: JSON.stringify({ status: next }),
}); });
await loadStores(); 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 { } catch {
/* request 已 toast */ /* request 已 toast */
} finally { } finally {
@@ -108,7 +85,7 @@ export default function StoreListPage() {
} }
return ( return (
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top"> <div className="page partner-store-page partner-home--flush-top">
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>} {error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
<div className="partner-sticky-filter"> <div className="partner-sticky-filter">
@@ -211,24 +188,6 @@ export default function StoreListPage() {
</div> </div>
); );
})} })}
{closeTarget && ( </div>
<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>
); );
} }
+5 -10
View File
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { PartnerWeeklyReportResponse } from '@dukang/shared-types'; import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { fetchPartnerWeeklyReport } from '../lib/weeklyReport'; import { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
function fmtMoney(n: number) { function fmtMoney(n: number) {
@@ -21,10 +20,10 @@ export default function WeeklyReportPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const loadWeeklyReport = useCallback(() => { useEffect(() => {
setLoading(true); setLoading(true);
setError(''); setError('');
return fetchPartnerWeeklyReport(selectedStart) fetchPartnerWeeklyReport(selectedStart)
.then(setData) .then(setData)
.catch((e) => { .catch((e) => {
setData(null); setData(null);
@@ -33,10 +32,6 @@ export default function WeeklyReportPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [selectedStart]); }, [selectedStart]);
useEffect(() => {
void loadWeeklyReport();
}, [loadWeeklyReport]);
const maxDailyGmv = useMemo(() => { const maxDailyGmv = useMemo(() => {
if (!data?.dailyGmv.length) return 1; if (!data?.dailyGmv.length) return 1;
return Math.max(1, ...data.dailyGmv.map((item) => item.amount)); return Math.max(1, ...data.dailyGmv.map((item) => item.amount));
@@ -47,7 +42,7 @@ export default function WeeklyReportPage() {
const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0; const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0;
return ( return (
<PullToRefresh onRefresh={loadWeeklyReport} className="page-no-tab"> <div className="page-no-tab">
<PageHeader title="数据周报" onBack={() => navigate('/')} /> <PageHeader title="数据周报" onBack={() => navigate('/')} />
<main className="partner-weekly-page"> <main className="partner-weekly-page">
@@ -216,6 +211,6 @@ export default function WeeklyReportPage() {
</> </>
)} )}
</main> </main>
</PullToRefresh> </div>
); );
} }
+1 -146
View File
@@ -3315,145 +3315,6 @@ body {
border-top: 1px dashed rgba(166, 29, 36, 0.15); border-top: 1px dashed rgba(166, 29, 36, 0.15);
} }
.partner-proxy-mode-tabs {
display: flex;
gap: 8px;
}
.partner-proxy-mode-tab {
flex: 1;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--color-border);
background: #fff;
font-size: 14px;
color: var(--color-ink-black);
}
.partner-proxy-mode-tab.is-active {
border-color: var(--color-heritage-red);
color: var(--color-heritage-red);
background: rgba(166, 29, 36, 0.06);
font-weight: 600;
}
.partner-proxy-auto-receive {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 4px 0 16px;
font-size: 13px;
line-height: 1.45;
color: var(--color-ink-black);
}
.partner-proxy-auto-receive input {
margin-top: 3px;
flex-shrink: 0;
}
.partner-proxy-product-picker {
position: relative;
width: 100%;
text-align: left;
padding: 12px 36px 12px 14px;
border-radius: 12px;
border: 1px solid var(--color-border);
background: #fff;
display: flex;
flex-direction: column;
gap: 4px;
}
.partner-proxy-product-picker-arrow {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 22px;
color: var(--color-muted, #999);
}
.partner-proxy-product-list {
padding: 0 16px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.partner-proxy-product-card {
display: flex;
gap: 12px;
width: 100%;
text-align: left;
padding: 12px;
border-radius: 16px;
border: 1px solid var(--color-border);
background: #fff;
}
.partner-proxy-product-card--active {
border-color: var(--color-heritage-red);
box-shadow: 0 0 0 1px rgba(166, 29, 36, 0.2);
}
.partner-proxy-product-thumb {
width: 72px;
height: 72px;
border-radius: 12px;
overflow: hidden;
background: #f3f1ee;
flex-shrink: 0;
}
.partner-proxy-product-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.partner-proxy-product-thumb-empty {
display: block;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #f3f1ee, #e8e4de);
}
.partner-proxy-product-main {
flex: 1;
min-width: 0;
}
.partner-proxy-product-row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.partner-proxy-product-name {
font-weight: 600;
font-size: 15px;
color: var(--color-ink-black);
}
.partner-proxy-product-price {
color: var(--color-heritage-red);
font-weight: 600;
flex-shrink: 0;
}
.partner-proxy-product-spec,
.partner-proxy-product-benefit {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-muted, #888);
}
.partner-proxy-confirm {
padding-top: 8px;
border-top: 1px dashed rgba(0, 0, 0, 0.08);
}
.partner-warehouse-denied { .partner-warehouse-denied {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -3468,16 +3329,10 @@ body {
} }
.partner-home--flush-top.partner-store-page, .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; padding-top: 12px;
} }
.partner-home--flush-top.partner-page-sticky .partner-stepper {
padding-top: 4px;
}
.partner-home--flush-top .partner-sticky-filter { .partner-home--flush-top .partner-sticky-filter {
padding-top: 12px; padding-top: 12px;
} }
+1 -2
View File
@@ -3,8 +3,7 @@ import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
export default defineConfig({ export default defineConfig({
// 独立域名 partner.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。 base: '/partner/',
base: process.env.VITE_PUBLIC_BASE ?? '/',
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
+2 -1
View File
@@ -5,6 +5,7 @@ import LoginPage from './pages/LoginPage';
import LegalPage from './pages/LegalPage'; import LegalPage from './pages/LegalPage';
import SelectStorePage from './pages/SelectStorePage'; import SelectStorePage from './pages/SelectStorePage';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
import RedeemConfirmPage from './pages/RedeemConfirmPage';
import PhoneRedeemPage from './pages/PhoneRedeemPage'; import PhoneRedeemPage from './pages/PhoneRedeemPage';
import RedeemSuccessPage from './pages/RedeemSuccessPage'; import RedeemSuccessPage from './pages/RedeemSuccessPage';
import RecordsPage from './pages/RecordsPage'; import RecordsPage from './pages/RecordsPage';
@@ -21,7 +22,7 @@ export default function App() {
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} /> <Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
<Route path="/select-store" element={<SelectStorePage />} /> <Route path="/select-store" element={<SelectStorePage />} />
<Route path="/staff" element={<StaffPage />} /> <Route path="/staff" element={<StaffPage />} />
<Route path="/redeem" element={<Navigate to="/redeem/phone" replace />} /> <Route path="/redeem" element={<RedeemConfirmPage />} />
<Route path="/redeem/phone" element={<PhoneRedeemPage />} /> <Route path="/redeem/phone" element={<PhoneRedeemPage />} />
<Route path="/redeem/success" element={<RedeemSuccessPage />} /> <Route path="/redeem/success" element={<RedeemSuccessPage />} />
<Route element={<TabLayout />}> <Route element={<TabLayout />}>
+5
View File
@@ -1,4 +1,5 @@
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { getStoreProfile, hasShopWxSession } from '../lib/api';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']); const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
@@ -25,6 +26,10 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
} }
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) { if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
const profile = getStoreProfile();
if (profile && hasShopWxSession() && location.pathname !== '/login') {
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
}
return <Navigate to="/login" replace state={{ from: location }} />; return <Navigate to="/login" replace state={{ from: location }} />;
} }
@@ -19,7 +19,6 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
const [photoResourceId, setPhotoResourceId] = useState(''); const [photoResourceId, setPhotoResourceId] = useState('');
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null); const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
async function handleFile(file: File) { async function handleFile(file: File) {
setUploading(true); setUploading(true);
@@ -50,11 +49,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
} }
} catch (e) { } catch (e) {
const text = e instanceof Error ? e.message : '选图失败'; const text = e instanceof Error ? e.message : '选图失败';
if (!/cancel/i.test(text)) { if (!/cancel/i.test(text)) setMsg(text);
setMsg(`${text},可改从系统相册选择`);
setShowAlbumFallback(true);
inputRef.current?.click();
}
} finally { } finally {
setUploading(false); setUploading(false);
} }
@@ -125,13 +120,11 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
ref={inputRef} ref={inputRef}
type="file" type="file"
accept="image/*" accept="image/*"
capture="environment"
hidden hidden
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) void handleFile(file);
setShowAlbumFallback(false);
void handleFile(file);
}
}} }}
/> />
{previewUrl && ( {previewUrl && (
@@ -141,16 +134,6 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
<button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}> <button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}>
{uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'} {uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'}
</button> </button>
{showAlbumFallback && isWechatEnv() && (
<button
type="button"
className="shop-btn-outline"
disabled={uploading}
onClick={() => inputRef.current?.click()}
>
</button>
)}
<button <button
type="button" type="button"
className="shop-redeem-confirm-btn" className="shop-redeem-confirm-btn"
+4 -9
View File
@@ -53,7 +53,7 @@ const STORE_PROFILE = 'shopStoreProfile';
const SESSION_EXPIRES_AT = 'shopSessionExpiresAt'; const SESSION_EXPIRES_AT = 'shopSessionExpiresAt';
export const SHOP_WX_BOUND = 'shopWxBound'; export const SHOP_WX_BOUND = 'shopWxBound';
/** 手机号或微信验证通过后的免登录时长 */ /** 微信验证通过后的免登录时长 */
export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const AUTH_RECOVERY_EXEMPT_PATHS = [ const AUTH_RECOVERY_EXEMPT_PATHS = [
@@ -87,7 +87,7 @@ export function isShopSessionExpired() {
} }
export function touchShopSession() { export function touchShopSession() {
if (!localStorage.getItem(REFRESH_TOKEN)) return; if (!hasShopWxSession()) return;
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS)); localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
} }
@@ -119,16 +119,11 @@ export function saveAuth(data: ShopSessionPayload) {
} }
} }
/** 手机号验证成功后写入 7 天免验证码会话 */
export function saveRememberedSession(data: ShopSessionPayload) {
saveAuth(data);
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
}
/** 微信登录/绑定成功后写入 7 天免登录 session */ /** 微信登录/绑定成功后写入 7 天免登录 session */
export function saveWechatSession(data: ShopSessionPayload) { export function saveWechatSession(data: ShopSessionPayload) {
saveRememberedSession(data); saveAuth(data);
localStorage.setItem(SHOP_WX_BOUND, '1'); localStorage.setItem(SHOP_WX_BOUND, '1');
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
} }
export function clearAuth(options?: { keepProfile?: boolean }) { export function clearAuth(options?: { keepProfile?: boolean }) {
+146 -18
View File
@@ -1,20 +1,53 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
import {
authorizeShopWechat,
checkNeedsWechatAuth,
fetchShopAccount,
handleShopWechatCallback,
handleShopWechatLoginResult,
} from '../lib/wechat-auth';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import WechatScanAuthModal from '../components/WechatScanAuthModal';
const PENDING_SCAN_KEY = 'shop_pending_scan';
function formatMoney(n: number) { function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
} }
function formatScanError(e: unknown): string {
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
if (/invalid signature/i.test(msg)) {
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
}
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
return `${msg}(可在 URL 后加 ?wxdebug=1 开启 JSSDK 调试查看详情)`;
}
return msg;
}
export default function HomePage() { export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession } = useStoreSession();
const [searchParams, setSearchParams] = useSearchParams();
const [dash, setDash] = useState<Record<string, unknown> | null>(null); const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [open, setOpen] = useState(true);
const [scanMsg, setScanMsg] = useState('');
const [scanning, setScanning] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState('');
const loadDashboard = useCallback(() => { const loadDashboard = useCallback(() => {
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard') return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
.then((d) => { .then((d) => {
setDash(d); setDash(d);
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
}) })
.catch(() => {}); .catch(() => {});
}, []); }, []);
@@ -25,6 +58,7 @@ export default function HomePage() {
useEffect(() => { useEffect(() => {
function onResume() { function onResume() {
setScanning(false);
void loadDashboard(); void loadDashboard();
} }
function onVisibility() { function onVisibility() {
@@ -40,19 +74,95 @@ export default function HomePage() {
}; };
}, [loadDashboard]); }, [loadDashboard]);
useEffect(() => {
if (!isWechatEnv() || !searchParams.get('code')) return;
void handleShopWechatCallback()
.then((result) => {
if (!result) return;
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
}
setAuthModalOpen(false);
setAuthError('');
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
sessionStorage.removeItem(PENDING_SCAN_KEY);
if (shouldScan) {
window.setTimeout(() => void runScan(), 0);
}
})
.catch((e) => {
setAuthError(e instanceof Error ? e.message : '微信授权失败');
});
}, [searchParams, applySession, setSearchParams]);
async function runScan() {
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
}
setScanning(true);
setScanMsg('');
try {
await weixinSdk.init();
const raw = await weixinSdk.scanQrCode();
if (!raw) {
void loadDashboard();
return;
}
const token = parseRedeemTokenFromScan(raw);
if (!token) {
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
return;
}
navigate(`/redeem?token=${encodeURIComponent(token)}`);
} catch (e) {
setScanMsg(formatScanError(e));
} finally {
setScanning(false);
}
}
async function handleScan() {
setScanMsg('');
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
}
try {
const profile = await fetchShopAccount();
if (await checkNeedsWechatAuth(profile)) {
setAuthModalOpen(true);
return;
}
await runScan();
} catch (e) {
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
}
}
async function startWechatAuth() {
setAuthLoading(true);
setAuthError('');
try {
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
await authorizeShopWechat();
} catch (e) {
sessionStorage.removeItem(PENDING_SCAN_KEY);
setAuthError(e instanceof Error ? e.message : '微信授权失败');
setAuthLoading(false);
}
}
const store = dash?.store as Record<string, unknown> | undefined; const store = dash?.store as Record<string, unknown> | undefined;
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || []; const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
const status = String(store?.status || ''); const openTime = String(store?.openTime || '10:00');
const open = status === 'OPEN'; 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('') : '10:00 - 22:00';
const statusText =
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
return ( return (
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page"> <div className="shop-home-page">
<header className="shop-home-header"> <header className="shop-home-header">
<h1 className="app-page-title"></h1> <h1 className="app-page-title"></h1>
</header> </header>
@@ -79,13 +189,20 @@ export default function HomePage() {
</section> </section>
<section className="shop-home-scan"> <section className="shop-home-scan">
<Link <button
to="/redeem/phone" type="button"
className="shop-home-scan-btn" className="shop-home-scan-btn"
disabled={scanning}
onClick={() => void handleScan()}
> >
<span className="material-symbols-outlined">qr_code_scanner</span>
</button>
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
<Link to="/redeem/phone" className="shop-home-phone-link">
<span className="material-symbols-outlined">smartphone</span> <span className="material-symbols-outlined">smartphone</span>
</Link> </Link>
<p className="shop-home-scan-label"></p>
</section> </section>
<section className="shop-home-status"> <section className="shop-home-status">
@@ -95,12 +212,12 @@ export default function HomePage() {
</div> </div>
<div> <div>
<p className="shop-home-status-title"></p> <p className="shop-home-status-title"></p>
<p className="shop-home-status-sub">{statusText}</p> <p className="shop-home-status-sub">{open ? '当前正在营业中' : '当前已停止营业'}</p>
<p className="shop-home-status-sub">: {hoursText}</p> <p className="shop-home-status-sub">: {openTime} - {closeTime}</p>
</div> </div>
</div> </div>
<label className="shop-home-switch" onClick={() => navigate('/status')}> <label className="shop-home-switch" onClick={() => navigate('/status')}>
<input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} /> <input type="checkbox" checked={open} readOnly tabIndex={-1} />
<span className="shop-home-switch-track" /> <span className="shop-home-switch-track" />
</label> </label>
</section> </section>
@@ -132,6 +249,17 @@ export default function HomePage() {
</section> </section>
</div> </div>
</PullToRefresh> <WechatScanAuthModal
open={authModalOpen}
loading={authLoading}
error={authError}
onAuthorize={() => void startWechatAuth()}
onCancel={() => {
setAuthModalOpen(false);
setAuthError('');
sessionStorage.removeItem(PENDING_SCAN_KEY);
}}
/>
</div>
); );
} }
+185 -13
View File
@@ -1,14 +1,32 @@
import { useRef, useState, type RefObject } from 'react'; import { useEffect, useRef, useState, type RefObject } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage'; import AppImage from '@dukang/shared-ui/AppImage';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api';
getLastPhone,
request,
saveRememberedSession,
type ShopSessionPayload,
} from '../lib/api';
import { routeAfterShopLogin } from './SelectStorePage'; import { routeAfterShopLogin } from './SelectStorePage';
import {
bindShopWechatAfterSmsLogin,
fetchClientConfig,
handleShopWechatCallback,
handleShopWechatLoginResult,
loginShopWithWechat,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
function maskPhone(phone: string) {
if (phone.length < 7) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
}
function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定门店账号,请先使用手机验证码登录,登录后将自动关联微信';
}
return text;
}
function ShopAgreementCheckbox({ function ShopAgreementCheckbox({
agreed, agreed,
@@ -17,7 +35,7 @@ function ShopAgreementCheckbox({
}: { }: {
agreed: boolean; agreed: boolean;
onChange: (next: boolean) => void; onChange: (next: boolean) => void;
labelRef?: RefObject<HTMLLabelElement>; labelRef?: RefObject<HTMLLabelElement | null>;
}) { }) {
return ( return (
<label className="shop-login-agreement" ref={labelRef}> <label className="shop-login-agreement" ref={labelRef}>
@@ -43,14 +61,44 @@ function ShopAgreementCheckbox({
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession } = useStoreSession(); const { applySession } = useStoreSession();
const [params, setSearchParams] = useSearchParams();
const quick = params.get('quick') === '1';
const savedProfile = getStoreProfile();
const [phone, setPhone] = useState(getLastPhone()); const [phone, setPhone] = useState(getLastPhone());
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0); const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null); const agreementRef = useRef<HTMLLabelElement>(null);
useEffect(() => {
fetchClientConfig()
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false));
}, []);
useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
void handleShopWechatCallback()
.then((result) => {
if (!result) return;
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
routeAfterShopLogin(session, navigate);
}
})
.catch((e) => setMsg(formatWechatError(e)));
}, [applySession, navigate, params, setSearchParams, wxAuthorize]);
const quickStoreName = savedProfile?.storeName ?? '门店管理中心';
const quickPhone = savedProfile?.phone || phone;
function ensureAgreed() { function ensureAgreed() {
if (!agreed) { if (!agreed) {
setMsg('请先阅读并同意用户协议'); setMsg('请先阅读并同意用户协议');
@@ -93,8 +141,13 @@ export default function LoginPage() {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone, code }), body: JSON.stringify({ phone, code }),
}); });
saveRememberedSession(data); saveAuth(data);
applySession(data); applySession(data);
if (isWechatEnv() && wxAuthorize) {
setMsg('登录成功,正在关联微信…');
await bindShopWechatAfterSmsLogin();
return;
}
routeAfterShopLogin(data, navigate); routeAfterShopLogin(data, navigate);
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败'); setMsg(e instanceof Error ? e.message : '登录失败');
@@ -103,6 +156,103 @@ export default function LoginPage() {
} }
} }
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
if (!isWechatEnv()) {
setMsg('请在微信内打开以使用微信一键登录');
return;
}
setWxLoading(true);
try {
const session = await loginShopWithWechat();
if (session) {
applySession(session);
routeAfterShopLogin(session, navigate);
}
} catch (e) {
setMsg(formatWechatError(e));
} finally {
setWxLoading(false);
}
}
if (quick) {
const canWechatQuick = wxAuthorize && isWechatEnv() && hasShopWxSession() && !!savedProfile;
return (
<div className="shop-quick-login-page">
<header className="shop-quick-header">
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="shop-quick-logo" fit="contain" />
<h1 className="shop-quick-welcome"></h1>
<div className="shop-quick-welcome-line" />
</header>
<section className="shop-quick-store-card">
<div className="shop-quick-store-inner">
<div className="shop-quick-store-icon">
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
</div>
<div>
<h2 className="shop-quick-store-name">{quickStoreName}</h2>
<p className="shop-quick-store-phone">{maskPhone(quickPhone)}</p>
</div>
<span className="shop-quick-verified">
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 14 }}>verified_user</span>
</span>
<div className="shop-quick-switch">
<Link to="/login">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>sync</span>
</Link>
</div>
</div>
</section>
<div className="shop-quick-actions">
{msg && <p className="shop-login-msg">{msg}</p>}
<ShopAgreementCheckbox
agreed={agreed}
onChange={setAgreed}
labelRef={agreementRef}
/>
{canWechatQuick ? (
<button
type="button"
className="shop-quick-login-btn shop-quick-login-btn--wechat"
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
<span className="material-symbols-outlined">chat</span>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
) : (
<p className="shop-login-msg" style={{ textAlign: 'center' }}>
{wxAuthorize && !isWechatEnv()
? '请在微信内打开以使用一键登录'
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
</p>
)}
{!canWechatQuick && (
<Link to="/login" className="shop-quick-login-btn" style={{ textAlign: 'center', textDecoration: 'none' }}>
</Link>
)}
<div className="shop-quick-secure">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>lock</span>
<span>{canWechatQuick ? '微信验证 · 7 天内免登录' : '加密环境安全登录中'}</span>
</div>
</div>
<footer className="shop-quick-footer">
<p className="shop-login-footer-brand">SECURED BY DUKANG HERITAGE</p>
<p style={{ fontSize: 10, fontFamily: 'var(--font-label)' }}>© 2024 </p>
</footer>
</div>
);
}
return ( return (
<div className="shop-login-page"> <div className="shop-login-page">
<header className="shop-login-hero"> <header className="shop-login-hero">
@@ -172,9 +322,26 @@ export default function LoginPage() {
<span>{loading ? '登录中...' : '登录'}</span> <span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>} {!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button> </button>
<p className="shop-login-msg" style={{ textAlign: 'center', marginTop: 12 }}>
7 {wxAuthorize && (
</p> <>
<div className="shop-login-divider">
<span className="shop-login-divider-line" />
<span className="shop-login-divider-text"></span>
<span className="shop-login-divider-line" />
</div>
<button
type="button"
className="shop-login-wechat"
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
<span className="material-symbols-outlined">chat</span>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
</>
)}
</div> </div>
</main> </main>
@@ -183,6 +350,11 @@ export default function LoginPage() {
<span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}> <span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}>
security security
</span> </span>
{hasShopWxSession() && savedProfile && (
<p style={{ marginTop: 16, textAlign: 'center' }}>
<Link to="/login?quick=1" className="text-primary body-md"></Link>
</p>
)}
</footer> </footer>
</div> </div>
); );
+18 -21
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk'; import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { getStoreProfile, request } from '../lib/api'; import { getStoreProfile, request } from '../lib/api';
@@ -25,21 +24,21 @@ export default function MinePage() {
const [binding, setBinding] = useState(false); const [binding, setBinding] = useState(false);
const [bindMsg, setBindMsg] = useState(''); const [bindMsg, setBindMsg] = useState('');
const loadMine = useCallback(() => { useEffect(() => {
return Promise.all([ request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null));
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(() => { useEffect(() => {
void loadMine(); fetchClientConfig()
}, [loadMine]); .then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false));
}, []);
useEffect(() => {
fetchShopAccount()
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
.catch(() => setHasWechat(null));
}, []);
useEffect(() => { useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return; if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
@@ -58,10 +57,8 @@ export default function MinePage() {
.catch((e) => setBindMsg(e instanceof Error ? e.message : '微信绑定失败')); .catch((e) => setBindMsg(e instanceof Error ? e.message : '微信绑定失败'));
}, [applySession, searchParams, setSearchParams, wxAuthorize]); }, [applySession, searchParams, setSearchParams, wxAuthorize]);
const hoursParts: string[] = []; const openTime = String(store?.openTime || '09:30');
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`); const closeTime = String(store?.closeTime || '22:00');
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 multiStore = (profile?.stores?.length ?? 0) > 1;
const showBindWechat = wxAuthorize && hasWechat === false; const showBindWechat = wxAuthorize && hasWechat === false;
@@ -95,7 +92,7 @@ export default function MinePage() {
} }
return ( return (
<PullToRefresh onRefresh={loadMine} className="shop-mine-page"> <div className="shop-mine-page">
<header className="shop-mine-header"> <header className="shop-mine-header">
<h1 className="app-page-title"></h1> <h1 className="app-page-title"></h1>
</header> </header>
@@ -128,7 +125,7 @@ export default function MinePage() {
<div className="shop-mine-info-row"> <div className="shop-mine-info-row">
<div> <div>
<p className="shop-mine-info-label"></p> <p className="shop-mine-info-label"></p>
<p className="shop-mine-info-value">{hoursText}</p> <p className="shop-mine-info-value">{openTime} - {closeTime}</p>
</div> </div>
<span className="material-symbols-outlined shop-mine-lock">lock</span> <span className="material-symbols-outlined shop-mine-lock">lock</span>
</div> </div>
@@ -177,6 +174,6 @@ export default function MinePage() {
退 退
</button> </button>
</div> </div>
</PullToRefresh> </div>
); );
} }
+211 -86
View File
@@ -1,22 +1,33 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
type BalanceResult = {
sessionId: string;
totalBalance: number;
maskedPhone: string;
user?: { nickname?: string; phone?: string; userNo?: string };
};
function formatAmount(n: number) { function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
type Step = 'lookup' | 'amount' | 'confirm';
export default function PhoneRedeemPage() { export default function PhoneRedeemPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [step, setStep] = useState<Step>('lookup');
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [amount, setAmount] = useState(''); const [lookupCode, setLookupCode] = useState('');
const [confirmCode, setConfirmCode] = useState(''); const [confirmCode, setConfirmCode] = useState('');
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null); const [amount, setAmount] = useState('');
const [balance, setBalance] = useState<BalanceResult | null>(null);
const [storeName, setStoreName] = useState(''); const [storeName, setStoreName] = useState('');
const [storeClosed, setStoreClosed] = useState(false); const [storeClosed, setStoreClosed] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [lookupCooldown, setLookupCooldown] = useState(0);
const [confirmCooldown, setConfirmCooldown] = useState(0); const [confirmCooldown, setConfirmCooldown] = useState(0);
useEffect(() => { useEffect(() => {
@@ -28,17 +39,62 @@ export default function PhoneRedeemPage() {
.catch(() => setStoreName('当前门店')); .catch(() => setStoreName('当前门店'));
}, []); }, []);
useEffect(() => {
if (lookupCooldown <= 0) return;
const timer = window.setTimeout(() => setLookupCooldown((v) => v - 1), 1000);
return () => window.clearTimeout(timer);
}, [lookupCooldown]);
useEffect(() => { useEffect(() => {
if (confirmCooldown <= 0) return; if (confirmCooldown <= 0) return;
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000); const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [confirmCooldown]); }, [confirmCooldown]);
async function sendConfirmSms() { async function sendLookupSms() {
if (!/^1\d{10}$/.test(phone.trim())) { if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入正确的手机号'); setMsg('请输入正确的手机号');
return; return;
} }
setLoading(true);
setMsg('');
try {
await request('SHOP_H5', '/shop/redeem/phone/send-lookup-sms', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
});
setLookupCooldown(60);
setMsg('验证码已发送至用户手机');
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
} finally {
setLoading(false);
}
}
async function queryBalance() {
if (!lookupCode.trim()) {
setMsg('请输入验证码');
return;
}
setLoading(true);
setMsg('');
try {
const res = await request<BalanceResult>('SHOP_H5', '/shop/redeem/phone/balance', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim(), code: lookupCode.trim() }),
});
setBalance(res);
setStep('amount');
setMsg('');
} catch (e) {
setMsg(e instanceof Error ? e.message : '查询失败');
} finally {
setLoading(false);
}
}
async function prepareRedeem() {
if (storeClosed) { if (storeClosed) {
setMsg('门店未营业,无法核销'); setMsg('门店未营业,无法核销');
return; return;
@@ -48,30 +104,28 @@ export default function PhoneRedeemPage() {
setMsg('请输入有效核销金额'); setMsg('请输入有效核销金额');
return; return;
} }
if (balance && value > balance.totalBalance) {
setMsg('核销金额不能超过可用权益');
return;
}
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', { await request('SHOP_H5', '/shop/redeem/phone/prepare', {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone: phone.trim(), amount: value }), body: JSON.stringify({ sessionId: balance?.sessionId, amount: value }),
}); });
setPrepared(result);
setConfirmCode('');
setConfirmCooldown(60); setConfirmCooldown(60);
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`); setStep('confirm');
setMsg('确认验证码已发送至用户手机,请向用户索取后输入');
} catch (e) { } catch (e) {
setPrepared(null); setMsg(e instanceof Error ? e.message : '发起核销失败');
setMsg(e instanceof Error ? e.message : '发送验证码失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
async function confirmRedeem() { async function confirmRedeem() {
if (!prepared) {
setMsg('请先发送核销验证码');
return;
}
if (!confirmCode.trim()) { if (!confirmCode.trim()) {
setMsg('请输入确认验证码'); setMsg('请输入确认验证码');
return; return;
@@ -82,13 +136,13 @@ export default function PhoneRedeemPage() {
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', { const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
sessionId: prepared.sessionId, sessionId: balance?.sessionId,
code: confirmCode.trim(), code: confirmCode.trim(),
}), }),
}); });
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result)); sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
navigate('/redeem/success', { navigate('/redeem/success', {
state: { result, storeName, user: prepared.user }, state: { result, storeName, user: balance?.user },
}); });
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '核销失败'); setMsg(e instanceof Error ? e.message : '核销失败');
@@ -97,9 +151,7 @@ export default function PhoneRedeemPage() {
} }
} }
const amountValue = Number(amount); const userLabel = balance?.user?.nickname || balance?.maskedPhone || '—';
const canSendCode =
/^1\d{10}$/.test(phone.trim()) && Number.isFinite(amountValue) && amountValue > 0;
return ( return (
<div className="shop-redeem-page"> <div className="shop-redeem-page">
@@ -127,77 +179,150 @@ export default function PhoneRedeemPage() {
</div> </div>
<div className="shop-redeem-body"> <div className="shop-redeem-body">
<div className="shop-phone-field"> {step === 'lookup' && (
<label className="shop-phone-label"></label> <>
<input <div className="shop-phone-field">
className="shop-phone-input" <label className="shop-phone-label"></label>
type="tel" <input
maxLength={11} className="shop-phone-input"
placeholder="请输入用户手机号" type="tel"
value={phone} maxLength={11}
disabled={loading} placeholder="请输入用户手机号"
onChange={(e) => { value={phone}
setPhone(e.target.value.replace(/\D/g, '')); onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
setPrepared(null); />
setConfirmCode(''); </div>
setConfirmCooldown(0); <div className="shop-phone-field">
}} <label className="shop-phone-label"></label>
/> <div className="shop-phone-code-row">
</div> <input
className="shop-phone-input"
<div className="shop-phone-field"> type="text"
<label className="shop-phone-label"></label> maxLength={6}
<input placeholder="用户收到的验证码"
className="shop-phone-input" value={lookupCode}
type="number" onChange={(e) => setLookupCode(e.target.value.replace(/\D/g, ''))}
min={0.01} />
step={0.01} <button
placeholder="请输入待核销金额" type="button"
value={amount} className="shop-phone-code-btn"
disabled={loading} disabled={loading || lookupCooldown > 0 || !phone.trim()}
onChange={(e) => { onClick={() => void sendLookupSms()}
setAmount(e.target.value); >
setPrepared(null); {lookupCooldown > 0 ? `${lookupCooldown}s` : '获取验证码'}
setConfirmCode(''); </button>
setConfirmCooldown(0); </div>
}} </div>
/>
</div>
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<div className="shop-phone-code-row">
<input
className="shop-phone-input"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="输入用户收到的验证码"
value={confirmCode}
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
/>
<button <button
type="button" type="button"
className="shop-phone-code-btn" className="shop-redeem-confirm-btn"
disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode} disabled={loading || storeClosed}
onClick={() => void sendConfirmSms()} onClick={() => void queryBalance()}
> >
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
</button> </button>
</div> </>
<p className="shop-redeem-hint" style={{ marginTop: 8 }}> )}
</p>
</div>
<button {step === 'amount' && balance && (
type="button" <>
className="shop-redeem-confirm-btn" <div className="shop-redeem-user">
disabled={loading || storeClosed || !prepared || !confirmCode.trim()} <div className="shop-redeem-user-left">
onClick={() => void confirmRedeem()} <span className="material-symbols-outlined">person</span>
> <span></span>
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`} </div>
</button> <span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
{userLabel}
</span>
</div>
<div className="shop-redeem-amount-section">
<p className="shop-redeem-amount-label"></p>
<div className="shop-redeem-amount">
<span className="shop-redeem-amount-symbol">¥</span>
<span className="shop-redeem-amount-value">{formatAmount(balance.totalBalance)}</span>
</div>
</div>
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<input
className="shop-phone-input"
type="number"
min={0.01}
step={0.01}
placeholder="请输入核销金额"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</div>
<button
type="button"
className="shop-redeem-confirm-btn"
disabled={loading || storeClosed || balance.totalBalance <= 0}
onClick={() => void prepareRedeem()}
>
</button>
<button
type="button"
className="shop-phone-link-btn"
onClick={() => {
setStep('lookup');
setBalance(null);
setAmount('');
setLookupCode('');
}}
>
</button>
</>
)}
{step === 'confirm' && balance && (
<>
<div className="shop-redeem-details">
<div className="shop-redeem-detail-row">
<span></span>
<span>{userLabel}</span>
</div>
<div className="shop-redeem-detail-row">
<span></span>
<span>¥{formatAmount(Number(amount))}</span>
</div>
</div>
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<input
className="shop-phone-input"
type="text"
maxLength={6}
placeholder="用户手机收到的确认码"
value={confirmCode}
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
/>
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
{confirmCooldown > 0 ? `${confirmCooldown}s 后可重新发送` : '未收到可向用户确认或返回上一步重发'}
</p>
</div>
<button
type="button"
className="shop-redeem-confirm-btn"
disabled={loading || storeClosed}
onClick={() => void confirmRedeem()}
>
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(Number(amount))}`}
</button>
<button
type="button"
className="shop-phone-link-btn"
onClick={() => {
setStep('amount');
setConfirmCode('');
}}
>
</button>
</>
)}
{msg && <p className="shop-redeem-error">{msg}</p>} {msg && <p className="shop-redeem-error">{msg}</p>}
</div> </div>
+10 -17
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api'; import { request } from '../lib/api';
type RangeKey = 'today' | '7d' | '30d'; type RangeKey = 'today' | '7d' | '30d';
@@ -29,20 +28,14 @@ export default function RecordsPage() {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all'); const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [storeName, setStoreName] = useState(''); const [storeName, setStoreName] = useState('');
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(() => { useEffect(() => {
void loadRecords(); request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
}, [loadRecords]); setRecords(d.list || []);
});
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
.then((s) => setStoreName(String(s.name || '')))
.catch(() => {});
}, []);
const filtered = useMemo(() => { const filtered = useMemo(() => {
return records.filter((r) => { return records.filter((r) => {
@@ -62,7 +55,7 @@ export default function RecordsPage() {
}, [filtered]); }, [filtered]);
return ( return (
<PullToRefresh onRefresh={loadRecords} className="shop-records-page"> <div className="shop-records-page">
<header className="shop-records-header"> <header className="shop-records-header">
<h1 className="app-page-title"></h1> <h1 className="app-page-title"></h1>
</header> </header>
@@ -183,6 +176,6 @@ export default function RecordsPage() {
</div> </div>
)} )}
</div> </div>
</PullToRefresh> </div>
); );
} }
+8 -14
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { import {
needsStoreSelection, needsStoreSelection,
@@ -20,19 +19,15 @@ export default function SelectStorePage() {
const currentStoreId = store?.storeId || ''; const currentStoreId = store?.storeId || '';
const canGoBack = Boolean(currentStoreId); 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(() => { useEffect(() => {
if (!authenticated) { if (!authenticated) {
navigate('/login', { replace: true }); navigate('/login', { replace: true });
return; return;
} }
void loadStores(); void request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
}, [authenticated, navigate, loadStores]); .then((list) => setStores(list))
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
}, [authenticated, navigate]);
async function onSelect(storeId: string) { async function onSelect(storeId: string) {
if (loadingId) return; if (loadingId) return;
@@ -63,13 +58,12 @@ export default function SelectStorePage() {
function statusLabel(status: string) { function statusLabel(status: string) {
if (status === 'OPEN') return '营业中'; if (status === 'OPEN') return '营业中';
if (status === 'PAUSED') return '临时闭店'; if (status === 'PAUSED') return '暂停营业';
if (status === 'CLOSED') return '永久关闭';
return status || '门店'; return status || '门店';
} }
return ( return (
<PullToRefresh onRefresh={loadStores} className="shop-select-store-page"> <div className="shop-select-store-page">
<header className="shop-subpage-header"> <header className="shop-subpage-header">
{canGoBack ? ( {canGoBack ? (
<button <button
@@ -157,7 +151,7 @@ export default function SelectStorePage() {
) : null} ) : null}
</section> </section>
</div> </div>
</PullToRefresh> </div>
); );
} }
+2 -3
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { STORE_STAFF_ROLE_LABELS, type StoreStaffRole } from '@dukang/shared-types'; import { STORE_STAFF_ROLE_LABELS, type StoreStaffRole } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { getStoreProfile, request } from '../lib/api'; import { getStoreProfile, request } from '../lib/api';
@@ -117,7 +116,7 @@ export default function StaffPage() {
form.storeIds.length === 0 ? ownedStores.length : form.storeIds.length; form.storeIds.length === 0 ? ownedStores.length : form.storeIds.length;
return ( return (
<PullToRefresh onRefresh={reload} className="shop-staff-page"> <div className="shop-staff-page">
<header className="shop-subpage-header"> <header className="shop-subpage-header">
<button <button
type="button" type="button"
@@ -323,6 +322,6 @@ export default function StaffPage() {
</section> </section>
)} )}
</div> </div>
</PullToRefresh> </div>
); );
} }
+29 -61
View File
@@ -3,35 +3,20 @@ import { useNavigate } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api'; 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() { export default function StatusPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { resetSession } = useStoreSession(); const { resetSession } = useStoreSession();
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [permanentlyClosed, setPermanentlyClosed] = useState(false);
const [store, setStore] = useState<Record<string, unknown> | null>(null); const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [lastUpdate, setLastUpdate] = useState(''); const [lastUpdate, setLastUpdate] = useState('');
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [pendingOpen, setPendingOpen] = useState<boolean | null>(null); const [pendingOpen, setPendingOpen] = useState<boolean | null>(null);
const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
request('SHOP_H5', '/shop/dashboard').then((d) => { request('SHOP_H5', '/shop/dashboard').then((d) => {
const s = d.store as Record<string, unknown>; const s = d.store as Record<string, unknown>;
setStore(s); setStore(s);
const status = String(s?.status || ''); setOpen(String(s?.status) === 'OPEN');
setPermanentlyClosed(status === 'CLOSED');
setOpen(status === 'OPEN');
if (s?.updatedAt) { if (s?.updatedAt) {
setLastUpdate(new Date(String(s.updatedAt)).toLocaleString('zh-CN')); setLastUpdate(new Date(String(s.updatedAt)).toLocaleString('zh-CN'));
} }
@@ -39,33 +24,31 @@ export default function StatusPage() {
}, []); }, []);
function requestToggle(next: boolean) { function requestToggle(next: boolean) {
if (permanentlyClosed) return;
if (next === open) return; if (next === open) return;
setPendingOpen(next); setPendingOpen(next);
setShowModal(true); setShowModal(true);
} }
async function confirmToggle() { async function confirmToggle() {
if (pendingOpen === null || permanentlyClosed) return; if (pendingOpen === null) return;
const next = pendingOpen ? 'OPEN' : 'PAUSED'; const next = pendingOpen ? 'OPEN' : 'PAUSED';
try { try {
setError('');
await request('SHOP_H5', '/shop/store/status', { await request('SHOP_H5', '/shop/store/status', {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ status: next }), body: JSON.stringify({ status: next }),
}); });
setOpen(pendingOpen); setOpen(pendingOpen);
setLastUpdate(new Date().toLocaleString('zh-CN')); setLastUpdate(new Date().toLocaleString('zh-CN'));
} catch (e) { } catch {
setError(e instanceof Error ? e.message : '状态切换失败'); /* keep current state */
} finally { } finally {
setShowModal(false); setShowModal(false);
setPendingOpen(null); setPendingOpen(null);
} }
} }
const hoursText = formatShopHours(store); const openTime = String(store?.openTime || '09:30');
const statusLabel = permanentlyClosed ? '永久关闭' : open ? '营业中' : '临时闭店'; const closeTime = String(store?.closeTime || '22:00');
return ( return (
<div className="shop-status-page"> <div className="shop-status-page">
@@ -87,61 +70,46 @@ export default function StatusPage() {
<div className="shop-status-content"> <div className="shop-status-content">
<section className="shop-status-card"> <section className="shop-status-card">
<div className="shop-status-icon-wrap"> <div className="shop-status-icon-wrap">
<div className={`shop-status-icon-outer${open && !permanentlyClosed ? ' open' : ' closed'}`}> <div className={`shop-status-icon-outer${open ? ' open' : ' closed'}`}>
<div className={`shop-status-icon-inner${open && !permanentlyClosed ? ' open' : ' closed'}`}> <div className={`shop-status-icon-inner${open ? ' open' : ' closed'}`}>
<span className="material-symbols-outlined">storefront</span> <span className="material-symbols-outlined">storefront</span>
</div> </div>
</div> </div>
<span className="shop-status-check"> <span className="shop-status-check">
<span <span className={`material-symbols-outlined shop-fill-icon${open ? '' : ''}`} style={{ fontSize: 14, color: open ? 'var(--color-success-green)' : 'var(--color-subtle-gray)' }}>
className="material-symbols-outlined shop-fill-icon" {open ? 'check_circle' : 'cancel'}
style={{
fontSize: 14,
color: open && !permanentlyClosed ? 'var(--color-success-green)' : 'var(--color-subtle-gray)',
}}
>
{open && !permanentlyClosed ? 'check_circle' : 'cancel'}
</span> </span>
</span> </span>
</div> </div>
<h2 className={`shop-status-label${open && !permanentlyClosed ? ' open' : ' closed'}`}> <h2 className={`shop-status-label${open ? ' open' : ' closed'}`}>
{statusLabel} {open ? '营业中' : '临时闭店'}
</h2> </h2>
{permanentlyClosed ? ( <label className={`shop-status-switch${open ? ' open' : ' closed'}`}>
<p className="shop-status-switch-caption" style={{ marginTop: 12 }}> <input
type="checkbox"
</p> checked={open}
) : ( onChange={(e) => requestToggle(e.target.checked)}
<label className={`shop-status-switch${open ? ' open' : ' closed'}`}> aria-label={open ? '切换为临时闭店' : '切换为营业中'}
<input />
type="checkbox" <span className="shop-status-switch-track" />
checked={open} <span className="shop-status-switch-caption">
onChange={(e) => requestToggle(e.target.checked)} {open ? '点击可临时闭店' : '点击恢复营业'}
aria-label={open ? '切换为临时闭店' : '切换为营业中'} </span>
/> </label>
<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-label"></p>
<p className="shop-status-hours">{hoursText}</p> <p className="shop-status-hours">{openTime} - {closeTime}</p>
{lastUpdate && <p className="shop-status-updated"> {lastUpdate}</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> </section>
<div className={`shop-status-hint${open && !permanentlyClosed ? ' open' : ' closed'}`}> <div className={`shop-status-hint${open ? ' open' : ' closed'}`}>
<span className="material-symbols-outlined">info</span> <span className="material-symbols-outlined">info</span>
<p> <p>
{permanentlyClosed {open
? '门店已永久关闭。如需重新营业,请联系总部或合伙人处理。' ? '当前处于营业状态,用户可在您的门店核销餐券。'
: open : '当前处于休息状态,用户将无法看到您的门店或进行核销。'}
? '当前处于营业状态,用户可在您的门店核销餐券。'
: '当前处于临时闭店状态,用户将无法看到您的门店或进行核销。'}
</p> </p>
</div> </div>
</div> </div>
+1 -2
View File
@@ -3,8 +3,7 @@ import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
export default defineConfig({ export default defineConfig({
// 独立域名 shop.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。 base: '/shop/',
base: process.env.VITE_PUBLIC_BASE ?? '/',
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
-2
View File
@@ -1,2 +0,0 @@
# 企业微信客服链接(覆盖 shared-types 默认值)
# VITE_CS_WECOM_URL=https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd
-8
View File
@@ -20,10 +20,6 @@ import RedeemCodePage from './pages/RedeemCodePage';
import RedeemSuccessPage from './pages/RedeemSuccessPage'; import RedeemSuccessPage from './pages/RedeemSuccessPage';
import PayPage from './pages/PayPage'; import PayPage from './pages/PayPage';
import CustomerServicePage from './pages/CustomerServicePage'; import CustomerServicePage from './pages/CustomerServicePage';
import AfterSalePage from './pages/AfterSalePage';
import AfterSaleListPage from './pages/AfterSaleListPage';
import InvoiceApplyPage from './pages/InvoiceApplyPage';
import InvoiceListPage from './pages/InvoiceListPage';
import { UserSessionProvider } from './contexts/UserSessionContext'; import { UserSessionProvider } from './contexts/UserSessionContext';
import { capturePromoFromUrl } from './lib/promo'; import { capturePromoFromUrl } from './lib/promo';
import WechatShareBootstrap from './components/WechatShareBootstrap'; import WechatShareBootstrap from './components/WechatShareBootstrap';
@@ -55,10 +51,6 @@ export default function App() {
<Route path="/order/confirm" element={<OrderConfirmPage />} /> <Route path="/order/confirm" element={<OrderConfirmPage />} />
<Route path="/pay" element={<PayPage />} /> <Route path="/pay" element={<PayPage />} />
<Route path="/customer-service" element={<CustomerServicePage />} /> <Route path="/customer-service" element={<CustomerServicePage />} />
<Route path="/after-sale" element={<AfterSalePage />} />
<Route path="/after-sale/list" element={<AfterSaleListPage />} />
<Route path="/invoices" element={<InvoiceListPage />} />
<Route path="/invoices/apply" element={<InvoiceApplyPage />} />
<Route path="/addresses" element={<AddressListPage />} /> <Route path="/addresses" element={<AddressListPage />} />
<Route path="/addresses/new" element={<AddressEditPage />} /> <Route path="/addresses/new" element={<AddressEditPage />} />
<Route path="/addresses/:id/edit" element={<AddressEditPage />} /> <Route path="/addresses/:id/edit" element={<AddressEditPage />} />
@@ -1,6 +1,6 @@
import { useNavigate } from 'react-router-dom';
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types'; import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
import { track } from '../lib/analytics'; import { track } from '../lib/analytics';
import { openWecomCustomerService } from '../lib/customer-service';
type ContactCustomerSheetProps = { type ContactCustomerSheetProps = {
orderId?: string; orderId?: string;
@@ -8,7 +8,8 @@ type ContactCustomerSheetProps = {
onClose: () => void; onClose: () => void;
}; };
export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) { export default function ContactCustomerSheet({ orderId, orderNo, onClose }: ContactCustomerSheetProps) {
const navigate = useNavigate();
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, ''); const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
function openPhone() { function openPhone() {
@@ -17,11 +18,13 @@ export default function ContactCustomerSheet({ orderId, onClose }: ContactCustom
onClose(); onClose();
} }
function openOnline() { function openChat() {
track('cs_contact', { type: 'wecom_kf', orderId }); track('cs_contact', { type: 'chat', orderId });
if (openWecomCustomerService()) { const qs = new URLSearchParams();
onClose(); if (orderId) qs.set('orderId', orderId);
} if (orderNo) qs.set('orderNo', orderNo);
onClose();
navigate(`/customer-service?${qs.toString()}`);
} }
return ( return (
@@ -46,7 +49,7 @@ export default function ContactCustomerSheet({ orderId, onClose }: ContactCustom
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span> <span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
</button> </button>
<button type="button" className="contact-customer-option" onClick={openOnline}> <button type="button" className="contact-customer-option" onClick={openChat}>
<div className="contact-customer-option-icon"> <div className="contact-customer-option-icon">
<span className="material-symbols-outlined">chat</span> <span className="material-symbols-outlined">chat</span>
</div> </div>
-23
View File
@@ -1,23 +0,0 @@
import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types';
import { isWechatEnv } from './weixin';
/** 企微客服链接:优先 Vite env,否则 shared-types 默认 */
export function getCustomerServiceWecomUrl(): string {
const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim();
return fromEnv || CUSTOMER_SERVICE_WECOM_URL;
}
/**
* 打开企业微信客服会话(须在微信内;需用户点击手势)。
* @returns true 已跳转;false 非微信环境已提示
*/
export function openWecomCustomerService(): boolean {
if (!isWechatEnv()) {
window.alert('请在微信中打开以联系在线客服');
return false;
}
window.location.href = getCustomerServiceWecomUrl();
return true;
}
export { CUSTOMER_SERVICE_PHONE };
-37
View File
@@ -1,37 +0,0 @@
import { apiBase } from './api';
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
export type UploadFileResult = {
url: string;
ossKey: string;
bucket: string;
mock: boolean;
};
/** 经 API 服务端转存 OSS */
export async function uploadFileToOss(
file: File,
options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const formData = new FormData();
formData.append('file', file);
formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType);
const headers: Record<string, string> = {
'X-Client-App': 'USER_H5',
};
const token = localStorage.getItem('accessToken');
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}/common/resources/upload`, {
method: 'POST',
headers,
body: formData,
});
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '上传失败');
return json.data as UploadFileResult;
}
@@ -1,71 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
type TicketRow = {
id: string;
ticketNo: string;
ticketType: TicketTypeDto;
status: string;
orderNo?: string;
remark?: string;
createdAt: string;
};
const STATUS_LABEL: Record<string, string> = {
PENDING: '待处理',
OPEN: '处理中',
RESOLVED: '已完成',
REJECTED: '已驳回',
CLOSED: '已关闭',
};
export default function AfterSaleListPage() {
const navigate = useNavigate();
const [items, setItems] = useState<TicketRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
request<{ items: TicketRow[] }>('USER_H5', '/trade/after-sale-tickets?pageSize=50')
.then((res) => setItems(res.items ?? []))
.catch(() => setItems([]))
.finally(() => setLoading(false));
}, []);
return (
<div className="after-sale-page">
<SubPageHeader title="我的售后" onBack={() => navigate(-1)} />
<main className="after-sale-body">
{loading ? (
<p className="after-sale-empty"></p>
) : items.length === 0 ? (
<div className="after-sale-empty-box">
<p className="after-sale-empty"></p>
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale')}>
</button>
</div>
) : (
<div className="after-sale-order-list">
{items.map((t) => (
<div key={t.id} className="after-sale-order-item after-sale-ticket-card">
<div className="after-sale-ticket-head">
<span>{TICKET_TYPE_LABELS[t.ticketType] ?? t.ticketType}</span>
<span className="after-sale-ticket-status">{STATUS_LABEL[t.status] ?? t.status}</span>
</div>
<p className="after-sale-order-no">{t.ticketNo}</p>
<p className="after-sale-order-meta"> {t.orderNo ?? '—'} · {new Date(t.createdAt).toLocaleString()}</p>
{t.remark ? <p className="after-sale-order-meta">{t.remark}</p> : null}
</div>
))}
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale')}>
</button>
</div>
)}
</main>
</div>
);
}
-232
View File
@@ -1,232 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
AFTER_SALE_TICKET_TYPES,
TICKET_TYPE_LABELS,
type AfterSaleTicketType,
} from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
import { uploadFileToOss } from '../lib/upload';
type OrderRow = {
id: string;
orderNo: string;
status: string;
payAmount: number | string;
productName?: string;
createdAt?: string;
};
const STEPS = ['类型', '订单', '凭证', '完成'] as const;
export default function AfterSalePage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const presetOrderId = params.get('orderId') || '';
const presetType = (params.get('type') as AfterSaleTicketType | null) || null;
const [step, setStep] = useState(0);
const [ticketType, setTicketType] = useState<AfterSaleTicketType | null>(
presetType && AFTER_SALE_TICKET_TYPES.includes(presetType) ? presetType : null,
);
const [orders, setOrders] = useState<OrderRow[]>([]);
const [orderId, setOrderId] = useState(presetOrderId);
const [remark, setRemark] = useState('');
const [evidenceUrls, setEvidenceUrls] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [ticketNo, setTicketNo] = useState('');
useEffect(() => {
Promise.all([
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=paid&pageSize=50'),
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=completed&pageSize=50'),
])
.then(([paid, completed]) => {
const map = new Map<string, OrderRow>();
[...(paid.list ?? []), ...(completed.list ?? [])].forEach((o) => map.set(o.id, o));
setOrders([...map.values()]);
})
.catch(() => setOrders([]));
}, []);
const selectedOrder = useMemo(() => orders.find((o) => o.id === orderId), [orders, orderId]);
async function onPickFiles(files: FileList | null) {
if (!files?.length) return;
setUploading(true);
try {
const uploaded: string[] = [];
for (const file of Array.from(files).slice(0, 6 - evidenceUrls.length)) {
const res = await uploadFileToOss(file, { bizType: 'after-sale' });
uploaded.push(res.url);
}
setEvidenceUrls((prev) => [...prev, ...uploaded].slice(0, 6));
} catch (e) {
window.alert(e instanceof Error ? e.message : '上传失败');
} finally {
setUploading(false);
}
}
async function submit() {
if (!ticketType || !orderId) return;
setSubmitting(true);
try {
const ticket = await request<{ ticketNo: string }>('USER_H5', `/trade/orders/${orderId}/after-sale-tickets`, {
method: 'POST',
body: JSON.stringify({
ticketType,
remark: remark.trim() || undefined,
evidenceUrls,
}),
});
setTicketNo(ticket.ticketNo);
setStep(3);
} catch (e) {
window.alert(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false);
}
}
function nextFromType() {
if (!ticketType) {
window.alert('请选择售后类型');
return;
}
setStep(1);
}
function nextFromOrder() {
if (!orderId) {
window.alert('请选择订单');
return;
}
setStep(2);
}
return (
<div className="after-sale-page">
<SubPageHeader title="申请售后" onBack={() => navigate(-1)} />
<div className="after-sale-steps">
{STEPS.map((label, i) => (
<span key={label} className={`after-sale-step${i === step ? ' is-active' : i < step ? ' is-done' : ''}`}>
{label}
</span>
))}
</div>
<main className="after-sale-body">
{step === 0 && (
<div className="after-sale-type-list">
{AFTER_SALE_TICKET_TYPES.map((t) => (
<button
key={t}
type="button"
className={`after-sale-type-item${ticketType === t ? ' is-selected' : ''}`}
onClick={() => setTicketType(t)}
>
<span>{TICKET_TYPE_LABELS[t]}</span>
<span className="material-symbols-outlined">chevron_right</span>
</button>
))}
<button type="button" className="after-sale-primary" onClick={nextFromType}>
</button>
</div>
)}
{step === 1 && (
<div className="after-sale-order-list">
{orders.length === 0 ? (
<p className="after-sale-empty"></p>
) : (
orders.map((o) => (
<button
key={o.id}
type="button"
className={`after-sale-order-item${orderId === o.id ? ' is-selected' : ''}`}
onClick={() => setOrderId(o.id)}
>
<p className="after-sale-order-no">{o.orderNo}</p>
<p className="after-sale-order-meta">
{o.productName || '商品'} · ¥{Number(o.payAmount).toFixed(2)}
</p>
</button>
))
)}
<div className="after-sale-actions">
<button type="button" className="after-sale-secondary" onClick={() => setStep(0)}>
</button>
<button type="button" className="after-sale-primary" onClick={nextFromOrder}>
</button>
</div>
</div>
)}
{step === 2 && (
<div className="after-sale-form">
<p className="after-sale-summary">
{ticketType ? TICKET_TYPE_LABELS[ticketType] : ''} · {selectedOrder?.orderNo ?? orderId}
</p>
<label className="after-sale-label"></label>
<textarea
className="after-sale-textarea"
rows={4}
placeholder="请描述问题(选填)"
value={remark}
onChange={(e) => setRemark(e.target.value)}
/>
<label className="after-sale-label"></label>
<div className="after-sale-evidence">
{evidenceUrls.map((url) => (
<img key={url} src={url} alt="" className="after-sale-evidence-img" />
))}
{evidenceUrls.length < 6 && (
<label className="after-sale-evidence-add">
<input
type="file"
accept="image/*"
multiple
hidden
disabled={uploading}
onChange={(e) => void onPickFiles(e.target.files)}
/>
{uploading ? '上传中' : '+'}
</label>
)}
</div>
<div className="after-sale-actions">
<button type="button" className="after-sale-secondary" onClick={() => setStep(1)}>
</button>
<button type="button" className="after-sale-primary" disabled={submitting} onClick={() => void submit()}>
{submitting ? '提交中…' : '提交工单'}
</button>
</div>
</div>
)}
{step === 3 && (
<div className="after-sale-success">
<span className="material-symbols-outlined after-sale-success-icon">check_circle</span>
<p className="after-sale-success-title"></p>
<p className="after-sale-success-no"> {ticketNo}</p>
<p className="after-sale-success-hint"></p>
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale/list')}>
</button>
<button type="button" className="after-sale-secondary" onClick={() => navigate('/orders')}>
</button>
</div>
)}
</main>
</div>
);
}
+171 -20
View File
@@ -1,35 +1,186 @@
import { useNavigate } from 'react-router-dom'; import { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import SubPageHeader from '../components/SubPageHeader'; import SubPageHeader from '../components/SubPageHeader';
import { CUSTOMER_SERVICE_PHONE, openWecomCustomerService } from '../lib/customer-service'; import { request } from '../lib/api';
import { track } from '../lib/analytics';
type ChatMessage = {
id: string;
role: 'user' | 'agent' | 'system';
text: string;
time?: string;
};
const QUICK_QUESTIONS = [
{ key: 'logistics', label: '物流查询' },
{ key: 'damage', label: '破损补发' },
{ key: 'refund', label: '申请退款' },
{ key: 'address', label: '修改地址' },
] as const;
function nowLabel() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function agentReply(userText: string, orderNo?: string) {
if (/订单|DK\d+/i.test(userText) || orderNo) {
return '已收到您的订单信息,客服将在工作时间 9:00-18:00 内为您处理,请保持电话畅通。';
}
if (userText.includes('破损') || userText.includes('补发')) {
return '非常抱歉给您带来不便。请提供订单号并描述破损情况,我们将尽快安排补发。';
}
if (userText.includes('退款')) {
return '请提供订单号与退款原因,客服将为您核实订单状态并协助办理。';
}
if (userText.includes('地址')) {
return '待发货/出库中的订单可在订单详情修改收货地址;已发货订单请联系客服协助处理。';
}
if (userText.includes('物流')) {
return '您可在订单详情查看配送进度;如有异常请提供订单号,我们为您查询。';
}
return '您好,杜康客服已收到您的消息,请稍候,我们将尽快回复。';
}
export default function CustomerServicePage() { export default function CustomerServicePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, ''); const [params] = useSearchParams();
const orderId = params.get('orderId') || '';
const orderNo = params.get('orderNo') || '';
const [input, setInput] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [sending, setSending] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
function openOnline() { useEffect(() => {
track('cs_contact', { type: 'wecom_kf' }); const welcome: ChatMessage[] = [
openWecomCustomerService(); { id: 'sys-1', role: 'system', text: nowLabel(), time: nowLabel() },
{
id: 'agent-welcome',
role: 'agent',
text: orderNo
? `您好,我是杜康好客客服。已为您关联订单 ${orderNo},请问有什么可以帮您?`
: '您好,我是杜康好客客服。请问有什么可以帮您?',
},
];
setMessages(welcome);
}, [orderNo]);
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' });
}, [messages]);
function pushMessage(role: ChatMessage['role'], text: string) {
setMessages((prev) => [...prev, { id: `${Date.now()}-${prev.length}`, role, text }]);
} }
async function sendText(text: string) {
const trimmed = text.trim();
if (!trimmed || sending) return;
setSending(true);
pushMessage('user', trimmed);
setInput('');
window.setTimeout(() => {
pushMessage('agent', agentReply(trimmed, orderNo));
setSending(false);
}, 600);
}
async function loadOrderContext() {
if (!orderId) return null;
try {
return await request<Record<string, unknown>>('USER_H5', `/trade/orders/${orderId}`);
} catch {
return null;
}
}
useEffect(() => {
if (!orderId) return;
loadOrderContext().then((order) => {
if (!order) return;
const no = String(order.orderNo || orderNo);
if (no && !orderNo) {
pushMessage('system', `已关联订单 ${no}`);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [orderId]);
return ( return (
<div className="customer-service-page customer-service-page--oa"> <div className="customer-service-page">
<SubPageHeader title="在线客服" onBack={() => navigate(-1)} /> <SubPageHeader title="在线客服" onBack={() => navigate(-1)} />
<div className="customer-service-oa-body"> {orderNo && (
<p className="customer-service-wecom-title"></p> <div className="customer-service-order-card">
<p className="customer-service-wecom-hint">线</p> <span className="material-symbols-outlined">receipt_long</span>
<div>
<p className="customer-service-order-label"></p>
<p className="customer-service-order-no">{orderNo}</p>
</div>
</div>
)}
<button type="button" className="customer-service-wecom-btn" onClick={openOnline}> <div className="customer-service-chat" ref={listRef}>
<span className="material-symbols-outlined">headset_mic</span> {messages.map((m) => {
线 if (m.role === 'system') {
</button> return (
<div key={m.id} className="customer-service-time">
<a className="customer-service-phone-link" href={`tel:${tel}`}> <span>{m.text}</span>
<span className="material-symbols-outlined">call</span> </div>
{CUSTOMER_SERVICE_PHONE} );
</a> }
const isUser = m.role === 'user';
return (
<div
key={m.id}
className={`customer-service-bubble-row${isUser ? ' is-user' : ' is-agent'}`}
>
{!isUser && (
<div className="customer-service-avatar" aria-hidden>
<span className="material-symbols-outlined">support_agent</span>
</div>
)}
<div className={`customer-service-bubble${isUser ? ' is-user' : ''}`}>{m.text}</div>
</div>
);
})}
</div> </div>
<div className="customer-service-quick">
{QUICK_QUESTIONS.map((q) => (
<button
key={q.key}
type="button"
className="customer-service-quick-btn"
disabled={sending}
onClick={() => sendText(q.label)}
>
{q.label}
</button>
))}
</div>
<footer className="customer-service-inputbar">
<input
type="text"
className="customer-service-input"
placeholder="请输入您的问题..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void sendText(input);
}}
/>
<button
type="button"
className="customer-service-send"
disabled={sending || !input.trim()}
onClick={() => sendText(input)}
>
</button>
</footer>
</div> </div>
); );
} }
-158
View File
@@ -1,158 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
INVOICE_KIND_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceKind,
type InvoiceTitleType,
} from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
type OrderRow = { id: string; orderNo: string; payAmount: number | string; productName?: string };
export default function InvoiceApplyPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const presetOrderId = params.get('orderId') || '';
const [orders, setOrders] = useState<OrderRow[]>([]);
const [orderId, setOrderId] = useState(presetOrderId);
const [titleType, setTitleType] = useState<InvoiceTitleType>('PERSONAL');
const [invoiceKind, setInvoiceKind] = useState<InvoiceKind>('NORMAL');
const [titleName, setTitleName] = useState('');
const [taxNo, setTaxNo] = useState('');
const [addressPhone, setAddressPhone] = useState('');
const [bankAccount, setBankAccount] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=completed&pageSize=50')
.then((res) => setOrders(res.list ?? []))
.catch(() => setOrders([]));
}, []);
useEffect(() => {
if (invoiceKind === 'SPECIAL') setTitleType('ENTERPRISE');
}, [invoiceKind]);
async function submit() {
if (!orderId) {
window.alert('请选择订单');
return;
}
if (!titleName.trim() || !email.trim() || !phone.trim()) {
window.alert('请填写抬头名称、邮箱与手机号');
return;
}
setSubmitting(true);
try {
await request('USER_H5', `/trade/orders/${orderId}/invoices`, {
method: 'POST',
body: JSON.stringify({
titleType,
invoiceKind,
titleName: titleName.trim(),
taxNo: taxNo.trim() || undefined,
addressPhone: addressPhone.trim() || undefined,
bankAccount: bankAccount.trim() || undefined,
email: email.trim(),
phone: phone.trim(),
}),
});
window.alert('发票申请已提交,总部将在 2 个工作日内开具');
navigate('/invoices');
} catch (e) {
window.alert(e instanceof Error ? e.message : '申请失败');
} finally {
setSubmitting(false);
}
}
return (
<div className="after-sale-page">
<SubPageHeader title="申请发票" onBack={() => navigate(-1)} />
<main className="after-sale-body after-sale-form">
<label className="after-sale-label"></label>
<div className="after-sale-order-list" style={{ marginBottom: 16 }}>
{orders.length === 0 ? (
<p className="after-sale-empty"></p>
) : (
orders.map((o) => (
<button
key={o.id}
type="button"
className={`after-sale-order-item${orderId === o.id ? ' is-selected' : ''}`}
onClick={() => setOrderId(o.id)}
>
<p className="after-sale-order-no">{o.orderNo}</p>
<p className="after-sale-order-meta">
{o.productName || '商品'} · ¥{Number(o.payAmount).toFixed(2)}
</p>
</button>
))
)}
</div>
<label className="after-sale-label"></label>
<div className="invoice-chip-row">
{(Object.keys(INVOICE_KIND_LABELS) as InvoiceKind[]).map((k) => (
<button
key={k}
type="button"
className={`invoice-chip${invoiceKind === k ? ' is-selected' : ''}`}
onClick={() => setInvoiceKind(k)}
>
{INVOICE_KIND_LABELS[k]}
</button>
))}
</div>
<label className="after-sale-label"></label>
<div className="invoice-chip-row">
{(Object.keys(INVOICE_TITLE_TYPE_LABELS) as InvoiceTitleType[]).map((t) => (
<button
key={t}
type="button"
className={`invoice-chip${titleType === t ? ' is-selected' : ''}`}
disabled={invoiceKind === 'SPECIAL' && t === 'PERSONAL'}
onClick={() => setTitleType(t)}
>
{INVOICE_TITLE_TYPE_LABELS[t]}
</button>
))}
</div>
<label className="after-sale-label"></label>
<input className="after-sale-input" value={titleName} onChange={(e) => setTitleName(e.target.value)} placeholder="个人姓名或公司全称" />
{(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && (
<>
<label className="after-sale-label"></label>
<input className="after-sale-input" value={taxNo} onChange={(e) => setTaxNo(e.target.value)} placeholder="纳税人识别号" />
</>
)}
{invoiceKind === 'SPECIAL' && (
<>
<label className="after-sale-label"></label>
<input className="after-sale-input" value={addressPhone} onChange={(e) => setAddressPhone(e.target.value)} />
<label className="after-sale-label"></label>
<input className="after-sale-input" value={bankAccount} onChange={(e) => setBankAccount(e.target.value)} />
</>
)}
<label className="after-sale-label"></label>
<input className="after-sale-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<label className="after-sale-label"></label>
<input className="after-sale-input" value={phone} onChange={(e) => setPhone(e.target.value)} />
<button type="button" className="after-sale-primary" disabled={submitting} onClick={() => void submit()}>
{submitting ? '提交中…' : '提交申请'}
</button>
</main>
</div>
);
}
@@ -1,68 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
INVOICE_KIND_LABELS,
INVOICE_STATUS_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceDto,
} from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
export default function InvoiceListPage() {
const navigate = useNavigate();
const [items, setItems] = useState<InvoiceDto[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
request<{ items: InvoiceDto[] }>('USER_H5', '/trade/invoices?pageSize=50')
.then((res) => setItems(res.items ?? []))
.catch(() => setItems([]))
.finally(() => setLoading(false));
}, []);
return (
<div className="after-sale-page">
<SubPageHeader title="我的发票" onBack={() => navigate(-1)} />
<main className="after-sale-body">
{loading ? (
<p className="after-sale-empty"></p>
) : items.length === 0 ? (
<div className="after-sale-empty-box">
<p className="after-sale-empty"></p>
<button type="button" className="after-sale-primary" onClick={() => navigate('/invoices/apply')}>
</button>
</div>
) : (
<div className="after-sale-order-list">
{items.map((inv) => (
<div key={inv.id} className="after-sale-order-item after-sale-ticket-card">
<div className="after-sale-ticket-head">
<span>
{INVOICE_TITLE_TYPE_LABELS[inv.titleType]} · {INVOICE_KIND_LABELS[inv.invoiceKind]}
</span>
<span className="after-sale-ticket-status">
{INVOICE_STATUS_LABELS[inv.status]}
</span>
</div>
<p className="after-sale-order-no">{inv.titleName}</p>
<p className="after-sale-order-meta">
{inv.invoiceNo} · {inv.orderNo ?? inv.orderId}
</p>
{inv.status === 'ISSUED' && inv.fileUrl ? (
<a className="after-sale-file-link" href={inv.fileUrl} target="_blank" rel="noreferrer">
/
</a>
) : null}
</div>
))}
<button type="button" className="after-sale-primary" onClick={() => navigate('/invoices/apply')}>
</button>
</div>
)}
</main>
</div>
);
}
-2
View File
@@ -17,8 +17,6 @@ const ORDER_SHORTCUTS = [
const SERVICES = [ const SERVICES = [
{ icon: 'location_on', label: '地址管理', to: '/addresses' }, { icon: 'location_on', label: '地址管理', to: '/addresses' },
{ icon: 'assignment_return', label: '售后工单', to: '/after-sale/list' },
{ icon: 'receipt_long', label: '我的发票', to: '/invoices' },
{ icon: 'storefront', label: '可用门店', to: '/stores' }, { icon: 'storefront', label: '可用门店', to: '/stores' },
{ icon: 'headset_mic', label: '联系客服', badge: '在线中', action: 'cs' as const }, { icon: 'headset_mic', label: '联系客服', badge: '在线中', action: 'cs' as const },
{ icon: 'info', label: '关于我们', action: 'about' as const }, { icon: 'info', label: '关于我们', action: 'about' as const },
+18 -24
View File
@@ -37,8 +37,6 @@ type Order = {
id: string; id: string;
orderNo: string; orderNo: string;
orderType?: string; orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
status: string; status: string;
originOrderId?: string | null; originOrderId?: string | null;
remark?: string | null; remark?: string | null;
@@ -140,7 +138,6 @@ export default function OrderDetailPage() {
const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]); const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]);
const isReship = order?.orderType === 'RESHIPMENT'; const isReship = order?.orderType === 'RESHIPMENT';
const isProxy = order?.orderType === 'PROXY' || order?.isProxyOrder === true;
async function loadTrack() { async function loadTrack() {
if (!id) return; if (!id) return;
@@ -181,7 +178,6 @@ export default function OrderDetailPage() {
order && order &&
!canPay && !canPay &&
['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status); ['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
const canInvoice = order?.status === 'COMPLETED';
const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0); const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
const freightTotal = Number(order?.freightAmount ?? 0); const freightTotal = Number(order?.freightAmount ?? 0);
@@ -211,6 +207,22 @@ export default function OrderDetailPage() {
} }
} }
async function requestRefund() {
if (!id || !canRefund) return;
setConfirming(true);
try {
await request('USER_H5', `/trade/orders/${id}/refund-requests`, {
method: 'POST',
body: JSON.stringify({ remark: '用户申请退款' }),
});
await loadOrder();
} catch (e) {
window.alert(e instanceof Error ? e.message : '申请退款失败');
} finally {
setConfirming(false);
}
}
if (!order) return <div className="empty">...</div>; if (!order) return <div className="empty">...</div>;
return ( return (
@@ -243,11 +255,6 @@ export default function OrderDetailPage() {
<h2>{banner.title}</h2> <h2>{banner.title}</h2>
</div> </div>
<p>{banner.subtitle}</p> <p>{banner.subtitle}</p>
{isProxy && order?.proxyPartnerName ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
· {order.proxyPartnerName}
</p>
) : null}
</div> </div>
</section> </section>
@@ -463,21 +470,8 @@ export default function OrderDetailPage() {
</button> </button>
{canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && ( {canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && (
<button <button type="button" className="order-detail-action-outline" disabled={confirming} onClick={requestRefund}>
type="button" 退
className="order-detail-action-outline"
onClick={() => navigate(`/after-sale?orderId=${order.id}&type=REFUND`)}
>
</button>
)}
{canInvoice && (
<button
type="button"
className="order-detail-action-outline"
onClick={() => navigate(`/invoices/apply?orderId=${order.id}`)}
>
</button> </button>
)} )}
{canPay && ( {canPay && (
+4 -18
View File
@@ -14,14 +14,11 @@ const TABS = [
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
PENDING_PAY: '待付款', PENDING_PAY: '待付款',
PENDING_SHIP: '待发货', PENDING_SHIP: '已付款',
OUT_WAREHOUSE: '已出库', OUT_WAREHOUSE: '已付款',
SHIPPING: '配送中', SHIPPING: '已付款',
PENDING_RECEIVE: '待收货', PENDING_RECEIVE: '已付款',
COMPLETED: '已完成', COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
}; };
function isReshipOrder(order: Record<string, unknown>) { function isReshipOrder(order: Record<string, unknown>) {
@@ -52,7 +49,6 @@ export default function OrderListPage() {
const items = (o.items as Array<Record<string, unknown>>) || []; const items = (o.items as Array<Record<string, unknown>>) || [];
const item = items[0]; const item = items[0];
const isReship = isReshipOrder(o); const isReship = isReshipOrder(o);
const isProxy = String(o.orderType) === 'PROXY' || o.isProxyOrder === true;
const isPendingPay = String(o.status) === 'PENDING_PAY'; const isPendingPay = String(o.status) === 'PENDING_PAY';
return ( return (
<div key={String(o.id)} className="card"> <div key={String(o.id)} className="card">
@@ -64,19 +60,9 @@ export default function OrderListPage() {
</span> </span>
)} )}
{isProxy && (
<span className="tag-reship" style={{ marginLeft: 8 }}>
</span>
)}
</span> </span>
<span className="status-tag">{orderStatusLabel(o)}</span> <span className="status-tag">{orderStatusLabel(o)}</span>
</div> </div>
{isProxy && o.proxyPartnerName ? (
<div className="label-md text-muted" style={{ marginBottom: 8 }}>
{String(o.proxyPartnerName)}
</div>
) : null}
{item && ( {item && (
<div className="card-row"> <div className="card-row">
<AppImage <AppImage
+2 -10
View File
@@ -24,9 +24,6 @@ type StoreDetail = {
status: string; status: string;
openTime?: string | null; openTime?: string | null;
closeTime?: string | null; closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
category?: { name: string } | null; category?: { name: string } | null;
media?: StoreMedia[]; media?: StoreMedia[];
}; };
@@ -44,10 +41,8 @@ const DEFAULT_INTRO =
'作为本地优质餐饮合作伙伴,门店融合地域饮食文化与高端社交场景,设有杜康文化体验区,让宾客在用餐之余领略中华酒祖的千年传承。主打精品地方菜与创意融合菜,氛围庄重而不失亲和力,是商务宴请、亲友小聚以及文化交流的理想场所。'; '作为本地优质餐饮合作伙伴,门店融合地域饮食文化与高端社交场景,设有杜康文化体验区,让宾客在用餐之余领略中华酒祖的千年传承。主打精品地方菜与创意融合菜,氛围庄重而不失亲和力,是商务宴请、亲友小聚以及文化交流的理想场所。';
function formatHours(store: StoreDetail) { function formatHours(store: StoreDetail) {
const parts: string[] = []; if (store.openTime && store.closeTime) return `${store.openTime} - ${store.closeTime}`;
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`); return '09:30 - 22:00';
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
return parts.length ? parts.join('') : '09:30-22:00';
} }
function fullAddress(store: StoreDetail) { function fullAddress(store: StoreDetail) {
@@ -140,9 +135,6 @@ export default function StoreDetailPage() {
<div> <div>
<h2 className="store-detail-name">{store.name}</h2> <h2 className="store-detail-name">{store.name}</h2>
<p className="store-detail-hours">{formatHours(store)}</p> <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 && ( {store.category?.name && (
<span className="store-detail-category">{store.category.name}</span> <span className="store-detail-category">{store.category.name}</span>
)} )}
+4 -10
View File
@@ -32,9 +32,6 @@ type StoreItem = {
status: string; status: string;
openTime?: string | null; openTime?: string | null;
closeTime?: string | null; closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
category?: { name: string } | null; category?: { name: string } | null;
}; };
@@ -53,10 +50,10 @@ function storeCover(store: StoreItem, index: number) {
} }
function formatHours(store: StoreItem) { function formatHours(store: StoreItem) {
const parts: string[] = []; if (store.openTime && store.closeTime) {
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`); return `营业时间: ${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 '营业时间: 10:00-22:00';
} }
export default function StoreListPage() { export default function StoreListPage() {
@@ -241,9 +238,6 @@ export default function StoreListPage() {
<div className="store-card-meta"> <div className="store-card-meta">
<span className="status-open"></span> <span className="status-open"></span>
<span className="store-card-hours">{formatHours(s)}</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> </div>
<div className="store-card-bottom"> <div className="store-card-bottom">
+86 -228
View File
@@ -5786,6 +5786,7 @@
color: #1a1c1b; color: #1a1c1b;
} }
/* Customer service chat page */
.customer-service-page { .customer-service-page {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
@@ -5793,285 +5794,142 @@
background: #faf9f7; background: #faf9f7;
} }
.customer-service-page--oa .customer-service-oa-body { .customer-service-order-card {
flex: 1;
padding: 24px 20px calc(24px + env(safe-area-inset-bottom));
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; gap: 12px;
text-align: center; margin: 12px 16px 0;
}
.customer-service-wecom-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: #1a1c1b;
}
.customer-service-wecom-hint {
margin: 0 0 28px;
font-size: 13px;
line-height: 1.5;
color: #5a413f;
}
.customer-service-wecom-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
max-width: 320px;
padding: 14px 20px;
border: none;
border-radius: 12px;
background: #a61d24;
color: #fff;
font-size: 16px;
font-weight: 600;
}
.customer-service-wecom-btn .material-symbols-outlined {
font-size: 22px;
}
.customer-service-phone-link {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 24px;
font-size: 14px;
color: #a61d24;
text-decoration: none;
}
.after-sale-page {
min-height: 100dvh;
background: #faf9f7;
display: flex;
flex-direction: column;
}
.after-sale-steps {
display: flex;
justify-content: space-around;
padding: 12px 16px; padding: 12px 16px;
background: #fff; background: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.06); border-radius: 12px;
} box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
.after-sale-step {
font-size: 12px;
color: #999;
}
.after-sale-step.is-active,
.after-sale-step.is-done {
color: #a61d24; color: #a61d24;
}
.customer-service-order-label {
margin: 0;
font-size: 12px;
color: #5a413f;
}
.customer-service-order-no {
margin: 4px 0 0;
font-size: 14px;
font-weight: 600; font-weight: 600;
color: #1a1c1b;
} }
.after-sale-body { .customer-service-chat {
flex: 1; flex: 1;
padding: 16px 16px calc(24px + env(safe-area-inset-bottom)); overflow-y: auto;
} padding: 16px;
.after-sale-type-list,
.after-sale-order-list,
.after-sale-form,
.after-sale-success {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
min-height: 0;
} }
.after-sale-type-item, .customer-service-time {
.after-sale-order-item {
display: flex; display: flex;
align-items: center; justify-content: center;
justify-content: space-between;
width: 100%;
text-align: left;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(226, 190, 188, 0.35);
background: #fff;
} }
.after-sale-type-item.is-selected, .customer-service-time span {
.after-sale-order-item.is-selected {
border-color: #a61d24;
box-shadow: 0 0 0 1px #a61d24 inset;
}
.after-sale-order-no {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1a1c1b;
}
.after-sale-order-meta {
margin: 4px 0 0;
font-size: 12px; font-size: 12px;
color: #5a413f; color: #999;
background: rgba(227, 226, 224, 0.3);
padding: 4px 12px;
border-radius: 999px;
} }
.after-sale-primary, .customer-service-bubble-row {
.after-sale-secondary {
width: 100%;
padding: 14px;
border-radius: 12px;
border: none;
font-size: 15px;
font-weight: 600;
}
.after-sale-primary {
background: #a61d24;
color: #fff;
}
.after-sale-secondary {
background: #e3e2e0;
color: #1a1c1b;
}
.after-sale-actions {
display: flex; display: flex;
gap: 12px;
margin-top: 8px;
}
.after-sale-label {
font-size: 13px;
color: #5a413f;
margin-top: 4px;
}
.after-sale-textarea,
.after-sale-input {
width: 100%;
border: 1px solid #e3e2e0;
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
background: #fff;
box-sizing: border-box;
}
.after-sale-evidence {
display: flex;
flex-wrap: wrap;
gap: 8px; gap: 8px;
max-width: 85%;
} }
.after-sale-evidence-img, .customer-service-bubble-row.is-user {
.after-sale-evidence-add { align-self: flex-end;
width: 72px;
height: 72px;
border-radius: 8px;
object-fit: cover;
} }
.after-sale-evidence-add { .customer-service-bubble-row.is-agent {
align-self: flex-start;
}
.customer-service-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: #fff;
border: 1px solid rgba(226, 190, 188, 0.2);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px dashed #ccc; color: #a61d24;
background: #fff; flex-shrink: 0;
font-size: 24px;
color: #999;
} }
.after-sale-summary { .customer-service-bubble {
margin: 0;
padding: 12px; padding: 12px;
border-radius: 10px; border-radius: 12px;
background: rgba(166, 29, 36, 0.06); font-size: 14px;
font-size: 13px; line-height: 1.5;
color: #a61d24; background: #fff;
color: #1a1c1b;
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
} }
.after-sale-empty, .customer-service-bubble.is-user {
.after-sale-empty-box { background: #a61d24;
text-align: center; color: #fff;
color: #999; box-shadow: 0 2px 8px rgba(166, 29, 36, 0.2);
padding: 24px 0;
} }
.after-sale-success { .customer-service-quick {
align-items: center;
text-align: center;
padding-top: 40px;
}
.after-sale-success-icon {
font-size: 56px;
color: #2e7d32;
}
.after-sale-success-title {
margin: 8px 0 4px;
font-size: 18px;
font-weight: 600;
}
.after-sale-success-no,
.after-sale-success-hint {
margin: 0;
font-size: 13px;
color: #5a413f;
}
.after-sale-ticket-head {
display: flex;
justify-content: space-between;
width: 100%;
font-size: 13px;
font-weight: 600;
}
.after-sale-ticket-status {
color: #a61d24;
}
.after-sale-ticket-card {
flex-direction: column;
align-items: flex-start;
}
.after-sale-file-link {
margin-top: 8px;
font-size: 13px;
color: #a61d24;
}
.invoice-chip-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
padding: 8px 16px 12px;
} }
.invoice-chip { .customer-service-quick-btn {
padding: 8px 12px; padding: 6px 12px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(166, 29, 36, 0.25); border: 1px solid rgba(166, 29, 36, 0.25);
background: #fff; background: #fff;
font-size: 12px;
color: #a61d24; color: #a61d24;
font-size: 12px;
} }
.invoice-chip.is-selected { .customer-service-inputbar {
display: flex;
gap: 8px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #e3e2e0;
}
.customer-service-input {
flex: 1;
border: 1px solid #e3e2e0;
border-radius: 999px;
padding: 10px 16px;
font-size: 14px;
outline: none;
}
.customer-service-send {
border: none;
border-radius: 999px;
padding: 10px 18px;
background: #a61d24; background: #a61d24;
color: #fff; color: #fff;
border-color: #a61d24; font-size: 14px;
font-weight: 600;
} }
.invoice-chip:disabled { .customer-service-send:disabled {
opacity: 0.4; opacity: 0.5;
} }
/* 微信 H5 系统标题已展示:隐藏页内重复标题;仅标题顶栏收起 */ /* 微信 H5 系统标题已展示:隐藏页内重复标题;仅标题顶栏收起 */
+2 -1
View File
@@ -52,7 +52,8 @@ scroll-view::-webkit-scrollbar,
.taro-scroll::-webkit-scrollbar, .taro-scroll::-webkit-scrollbar,
.taro-scroll-view::-webkit-scrollbar, .taro-scroll-view::-webkit-scrollbar,
.taro-scroll-view__scroll-x::-webkit-scrollbar, .taro-scroll-view__scroll-x::-webkit-scrollbar,
.taro-scroll-view__scroll-y::-webkit-scrollbar { .taro-scroll-view__scroll-y::-webkit-scrollbar,
*::-webkit-scrollbar {
display: none; display: none;
width: 0 !important; width: 0 !important;
height: 0 !important; height: 0 !important;
+4 -7
View File
@@ -9,11 +9,8 @@
pnpm --filter @dukang/mini-user dev pnpm --filter @dukang/mini-user dev
# → http://localhost:5177 # → http://localhost:5177
# 微信小程序(本地联调,development # 微信小程序
pnpm --filter @dukang/mini-user dev:weapp pnpm --filter @dukang/mini-user dev:weapp
# 微信小程序(生产构建 → API: https://api.dukanghaoke.com
pnpm build:mini-user:weapp
# 用微信开发者工具打开 apps/mini-userminiprogramRoot = dist/ # 用微信开发者工具打开 apps/mini-userminiprogramRoot = dist/
``` ```
@@ -21,7 +18,7 @@ pnpm build:mini-user:weapp
| 环节 | 文件 | 说明 | | 环节 | 文件 | 说明 |
|------|------|------| |------|------|------|
| **编译注入** | `config/index.ts``defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3000``build:weapp``--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 | | **编译注入** | `config/index.ts``defineConstants.TARO_APP_API_ORIGIN` | 本地默认 `http://localhost:3000``NODE_ENV=production` 默认 `https://dkapi.runxian.top`;可用 `VITE_API_TARGET` 覆盖 |
| **运行时拼装** | `src/lib/api.ts``resolveApiBase()` | `{origin}/api/v1` | | **运行时拼装** | `src/lib/api.ts``resolveApiBase()` | `{origin}/api/v1` |
### 微信登录 `invalid code` ### 微信登录 `invalid code`
@@ -48,10 +45,10 @@ pnpm --filter @dukang/mini-user dev:weapp
**连远程 API** **连远程 API**
```bash ```bash
$env:VITE_API_TARGET="https://api.dukanghaoke.com"; pnpm --filter @dukang/mini-user dev $env:VITE_API_TARGET="https://dkapi.runxian.top"; pnpm --filter @dukang/mini-user dev
``` ```
并在 `api.dukanghaoke.com` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。 并在 `dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。
## 页面结构(18 页) ## 页面结构(18 页)
+2 -8
View File
@@ -2,16 +2,10 @@ import { createRequire } from 'node:module';
import path from 'node:path'; import path from 'node:path';
import { defineConfig } from '@tarojs/cli'; import { defineConfig } from '@tarojs/cli';
/** watch / --mode development 视为本地联调;其余(含 build:weapp)走生产 */
const isDevMode =
process.env.NODE_ENV === 'development' ||
process.argv.includes('--watch') ||
process.argv.includes('development');
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */ /** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
const API_ORIGIN = const API_ORIGIN =
process.env.VITE_API_TARGET ?? process.env.VITE_API_TARGET ??
(isDevMode ? 'http://localhost:3000' : 'https://api.dukanghaoke.com'); (process.env.NODE_ENV === 'production' ? 'https://dkapi.runxian.top' : 'http://localhost:3000');
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json')); const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
@@ -97,7 +91,7 @@ export default defineConfig(async () => ({
}, },
mini: { mini: {
/** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */ /** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
debugReact: isDevMode, debugReact: process.env.NODE_ENV !== 'production',
postcss: { postcss: {
pxtransform: { enable: true, config: {} }, pxtransform: { enable: true, config: {} },
cssModules: { enable: false }, cssModules: { enable: false },
+2 -2
View File
@@ -7,8 +7,8 @@
"dev": "node ../../scripts/dev-mini-user-h5.mjs", "dev": "node ../../scripts/dev-mini-user-h5.mjs",
"dev:vite": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --watch", "dev:vite": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --watch",
"dev:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --watch --mode development", "dev:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --watch --mode development",
"build": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --mode production", "build": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5",
"build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --mode production", "build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp",
"lint": "echo ok" "lint": "echo ok"
}, },
"dependencies": { "dependencies": {

Some files were not shown because too many files have changed in this diff Show More