31 Commits

Author SHA1 Message Date
jacy 2a208d5dea 小程序端界面修改
CI / verify (pull_request) Has been cancelled
2026-07-23 15:23:23 +08:00
jacy 82c447b193 工单和发票
酒厂银行账户
2026-07-23 14:51:20 +08:00
jacy 75765bf9d4 后端修改权限和折叠功能
CI / verify (pull_request) Has been cancelled
2026-07-23 14:38:52 +08:00
jacy 6479365d6a 小程序页面优化
CI / verify (pull_request) Has been cancelled
2026-07-23 14:03:22 +08:00
jacy d11757c854 fix(admin): improve promo layout and system settings save UX
CI / verify (pull_request) Has been cancelled
Fix mini home media form persistence; float save with unsaved leave prompt; promo detail QR on the right.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 13:54:10 +08:00
jacy 479de04f7c feat(mini-user): add configurable home banner and footer
CI / verify (pull_request) Has been cancelled
HQ system settings WeChat mini config with OSS swiper/footer uploads; client-config exposes miniHome.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 12:15:15 +08:00
jacy cebeefdb22 feat(trade): add on-site pickup order flow
CI / verify (pull_request) Has been cancelled
Product flag, ON_SITE_PICKUP delivery, mini confirm/receive pages; pay skips warehouse auto-advance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:17:59 +08:00
jacy e93e4a7721 推广码后端页面优化 2026-07-23 00:43:49 +08:00
jacy cd6b82abd8 小程序端核销权益输入框限制
CI / verify (pull_request) Has been cancelled
2026-07-23 00:32:09 +08:00
jacy 047ffd7b16 chore(admin): label promo QR as mini-program code
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 00:21:55 +08:00
jacy bca1d8afea feat(promo): generate WeChat mini-program codes on create
Use getwxacodeunlimit with activity id as scene, upload PNG to OSS qrcode/, and show in HQ admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 00:21:10 +08:00
jacy 0418370c31 小程序端核销权益券金额输入限制
CI / verify (pull_request) Has been cancelled
现场取货功能
2026-07-23 00:03:21 +08:00
jacy cd206d6baa feat(ops): show redeem records on benefit coupon detail
CI / verify (pull_request) Has been cancelled
Reuse the same coupon redeem trace as order detail (primary/secondary, allocations).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:52:48 +08:00
jacy aeb72154a7 开城合伙人页与城市详情内合伙人 Tab 已同步 2026-07-22 23:43:16 +08:00
jacy e0d3c840a2 feat(ops): show order redeem records including FIFO secondary coupons
CI / verify (pull_request) Has been cancelled
Persist redeem allocations and surface them on HQ order detail with backfill for history.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:38:51 +08:00
jacy f2303f7fb8 核销金额:最低 0.01 元;输入最多两位小数;低于该值会 toast 提示,无法生成核销码。
底部提示:左右留白 + 内边距,浅底圆角块,文案改为最低 0.01、余额上限、3 分钟到店出示。
新增地址:进入页会尝试定位,自动选中当前省市区(失败则保留默认郑州);编辑地址不重定位。
2026-07-22 23:11:34 +08:00
jacy fb530e2ff5 城市合伙人县区不做限制 2026-07-22 23:06:51 +08:00
jacy 8c4419e04e fix(admin): city-scoped district pick and clearer overlap errors
CI / verify (pull_request) Has been cancelled
Region partners only select districts under the chosen city; overlap errors name the occupying partner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:14:54 +08:00
jacy 85f2c156b0 fix(admin): prevent false partner commission cap errors
CI / verify (pull_request) Has been cancelled
Coerce city maxPartnerCommissionRate to Number so Decimal JSON strings do not break sum checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:03:32 +08:00
jacy addfcfb0b1 小程序页面优化(门店) 2026-07-22 17:56:25 +08:00
jacy b392c28787 feat(settlement): redesign factory/partner/store statement bills
CI / verify (pull_request) Has been cancelled
Add WineryBill/StoreBill tables, partner review-send-confirm flow, daily/monthly cron, and admin multi-select confirm with status filters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 17:25:49 +08:00
jacy b534a569f8 小程序页面优化
CI / verify (pull_request) Has been cancelled
2026-07-22 15:12:19 +08:00
jacy b0f565103f 用户端修改:付款按钮,显示单价,订单的图片 2026-07-22 13:02:52 +08:00
jacy 5e33dd9847 开店第三页银行卡号:只允许数字,最多 19 位;校验仍为 16–19 位。
门店详情 / 开店页:去掉顶栏返回与标题空白,内容上顶,顶部约 12px,与「门店管理」一致。
开店第一页底部:在「下一步」左侧加了「返回」(回门店列表);第 2、3 步仍是「上一步」。
2026-07-22 12:44:12 +08:00
jacy 9a550cbaaf feat(store): add two-level store categories for admin and partner open-store
CI / verify (pull_request) Has been cancelled
Admin CRUD under stores menu; partner picks leaf category on create.
Default sync only inserts missing rows and never updates existing ones.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 12:10:02 +08:00
jacy 823e439101 开门营业成功后提示「开店成功」
关闭门店改为自定义确认框,不再用 window.confirm,不会再带出页面域名
2026-07-22 11:50:05 +08:00
jacy 6c299f1a1d 管理端门店详情(审核抽屉)加上「审核材料」区 2026-07-22 11:45:36 +08:00
jacy 7c9827875f 合伙人端开店的流程里加入开店营业时间,放在开店流程的第一页。 2026-07-22 11:30:25 +08:00
jacy ae7c63c08d feat(h5): add pull-to-refresh on user partner and shop pages
CI / verify (pull_request) Has been cancelled
Share PullToRefresh for partner/shop H5; enable Taro pull-down on mini-user lists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 11:19:42 +08:00
jacy 270cebc79a fix(partner): move store-open phone verify to step 3
CI / verify (pull_request) Has been cancelled
Confirm phone and SMS after bank details, then submit directly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 10:22:53 +08:00
jacy ba21cc89c6 Merge commit '56dfffd12647e1128ac4360aa9a4044361f0f7ab' into dev_jacy
CI / verify (pull_request) Has been cancelled
2026-07-22 09:05:51 +08:00
147 changed files with 7911 additions and 2031 deletions
+2
View File
@@ -20,6 +20,7 @@ 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';
@@ -69,6 +70,7 @@ export default function App() {
<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 />} />
@@ -0,0 +1,42 @@
import { useMemo } from 'react';
import { Select } from 'antd';
import { getDistrictOptionsByCityCode } from '../lib/china-region';
type Props = {
cityCode?: string | null;
value?: string[];
onChange?: (value: string[]) => void;
placeholder?: string;
disabled?: boolean;
};
/** 已选开城城市后,仅多选该市下区县(不再选省/市) */
export default function CityDistrictMultiSelect({
cityCode,
value,
onChange,
placeholder,
disabled,
}: Props) {
const options = useMemo(() => getDistrictOptionsByCityCode(cityCode), [cityCode]);
const ready = Boolean(cityCode) && options.length > 0;
return (
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
value={value}
onChange={onChange}
disabled={disabled || !ready}
placeholder={
placeholder ??
(cityCode ? (ready ? '请选择区县(可多选)' : '该城市暂无区县数据') : '请先选择开城城市')
}
options={options}
style={{ width: '100%' }}
maxTagCount="responsive"
/>
);
}
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
Button, Button,
Cascader,
Checkbox, Checkbox,
Drawer, Drawer,
Form, Form,
@@ -27,8 +26,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 { CHINA_REGION_OPTIONS } from '../lib/china-region'; import { districtCodeLabel, formatDistrictLabels } 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,6 +37,7 @@ 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;
@@ -65,6 +66,7 @@ type PartnerDetail = PartnerRow & {
type Props = { type Props = {
cityId: string; cityId: string;
cityCode?: string;
maxPartnerCommissionRate?: number; maxPartnerCommissionRate?: number;
onChanged?: () => void; onChanged?: () => void;
}; };
@@ -92,14 +94,31 @@ function commissionSumError(
redeemPercent: number, redeemPercent: number,
maxRate: number, maxRate: number,
): string | null { ): string | null {
const sum = orderPercent / 100 + redeemPercent / 100; // API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
if (sum > maxRate + 1e-9) { const max = Number(maxRate);
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`; const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
if (sum > max + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
} }
return null; return null;
} }
export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0.05, onChanged }: Props) { function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
export default function CityPartnersPanel({
cityId,
cityCode,
maxPartnerCommissionRate = 0.05,
onChanged,
}: Props) {
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [subForm] = Form.useForm(); const [subForm] = Form.useForm();
@@ -153,29 +172,34 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
const v = await editForm.validateFields(); try {
const err = commissionSumError( const v = await editForm.validateFields();
Number(v.orderCommissionRate ?? 0), const err = commissionSumError(
Number(v.redeemCommissionRate ?? 0), Number(v.orderCommissionRate ?? 0),
maxPartnerCommissionRate, Number(v.redeemCommissionRate ?? 0),
); maxPartnerCommissionRate,
if (err) { );
message.error(err); if (err) {
return; message.error(err);
return;
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void loadPartners();
onChanged?.();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
} }
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void loadPartners();
onChanged?.();
} }
async function deleteSubAccount(subId: string) { async function deleteSubAccount(subId: string) {
@@ -187,6 +211,14 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
} }
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 },
{ {
@@ -261,8 +293,12 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
<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 name="districtCodes" label="区县"> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -316,30 +352,35 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
</Drawer> </Drawer>
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => { <Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields(); try {
const err = commissionSumError( const v = await createForm.validateFields();
Number(v.orderCommissionRate ?? 0), const err = commissionSumError(
Number(v.redeemCommissionRate ?? 3), Number(v.orderCommissionRate ?? 0),
maxPartnerCommissionRate, Number(v.redeemCommissionRate ?? 3),
); maxPartnerCommissionRate,
if (err) { );
message.error(err); if (err) {
return; message.error(err);
return;
}
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
cityId,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void loadPartners();
onChanged?.();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
} }
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
cityId,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void loadPartners();
onChanged?.();
}}> }}>
<Form form={createForm} layout="vertical"> <Form form={createForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
@@ -350,8 +391,13 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && ( {createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]}
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react';
import { Button, Space } from 'antd';
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import {
parseMiniHomeBanners,
serializeMiniHomeBanners,
} from '@dukang/shared-types';
import OssUpload from './OssUpload';
const MAX_BANNERS = 8;
/** 编辑态保留空位;下发/入库仍用 parseMiniHomeBanners 过滤空串 */
function parseBannersForEdit(raw?: string | null): string[] {
if (!raw?.trim()) return [];
try {
const parsed = JSON.parse(raw.trim()) as unknown;
if (!Array.isArray(parsed)) return parseMiniHomeBanners(raw);
return parsed
.filter((u): u is string => typeof u === 'string')
.map((u) => u.trim())
.slice(0, MAX_BANNERS);
} catch {
return parseMiniHomeBanners(raw);
}
}
/** Ant Form 控件:单图 URL 字符串(默认 OSS 路径 footer */
export function ConfigImageField({
value,
onChange,
bizType = 'footer',
}: {
value?: string;
onChange?: (url: string) => void;
bizType?: string;
}) {
return (
<OssUpload
bizType={bizType}
mediaType="IMAGE"
value={value ?? ''}
onChange={(url) => onChange?.(url ?? '')}
placeholder="上传或粘贴图片 URL"
/>
);
}
/** Ant Form 控件:多图 JSON 字符串(默认 OSS 路径 swiper */
export function ConfigImageListField({
value,
onChange,
bizType = 'swiper',
}: {
value?: string;
onChange?: (json: string) => void;
bizType?: string;
}) {
const [urls, setUrls] = useState<string[]>(() => parseBannersForEdit(value));
useEffect(() => {
setUrls(parseBannersForEdit(value));
}, [value]);
/** 本地可含空位;写入 Form 时去掉空串 */
function commit(next: string[]) {
const clipped = next.slice(0, MAX_BANNERS);
setUrls(clipped);
onChange?.(serializeMiniHomeBanners(clipped));
}
function updateAt(index: number, url: string) {
const next = [...urls];
next[index] = url;
commit(next);
}
function removeAt(index: number) {
commit(urls.filter((_, i) => i !== index));
}
function move(index: number, delta: number) {
const target = index + delta;
if (target < 0 || target >= urls.length) return;
const next = [...urls];
const tmp = next[index];
next[index] = next[target];
next[target] = tmp;
commit(next);
}
function add() {
if (urls.length >= MAX_BANNERS) return;
// 只加本地空位,避免 serialize 过滤空串导致「点击无反应」
setUrls((prev) => [...prev, '']);
}
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{urls.map((url, index) => (
<Space key={`banner-${index}`} align="start" style={{ width: '100%' }} wrap>
<div style={{ flex: 1, minWidth: 240 }}>
<OssUpload
bizType={bizType}
mediaType="IMAGE"
value={url}
onChange={(u) => updateAt(index, u)}
placeholder="上传或粘贴图片 URL"
/>
</div>
<Space>
<Button
type="text"
icon={<ArrowUpOutlined />}
disabled={index === 0}
onClick={() => move(index, -1)}
/>
<Button
type="text"
icon={<ArrowDownOutlined />}
disabled={index === urls.length - 1}
onClick={() => move(index, 1)}
/>
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeAt(index)} />
</Space>
</Space>
))}
<Button
type="dashed"
block
icon={<PlusOutlined />}
disabled={urls.length >= MAX_BANNERS}
onClick={add}
>
{urls.length}/{MAX_BANNERS}
</Button>
</Space>
);
}
+82 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, 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';
@@ -18,11 +18,14 @@ 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: '用户' },
@@ -44,6 +47,7 @@ const MENU_ITEMS: MenuProps['items'] = [
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: '门店资源' },
], ],
@@ -110,6 +114,75 @@ 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 {
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',
'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',
'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',
'/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();
@@ -137,6 +210,12 @@ 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
@@ -155,8 +234,8 @@ export default function AdminLayout() {
theme="dark" theme="dark"
mode="inline" mode="inline"
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'finance-group', 'benefit-group', 'logs-group', 'deliveries-group']} defaultOpenKeys={[]}
items={MENU_ITEMS} items={menuItems}
onClick={({ key }) => { onClick={({ key }) => {
if (key.startsWith('/')) navigate(key); if (key.startsWith('/')) navigate(key);
}} }}
+1
View File
@@ -7,6 +7,7 @@ export type HqProfile = {
name: string; name: string;
adminRole: string; adminRole: string;
status: string; status: string;
permissionKeys?: string[];
}; };
export function getToken() { export function getToken() {
+33
View File
@@ -79,6 +79,39 @@ 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 -3
View File
@@ -62,9 +62,11 @@ 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> = {
DRAFT: '草稿', PENDING_REVIEW: '待审核',
CONFIRMED: '确认', AWAITING_CONFIRM: '待合伙人确认',
PAID: '已结算', UNPAID: '未打款',
PAID: '已打款',
REJECTED: '已驳回',
}; };
export const MEDIA_TYPE_LABELS: Record<string, string> = { export const MEDIA_TYPE_LABELS: Record<string, string> = {
+15
View File
@@ -27,6 +27,10 @@ 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_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: '删除商品' },
@@ -34,11 +38,22 @@ 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: '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(
+237 -15
View File
@@ -33,6 +33,34 @@ 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>();
@@ -50,8 +78,25 @@ export default function BenefitCouponsPage() {
}, },
[filters], [filters],
); );
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<CouponDetail | 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 },
@@ -197,7 +242,7 @@ export default function BenefitCouponsPage() {
<Drawer <Drawer
title="权益券详情" title="权益券详情"
width={560} width={720}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={ extra={
@@ -218,25 +263,202 @@ 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(detail.couponNo)}</Descriptions.Item> <Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
<Descriptions.Item label="用户"> <Descriptions.Item label="核销额">
{String((detail.user as { userNo?: string } | undefined)?.userNo ?? '—')} ¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="手机号"> <Descriptions.Item label="结算额">
{String((detail.user as { phone?: string } | undefined)?.phone ?? '—')} ¥{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>
<Descriptions.Item label="关联订单"> <Descriptions.Item label="关联订单">
{String((detail.order as { orderNo?: string } | null | undefined)?.orderNo ?? '—')} {String(
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
'—',
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="总额">¥{String(detail.totalAmount)}</Descriptions.Item> {Array.isArray(redeemDetail.allocations) &&
<Descriptions.Item label="余额">¥{String(detail.balance)}</Descriptions.Item> (redeemDetail.allocations as unknown[]).length > 0 ? (
<Descriptions.Item label="状态"> <Descriptions.Item label="券分摊">
{COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)} {(
</Descriptions.Item> redeemDetail.allocations as Array<{
<Descriptions.Item label="来源">{String(detail.sourceProduct)}</Descriptions.Item> 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> </Descriptions>
)} ) : null}
</Drawer> </Drawer>
</div> </div>
); );
+1
View File
@@ -274,6 +274,7 @@ 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)
+110 -61
View File
@@ -3,7 +3,6 @@ import { Link } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
Cascader,
Checkbox, Checkbox,
Descriptions, Descriptions,
Drawer, Drawer,
@@ -32,9 +31,10 @@ 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 { CHINA_REGION_OPTIONS } from '../lib/china-region'; import { districtCodeLabel, formatDistrictLabels } 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,6 +48,7 @@ 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;
@@ -98,13 +99,25 @@ 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 {
const sum = orderPercent / 100 + redeemPercent / 100; // API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
if (sum > maxRate + 1e-9) { const max = Number(maxRate);
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`; const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
if (sum > max + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
} }
return null; 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();
@@ -135,6 +148,7 @@ 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}`);
@@ -145,11 +159,15 @@ 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(d.maxPartnerCommissionRate ?? 0.05); setMaxCommissionRate(Number(d.maxPartnerCommissionRate ?? 0.05));
editForm.setFieldsValue({ editForm.setFieldsValue({
name: d.name, name: d.name,
phone: d.phone, phone: d.phone,
@@ -171,28 +189,33 @@ export default function CityPartnersPage() {
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
const v = await editForm.validateFields(); try {
const err = commissionSumError( const v = await editForm.validateFields();
Number(v.orderCommissionRate ?? 0), const err = commissionSumError(
Number(v.redeemCommissionRate ?? 0), Number(v.orderCommissionRate ?? 0),
maxCommissionRate, Number(v.redeemCommissionRate ?? 0),
); maxCommissionRate,
if (err) { );
message.error(err); if (err) {
return; message.error(err);
return;
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
} }
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void reload();
} }
async function refreshPartnerContext(parentId: string) { async function refreshPartnerContext(parentId: string) {
@@ -229,13 +252,24 @@ 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(cityRes.maxPartnerCommissionRate ?? 0.05); setCreateMaxRate(Number(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 },
{ {
@@ -299,6 +333,7 @@ export default function CityPartnersPage() {
}); });
setCreateScopeType(CityPartnerScopeType.CITY_WIDE); setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateMaxRate(0.05); setCreateMaxRate(0.05);
setCreateCityCode(undefined);
setCreateOpen(true); setCreateOpen(true);
}} }}
> >
@@ -327,12 +362,6 @@ 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
@@ -343,6 +372,12 @@ 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">
@@ -447,8 +482,12 @@ 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 name="districtCodes" label="区县"> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={editCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -502,29 +541,34 @@ export default function CityPartnersPage() {
width={560} width={560}
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
onOk={async () => { onOk={async () => {
const v = await createForm.validateFields(); try {
const err = commissionSumError( const v = await createForm.validateFields();
Number(v.orderCommissionRate ?? 0), const err = commissionSumError(
Number(v.redeemCommissionRate ?? 3), Number(v.orderCommissionRate ?? 0),
createMaxRate, Number(v.redeemCommissionRate ?? 3),
); createMaxRate,
if (err) { );
message.error(err); if (err) {
return; message.error(err);
return;
}
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes:
createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
} }
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes:
createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void reload();
}} }}
> >
<Form form={createForm} layout="vertical"> <Form form={createForm} layout="vertical">
@@ -552,8 +596,13 @@ export default function CityPartnersPage() {
<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 name="districtCodes" label="区县" rules={[{ required: true }]}> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]}
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
+28 -9
View File
@@ -5,6 +5,7 @@ import {
Card, Card,
Checkbox, Checkbox,
Col, Col,
Divider,
Form, Form,
Row, Row,
Select, Select,
@@ -33,6 +34,8 @@ 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 PermissionChecklist({ function PermissionChecklist({
value, value,
onChange, onChange,
@@ -49,13 +52,24 @@ function PermissionChecklist({
disabled={disabled} disabled={disabled}
onChange={(checked) => onChange(checked as string[])} onChange={(checked) => onChange(checked as string[])}
> >
<Row gutter={[8, 8]}> {CATALOG_GROUPS.map((group) => {
{HQ_PERMISSION_CATALOG.map((item) => ( const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
<Col key={item.key} span={8}> return (
<Checkbox value={item.key}>{item.label}</Checkbox> <div key={group} style={{ marginBottom: 16 }}>
</Col> <Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
))} {group}
</Row> </Typography.Text>
<Row gutter={[8, 8]}>
{items.map((item) => (
<Col key={item.key} span={8}>
<Checkbox value={item.key}>{item.label}</Checkbox>
</Col>
))}
</Row>
<Divider style={{ margin: '12px 0 0' }} />
</div>
);
})}
</Checkbox.Group> </Checkbox.Group>
); );
} }
@@ -159,6 +173,7 @@ export default function HqPermissionsPage() {
<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,7 +246,7 @@ export default function HqPermissionsPage() {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key); const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
return ( return (
<Tag key={key} color="blue"> <Tag key={key} color="blue">
{item?.label || key} {item?.group ? `${item.group}·${item.label}` : item?.label || key}
</Tag> </Tag>
); );
})} })}
@@ -244,7 +259,11 @@ export default function HqPermissionsPage() {
<span></span> <span></span>
{previewEffectiveKeys.map((key) => { {previewEffectiveKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
return <Tag key={key}>{item?.label || key}</Tag>; return (
<Tag key={key}>
{item?.group ? `${item.group}·${item.label}` : item?.label || key}
</Tag>
);
})} })}
</Space> </Space>
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
+181 -4
View File
@@ -5,7 +5,9 @@ import {
Drawer, Drawer,
Form, Form,
Input, Input,
Modal,
Select, Select,
Space,
Table, Table,
Tag, Tag,
Typography, Typography,
@@ -47,6 +49,19 @@ type Row = {
remark?: 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() { export default function InvoicesPage() {
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, reload } = useAdminList<Row>(
@@ -61,6 +76,11 @@ export default function InvoicesPage() {
const [detail, setDetail] = useState<Row | null>(null); const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [uploading, setUploading] = 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) { async function openDetail(id: string) {
setDetail(await request(`/admin/invoices/${id}`)); setDetail(await request(`/admin/invoices/${id}`));
@@ -98,6 +118,36 @@ export default function InvoicesPage() {
setDrawerOpen(false); 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> = [ const columns: ColumnsType<Row> = [
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 }, { title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
{ title: '订单号', dataIndex: 'orderNo', width: 160 }, { title: '订单号', dataIndex: 'orderNo', width: 160 },
@@ -138,7 +188,30 @@ export default function InvoicesPage() {
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> <div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button
type="primary"
onClick={() => {
createForm.setFieldsValue({
titleType: 'PERSONAL',
invoiceKind: 'NORMAL',
});
setCreateOpen(true);
}}
>
</Button>
</div>
<Form <Form
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
@@ -187,7 +260,7 @@ export default function InvoicesPage() {
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={ extra={
detail?.status === 'PENDING' ? ( detail?.status === 'PENDING' ? (
<> <Space>
<Upload <Upload
accept="image/*,.pdf" accept="image/*,.pdf"
showUploadList={false} showUploadList={false}
@@ -196,14 +269,14 @@ export default function InvoicesPage() {
return false; return false;
}} }}
> >
<Button type="primary" loading={uploading} style={{ marginRight: 8 }}> <Button type="primary" loading={uploading}>
</Button> </Button>
</Upload> </Upload>
<Button danger onClick={() => void reject()}> <Button danger onClick={() => void reject()}>
</Button> </Button>
</> </Space>
) : null ) : null
} }
> >
@@ -242,6 +315,110 @@ export default function InvoicesPage() {
</Descriptions> </Descriptions>
)} )}
</Drawer> </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> </div>
); );
} }
+223 -1
View File
@@ -55,6 +55,28 @@ 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;
@@ -81,6 +103,8 @@ 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;
@@ -135,6 +159,9 @@ export default function OrdersPage() {
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);
@@ -152,6 +179,20 @@ export default function OrdersPage() {
[data?.items, selectedRowKeys], [data?.items, selectedRowKeys],
); );
async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true);
setRedeemDrawerOpen(true);
try {
const res = await request<Record<string, unknown>>(`/admin/redeem-records/${redeemId}`);
setRedeemDetail(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载核销详情失败');
setRedeemDrawerOpen(false);
} finally {
setRedeemDetailLoading(false);
}
}
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -468,7 +509,7 @@ export default function OrdersPage() {
<Drawer <Drawer
title="订单详情" title="订单详情"
width={640} width={720}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={detail && ( extra={detail && (
@@ -570,6 +611,98 @@ 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>
@@ -618,6 +751,95 @@ 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}
+103 -35
View File
@@ -38,15 +38,17 @@ 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> = {
DRAFT: '待合伙人确认', PENDING_REVIEW: '待审核',
CONFIRMED: '待打款审核', AWAITING_CONFIRM: '待合伙人确认',
UNPAID: '未打款',
PAID: '已打款', PAID: '已打款',
REJECTED: '已驳回', REJECTED: '已驳回',
}; };
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
DRAFT: 'default', PENDING_REVIEW: 'gold',
CONFIRMED: 'orange', AWAITING_CONFIRM: 'blue',
UNPAID: 'red',
PAID: 'green', PAID: 'green',
REJECTED: 'red', REJECTED: 'red',
}; };
@@ -74,6 +76,7 @@ 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}`)
@@ -81,10 +84,10 @@ export default function PartnerBillsPage() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
async function generateBill(values: { partnerId?: string; month: Dayjs; all?: boolean }) { async function generateBill(values: { partnerId?: string; month: Dayjs }) {
const year = values.month.year(); const year = values.month.year();
const month = values.month.month() + 1; const month = values.month.month() + 1;
if (values.all || !values.partnerId) { if (!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 }) },
@@ -95,31 +98,71 @@ 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();
} }
async function confirmBill(id: string) { function sendBills(ids: string[]) {
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' }); Modal.confirm({
message.success('已代确认,进入待打款审核'); title: '发送给合伙人?',
reload(); content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`,
okText: '确认发送',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/partner-bills/${ids[0]}/send`, { method: 'POST' });
} else {
await request('/admin/partner-bills/batch-send', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已发送');
setSelectedKeys([]);
reload();
},
});
} }
async function markPaid(id: string) { function markPaid(ids: string[], amountHint?: number) {
await request(`/admin/partner-bills/${id}/mark-paid`, { Modal.confirm({
method: 'POST', title: '确认打款?',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }), content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}`,
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, {
method: 'POST',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
});
} else {
await request('/admin/partner-bills/batch-mark-paid', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已标记打款');
setSelectedKeys([]);
reload();
},
}); });
message.success('已通过并标记打款');
reload();
} }
function openReject(row: Row) { function openReject(row: Row) {
setRejectTarget(row); Modal.confirm({
rejectForm.resetFields(); title: '驳回该账单?',
setRejectOpen(true); content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。',
okText: '继续填写理由',
cancelText: '取消',
onOk: () => {
setRejectTarget(row);
rejectForm.resetFields();
setRejectOpen(true);
},
});
} }
async function submitReject(values: { reason: string }) { async function submitReject(values: { reason: string }) {
@@ -130,7 +173,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();
@@ -158,6 +201,10 @@ 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 },
@@ -194,7 +241,7 @@ export default function PartnerBillsPage() {
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 120, width: 130,
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>
@@ -212,21 +259,26 @@ export default function PartnerBillsPage() {
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size={0} wrap> <Space size={0} wrap>
{row.status === 'DRAFT' && ( {(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}> <Button type="link" size="small" onClick={() => sendBills([row.id])}>
</Button> </Button>
)} )}
{row.status === 'CONFIRMED' && ( {row.status === 'UNPAID' && (
<> <>
<Button type="link" size="small" onClick={() => void markPaid(row.id)}> <Button type="link" size="small" onClick={() => markPaid([row.id], Number(row.totalAmount))}>
</Button> </Button>
<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>
), ),
}, },
@@ -239,7 +291,7 @@ export default function PartnerBillsPage() {
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
T+30 1 8:00
</Typography.Text> </Typography.Text>
</Space> </Space>
@@ -287,7 +339,7 @@ export default function PartnerBillsPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select <Select
allowClear allowClear
style={{ width: 140 }} style={{ width: 150 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
@@ -317,6 +369,20 @@ 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
@@ -325,6 +391,10 @@ 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,
@@ -338,7 +408,7 @@ export default function PartnerBillsPage() {
}} }}
/> />
<Modal title="生成合伙人账单T+30" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}> <Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
<Form <Form
form={genForm} form={genForm}
layout="vertical" layout="vertical"
@@ -361,7 +431,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>
@@ -378,8 +448,6 @@ 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
@@ -390,7 +458,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}>
+77 -33
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
Button, Button,
Cascader,
Drawer, Drawer,
Form, Form,
Input, Input,
@@ -24,9 +23,10 @@ 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 { CHINA_REGION_OPTIONS } from '../lib/china-region'; import { districtCodeLabel, formatDistrictLabels } 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,6 +37,7 @@ 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;
@@ -102,6 +103,18 @@ 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);
@@ -142,22 +155,35 @@ export default function PartnersPage() {
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
const v = await editForm.validateFields(); try {
const body = { const v = await editForm.validateFields();
...v, const body = {
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, ...v,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
}; districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) }); };
message.success('已保存'); await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
setDrawerOpen(false); message.success('已保存');
void reload(); setDrawerOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
} }
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 100 }, { title: '城市', dataIndex: 'cityName', width: 100 },
{
title: '区县',
dataIndex: 'districtCodes',
width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '主账号', dataIndex: 'phone', width: 130 }, { title: '主账号', dataIndex: 'phone', width: 130 },
{ {
title: '管辖', title: '管辖',
@@ -231,8 +257,12 @@ export default function PartnersPage() {
<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 name="districtCodes" label="区县"> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={editCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -286,27 +316,36 @@ export default function PartnersPage() {
width={560} width={560}
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
onOk={async () => { onOk={async () => {
const v = await createForm.validateFields(); try {
await request('/admin/partners', { const v = await createForm.validateFields();
method: 'POST', await request('/admin/partners', {
body: JSON.stringify({ method: 'POST',
...v, body: JSON.stringify({
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, ...v,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
}), districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}); }),
message.success('已创建'); });
setCreateOpen(false); message.success('已创建');
createForm.resetFields(); setCreateOpen(false);
void reload(); createForm.resetFields();
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}} }}
> >
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}> <Form 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) => { setCreateCityId(id); void loadWarehouses(id); }} onChange={(id) => {
setCreateCityId(id);
createForm.setFieldsValue({ districtCodes: undefined });
void loadWarehouses(id);
}}
/> />
</Form.Item> </Form.Item>
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
@@ -317,8 +356,13 @@ export default function PartnersPage() {
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && ( {createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]}
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
+14 -2
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,
Table, Tabs, Tag, Typography, message, Switch, 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,6 +31,7 @@ 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[];
@@ -49,6 +50,7 @@ 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[];
@@ -100,6 +102,7 @@ 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,
@@ -217,6 +220,9 @@ 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>
@@ -266,6 +272,12 @@ export default function ProductsPage() {
{ 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) => <Tag>{PRODUCT_STATUS_LABELS[s] || s}</Tag> }, { title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{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 },
{ {
@@ -357,7 +369,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, aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, allowOnSitePickup: false,
carouselUrls: [''], detailImageUrls: [''], carouselUrls: [''], detailImageUrls: [''],
features: [{ icon: 'water_drop', title: '', desc: '' }], features: [{ icon: 'water_drop', title: '', desc: '' }],
}}> }}>
+244 -236
View File
@@ -1,236 +1,244 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom'; import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
import { } from 'antd';
import type { ColumnsType } from 'antd/es/table';
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, import {
PROMO_CODE_SCENE_LABELS,
} from 'antd'; PROMO_CODE_STATUS_LABELS,
promoConversion,
import type { ColumnsType } from 'antd/es/table'; type PromoCodeItem,
type PromoCodeScene,
import { } from '@dukang/shared-types';
import { request } from '../lib/api';
PROMO_CODE_SCENE_LABELS, import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
PROMO_CODE_STATUS_LABELS,
type Row = PromoCodeItem;
promoConversion,
type SceneOption = { value: PromoCodeScene; label: string };
type PromoCodeItem,
async function downloadQrcode(url: string, filename: string) {
type PromoCodeScene, try {
const res = await fetch(url);
} from '@dukang/shared-types'; const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
import { request } from '../lib/api'; const a = document.createElement('a');
a.href = objectUrl;
import { fmtTime } from '../lib/constants'; a.download = filename;
a.click();
import { useAdminList } from '../lib/useAdminList'; URL.revokeObjectURL(objectUrl);
} catch {
window.open(url, '_blank');
}
type Row = PromoCodeItem; }
export default function PromoCodesPage() {
const navigate = useNavigate();
type SceneOption = { value: PromoCodeScene; label: string }; const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [scenes, setScenes] = useState<SceneOption[]>(
async function downloadQrcode(url: string, filename: string) { Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
value: value as PromoCodeScene,
try { label,
})),
const res = await fetch(url); );
const [createOpen, setCreateOpen] = useState(false);
const blob = await res.blob(); const [creating, setCreating] = useState(false);
const objectUrl = URL.createObjectURL(blob); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/promo-codes',
const a = document.createElement('a'); () => {
const qs = new URLSearchParams();
a.href = objectUrl; if (filters.name) qs.set('name', filters.name);
if (filters.code) qs.set('code', filters.code);
a.download = filename; if (filters.status) qs.set('status', filters.status);
if (filters.scene) qs.set('scene', filters.scene);
a.click(); return qs;
},
URL.revokeObjectURL(objectUrl); [filters],
);
} catch {
async function loadScenes() {
window.open(url, '_blank'); try {
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
} if (list.length) setScenes(list);
} catch {
} /* 使用本地默认场景 */
}
}
export default function PromoCodesPage() { useEffect(() => {
void loadScenes();
const navigate = useNavigate(); }, []);
const [filterForm] = Form.useForm(); const columns: ColumnsType<Row> = [
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
const [createForm] = Form.useForm(); { title: '码值', dataIndex: 'code', width: 110 },
{
const [filters, setFilters] = useState<Record<string, string>>({}); title: '场景',
dataIndex: 'scene',
const [scenes, setScenes] = useState<SceneOption[]>( width: 110,
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ },
{
value: value as PromoCodeScene, title: '状态',
dataIndex: 'status',
label, width: 90,
render: (s) => (
})), <Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
); </Tag>
),
const [createOpen, setCreateOpen] = useState(false); },
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
const [creating, setCreating] = useState(false); { title: '订单', dataIndex: 'orderCount', width: 70 },
{
title: '转化率',
width: 90,
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( render: (_, row) => promoConversion(row.scanCount, row.orderCount),
},
'/admin/promo-codes', {
title: '渠道负责人',
() => { width: 120,
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
const qs = new URLSearchParams(); },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
if (filters.name) qs.set('name', filters.name); {
title: '操作',
if (filters.code) qs.set('code', filters.code); width: 220,
fixed: 'right',
if (filters.status) qs.set('status', filters.status); render: (_, row) => (
<Space size="small" wrap>
if (filters.scene) qs.set('scene', filters.scene); <Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
return qs; </Button>
<Button
}, type="link"
size="small"
[filters], disabled={!row.qrcodeUrl}
onClick={() => {
); if (!row.qrcodeUrl) return;
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
}}
>
async function loadScenes() {
</Button>
try { <Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
const list = await request<SceneOption[]>('/admin/promo-codes/scenes'); </Button>
</Space>
if (list.length) setScenes(list); ),
},
} catch { ];
/* 使用本地默认场景 */ async function handleCreate(values: Record<string, string>) {
setCreating(true);
} try {
const created = await request<PromoCodeItem>('/admin/promo-codes', {
} method: 'POST',
body: JSON.stringify({
name: values.name,
code: values.code?.trim() || undefined,
useEffect(() => { scene: values.scene,
ownerUserId: values.ownerUserId?.trim() || undefined,
void loadScenes(); remark: values.remark?.trim() || undefined,
page: values.page?.trim() || undefined,
}, []); }),
});
message.success('推广码已创建');
setCreateOpen(false);
const columns: ColumnsType<Row> = [ createForm.resetFields();
void reload();
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true }, navigate(`/promo-codes/${created.id}`);
} catch (e) {
{ title: '码值', dataIndex: 'code', width: 110 }, message.error(e instanceof Error ? e.message : '创建失败');
} finally {
{ setCreating(false);
}
title: '场景', }
dataIndex: 'scene', return (
<div>
width: 110, <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>广</Typography.Title>
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s, <Button type="primary" onClick={() => setCreateOpen(true)}>广</Button>
</div>
},
<Form
{ form={filterForm}
layout="inline"
title: '状态', style={{ marginBottom: 16 }}
onFinish={(v) => { setFilters(v); setPage(1); }}
dataIndex: 'status', >
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
width: 90, <Form.Item name="code" label="码值"><Input allowClear /></Form.Item>
<Form.Item name="scene" label="场景">
render: (s) => ( <Select allowClear style={{ width: 130 }} options={scenes} />
</Form.Item>
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}> <Form.Item name="status" label="状态">
<Select
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s} allowClear
style={{ width: 100 }}
</Tag> options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
), </Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
}, </Form>
{ title: '扫码', dataIndex: 'scanCount', width: 70 }, <Table
rowKey="id"
{ title: '订单', dataIndex: 'orderCount', width: 70 }, className="admin-table-nowrap"
loading={loading}
{ columns={columns}
dataSource={data?.items ?? []}
title: '转化率', scroll={{ x: 1200 }}
pagination={{
width: 90, current: page,
pageSize,
render: (_, row) => promoConversion(row.scanCount, row.orderCount), total: data?.total ?? 0,
showSizeChanger: true,
}, onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
{ />
title: '渠道负责人', <Modal
title="创建推广码"
width: 120, open={createOpen}
onCancel={() => setCreateOpen(false)}
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—', footer={null}
destroyOnClose
}, >
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ scene: 'ONLINE_LINK' }}>
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, <Form.Item name="name" label="推广码名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:郑州品鉴会、门店地推" />
{ </Form.Item>
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
title: '操作', <Select options={scenes} />
</Form.Item>
width: 220, <Form.Item name="code" label="自定义码值(选填)">
<Input placeholder="留空自动生成,如 DKDEMO1" />
fixed: 'right', </Form.Item>
<Form.Item
render: (_, row) => ( name="page"
label="小程序落地页(选填)"
<Space size="small" wrap> extra="如 pages/home/index;留空则使用服务端环境变量 WX_MINI_PROMO_PAGE"
>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}> <Input placeholder="pages/home/index" />
</Form.Item>
<Form.Item name="ownerUserId" label="关联用户 ID(选填)">
<Input placeholder="渠道负责人,填写用户数据库 ID" />
</Button> </Form.Item>
<Form.Item name="remark" label="备注">
<Button <Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={creating} block>
</Button>
</Form>
</Modal>
</div>
);
}
+134 -92
View File
@@ -6,6 +6,7 @@ import {
Descriptions, Descriptions,
Drawer, Drawer,
Form, Form,
Modal,
Select, Select,
Space, Space,
Statistic, Statistic,
@@ -23,26 +24,26 @@ import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
id: string; id: string;
billNo: string;
billDate: string;
redeemCount: number;
redeemAmount: number; redeemAmount: number;
payoutAmount: number;
settlementRate: number; settlementRate: number;
payoutAmount: number;
status: string; status: string;
expectedPayAt: string; paidAt?: string | null;
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 PAYOUT_STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
PENDING: '打款', UNPAID: '打款',
PAID: '已打款', PAID: '已打款',
}; };
const PAYOUT_STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
PENDING: 'orange', UNPAID: 'red',
PAID: 'green', PAID: 'green',
}; };
@@ -51,7 +52,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-payouts', '/admin/store-bills',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', filters.status);
@@ -65,6 +66,8 @@ export default function StoreBillsPage() {
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [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}`)
@@ -72,13 +75,37 @@ export default function StoreBillsPage() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
async function confirmPayout(id: string) { function confirmPay(ids: string[], amountHint?: number) {
await request(`/admin/store-payouts/${id}/confirm`, { Modal.confirm({
method: 'POST', title: '确认打款?',
body: JSON.stringify({ remark: '财务确认打款' }), content: `将确认 ${ids.length} 笔门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
} else {
setBatchLoading(true);
try {
await request('/admin/store-bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
} finally {
setBatchLoading(false);
}
}
message.success('已确认打款');
setSelectedKeys([]);
reload();
},
}); });
message.success('已确认打款'); }
reload();
async function openDetail(id: string) {
const d = await request<Record<string, unknown>>(`/admin/store-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
} }
async function exportExcel() { async function exportExcel() {
@@ -89,10 +116,8 @@ 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 }>( const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
`/admin/store-payouts/export?${qs}`, downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
);
downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
message.success(`已导出 ${result.count}`); message.success(`已导出 ${result.count}`);
} finally { } finally {
setExporting(false); setExporting(false);
@@ -100,62 +125,56 @@ 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: (_, r) => render: (v) => String(v || '').slice(0, 10),
(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: ['redeemRecord', 'redeemNo'], dataIndex: 'redeemAmount',
width: 160, width: 110,
ellipsis: true, render: (v) => `¥${Number(v).toFixed(2)}`,
render: (v) => v || '—',
}, },
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
{ {
title: '核销比例', title: '结算比例',
dataIndex: 'settlementRate', dataIndex: 'settlementRate',
width: 90, width: 90,
render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'), render: (v) => `${Math.round(Number(v) * 100)}%`,
}, },
{ {
title: '应付门店', title: '应付金额',
dataIndex: 'payoutAmount', dataIndex: 'payoutAmount',
width: 100, width: 110,
render: (v) => `¥${v}`, render: (v) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
title: '打款状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 100, width: 90,
render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>, render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
}, },
{ title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 140, width: 160,
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Space size={0}>
<Button <Button type="link" size="small" onClick={() => void openDetail(row.id)}>
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/store-payouts/${row.id}`));
setDrawerOpen(true);
}}
>
</Button> </Button>
{row.status === 'PENDING' && ( {row.status === 'UNPAID' && (
<Button type="link" size="small" onClick={() => void confirmPayout(row.id)}> <Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.payoutAmount))}>
</Button> </Button>
)} )}
@@ -168,19 +187,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">
T+1 = × 8:00 绿
</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 ?? summary.totalAmount} prefix="¥" precision={2} /> <Statistic title="应付合计" value={summary.payoutAmount ?? 0} prefix="¥" precision={2} />
</Space> </Space>
</Card> </Card>
)} )}
@@ -189,16 +208,12 @@ export default function StoreBillsPage() {
form={form} form={form}
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
onFinish={(v: { onFinish={(v: { storeId?: string; status?: string; range?: [Dayjs, Dayjs] }) => {
storeId?: string;
status?: string;
dateRange?: [Dayjs, Dayjs];
}) => {
setFilters({ setFilters({
storeId: v.storeId || '', storeId: v.storeId || '',
status: v.status || '', status: v.status || '',
dateFrom: v.dateRange?.[0]?.format('YYYY-MM-DD') || '', dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
dateTo: v.dateRange?.[1]?.format('YYYY-MM-DD') || '', dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
}); });
setPage(1); setPage(1);
}} }}
@@ -210,21 +225,17 @@ export default function StoreBillsPage() {
placeholder="全部门店" placeholder="全部门店"
style={{ width: 200 }} style={{ width: 200 }}
optionFilterProp="label" optionFilterProp="label"
options={stores.map((s) => ({ options={stores.map((s) => ({ value: s.id, label: s.name || s.phone || s.id }))}
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 }}
placeholder="全部" options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="dateRange" label="核销日期"> <Form.Item name="range" label="账单日">
<DatePicker.RangePicker /> <DatePicker.RangePicker />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
@@ -248,6 +259,16 @@ 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
@@ -256,7 +277,12 @@ export default function StoreBillsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1300 }} rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
}}
scroll={{ x: 1200 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -269,28 +295,44 @@ export default function StoreBillsPage() {
}} }}
/> />
<Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}> <Drawer title="门店对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
{detail && ( {detail && (
<Descriptions column={1} bordered size="small"> <>
<Descriptions.Item label="门店"> <Descriptions column={1} size="small" bordered>
{String((detail.store as { name?: string })?.name ?? '—')} <Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
</Descriptions.Item> <Descriptions.Item label="账单日">{String(detail.billDate || '').slice(0, 10)}</Descriptions.Item>
<Descriptions.Item label="核销单号"> <Descriptions.Item label="应付">¥{Number(detail.payoutAmount ?? 0).toFixed(2)}</Descriptions.Item>
{String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')} <Descriptions.Item label="状态">{STATUS_LABELS[String(detail.status)] || String(detail.status)}</Descriptions.Item>
</Descriptions.Item> <Descriptions.Item label="打款时间">
<Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item> {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
<Descriptions.Item label="应付金额">¥{String(detail.payoutAmount)}</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="结算比例"> </Descriptions>
{detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'} <Typography.Title level={5} style={{ marginTop: 16 }}>
</Descriptions.Item>
<Descriptions.Item label="状态"> </Typography.Title>
{PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)} <Table
</Descriptions.Item> size="small"
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item> rowKey="id"
<Descriptions.Item label="实际打款"> pagination={false}
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} dataSource={(detail.payouts as Array<Record<string, unknown>>) || []}
</Descriptions.Item> columns={[
</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>
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from 'react';
import {
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
type CategoryNode = {
id: string;
code: string;
name: string;
sort: number;
parentId: string | null;
status: string;
children?: CategoryNode[];
};
type FlatRow = CategoryNode & { level: 1 | 2; parentName?: string };
function flattenTree(tree: CategoryNode[]): FlatRow[] {
const rows: FlatRow[] = [];
for (const root of tree) {
rows.push({ ...root, level: 1, children: undefined });
for (const child of root.children ?? []) {
rows.push({
...child,
level: 2,
parentName: root.name,
children: undefined,
});
}
}
return rows;
}
export default function StoreCategoriesPage() {
const [tree, setTree] = useState<CategoryNode[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<FlatRow | null>(null);
const [form] = Form.useForm();
const rows = useMemo(() => flattenTree(tree), [tree]);
const rootOptions = useMemo(
() => tree.filter((n) => n.status === 'ACTIVE').map((n) => ({ value: n.id, label: n.name })),
[tree],
);
async function reload() {
setLoading(true);
try {
const data = await request<CategoryNode[]>('/admin/store-categories');
setTree(Array.isArray(data) ? data : []);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void reload();
}, []);
function openCreate(parentId?: string) {
setEditing(null);
form.setFieldsValue({
code: '',
name: '',
sort: 0,
parentId: parentId || undefined,
status: 'ACTIVE',
});
setModalOpen(true);
}
function openEdit(row: FlatRow) {
setEditing(row);
form.setFieldsValue({
code: row.code,
name: row.name,
sort: row.sort,
parentId: row.parentId || undefined,
status: row.status,
});
setModalOpen(true);
}
async function submit() {
const values = await form.validateFields();
const payload = {
code: String(values.code).trim().toUpperCase(),
name: String(values.name).trim(),
sort: Number(values.sort ?? 0),
parentId: values.parentId || null,
status: values.status || 'ACTIVE',
};
if (editing) {
await request(`/admin/store-categories/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
message.success('已保存');
} else {
await request('/admin/store-categories', {
method: 'POST',
body: JSON.stringify(payload),
});
message.success('已创建');
}
setModalOpen(false);
void reload();
}
const columns: ColumnsType<FlatRow> = [
{
title: '层级',
dataIndex: 'level',
width: 80,
render: (level) => (level === 1 ? <Tag color="blue"></Tag> : <Tag></Tag>),
},
{
title: '名称',
dataIndex: 'name',
render: (name, row) => (
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
{name}
</span>
),
},
{ title: '编码', dataIndex: 'code', width: 140 },
{ title: '排序', dataIndex: 'sort', width: 80 },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
),
},
{
title: '操作',
width: 220,
render: (_, row) => (
<Space wrap>
<Button type="link" size="small" onClick={() => openEdit(row)}></Button>
{row.level === 1 ? (
<Button type="link" size="small" onClick={() => openCreate(row.id)}></Button>
) : null}
<Popconfirm
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
onConfirm={async () => {
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
message.success('已处理');
void reload();
}}
>
<Button type="link" size="small" danger></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">/宿/ </Typography.Text>
</Space>
<Space>
<Button
onClick={async () => {
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
message.success('已同步默认分类');
void reload();
}}
>
</Button>
<Button type="primary" onClick={() => openCreate()}></Button>
</Space>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={rows}
pagination={false}
className="admin-table-nowrap"
/>
<Modal
title={editing ? '编辑分类' : '新增分类'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void submit()}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请填写编码' }]}>
<Input placeholder="如 DINING / HOTPOT" disabled={!!editing} />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="分类名称" />
</Form.Item>
<Form.Item name="parentId" label="上级分类(空=一级)">
<Select
allowClear
placeholder="不选则为一级分类"
options={rootOptions.filter((o) => o.value !== editing?.id)}
disabled={editing?.level === 1 && (tree.find((t) => t.id === editing.id)?.children?.length ?? 0) > 0}
/>
</Form.Item>
<Form.Item name="sort" label="排序" initialValue={0}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="ACTIVE">
<Select
options={[
{ value: 'ACTIVE', label: '启用' },
{ value: 'DISABLED', label: '停用' },
]}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}
+213 -7
View File
@@ -19,8 +19,15 @@ import {
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { FilePdfOutlined, LinkOutlined } from '@ant-design/icons';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, STORE_AUDIT_STATUS_LABELS, STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; import {
ADMIN_OPTIONS_PAGE_SIZE,
RESOURCE_BIZ_TYPE_LABELS,
STORE_AUDIT_STATUS_LABELS,
STORE_STATUS_LABELS,
fmtTime,
} from '../lib/constants';
import { import {
validateStoreCreateStep1, validateStoreCreateStep1,
validateStoreCreateStep3, validateStoreCreateStep3,
@@ -37,6 +44,209 @@ const CREATE_STEPS = [
{ title: '结算资质' }, { title: '结算资质' },
]; ];
type StoreMediaItem = {
id?: string;
bizType?: string;
mediaType?: string;
url?: string | null;
};
function isImageMedia(url: string, mediaType?: string) {
if (mediaType === 'IMAGE') return true;
if (mediaType === 'VIDEO' || mediaType === 'FILE') {
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
}
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
}
function isPdfUrl(url: string) {
return /\.pdf(\?|#|$)/i.test(url);
}
function collectMediaUrls(detail: Record<string, unknown>) {
const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : [];
const byType = (bizType: string) =>
media
.filter((item) => String(item.bizType || '').toUpperCase() === bizType)
.map((item) => ({
id: String(item.id || item.url || ''),
url: String(item.url || '').trim(),
mediaType: item.mediaType ? String(item.mediaType) : undefined,
}))
.filter((item) => item.url);
const covers = byType('COVER');
const coverUrl = detail.coverUrl ? String(detail.coverUrl).trim() : '';
if (coverUrl && !covers.some((item) => item.url === coverUrl)) {
covers.unshift({ id: 'cover', url: coverUrl, mediaType: 'IMAGE' });
}
return {
covers,
envs: byType('ENV'),
contracts: byType('CONTRACT'),
};
}
function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> }) {
const { covers, envs, contracts } = collectMediaUrls(detail);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const gallery = [...covers, ...envs].filter((item) => isImageMedia(item.url, item.mediaType));
if (covers.length === 0 && envs.length === 0 && contracts.length === 0) {
return (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="暂无门头照 / 环境照 / 签约合同,请谨慎审核"
/>
);
}
return (
<div style={{ marginBottom: 16 }}>
<Typography.Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
</Typography.Title>
{(covers.length > 0 || envs.length > 0) && (
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
/
</Typography.Text>
<Image.PreviewGroup>
<Space wrap size={12}>
{gallery.map((item) => (
<div key={item.id} style={{ textAlign: 'center' }}>
<Image
src={item.url}
width={112}
height={84}
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
/>
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 4 }}>
{covers.some((c) => c.id === item.id) ? '门头照' : '环境照'}
</div>
</div>
))}
</Space>
</Image.PreviewGroup>
</div>
)}
{contracts.length > 0 && (
<div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
</Typography.Text>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{contracts.map((item, index) => {
const imageLike = isImageMedia(item.url, item.mediaType);
const pdf = isPdfUrl(item.url);
return (
<div
key={item.id || `${item.url}-${index}`}
style={{
display: 'flex',
gap: 12,
alignItems: 'center',
padding: 12,
border: '1px solid #f0f0f0',
borderRadius: 8,
background: '#fafafa',
}}
>
{imageLike ? (
<Image.PreviewGroup>
<Image
src={item.url}
width={96}
height={72}
style={{ objectFit: 'cover', borderRadius: 6 }}
/>
</Image.PreviewGroup>
) : (
<div
style={{
width: 96,
height: 72,
borderRadius: 6,
background: '#fff',
border: '1px dashed #d9d9d9',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#cf1322',
}}
>
<FilePdfOutlined style={{ fontSize: 28 }} />
</div>
)}
<Space direction="vertical" size={4} style={{ flex: 1, minWidth: 0 }}>
<Typography.Text strong>
{RESOURCE_BIZ_TYPE_LABELS.CONTRACT || '合同'}
{contracts.length > 1 ? ` ${index + 1}` : ''}
</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ maxWidth: '100%' }}>
{item.url}
</Typography.Text>
<Space wrap>
{imageLike ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
{pdf ? (
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setPdfUrl(item.url)}>
PDF
</Button>
) : null}
<Button
type="link"
size="small"
icon={<LinkOutlined />}
style={{ padding: 0 }}
href={item.url}
target="_blank"
rel="noreferrer"
>
</Button>
</Space>
</Space>
</div>
);
})}
</Space>
</div>
)}
<Modal
title="合同预览"
open={!!pdfUrl}
onCancel={() => setPdfUrl(null)}
width={900}
footer={[
<Button key="open" href={pdfUrl || undefined} target="_blank" rel="noreferrer">
</Button>,
<Button key="close" type="primary" onClick={() => setPdfUrl(null)}>
</Button>,
]}
destroyOnClose
>
{pdfUrl ? (
<iframe
title="合同 PDF 预览"
src={pdfUrl}
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 8 }}
/>
) : null}
</Modal>
</div>
);
}
type StoreRow = { type StoreRow = {
id: string; id: string;
name: string; name: string;
@@ -307,7 +517,7 @@ export default function StoresPage() {
</Form> </Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }} <Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="门店详情" width={600} open={drawerOpen} onClose={() => setDrawerOpen(false)} <Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && ( extra={detail && (
<Space wrap> <Space wrap>
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? ( {String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
@@ -367,6 +577,7 @@ export default function StoresPage() {
)}> )}>
{detail && ( {detail && (
<> <>
<StoreAuditMediaSection detail={detail} />
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}> <Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item> <Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="审核状态"> <Descriptions.Item label="审核状态">
@@ -390,11 +601,6 @@ export default function StoresPage() {
</Button> </Button>
</Descriptions.Item> </Descriptions.Item>
{detail.coverUrl ? (
<Descriptions.Item label="封面">
<Image src={String(detail.coverUrl)} width={120} />
</Descriptions.Item>
) : null}
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? ( {Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
<Descriptions.Item label="审核记录"> <Descriptions.Item label="审核记录">
<Space direction="vertical" size={4} style={{ width: '100%' }}> <Space direction="vertical" size={4} style={{ width: '100%' }}>
+122 -297
View File
@@ -1,283 +1,182 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { 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 { request } from '../lib/api'; import { ConfigImageField, ConfigImageListField } from '../components/ConfigMediaFields';
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 }
@@ -285,300 +184,226 @@ 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(() => { useEffect(() => {
void load(); void load();
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []); }, []);
useEffect(() => { 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 = await form.validateFields(); const values = form.getFieldsValue(true);
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
env </Button>
</>
</Button> ) : null}
<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)}>
<Form form={form} layout="vertical"> <Collapse defaultActiveKey={[]} items={collapseItems} />
<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>
); );
} }
+100 -5
View File
@@ -1,5 +1,18 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button, Descriptions, Drawer, Form, Image, Input, Select, Table, Typography, message } from 'antd'; import {
Button,
Descriptions,
Drawer,
Form,
Image,
Input,
Modal,
Select,
Space,
Table,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types'; import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
@@ -32,6 +45,13 @@ export default function TicketsPage() {
); );
const [detail, setDetail] = useState<Row | null>(null); const [detail, setDetail] = useState<Row | 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({}) });
@@ -50,6 +70,29 @@ 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 },
{ {
@@ -84,7 +127,21 @@ export default function TicketsPage() {
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> <div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
</div>
<Form <Form
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
@@ -137,14 +194,14 @@ export default function TicketsPage() {
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={ extra={
detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? ( detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
<> <Space>
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}> <Button type="primary" onClick={() => approve(String(detail.id))}>
</Button> </Button>
<Button danger onClick={() => reject(String(detail.id))}> <Button danger onClick={() => reject(String(detail.id))}>
</Button> </Button>
</> </Space>
) : null ) : null
} }
> >
@@ -173,6 +230,44 @@ export default function TicketsPage() {
</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>
); );
} }
+302 -54
View File
@@ -1,9 +1,13 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
Button, Button,
Card, Card,
DatePicker, DatePicker,
Descriptions,
Drawer,
Form, Form,
Input,
Modal,
Select, Select,
Space, Space,
Statistic, Statistic,
@@ -14,24 +18,44 @@ 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 { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types'; import {
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 } from '../lib/api'; import { request, type HqProfile } from '../lib/api';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
orderId: string; id: string;
orderNo: string; billNo: string;
deliveryType: string; billDate: string;
cityName?: string; orderCount: number;
receiverCity?: string; orderAmount: number;
payAmount: number;
wineryRate: number; wineryRate: number;
wineryAmount: number; wineryAmount: number;
status: string;
paidAt?: string | null;
};
type BillItem = {
id: string;
orderNo: string;
deliveryType: string;
payAmount: 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> = {
@@ -39,57 +63,146 @@ 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 } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = 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.deliveryType) qs.set('deliveryType', filters.deliveryType); if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
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.deliveryType) qs.set('deliveryType', filters.deliveryType); if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`); const 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: 'orderNo', width: 180, ellipsis: true }, { title: '单号', dataIndex: 'billNo', width: 170, ellipsis: true },
{ {
title: '配送类型', title: '账单日',
dataIndex: 'deliveryType', dataIndex: 'billDate',
width: 90,
render: (v) => <Tag>{DELIVERY_LABELS[v] || v}</Tag>,
},
{ title: '开城城市', dataIndex: 'cityName', width: 100, render: (v) => v || '—' },
{ title: '收货城市', dataIndex: 'receiverCity', width: 100, render: (v) => v || '—' },
{ title: '商品', dataIndex: 'productName', width: 140, ellipsis: true },
{ title: '数量', dataIndex: 'quantity', width: 70 },
{
title: '酒单实付',
dataIndex: 'payAmount',
width: 110, width: 110,
render: (v) => String(v || '').slice(0, 10),
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{
title: '酒单实付合计',
dataIndex: 'orderAmount',
width: 120,
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
@@ -105,30 +218,62 @@ export default function WineryBillsPage() {
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
title: '支付时间', title: '状态',
dataIndex: 'paidAt', dataIndex: 'status',
width: 170, width: 90,
render: (v) => (v ? fmtTime(v) : '—'), render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 160,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{row.status === 'UNPAID' && (
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
</Button>
)}
</Space>
),
}, },
]; ];
return ( return (
<div> <div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}> <div
<Typography.Title level={4} style={{ margin: 0 }}> style={{
display: 'flex',
</Typography.Title> justifyContent: 'space-between',
<Typography.Text type="secondary"> alignItems: 'flex-start',
T+30 / = × {ratePct}% marginBottom: 16,
</Typography.Text> gap: 16,
</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 ?? summary.totalAmount} prefix="¥" precision={2} /> <Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
</Space> </Space>
</Card> </Card>
)} )}
@@ -137,28 +282,30 @@ export default function WineryBillsPage() {
form={form} form={form}
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
onFinish={(v: { month?: Dayjs; deliveryType?: string }) => { onFinish={(v: { status?: string; month?: Dayjs; range?: [Dayjs, Dayjs] }) => {
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) : '',
deliveryType: v.deliveryType || '', dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
}); });
setPage(1); setPage(1);
}} }}
> >
<Form.Item name="month" label="账期月"> <Form.Item name="status" label="状态">
<DatePicker picker="month" />
</Form.Item>
<Form.Item name="deliveryType" label="配送类型">
<Select <Select
allowClear allowClear
style={{ width: 120 }} style={{ width: 120 }}
options={[ options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
{ value: 'LOCAL', label: '同城' },
{ value: 'CROSS_CITY', label: '跨城' },
]}
/> />
</Form.Item> </Form.Item>
<Form.Item name="month" label="账期月">
<DatePicker picker="month" />
</Form.Item>
<Form.Item name="range" label="账单日">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
@@ -180,15 +327,29 @@ 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="orderId" rowKey="id"
className="admin-table-nowrap" className="admin-table-nowrap"
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
}}
scroll={{ x: 1100 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -200,6 +361,93 @@ export default function WineryBillsPage() {
}, },
}} }}
/> />
<Drawer title="酒厂对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={640}>
{detail && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
<Descriptions.Item label="酒厂应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={detail.items ?? []}
columns={[
{ title: '订单号', dataIndex: 'orderNo', ellipsis: true },
{
title: '配送',
dataIndex: 'deliveryType',
width: 70,
render: (v) => DELIVERY_LABELS[v] || v,
},
{
title: '实付',
dataIndex: 'payAmount',
width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '酒厂应付',
dataIndex: 'wineryAmount',
width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '支付时间',
dataIndex: 'paidAt',
width: 150,
render: (v) => (v ? fmtTime(v) : '—'),
},
]}
/>
</>
)}
</Drawer>
<Modal
title="酒厂银行账户信息配置"
open={bankOpen}
onCancel={() => setBankOpen(false)}
onOk={() => void saveBank()}
confirmLoading={bankSaving}
destroyOnClose
okText="保存"
>
<Form form={bankForm} layout="vertical" disabled={bankLoading}>
<Form.Item
name="WINERY_BANK_ACCOUNT_NAME"
label="户名"
rules={[{ required: true, message: '请填写户名' }]}
>
<Input placeholder="收款账户户名" />
</Form.Item>
<Form.Item
name="WINERY_BANK_NAME"
label="开户银行"
rules={[{ required: true, message: '请填写开户银行' }]}
>
<Input placeholder="如:中国工商银行" />
</Form.Item>
<Form.Item name="WINERY_BANK_BRANCH" label="开户支行">
<Input placeholder="可选" />
</Form.Item>
<Form.Item
name="WINERY_BANK_ACCOUNT_NO"
label="银行账号"
rules={[{ required: true, message: '请填写银行账号' }]}
>
<Input placeholder="银行卡号" />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, type CSSProperties } from 'react';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import { import {
Button, Button,
@@ -25,6 +25,15 @@ 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);
@@ -72,46 +81,25 @@ export default function PromoCodeDetailPage() {
return ( return (
<> <>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]} align="top">
<Col xs={24} lg={8}> <Col flex="1 1 480px" style={{ minWidth: 0 }}>
<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 size="small" onClick={() => { <Button
editForm.setFieldsValue({ size="small"
name: detail.name, onClick={() => {
scene: detail.scene, editForm.setFieldsValue({
remark: detail.remark, name: detail.name,
ownerUserId: detail.ownerUser?.id, scene: detail.scene,
}); remark: detail.remark,
setEditOpen(true); ownerUserId: detail.ownerUser?.id,
}} });
setEditOpen(true);
}}
> >
</Button> </Button>
@@ -135,7 +123,18 @@ export default function PromoCodeDetailPage() {
</Space> </Space>
)} )}
> >
<Descriptions column={{ xs: 1, sm: 2 }} bordered size="small"> <Descriptions
column={2}
bordered
size="small"
layout="horizontal"
labelStyle={descLabelStyle}
contentStyle={descContentStyle}
styles={{
label: descLabelStyle,
content: descContentStyle,
}}
>
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item> <Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
<Descriptions.Item label="码值">{detail.code}</Descriptions.Item> <Descriptions.Item label="码值">{detail.code}</Descriptions.Item>
<Descriptions.Item label="场景"> <Descriptions.Item label="场景">
@@ -144,34 +143,81 @@ 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" span={2}> <Descriptions.Item label="活动 ID">
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text> <Typography.Text copyable={{ text: String(detail.id) }} style={{ whiteSpace: 'nowrap' }}>
{detail.id}
</Typography.Text>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="落地链接" span={2}> <Descriptions.Item label="二维码 ID">
<Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</Typography.Text> <Typography.Text copyable={{ text: detail.qrcodeId }} style={{ whiteSpace: 'nowrap' }}>
{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> <Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis style={{ maxWidth: '100%' }}>
{detail.qrcodeUrl} {detail.qrcodeUrl}
</Typography.Text> </Typography.Text>
) : '—'} ) : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="渠道负责人"> <Descriptions.Item label="渠道负责人">
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'} <span style={{ whiteSpace: 'nowrap' }}>
{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="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item> <Descriptions.Item label="创建时间">
<Descriptions.Item label="更新时间">{fmtTime(detail.updatedAt)}</Descriptions.Item> <span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.createdAt)}</span>
</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}>
@@ -187,11 +233,6 @@ 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
+42 -6
View File
@@ -8,6 +8,10 @@ export type StoreDraftForm = {
phone: string; phone: string;
storeSmsCode: string; storeSmsCode: string;
address: string; address: string;
openTime: string;
closeTime: string;
categoryParentId: string;
categoryId: string;
intro: string; intro: string;
coverUrl: string; coverUrl: string;
envPhotoUrls: string[]; envPhotoUrls: string[];
@@ -37,6 +41,10 @@ export const defaultStoreForm = (): StoreDraftForm => ({
phone: '', phone: '',
storeSmsCode: '', storeSmsCode: '',
address: '', address: '',
openTime: '10:00',
closeTime: '22:00',
categoryParentId: '',
categoryId: '',
intro: '', intro: '',
coverUrl: '', coverUrl: '',
envPhotoUrls: ['', '', ''], envPhotoUrls: ['', '', ''],
@@ -68,6 +76,10 @@ function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
phone: String(raw.phone ?? base.phone), phone: String(raw.phone ?? base.phone),
storeSmsCode: String(raw.storeSmsCode ?? base.storeSmsCode), storeSmsCode: String(raw.storeSmsCode ?? base.storeSmsCode),
address: String(raw.address ?? base.address), address: String(raw.address ?? base.address),
openTime: String(raw.openTime ?? base.openTime),
closeTime: String(raw.closeTime ?? base.closeTime),
categoryParentId: String(raw.categoryParentId ?? base.categoryParentId),
categoryId: String(raw.categoryId ?? base.categoryId),
intro: String(raw.intro ?? base.intro), intro: String(raw.intro ?? base.intro),
coverUrl: String(raw.coverUrl ?? base.coverUrl), coverUrl: String(raw.coverUrl ?? base.coverUrl),
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3), envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
@@ -119,21 +131,38 @@ export function clearAllStoreDrafts(accountId?: string) {
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(value: string): number {
const [h, m] = value.split(':').map(Number);
return h * 60 + m;
}
export function validateStoreStep1( export function validateStoreStep1(
form: Pick< form: Pick<
StoreDraftForm, StoreDraftForm,
'regionCodes' | 'cityId' | 'name' | 'phone' | 'storeSmsCode' | 'address' | 'intro' | 'regionCodes'
| 'cityId'
| 'name'
| 'address'
| 'openTime'
| 'closeTime'
| 'categoryId'
| 'intro'
>, >,
): 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.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 < 10 || len > 500) return '门店简介须为 10~500 字'; if (len < 10 || len > 500) return '门店简介须为 10~500 字';
@@ -158,11 +187,18 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
} }
export function validateStoreStep3( export function validateStoreStep3(
form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>, form: Pick<
StoreDraftForm,
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'storeSmsCode'
>,
): 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位手机号';
if (!form.storeSmsCode.trim()) return '请输入门店手机号验证码';
if (!/^\d{4,6}$/.test(form.storeSmsCode.trim())) return '验证码格式不正确';
return null; return null;
} }
+99 -52
View File
@@ -1,6 +1,7 @@
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';
@@ -11,16 +12,16 @@ function fmtMoney(n: number) {
function billStatusLabel(status: string) { function billStatusLabel(status: string) {
switch (status) { switch (status) {
case 'DRAFT': return '待确认'; case 'AWAITING_CONFIRM': return '待确认';
case 'CONFIRMED': return '审核中'; case 'UNPAID': return '未打款';
case 'PAID': return '已结算'; case 'PAID': return '已打款';
case 'REJECTED': return '已驳回'; case 'REJECTED': return '已驳回';
default: return status; default: return status;
} }
} }
function canApplyPayment(status: string) { function canConfirm(status: string) {
return status === 'DRAFT' || status === 'REJECTED'; return status === 'AWAITING_CONFIRM';
} }
export default function BillsPage() { export default function BillsPage() {
@@ -30,6 +31,7 @@ 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);
@@ -50,7 +52,7 @@ export default function BillsPage() {
const billId = searchParams.get('id'); const billId = searchParams.get('id');
const actionable = useMemo( const actionable = useMemo(
() => bills.filter((b) => canApplyPayment(b.status)), () => bills.filter((b) => canConfirm(b.status)),
[bills], [bills],
); );
const bill = billId const bill = billId
@@ -58,20 +60,32 @@ 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 && canApplyPayment(status); const showApplyForm = !!bill && canConfirm(status);
const isReviewing = status === 'CONFIRMED'; const isUnpaid = status === 'UNPAID';
const isRejected = status === 'REJECTED'; const isRejected = status === 'REJECTED';
const isPaid = status === 'PAID'; const isPaid = status === 'PAID';
async function confirmBill() { function askConfirm(ids: string[]) {
if (!bill || !confirmed || !canApplyPayment(String(bill.status))) return; if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
return;
}
void doConfirm(ids);
}
async function doConfirm(ids: string[]) {
setSubmitting(true); setSubmitting(true);
try { try {
await request('PARTNER_H5', `/partner/settlement/bills/${bill.id}/confirm`, { if (ids.length === 1) {
method: 'POST', await request('PARTNER_H5', `/partner/settlement/bills/${ids[0]}/confirm`, { method: 'POST' });
}); } else {
toastSuccess('申请已提交,请耐心等待总部打款审核'); await request('PARTNER_H5', '/partner/settlement/bills/batch-confirm', {
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 : '提交失败');
@@ -80,8 +94,12 @@ export default function BillsPage() {
} }
} }
function toggleSelect(id: string) {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}
return ( return (
<div className="partner-bills-page"> <PullToRefresh onRefresh={loadBills} 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">
@@ -89,7 +107,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%' : isReviewing || isRejected ? '75%' : '50%' }} style={{ width: isPaid ? '100%' : isUnpaid || isRejected ? '75%' : '50%' }}
/> />
</div> </div>
<div className="partner-step"> <div className="partner-step">
@@ -99,7 +117,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 || isReviewing || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}> <div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isUnpaid || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
{showApplyForm && !confirmed ? '2' : ( {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>
)} )}
@@ -107,20 +125,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' : isReviewing || isRejected ? ' partner-step-circle--active' : ''}`}> <div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isUnpaid || isRejected ? ' partner-step-circle--active' : ''}`}>
{isPaid ? ( {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${isReviewing || isRejected || isPaid ? ' partner-step-label--active' : ''}`}> <span className={`partner-step-label${isUnpaid || 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">
@@ -131,7 +149,7 @@ export default function BillsPage() {
<p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p> <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' : isReviewing ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}> <span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isUnpaid ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
{billStatusLabel(status)} {billStatusLabel(status)}
</span> </span>
</div> </div>
@@ -140,17 +158,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>
)} )}
{isReviewing && ( {isUnpaid && (
<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>
)} )}
@@ -177,29 +195,60 @@ export default function BillsPage() {
</section> </section>
)} )}
{bills.length > 1 && ( {bills.length > 0 && (
<div style={{ padding: '0 20px' }}> <div style={{ padding: '0 20px' }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
{bills.map((b) => ( <h3 className="headline-md" style={{ margin: 0 }}></h3>
<button {actionable.length > 1 && (
key={String(b.id)} <button
type="button" type="button"
className="partner-store-card" className="partner-fill-max"
style={{ margin: '0 0 12px', width: '100%', textAlign: 'left', cursor: 'pointer' }} style={{ fontSize: 13 }}
onClick={() => navigate(`/center/bills?id=${b.id}`)} disabled={!selectedIds.length || submitting}
> onClick={() => askConfirm(selectedIds)}
<div className="partner-store-card-header" style={{ marginBottom: 0 }}> >
<div> ({selectedIds.length})
<p className="body-md">{b.billNo}</p> </button>
<p className="label-md text-muted">{billStatusLabel(b.status)}</p> )}
{b.status === 'REJECTED' && b.rejectReason ? ( </div>
<p className="label-md text-primary" style={{ marginTop: 4 }}>{b.rejectReason}</p> {bills.map((b) => {
) : null} const id = String(b.id);
</div> const selectable = canConfirm(b.status);
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span> return (
<div
key={id}
className="partner-store-card"
style={{ margin: '0 0 12px', display: 'flex', gap: 10, alignItems: 'flex-start' }}
>
{selectable ? (
<input
type="checkbox"
checked={selectedIds.includes(id)}
onChange={() => toggleSelect(id)}
style={{ marginTop: 4 }}
/>
) : (
<span style={{ width: 16 }} />
)}
<button
type="button"
style={{ flex: 1, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
onClick={() => navigate(`/center/bills?id=${id}`)}
>
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div>
<p className="body-md">{b.billNo}</p>
<p className="label-md text-muted">{billStatusLabel(b.status)}</p>
{b.status === 'REJECTED' && b.rejectReason ? (
<p className="label-md text-primary" style={{ marginTop: 4 }}>{b.rejectReason}</p>
) : null}
</div>
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
</div>
</button>
</div> </div>
</button> );
))} })}
</div> </div>
)} )}
@@ -208,9 +257,7 @@ 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>
@@ -224,14 +271,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={() => void confirmBill()} onClick={() => askConfirm([String(bill!.id)])}
> >
{submitting ? '正在提交...' : isRejected ? '重新申请打款' : '确认并申请打款'} {submitting ? '正在提交...' : '确认账单'}
{!submitting && <span className="material-symbols-outlined">payments</span>} {!submitting && <span className="material-symbols-outlined">payments</span>}
</button> </button>
</footer> </footer>
</> </>
)} )}
</div> </PullToRefresh>
); );
} }
+23 -13
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, 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';
@@ -36,18 +37,27 @@ 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(() => {
if (!isPrimary) return; void loadCenter();
void request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills') }, [loadCenter]);
.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 === 'DRAFT' || b.status === 'REJECTED'); const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
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;
@@ -55,7 +65,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 === 'DRAFT' || b.status === 'CONFIRMED' || b.status === 'REJECTED') .filter((b) => b.status === 'AWAITING_CONFIRM' || b.status === 'UNPAID' || b.status === 'REJECTED')
.reduce((s, b) => s + Number(b.totalAmount || 0), 0), .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),
@@ -109,7 +119,7 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员'); const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员');
return ( return (
<div className="page partner-center-page partner-home--flush-top"> <PullToRefresh onRefresh={loadCenter} className="page partner-center-page partner-home--flush-top">
<section className="partner-profile-card"> <section className="partner-profile-card">
<button <button
type="button" type="button"
@@ -286,6 +296,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>
</div> </PullToRefresh>
); );
} }
+42 -24
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { 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 {
@@ -155,45 +156,62 @@ export default function HomePage() {
document.title = '工作台'; document.title = '工作台';
}, []); }, []);
useEffect(() => { const loadHome = useCallback(() => {
if (!isLoggedIn()) { navigate('/login'); return; } if (!isLoggedIn()) {
// 等 session 带上账号后再按权限发请求,避免无权限接口弹错 navigate('/login');
if (!account) return; return Promise.resolve();
}
if (!account) return Promise.resolve();
const tasks: Promise<unknown>[] = [];
if (canOrders) { if (canOrders) {
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true }) tasks.push(
.then((data) => setOrders(Array.isArray(data.list) ? data.list : [])) request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
.catch(() => setOrders([])); .then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
.catch(() => setOrders([])),
);
} else { } else {
setOrders([]); setOrders([]);
} }
if (canDashboard) { if (canDashboard) {
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true }) tasks.push(
.then(setDash) request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
.catch(() => setDash(null)); .then(setDash)
.catch(() => setDash(null)),
);
} else { } else {
setDash(null); setDash(null);
} }
if (canStores) { if (canStores) {
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true }) tasks.push(
.then((data) => setStores(Array.isArray(data) ? data : [])) request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
.catch(() => setStores([])); .then((data) => setStores(Array.isArray(data) ? data : []))
.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);
@@ -219,7 +237,7 @@ export default function HomePage() {
const profit = revenue * 0.25; const profit = revenue * 0.25;
return ( return (
<div className="page partner-home partner-home--flush-top"> <PullToRefresh onRefresh={loadHome} className="page partner-home partner-home--flush-top">
<main className="partner-home-body"> <main className="partner-home-body">
{isPrimary && ( {isPrimary && (
<> <>
@@ -346,6 +364,6 @@ export default function HomePage() {
<LeaderboardPreview entries={leaderboardEntries} /> <LeaderboardPreview entries={leaderboardEntries} />
</main> </main>
</div> </PullToRefresh>
); );
} }
+10 -5
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'; import { useCallback, 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';
@@ -33,10 +34,10 @@ export default function LeaderboardPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { const loadLeaderboard = useCallback(() => {
setLoading(true); setLoading(true);
setError(''); setError('');
fetchPartnerLeaderboard(period) return fetchPartnerLeaderboard(period)
.then(setData) .then(setData)
.catch((e) => { .catch((e) => {
setData(null); setData(null);
@@ -45,8 +46,12 @@ export default function LeaderboardPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [period]); }, [period]);
useEffect(() => {
void loadLeaderboard();
}, [loadLeaderboard]);
return ( return (
<div className="page-no-tab"> <PullToRefresh onRefresh={loadLeaderboard} className="page-no-tab">
<PageHeader title="团队贡献榜" onBack={() => navigate('/')} /> <PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
<div className="partner-leaderboard-tabs"> <div className="partner-leaderboard-tabs">
@@ -124,6 +129,6 @@ export default function LeaderboardPage() {
<span className="material-symbols-outlined">add</span> <span className="material-symbols-outlined">add</span>
</Link> </Link>
)} )}
</div> </PullToRefresh>
); );
} }
+15 -7
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { 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';
@@ -59,14 +60,17 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
document.title = '订单管理'; document.title = '订单管理';
}, []); }, []);
useEffect(() => { const loadOrders = useCallback(() => {
if (!isLoggedIn()) { navigate('/login'); return; } if (!isLoggedIn()) {
navigate('/login');
return Promise.resolve();
}
if (!warehouseOk) { if (!warehouseOk) {
setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' }); setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' });
setLoaded(true); setLoaded(true);
return; return Promise.resolve();
} }
request<OrdersResponse>('PARTNER_H5', '/partner/orders') return 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 : [],
@@ -78,6 +82,10 @@ 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();
@@ -142,7 +150,7 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
} }
return ( return (
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}> <PullToRefresh onRefresh={loadOrders} className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />} {!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
<div className="partner-segment"> <div className="partner-segment">
@@ -265,6 +273,6 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
</div> </div>
</div> </div>
)} )}
</div> </PullToRefresh>
); );
} }
+22 -17
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, 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';
@@ -12,9 +13,9 @@ function fmtMoney(n: number) {
function billStatusLabel(status: string) { function billStatusLabel(status: string) {
switch (status) { switch (status) {
case 'DRAFT': return '待结算'; case 'AWAITING_CONFIRM': return '待确认';
case 'CONFIRMED': return '审核中'; case 'UNPAID': return '未打款';
case 'PAID': return '已结算'; case 'PAID': return '已打款';
case 'REJECTED': return '已驳回'; case 'REJECTED': return '已驳回';
default: return status; default: return status;
} }
@@ -22,8 +23,8 @@ function billStatusLabel(status: string) {
function billStatusClass(status: string) { function billStatusClass(status: string) {
switch (status) { switch (status) {
case 'DRAFT': return 'partner-settlement-status--pending'; case 'AWAITING_CONFIRM': return 'partner-settlement-status--pending';
case 'CONFIRMED': return 'partner-settlement-status--reviewing'; case 'UNPAID': 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 '';
@@ -47,13 +48,17 @@ 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; }
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills); void loadBills();
}, [navigate]); }, [navigate, loadBills]);
const summary = useMemo(() => { const summary = useMemo(() => {
const pending = bills.filter((b) => b.status === 'DRAFT'); const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
const settled = bills.filter((b) => b.status === 'PAID'); const 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);
@@ -72,9 +77,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 === 'DRAFT' || b.status === 'REJECTED'; if (statusFilter === 'pending') return b.status === 'AWAITING_CONFIRM';
if (statusFilter === 'settled') return b.status === 'PAID'; if (statusFilter === 'settled') return b.status === 'PAID';
if (statusFilter === 'reviewing') return b.status === 'CONFIRMED'; if (statusFilter === 'reviewing') return b.status === 'UNPAID';
if (statusFilter === 'rejected') return b.status === 'REJECTED'; if (statusFilter === 'rejected') return b.status === 'REJECTED';
return true; return true;
}); });
@@ -95,14 +100,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 (
<div className="page-no-tab partner-settlement-page"> <PullToRefresh onRefresh={loadBills} 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">
@@ -197,7 +202,7 @@ export default function SettlementPage() {
{bill.rejectReason} {bill.rejectReason}
</p> </p>
) : null} ) : null}
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}> <p className={`partner-settlement-item-amount${bill.status === 'AWAITING_CONFIRM' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
¥{fmtMoney(Number(bill.totalAmount || 0))} ¥{fmtMoney(Number(bill.totalAmount || 0))}
</p> </p>
</div> </div>
@@ -211,6 +216,6 @@ export default function SettlementPage() {
)} )}
</section> </section>
</main> </main>
</div> </PullToRefresh>
); );
} }
+3 -2
View File
@@ -1,6 +1,7 @@
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,
@@ -86,7 +87,7 @@ export default function StaffListPage() {
} }
return ( return (
<div className="page-no-tab partner-staff-page"> <PullToRefresh onRefresh={loadStaff} 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' }}>
@@ -151,6 +152,6 @@ export default function StaffListPage() {
</Link> </Link>
</div> </div>
</div> </PullToRefresh>
); );
} }
+237 -179
View File
@@ -2,8 +2,6 @@ import { 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';
@@ -45,14 +43,15 @@ 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; storeSmsCode?: string;
}; };
@@ -93,8 +92,6 @@ 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('');
@@ -103,6 +100,8 @@ export default function StoreCreatePage() {
const [smsHint, setSmsHint] = useState(''); const [smsHint, setSmsHint] = useState('');
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const draftSaveDisabledRef = useRef(false); const draftSaveDisabledRef = useRef(false);
function reportFormError(message: string) { function reportFormError(message: string) {
@@ -153,6 +152,26 @@ export default function StoreCreatePage() {
}, []); }, []);
useEffect(() => {
void request<StoreCategoryNode[]>('PARTNER_H5', '/partner/store-categories')
.then((list) => {
const tree = Array.isArray(list) ? list : [];
setCategoryTree(tree);
if (form.categoryId && !form.categoryParentId) {
const parent = tree.find((root) =>
(root.children ?? []).some((child) => child.id === form.categoryId),
);
if (parent) patchForm({ categoryParentId: parent.id });
}
})
.catch(() => setCategoryTree([]));
}, []);
const categoryChildren = useMemo(() => {
const parent = categoryTree.find((item) => item.id === form.categoryParentId);
return Array.isArray(parent?.children) ? parent!.children! : [];
}, [categoryTree, form.categoryParentId]);
useEffect(() => { useEffect(() => {
@@ -354,54 +373,10 @@ export default function StoreCreatePage() {
const msg = validateStoreStep1(form); const msg = validateStoreStep1(form);
if (msg) { if (msg) {
if (isPhoneValidationMessage(msg)) { reportFormError(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) {
@@ -426,6 +401,14 @@ export default function StoreCreatePage() {
const msg = validateStoreStep3(form); const msg = validateStoreStep3(form);
if (msg) { if (msg) {
if (isPhoneValidationMessage(msg)) {
if (msg.includes('验证码')) {
setFieldErrors({ storeSmsCode: msg });
} else {
setFieldErrors({ phone: msg });
}
return;
}
reportFormError(msg); reportFormError(msg);
return; return;
} }
@@ -433,15 +416,6 @@ 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;
@@ -481,6 +455,8 @@ export default function StoreCreatePage() {
}); });
setSubmitting(false);
return; return;
} }
@@ -492,6 +468,7 @@ export default function StoreCreatePage() {
); );
if (!ok) { if (!ok) {
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' }); setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
setSubmitting(false);
return; return;
} }
confirmBindExisting = true; confirmBindExisting = true;
@@ -499,6 +476,7 @@ export default function StoreCreatePage() {
} catch (e) { } catch (e) {
reportFormError(e instanceof Error ? e.message : '手机号校验失败'); reportFormError(e instanceof Error ? e.message : '手机号校验失败');
setSubmitting(false);
return; return;
} }
@@ -530,6 +508,12 @@ export default function StoreCreatePage() {
address: form.address.trim(), address: form.address.trim(),
openTime: form.openTime.trim(),
closeTime: form.closeTime.trim(),
categoryId: form.categoryId.trim(),
intro: form.intro.trim() || undefined, intro: form.intro.trim() || undefined,
coverUrl: form.coverUrl.trim() || undefined, coverUrl: form.coverUrl.trim() || undefined,
@@ -567,7 +551,7 @@ export default function StoreCreatePage() {
} }
if (/验证码/.test(message)) { if (/验证码/.test(message)) {
setFieldErrors({ storeSmsCode: message }); setFieldErrors({ storeSmsCode: message });
goStep(1); goStep(3);
return; return;
} }
setSubmitError(message); setSubmitError(message);
@@ -584,17 +568,13 @@ 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 || checkingPhone; const nextDisabled = submitting;
return ( return (
<div className="partner-page-sticky"> <div className="partner-page-sticky partner-home--flush-top">
<PageHeader title="录入新门店" onBack={() => navigate('/stores')} />
<nav className="partner-stepper"> <nav className="partner-stepper">
@@ -694,100 +674,58 @@ 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-field-input"> <div className="partner-input-row" style={{ gap: 8 }}>
<span className="material-symbols-outlined">call</span> <select
<input className="partner-field-input partner-field-input--block"
type="tel" value={form.categoryParentId}
placeholder="请输入11位手机号" onChange={(e) => patchForm({ categoryParentId: e.target.value, categoryId: '' })}
value={form.phone} aria-label="一级店铺类型"
onChange={(e) => patchForm({ phone: e.target.value })}
/>
</div>
{fieldErrors.phone && (
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
)}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-input-row">
<div className="partner-input-wrap" style={{ flex: 1 }}>
<span className="material-symbols-outlined partner-input-icon">shield</span>
<input
className="partner-input"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="请输入短信验证码"
value={form.storeSmsCode}
onChange={(e) => patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })}
/>
</div>
<button
type="button"
className="partner-code-btn"
disabled={smsCooldown > 0 || checkingPhone}
onClick={() => void sendStorePhoneCode()}
> >
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'} <option value=""></option>
</button> {categoryTree.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
<select
className="partner-field-input partner-field-input--block"
value={form.categoryId}
onChange={(e) => patchForm({ categoryId: e.target.value })}
disabled={!form.categoryParentId}
aria-label="二级店铺类型"
>
<option value="">{form.categoryParentId ? '选择细类' : '请先选大类'}</option>
{categoryChildren.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</div> </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">
@@ -798,6 +736,52 @@ export default function StoreCreatePage() {
</div> </div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-input-row" style={{ alignItems: 'center', gap: 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>
<p className="label-md text-muted" style={{ marginTop: 8 }}>
10:0022:00
</p>
</div>
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
@@ -976,6 +960,54 @@ 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">
@@ -1008,54 +1040,76 @@ 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 className="partner-form-card">
<div className="partner-field"> <div className="partner-field">
<label> *</label> <label> <span className="text-primary">*</span></label>
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} /> <div className="partner-input-row">
</div> <div className="partner-input-wrap" style={{ flex: 1 }}>
<div className="partner-field"> <span className="material-symbols-outlined partner-input-icon">shield</span>
<label> *</label> <input
<input className="partner-field-input partner-field-input--block" placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => patchForm({ bankAccountNo: e.target.value })} /> className="partner-input"
</div> type="text"
<div className="partner-field"> inputMode="numeric"
<label> *</label> maxLength={6}
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} /> placeholder="请输入短信验证码"
value={form.storeSmsCode}
onChange={(e) => patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })}
/>
</div>
<button
type="button"
className="partner-code-btn"
disabled={smsCooldown > 0 || submitting}
onClick={() => void sendStorePhoneCode()}
>
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
</button>
</div>
{smsHint && (
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
)}
{fieldErrors.storeSmsCode && (
<p className="partner-field-error" role="alert">{fieldErrors.storeSmsCode}</p>
)}
</div> </div>
</section> </section>
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
<span className="material-symbols-outlined text-secondary">info</span>
<p className="body-md" style={{ color: 'var(--color-on-secondary-container)' }}>
</p>
</div>
</> </>
)} )}
@@ -1064,7 +1118,11 @@ 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>
@@ -1074,7 +1132,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>{checkingPhone ? '校验中…' : '下一步'}</span> <span></span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span> <span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
+46 -10
View File
@@ -1,6 +1,5 @@
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 { toastSuccess } from '../lib/toast'; import { toastSuccess } from '../lib/toast';
@@ -47,6 +46,7 @@ export default function StoreDetailPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false); const [mediaSaving, setMediaSaving] = useState(false);
const [actionError, setActionError] = useState(''); const [actionError, setActionError] = useState('');
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
function applyStore(data: Record<string, unknown>) { function applyStore(data: Record<string, unknown>) {
setStore(data); setStore(data);
@@ -92,8 +92,8 @@ export default function StoreDetailPage() {
return; return;
} }
if (next === 'CLOSED') { if (next === 'CLOSED') {
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?'); setCloseConfirmOpen(true);
if (!ok) return; return;
} }
setStatusSaving(true); setStatusSaving(true);
setActionError(''); setActionError('');
@@ -104,7 +104,27 @@ 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('状态已更新'); toastSuccess(next === 'OPEN' ? '开店成功' : '状态已更新');
} catch (e) {
setActionError(e instanceof Error ? e.message : '状态更新失败');
} finally {
setStatusSaving(false);
}
}
async function confirmCloseStore() {
if (!id || statusSaving) return;
setCloseConfirmOpen(false);
setStatusSaving(true);
setActionError('');
try {
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
method: 'PUT',
body: JSON.stringify({ status: 'CLOSED' }),
});
setStatus('CLOSED');
setStore((prev) => (prev ? { ...prev, ...updated, status: 'CLOSED' } : prev));
toastSuccess('门店已关闭');
} catch (e) { } catch (e) {
setActionError(e instanceof Error ? e.message : '状态更新失败'); setActionError(e instanceof Error ? e.message : '状态更新失败');
} finally { } finally {
@@ -178,8 +198,7 @@ export default function StoreDetailPage() {
if (loadError) { if (loadError) {
return ( return (
<div className="partner-detail-page"> <div className="partner-detail-page partner-home--flush-top">
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
<div className="empty">{loadError}</div> <div className="empty">{loadError}</div>
</div> </div>
); );
@@ -201,10 +220,8 @@ export default function StoreDetailPage() {
const canOpen = canPartnerOpenStore(auditStatus); const canOpen = canPartnerOpenStore(auditStatus);
return ( return (
<div className="partner-detail-page"> <div className="partner-detail-page partner-home--flush-top">
<PageHeader title="门店详情" onBack={() => navigate('/stores')} /> <main style={{ padding: '12px 20px 16px' }}>
<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' }}>
@@ -385,6 +402,25 @@ 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>
)}
</div> </div>
); );
} }
+45 -4
View File
@@ -1,6 +1,8 @@
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 {
@@ -35,6 +37,7 @@ 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);
@@ -66,8 +69,8 @@ export default function StoreListPage() {
return; return;
} }
if (next === 'CLOSED') { if (next === 'CLOSED') {
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?'); setCloseTarget(storeId);
if (!ok) return; return;
} }
setUpdatingId(storeId); setUpdatingId(storeId);
setError(''); setError('');
@@ -77,6 +80,26 @@ 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 {
@@ -85,7 +108,7 @@ export default function StoreListPage() {
} }
return ( return (
<div className="page partner-store-page partner-home--flush-top"> <PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>} {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">
@@ -188,6 +211,24 @@ export default function StoreListPage() {
</div> </div>
); );
})} })}
</div> {closeTarget && (
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseTarget(null)}>
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
</p>
<div className="partner-ship-actions">
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
</button>
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
</button>
</div>
</div>
</div>
)}
</PullToRefresh>
); );
} }
+10 -5
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, 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) {
@@ -20,10 +21,10 @@ export default function WeeklyReportPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { const loadWeeklyReport = useCallback(() => {
setLoading(true); setLoading(true);
setError(''); setError('');
fetchPartnerWeeklyReport(selectedStart) return fetchPartnerWeeklyReport(selectedStart)
.then(setData) .then(setData)
.catch((e) => { .catch((e) => {
setData(null); setData(null);
@@ -32,6 +33,10 @@ 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));
@@ -42,7 +47,7 @@ export default function WeeklyReportPage() {
const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0; const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0;
return ( return (
<div className="page-no-tab"> <PullToRefresh onRefresh={loadWeeklyReport} className="page-no-tab">
<PageHeader title="数据周报" onBack={() => navigate('/')} /> <PageHeader title="数据周报" onBack={() => navigate('/')} />
<main className="partner-weekly-page"> <main className="partner-weekly-page">
@@ -211,6 +216,6 @@ export default function WeeklyReportPage() {
</> </>
)} )}
</main> </main>
</div> </PullToRefresh>
); );
} }
+7 -1
View File
@@ -3329,10 +3329,16 @@ 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;
} }
+3 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api'; import { request } from '../lib/api';
function formatMoney(n: number) { function formatMoney(n: number) {
@@ -47,7 +48,7 @@ export default function HomePage() {
const closeTime = String(store?.closeTime || '22:00'); const closeTime = String(store?.closeTime || '22:00');
return ( return (
<div className="shop-home-page"> <PullToRefresh onRefresh={loadDashboard} 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>
@@ -127,6 +128,6 @@ export default function HomePage() {
</section> </section>
</div> </div>
</div> </PullToRefresh>
); );
} }
+16 -15
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useCallback, 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';
@@ -24,21 +25,21 @@ export default function MinePage() {
const [binding, setBinding] = useState(false); const [binding, setBinding] = useState(false);
const [bindMsg, setBindMsg] = useState(''); const [bindMsg, setBindMsg] = useState('');
useEffect(() => { const loadMine = useCallback(() => {
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null)); return Promise.all([
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null)),
fetchClientConfig()
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false)),
fetchShopAccount()
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
.catch(() => setHasWechat(null)),
]);
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchClientConfig() void loadMine();
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config))) }, [loadMine]);
.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;
@@ -92,7 +93,7 @@ export default function MinePage() {
} }
return ( return (
<div className="shop-mine-page"> <PullToRefresh onRefresh={loadMine} 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>
@@ -174,6 +175,6 @@ export default function MinePage() {
退 退
</button> </button>
</div> </div>
</div> </PullToRefresh>
); );
} }
+17 -10
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api'; import { request } from '../lib/api';
type RangeKey = 'today' | '7d' | '30d'; type RangeKey = 'today' | '7d' | '30d';
@@ -28,15 +29,21 @@ export default function RecordsPage() {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all'); const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [storeName, setStoreName] = useState(''); const [storeName, setStoreName] = useState('');
useEffect(() => { const loadRecords = useCallback(() => {
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => { return Promise.all([
setRecords(d.list || []); 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 || ''))) request<Record<string, unknown>>('SHOP_H5', '/shop/store')
.catch(() => {}); .then((s) => setStoreName(String(s.name || '')))
.catch(() => {}),
]);
}, []); }, []);
useEffect(() => {
void loadRecords();
}, [loadRecords]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
return records.filter((r) => { return records.filter((r) => {
if (!inRange(String(r.createdAt), range)) return false; if (!inRange(String(r.createdAt), range)) return false;
@@ -55,7 +62,7 @@ export default function RecordsPage() {
}, [filtered]); }, [filtered]);
return ( return (
<div className="shop-records-page"> <PullToRefresh onRefresh={loadRecords} 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>
@@ -176,6 +183,6 @@ export default function RecordsPage() {
</div> </div>
)} )}
</div> </div>
</div> </PullToRefresh>
); );
} }
+12 -7
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useCallback, 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,
@@ -19,15 +20,19 @@ 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 request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores') void loadStores();
.then((list) => setStores(list)) }, [authenticated, navigate, loadStores]);
.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,7 +68,7 @@ export default function SelectStorePage() {
} }
return ( return (
<div className="shop-select-store-page"> <PullToRefresh onRefresh={loadStores} className="shop-select-store-page">
<header className="shop-subpage-header"> <header className="shop-subpage-header">
{canGoBack ? ( {canGoBack ? (
<button <button
@@ -151,7 +156,7 @@ export default function SelectStorePage() {
) : null} ) : null}
</section> </section>
</div> </div>
</div> </PullToRefresh>
); );
} }
+3 -2
View File
@@ -1,6 +1,7 @@
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';
@@ -116,7 +117,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 (
<div className="shop-staff-page"> <PullToRefresh onRefresh={reload} className="shop-staff-page">
<header className="shop-subpage-header"> <header className="shop-subpage-header">
<button <button
type="button" type="button"
@@ -322,6 +323,6 @@ export default function StaffPage() {
</section> </section>
)} )}
</div> </div>
</div> </PullToRefresh>
); );
} }
+2
View File
@@ -7,9 +7,11 @@ export default defineAppConfig({
'pages/product-detail/index', 'pages/product-detail/index',
'pages/store-detail/index', 'pages/store-detail/index',
'pages/order-confirm/index', 'pages/order-confirm/index',
'pages/order-confirm-pickup/index',
'pages/pay/index', 'pages/pay/index',
'pages/orders/index', 'pages/orders/index',
'pages/order-detail/index', 'pages/order-detail/index',
'pages/pickup-receive/index',
'pages/addresses/index', 'pages/addresses/index',
'pages/address-edit/index', 'pages/address-edit/index',
'pages/customer-service/index', 'pages/customer-service/index',
@@ -0,0 +1,159 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
export type StoreCategoryNode = {
id: string;
name: string;
children?: StoreCategoryNode[];
};
export type CategorySelection = {
parentId: string;
parentName: string;
childId: string;
childName: string;
};
export const EMPTY_CATEGORY: CategorySelection = {
parentId: '',
parentName: '',
childId: '',
childName: '',
};
export function formatCategoryLabel(sel: CategorySelection): string {
if (sel.childName) return sel.childName;
if (sel.parentName) return sel.parentName;
return '全部分类';
}
type CategoryPickerProps = {
open: boolean;
tree: StoreCategoryNode[];
value: CategorySelection;
onClose: () => void;
onConfirm: (next: CategorySelection) => void;
};
type TabKey = 'parent' | 'child';
export default function CategoryPicker({
open,
tree,
value,
onClose,
onConfirm,
}: CategoryPickerProps) {
const [draft, setDraft] = useState<CategorySelection>(value);
const [activeTab, setActiveTab] = useState<TabKey>('parent');
useEffect(() => {
if (!open) return;
setDraft(value);
setActiveTab(value.parentId ? 'child' : 'parent');
}, [open, value]);
const children = useMemo(() => {
const parent = tree.find((n) => n.id === draft.parentId);
return parent?.children ?? [];
}, [tree, draft.parentId]);
if (!open) return null;
function selectParent(node: StoreCategoryNode | null) {
if (!node) {
setDraft(EMPTY_CATEGORY);
return;
}
setDraft({
parentId: node.id,
parentName: node.name,
childId: '',
childName: '',
});
setActiveTab('child');
}
function selectChild(node: StoreCategoryNode | null) {
if (!node) {
setDraft((prev) => ({ ...prev, childId: '', childName: '' }));
return;
}
setDraft((prev) => ({
...prev,
childId: node.id,
childName: node.name,
}));
}
function handleConfirm() {
onConfirm(draft);
onClose();
}
return (
<View className="region-picker-overlay" onClick={onClose}>
<View className="region-picker-sheet" onClick={(e) => e.stopPropagation()}>
<View className="region-picker-toolbar">
<View className="region-picker-tabs">
<Text
className={`region-picker-tab${activeTab === 'parent' ? ' active' : ''}`}
onClick={() => setActiveTab('parent')}
>
{draft.parentName || '大类'}
</Text>
<Text
className={`region-picker-tab${activeTab === 'child' ? ' active' : ''}${!draft.parentId ? ' disabled' : ''}`}
onClick={() => draft.parentId && setActiveTab('child')}
>
{draft.childName || '细类'}
</Text>
</View>
<Text className="region-picker-confirm ready" onClick={handleConfirm}>
</Text>
</View>
<ScrollView className="region-picker-list" scrollY showScrollbar={false}>
{activeTab === 'parent' ? (
<>
<View
className={`region-picker-option${!draft.parentId ? ' selected' : ''} region-picker-option--all`}
onClick={() => selectParent(null)}
>
<Text></Text>
</View>
{tree.map((item) => (
<View
key={item.id}
className={`region-picker-option${draft.parentId === item.id ? ' selected' : ''}`}
onClick={() => selectParent(item)}
>
<Text>{item.name}</Text>
</View>
))}
</>
) : (
<>
<View
className={`region-picker-option${!draft.childId ? ' selected' : ''} region-picker-option--all`}
onClick={() => selectChild(null)}
>
<Text></Text>
</View>
{children.map((item) => (
<View
key={item.id}
className={`region-picker-option${draft.childId === item.id ? ' selected' : ''}`}
onClick={() => selectChild(item)}
>
<Text>{item.name}</Text>
</View>
))}
</>
)}
</ScrollView>
</View>
</View>
);
}
@@ -7,10 +7,11 @@ type CouponBadgeProps = {
/** Taro 友好版权益角标(对齐 shared-ui CouponBadge */ /** Taro 友好版权益角标(对齐 shared-ui CouponBadge */
export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) { export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) {
const n = Number(amount);
const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount);
return ( return (
<Text className="coupon-badge"> <Text className="coupon-badge">
¥{amount} ¥{display} {label}
{label}
</Text> </Text>
); );
} }
@@ -26,6 +26,7 @@ export default function PageShell({
'page-shell', 'page-shell',
`page-shell--${variant}`, `page-shell--${variant}`,
hasFixedFooter ? 'page-shell--fixed-footer' : '', hasFixedFooter ? 'page-shell--fixed-footer' : '',
variant === 'tab' && process.env.TARO_ENV === 'weapp' ? 'page-shell--native-tabbar' : '',
className, className,
] ]
.filter(Boolean) .filter(Boolean)
@@ -3,6 +3,7 @@ import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav'; import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
import { toast } from '../lib/api'; import { toast } from '../lib/api';
import { capturePromoSceneAndTouchScan } from '../lib/promo';
import { saveWechatLoginResult } from '../lib/pay-wechat'; import { saveWechatLoginResult } from '../lib/pay-wechat';
import { applyWechatShare } from '../lib/wechat-share'; import { applyWechatShare } from '../lib/wechat-share';
import { handleWechatAuthCallback } from '../lib/wechat-auth'; import { handleWechatAuthCallback } from '../lib/wechat-auth';
@@ -29,13 +30,16 @@ function currentPagePathWithQuery(): string {
} }
/** /**
* H5 App 根节点专用:不可用 useDidShowApp 无页面 Context * H5 App 根节点iOS 签名 URL + 默认分享 + OAuth code 回调
* - 首次进页:捕获 iOS 签名 URL + 默认分享 + OAuth code 回调 * 小程序:冷启动时捕获推广码 scene 并回传扫码埋点。
* - 路由/回前台:刷新默认分享卡片
*/ */
export default function WechatShareBootstrap() { export default function WechatShareBootstrap() {
const handlingCode = useRef(false); const handlingCode = useRef(false);
useEffect(() => {
void capturePromoSceneAndTouchScan();
}, []);
useEffect(() => { useEffect(() => {
if (process.env.TARO_ENV !== 'h5') return; if (process.env.TARO_ENV !== 'h5') return;
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
+129
View File
@@ -0,0 +1,129 @@
import Taro from '@tarojs/taro';
import { request } from './api';
const PROMO_ID_KEY = 'dukang_promo_id';
/** 同一次进入只 touch 一次扫码计数,避免首页反复 onShow 刷量 */
let lastScanTouchKey = '';
function safeDecode(raw: string): string {
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
function normalizePromoId(raw: unknown): string | null {
if (raw == null || raw === '') return null;
const s = safeDecode(String(raw)).trim();
// 小程序码 scene 写入的是推广活动数字 ID
if (!/^\d+$/.test(s)) return null;
return s;
}
type EnterOptionsLike = {
scene?: string | number;
query?: Record<string, string | undefined>;
path?: string;
};
/** 从启动/进入参数解析推广活动 ID(优先 query.scene,与 getwxacodeunlimit 一致) */
export function extractPromoIdFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
if (!opts) return null;
const q = opts.query ?? {};
return (
normalizePromoId(q.scene) ||
normalizePromoId(q.promoId) ||
normalizePromoId(q.pid) ||
null
);
}
export function getStoredPromoId(): string | null {
try {
const v = Taro.getStorageSync(PROMO_ID_KEY);
return normalizePromoId(v);
} catch {
return null;
}
}
export function setStoredPromoId(promoId: string) {
const id = normalizePromoId(promoId);
if (!id) return;
try {
Taro.setStorageSync(PROMO_ID_KEY, id);
} catch {
/* ignore */
}
}
function readEnterOptions(): EnterOptionsLike | null {
try {
if (typeof Taro.getEnterOptionsSync === 'function') {
return Taro.getEnterOptionsSync() as EnterOptionsLike;
}
} catch {
/* ignore */
}
try {
if (typeof Taro.getLaunchOptionsSync === 'function') {
return Taro.getLaunchOptionsSync() as EnterOptionsLike;
}
} catch {
/* ignore */
}
// H5:从 URL query 读取
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
return {
query: {
scene: params.get('scene') || undefined,
promoId: params.get('promoId') || undefined,
pid: params.get('pid') || undefined,
},
};
}
return null;
}
/**
* 主页面进入时:取出 scene(活动 ID)本地缓存,并回传 /promo/touch 累加扫码次数。
* 同一进入会话只计一次扫码。
*/
export async function capturePromoSceneAndTouchScan(): Promise<void> {
const opts = readEnterOptions();
const fromEnter = extractPromoIdFromEnterOptions(opts);
if (fromEnter) {
setStoredPromoId(fromEnter);
const touchKey = `${fromEnter}|${opts?.path || ''}|${JSON.stringify(opts?.query || {})}|${String(opts?.scene ?? '')}`;
if (touchKey === lastScanTouchKey) return;
lastScanTouchKey = touchKey;
await touchPromo({ promoId: fromEnter, countScan: true });
return;
}
// 无新 scene 时不重复扫码计数
}
/** 登录成功后:用已缓存的活动 ID 做归因(不重复加扫码次数) */
export async function touchStoredPromoAfterLogin(): Promise<void> {
const promoId = getStoredPromoId();
if (!promoId) return;
await touchPromo({ promoId, countScan: false });
}
async function touchPromo(input: { promoId: string; countScan: boolean }): Promise<void> {
try {
await request('/promo/touch', {
method: 'POST',
data: {
promoId: input.promoId,
countScan: input.countScan,
},
});
} catch {
/* 静默失败,不阻断浏览 */
}
}
@@ -5,9 +5,15 @@ import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import RegionPicker from '../../components/RegionPicker'; import RegionPicker from '../../components/RegionPicker';
import { buildAddressListUrl, readCheckoutContext } from '../../lib/checkout-nav'; import { buildAddressListUrl, readCheckoutContext } from '../../lib/checkout-nav';
import { DEFAULT_REGION, formatRegion, type RegionSelection } from '../../lib/region-data'; import {
DEFAULT_REGION,
REGION_ALL,
formatRegion,
type RegionSelection,
} from '../../lib/region-data';
import { normalizePhoneInput, validateMobilePhone } from '../../lib/phone'; import { normalizePhoneInput, validateMobilePhone } from '../../lib/phone';
import { getStoredUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone'; import { getStoredUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
import { resolveUserCity } from '../../lib/user-location';
import { request, toast, type UserProfile } from '../../lib/api'; import { request, toast, type UserProfile } from '../../lib/api';
type AddressForm = { type AddressForm = {
@@ -27,6 +33,7 @@ export default function AddressEditPage() {
const checkoutCtx = readCheckoutContext(router.params); const checkoutCtx = readCheckoutContext(router.params);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [locating, setLocating] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [form, setForm] = useState<AddressForm>(() => ({ const [form, setForm] = useState<AddressForm>(() => ({
receiverName: '', receiverName: '',
@@ -49,6 +56,35 @@ export default function AddressEditPage() {
.catch(() => {}); .catch(() => {});
}, [id]); }, [id]);
useEffect(() => {
if (id) return;
let cancelled = false;
setLocating(true);
void resolveUserCity(true)
.then((resolved) => {
if (cancelled) return;
const district =
resolved.region.district && resolved.region.district !== REGION_ALL
? resolved.region.district
: resolved.district && resolved.district !== REGION_ALL
? resolved.district
: DEFAULT_REGION.district;
setForm((prev) => ({
...prev,
province: resolved.region.province || prev.province,
city: resolved.region.city || prev.city,
district: district || prev.district,
}));
})
.catch(() => {})
.finally(() => {
if (!cancelled) setLocating(false);
});
return () => {
cancelled = true;
};
}, [id]);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
request<Array<Record<string, unknown>>>('/user/addresses').then((list) => { request<Array<Record<string, unknown>>>('/user/addresses').then((list) => {
@@ -161,7 +197,11 @@ export default function AddressEditPage() {
style={{ display: 'flex', alignItems: 'center' }} style={{ display: 'flex', alignItems: 'center' }}
onClick={() => setPickerOpen(true)} onClick={() => setPickerOpen(true)}
> >
<Text>{regionText || '请选择省市区'}</Text> <Text>
{locating && !isEdit
? '定位中…'
: regionText || '请选择省市区'}
</Text>
</View> </View>
</View> </View>
<View className="address-form-field"> <View className="address-form-field">
@@ -1,4 +1,6 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom', navigationStyle: 'custom',
navigationBarTitleText: '地址管理', navigationBarTitleText: '地址管理',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
}); });
+5 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, useRouter } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import { import {
@@ -44,6 +44,10 @@ export default function AddressesPage() {
loadList(); loadList();
}); });
usePullDownRefresh(() => {
void Promise.resolve(loadList()).finally(() => Taro.stopPullDownRefresh());
});
function selectAddress(addr: Address) { function selectAddress(addr: Address) {
if (!selectMode) return; if (!selectMode) return;
Taro.redirectTo({ Taro.redirectTo({
@@ -1,4 +1,6 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom', navigationStyle: 'custom',
navigationBarTitleText: '好客权益', navigationBarTitleText: '好客权益',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
}); });
+33 -10
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useCallback, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
@@ -35,18 +35,19 @@ function usagePercent(coupon: CouponItem) {
export default function BenefitPage() { export default function BenefitPage() {
const metrics = useNavBarMetrics(); const metrics = useNavBarMetrics();
const loggedIn = isLoggedIn(); const [loggedIn, setLoggedIn] = useState(() => isLoggedIn());
const [summary, setSummary] = useState<BenefitSummary | null>(null); const [summary, setSummary] = useState<BenefitSummary | null>(null);
const [coupons, setCoupons] = useState<CouponItem[]>([]); const [coupons, setCoupons] = useState<CouponItem[]>([]);
const [tab, setTab] = useState<'available' | 'history'>('available'); const [tab, setTab] = useState<'available' | 'history'>('available');
useDidShow(() => { const resetGuestState = useCallback(() => {
syncTabBarSelected(2); setSummary(null);
}); setCoupons([]);
}, []);
useEffect(() => { const loadBenefit = useCallback(() => {
if (!loggedIn) return; if (!isLoggedIn()) return Promise.resolve();
Promise.all([ return Promise.all([
request<BenefitSummary>('/benefit/summary'), request<BenefitSummary>('/benefit/summary'),
request<CouponItem[]>('/benefit/coupons'), request<CouponItem[]>('/benefit/coupons'),
]) ])
@@ -55,7 +56,29 @@ export default function BenefitPage() {
setCoupons(Array.isArray(list) ? list : []); setCoupons(Array.isArray(list) ? list : []);
}) })
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')); .catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [loggedIn]); }, []);
useDidShow(() => {
syncTabBarSelected(2);
const loggedInNow = isLoggedIn();
setLoggedIn(loggedInNow);
if (loggedInNow) {
void loadBenefit();
} else {
resetGuestState();
}
});
usePullDownRefresh(() => {
const loggedInNow = isLoggedIn();
setLoggedIn(loggedInNow);
if (!loggedInNow) {
resetGuestState();
Taro.stopPullDownRefresh();
return;
}
void loadBenefit().finally(() => Taro.stopPullDownRefresh());
});
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0); const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID'); const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID');
@@ -1,3 +1,5 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '杜康好客', navigationBarTitleText: '杜康好客',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
}); });
+140 -20
View File
@@ -1,29 +1,39 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader'; import TabMainHeader from '../../components/TabMainHeader';
import CouponBadge from '../../components/CouponBadge'; import CouponBadge from '../../components/CouponBadge';
import ProductCarousel from '../../components/ProductCarousel';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { request, toast } from '../../lib/api'; import { goLogin } from '../../lib/auth-nav';
import { getProductImages } from '../../lib/product-images'; import { isLoggedIn, request, toast } from '../../lib/api';
import { ensurePayReady } from '../../lib/pay-ready';
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
import { getProductMainImage } from '../../lib/product-images';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location'; import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
type Product = { type Product = {
id: string; id: string;
name: string; name: string;
subtitle?: string; subtitle?: string;
spec?: string;
price: number; price: number;
benefitDisplay?: number; benefitDisplay?: number;
mainImageUrl?: string | null; mainImageUrl?: string | null;
carouselUrls?: string[] | null; carouselUrls?: string[] | null;
aromaType: string; aromaType: string;
allowOnSitePickup?: boolean;
};
type MiniHomeConfig = {
banners: string[];
footerUrl: string | null;
}; };
const AROMA_TABS = [ const AROMA_TABS = [
{ key: 'QINGXIANG', label: '清香型' }, { key: 'QINGXIANG', label: '清香型' },
{ key: 'JIANGXIANG', label: '酱香型' },
{ key: 'NONGXIANG', label: '浓香型' }, { key: 'NONGXIANG', label: '浓香型' },
{ key: 'JIANGXIANG', label: '酱香型' },
] as const; ] as const;
export default function HomePage() { export default function HomePage() {
@@ -32,23 +42,69 @@ export default function HomePage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [displayCity, setDisplayCity] = useState('郑州市'); const [displayCity, setDisplayCity] = useState('郑州市');
const [cityCode, setCityCode] = useState('410100'); const [cityCode, setCityCode] = useState('410100');
const [miniHome, setMiniHome] = useState<MiniHomeConfig>({ banners: [], footerUrl: null });
const loadMiniHome = useCallback(() => {
return request<{ miniHome?: MiniHomeConfig }>('/common/client-config')
.then((cfg) => {
const banners = Array.isArray(cfg.miniHome?.banners)
? cfg.miniHome!.banners.filter((u) => typeof u === 'string' && !!u.trim())
: [];
const footerUrl =
typeof cfg.miniHome?.footerUrl === 'string' && cfg.miniHome.footerUrl.trim()
? cfg.miniHome.footerUrl.trim()
: null;
setMiniHome({ banners, footerUrl });
})
.catch(() => {
/* 首页装饰图失败不阻断商品列表 */
});
}, []);
useDidShow(() => { useDidShow(() => {
syncTabBarSelected(0); syncTabBarSelected(0);
void capturePromoSceneAndTouchScan();
void loadMiniHome();
void resolveUserCity().then((resolved) => { void resolveUserCity().then((resolved) => {
setDisplayCity(resolved.displayCity); setDisplayCity(resolved.displayCity);
setCityCode(getCityCodeForCatalog(resolved)); setCityCode(getCityCodeForCatalog(resolved));
}); });
}); });
useEffect(() => { const loadProducts = useCallback(() => {
setLoading(true); setLoading(true);
request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`) return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`)
.then((list) => setProducts(Array.isArray(list) ? list : [])) .then((list) => setProducts(Array.isArray(list) ? list : []))
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')) .catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [cityCode]); }, [cityCode]);
useEffect(() => {
void loadProducts();
}, [loadProducts]);
usePullDownRefresh(() => {
void (async () => {
try {
const resolved = await resolveUserCity();
setDisplayCity(resolved.displayCity);
const nextCode = getCityCodeForCatalog(resolved);
setCityCode(nextCode);
setLoading(true);
const [list] = await Promise.all([
request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`),
loadMiniHome(),
]);
setProducts(Array.isArray(list) ? list : []);
} catch (e) {
toast(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
Taro.stopPullDownRefresh();
}
})();
});
const availableAromas = useMemo( const availableAromas = useMemo(
() => () =>
AROMA_TABS.filter((item) => AROMA_TABS.filter((item) =>
@@ -68,12 +124,43 @@ export default function HomePage() {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` }); Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
} }
async function goOnSitePickup(productId: string) {
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
if (!isLoggedIn()) {
goLogin(returnPath);
return;
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath });
}
const filtered = products.filter((p) => p.aromaType === tab); const filtered = products.filter((p) => p.aromaType === tab);
const banners = miniHome.banners;
const footerUrl = miniHome.footerUrl;
return ( return (
<PageShell variant="tab" className="home-page no-tab-header"> <PageShell variant="tab" className="home-page no-tab-header">
<TabMainHeader title="杜康好客" /> <TabMainHeader title="杜康好客" />
{banners.length > 0 ? (
<View className="home-promo-banner">
<Swiper
className="home-promo-banner-swiper"
indicatorDots={banners.length > 1}
autoplay={banners.length > 1}
circular={banners.length > 1}
interval={4000}
>
{banners.map((url) => (
<SwiperItem key={url}>
<Image className="home-promo-banner-img" src={url} mode="aspectFill" />
</SwiperItem>
))}
</Swiper>
</View>
) : null}
<View className="home-aroma-nav"> <View className="home-aroma-nav">
<View className="home-aroma-tabs"> <View className="home-aroma-tabs">
{availableAromas.map((t) => ( {availableAromas.map((t) => (
@@ -96,32 +183,65 @@ export default function HomePage() {
) : null} ) : null}
{!loading && {!loading &&
filtered.map((p) => { filtered.map((p) => {
const images = getProductImages(p); const thumb = getProductMainImage(p);
const spec = p.subtitle || p.spec || '';
return ( return (
<View key={p.id} className="home-product-card"> <View key={p.id} className="home-product-card">
<View onClick={() => openProductDetail(p.id)}> <View
<ProductCarousel images={images} alt={p.name} variant="home" /> className="home-product-card-inner"
<View className="home-product-body"> onClick={() => openProductDetail(p.id)}
>
<View className="home-product-thumb-wrap">
{thumb ? (
<Image className="home-product-thumb" src={thumb} mode="aspectFill" />
) : (
<View className="home-product-thumb home-product-thumb--empty" />
)}
</View>
<View className="home-product-main">
<View className="home-product-row"> <View className="home-product-row">
<Text className="home-product-name">{p.name}</Text> <Text className="home-product-name">{p.name}</Text>
<Text className="home-product-price">¥{Number(p.price).toFixed(2)}</Text> <Text className="home-product-price">¥{Number(p.price).toFixed(0)}</Text>
</View> </View>
{p.subtitle ? <Text className="home-product-sub">{p.subtitle}</Text> : null} {spec ? <Text className="home-product-sub">{spec}</Text> : null}
<View className="home-product-footer"> <View className="home-product-footer">
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" /> <CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
</View> </View>
<View className="home-product-actions">
{p.allowOnSitePickup ? (
<Text
className="home-pickup-btn"
onClick={(e) => {
e.stopPropagation?.();
void goOnSitePickup(p.id);
}}
>
</Text>
) : null}
<Text
className="home-buy-btn"
onClick={(e) => {
e.stopPropagation?.();
openProductDetail(p.id);
}}
>
</Text>
</View>
</View> </View>
</View> </View>
<View className="home-product-actions">
<Text className="home-buy-btn" onClick={() => openProductDetail(p.id)}>
</Text>
</View>
</View> </View>
); );
})} })}
</View> </View>
{footerUrl ? (
<View className="home-promo-footer">
<Image className="home-promo-footer-img" src={footerUrl} mode="aspectFill" />
</View>
) : null}
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null} {shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null}
</PageShell> </PageShell>
); );
+2
View File
@@ -24,6 +24,7 @@ import {
type MiniWechatProfile, type MiniWechatProfile,
} from '../../lib/mini-wechat-profile'; } from '../../lib/mini-wechat-profile';
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api'; import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
import { touchStoredPromoAfterLogin } from '../../lib/promo';
const IS_WEAPP = process.env.TARO_ENV === 'weapp'; const IS_WEAPP = process.env.TARO_ENV === 'weapp';
@@ -176,6 +177,7 @@ export default function LoginPage() {
refreshToken: data.refreshToken, refreshToken: data.refreshToken,
}); });
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile()); void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
void touchStoredPromoAfterLogin();
if (!phoneValue) { if (!phoneValue) {
void fetchUserProfile() void fetchUserProfile()
.then((me) => resolveDefaultUserPhone(me)) .then((me) => resolveDefaultUserPhone(me))
@@ -1,3 +1,5 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '我的', navigationBarTitleText: '我的',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
}); });
+14 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { View, Text, Image, Button, Input } from '@tarojs/components'; import { View, Text, Image, Button, Input } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types'; import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader'; import TabMainHeader from '../../components/TabMainHeader';
@@ -64,9 +64,9 @@ export default function MinePage() {
} }
function loadProfile() { function loadProfile() {
if (!isLoggedIn()) return; if (!isLoggedIn()) return Promise.resolve();
setProfileLoadError(''); setProfileLoadError('');
Promise.all([ return Promise.all([
request<UserProfile>('/auth/me'), request<UserProfile>('/auth/me'),
request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []), request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []),
...ORDER_SHORTCUTS.map((s) => ...ORDER_SHORTCUTS.map((s) =>
@@ -109,6 +109,17 @@ export default function MinePage() {
} }
}); });
usePullDownRefresh(() => {
const loggedInNow = isLoggedIn();
setAuthed(loggedInNow);
if (!loggedInNow) {
resetGuestState();
Taro.stopPullDownRefresh();
return;
}
void loadProfile().finally(() => Taro.stopPullDownRefresh());
});
useEffect(() => { useEffect(() => {
request<ClientRuntimeConfig>('/common/client-config') request<ClientRuntimeConfig>('/common/client-config')
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config))) .then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '现场取货确认',
});
@@ -0,0 +1,246 @@
import { useEffect, useRef, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { goLogin } from '../../lib/auth-nav';
import { buildPayUrl } from '../../lib/checkout-nav';
import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { request } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images';
type PreviewProduct = {
id: string;
name: string;
spec?: string;
subtitle?: string;
price: number;
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
};
type OrderPreview = {
product: PreviewProduct;
quantity: number;
deliveryType: string;
productAmount: number;
payAmount: number;
benefitAmount: number;
quantityOk?: boolean;
quantityMessage?: string | null;
minQty?: number;
};
export default function OrderConfirmPickupPage() {
const router = useRouter();
const productId = router.params.productId ?? '';
const [quantity, setQuantity] = useState(Math.max(1, Number(router.params.qty || 1)));
const [preview, setPreview] = useState<OrderPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
const phonePromptSkipped = useRef(false);
useEffect(() => {
if (!productId) return;
let cancelled = false;
setPreviewLoading(true);
request<OrderPreview>('/trade/orders/preview', {
method: 'POST',
data: { productId, quantity, onSitePickup: true },
})
.then((data) => {
if (!cancelled) {
setPreview(data);
setMsg(data.quantityOk === false ? data.quantityMessage || '' : '');
}
})
.catch((e) => {
if (!cancelled) {
setPreview(null);
setMsg(e instanceof Error ? e.message : '加载失败');
}
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
return () => {
cancelled = true;
};
}, [productId, quantity]);
const minQty = preview?.minQty ?? 1;
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
function updateQuantity(next: number) {
if (next < 1) return;
setQuantity(next);
}
async function doSubmit() {
const order = await request<{ id: string }>('/trade/orders', {
method: 'POST',
data: { productId, quantity, onSitePickup: true },
});
Taro.redirectTo({
url: buildPayUrl({
orderId: order.id,
productId,
qty: String(quantity),
}),
});
}
async function submit() {
if (!canSubmit) {
if (!quantityOk) setMsg(`现场取货至少购买 ${minQty}`);
return;
}
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=${quantity}`;
if (!phonePromptSkipped.current) {
try {
const profile = await fetchUserProfile();
const phoneBound =
!!profile.phoneVerified ||
(!!profile.phone && /^1[3-9]\d{9}$/.test(String(profile.phone)));
if (!phoneBound) {
const { confirm, cancel } = await Taro.showModal({
title: '建议绑定手机号',
content: '绑定后便于订单通知与售后联系;也可跳过,不绑定也能继续下单。',
confirmText: '去绑定',
cancelText: '暂不绑定',
});
if (confirm) {
goLogin(returnPath, { needPhone: '1' });
return;
}
if (cancel) {
phonePromptSkipped.current = true;
}
}
} catch {
/* 拉取档案失败不阻塞下单 */
}
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
}
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
const submitLabel = loading
? '提交中…'
: !quantityOk
? `至少购买 ${minQty}`
: '提交订单';
return (
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
<SubPageHeader title="现场取货确认" />
<View className="sub-page-body">
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="u-muted"> · · </Text>
</View>
{preview ? (
<>
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-product-row">
<View className="order-product-thumb">
{productImage ? (
<Image
className="order-product-thumb-img"
src={productImage}
mode="aspectFill"
/>
) : null}
</View>
<View style={{ flex: 1 }}>
<Text className="order-product-name">{preview.product.name}</Text>
{preview.product.spec ? (
<Text className="u-muted">{preview.product.spec}</Text>
) : null}
<Text className="order-product-price">
¥{Number(preview.product.price).toFixed(2)}
</Text>
</View>
</View>
<View className="order-qty-row">
<Text></Text>
<View className="order-qty-controls">
<View
className={`order-qty-btn${quantity <= 1 ? ' order-qty-btn--disabled' : ''}`}
onClick={() => updateQuantity(quantity - 1)}
>
<Text></Text>
</View>
<Text className="order-qty-value">{quantity}</Text>
<View className="order-qty-btn" onClick={() => updateQuantity(quantity + 1)}>
<Text></Text>
</View>
</View>
</View>
</View>
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">¥{Number(preview.productAmount).toFixed(2)}</Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value--price">¥{Number(preview.benefitAmount).toFixed(2)}</Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value"></Text>
</View>
</View>
</>
) : previewLoading ? (
<View className="u-empty"></View>
) : null}
{msg ? (
<Text className="u-muted" style={{ display: 'block', marginTop: 8 }}>
{msg}
</Text>
) : null}
</View>
<View className="order-confirm-bar">
<View className="order-confirm-total">
<Text className="order-confirm-total-label"></Text>
<Text className="order-confirm-total-value">
¥{preview ? Number(preview.payAmount).toFixed(2) : '—'}
</Text>
</View>
<View
className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
onClick={() => {
if (!canSubmit) return;
void submit();
}}
>
<Text>{submitLabel}</Text>
</View>
</View>
</PageShell>
);
}
@@ -333,7 +333,11 @@ export default function OrderConfirmPage() {
{!previewLoading && !preview && productId ? ( {!previewLoading && !preview && productId ? (
<View className="u-empty"></View> <View className="u-empty"></View>
) : null} ) : null}
{msg ? <Text className="u-muted" style={{ display: 'block', marginTop: 8 }}>{msg}</Text> : null} {msg ? (
<Text className="u-muted" style={{ display: 'block', marginTop: 8, textAlign: 'center' }}>
{msg}
</Text>
) : null}
</View> </View>
<View className="order-confirm-bar"> <View className="order-confirm-bar">
@@ -47,7 +47,9 @@ const STATUS_LABELS: Record<string, string> = {
PENDING_SHIP: '待发货', PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '出库中', OUT_WAREHOUSE: '出库中',
SHIPPING: '配送中', SHIPPING: '配送中',
SHIPPED: '配送中',
PENDING_RECEIVE: '待签收', PENDING_RECEIVE: '待签收',
DELIVERED: '待签收',
COMPLETED: '已完成', COMPLETED: '已完成',
CANCELLED: '已取消', CANCELLED: '已取消',
REFUNDING: '退款中', REFUNDING: '退款中',
@@ -68,6 +70,7 @@ export default function OrderDetailPage() {
const router = useRouter(); const router = useRouter();
const orderId = router.params.id ?? ''; const orderId = router.params.id ?? '';
const [order, setOrder] = useState<OrderDetail | null>(null); const [order, setOrder] = useState<OrderDetail | null>(null);
const [confirming, setConfirming] = useState(false);
useEffect(() => { useEffect(() => {
if (!orderId) return; if (!orderId) return;
@@ -76,7 +79,11 @@ export default function OrderDetailPage() {
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')); .catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [orderId]); }, [orderId]);
const canPay = !!order && order.status === 'PENDING_PAY' && !order.originOrderId; const isReship = !!order?.originOrderId;
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
const canConfirmReceive =
!!order && !isReship && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
const item = order?.items?.[0]; const item = order?.items?.[0];
const productName = item?.productName || order?.productName || '杜康商品'; const productName = item?.productName || order?.productName || '杜康商品';
const quantity = item?.quantity ?? order?.quantity ?? order?.qty ?? 1; const quantity = item?.quantity ?? order?.quantity ?? order?.qty ?? 1;
@@ -111,10 +118,36 @@ export default function OrderDetailPage() {
Taro.navigateTo({ url: '/pages/customer-service/index' }); Taro.navigateTo({ url: '/pages/customer-service/index' });
} }
async function confirmReceive() {
if (!order || !canConfirmReceive || confirming) return;
const { confirm } = await Taro.showModal({
title: '确认收货?',
content: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
confirmText: '确认收货',
cancelText: '再想想',
});
if (!confirm) return;
setConfirming(true);
try {
const updated = await request<OrderDetail>(`/trade/orders/${order.id}/confirm-receive`, {
method: 'POST',
data: {},
});
setOrder(updated);
toast('已确认收货');
} catch (e) {
toast(e instanceof Error ? e.message : '确认收货失败');
} finally {
setConfirming(false);
}
}
const pageClass = [ const pageClass = [
'order-detail-page', 'order-detail-page',
order ? 'order-detail-page--with-actions' : '', order ? 'order-detail-page--with-actions' : '',
canPay ? 'order-detail-page--with-pay' : '', canPay || canConfirmReceive ? 'order-detail-page--with-pay' : '',
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
@@ -189,7 +222,11 @@ export default function OrderDetailPage() {
</View> </View>
{order ? ( {order ? (
<View className={`order-detail-actionbar${canPay ? ' order-detail-actionbar--with-pay' : ''}`}> <View
className={`order-detail-actionbar${
canPay || canConfirmReceive ? ' order-detail-actionbar--with-pay' : ''
}`}
>
{isWeapp ? ( {isWeapp ? (
<ContactCsButton <ContactCsButton
className="order-detail-cs-btn" className="order-detail-cs-btn"
@@ -219,6 +256,14 @@ export default function OrderDetailPage() {
</View> </View>
</> </>
) : null} ) : null}
{canConfirmReceive ? (
<View
className={`order-confirm-submit${confirming ? ' order-confirm-submit--disabled' : ''}`}
onClick={confirming ? undefined : () => void confirmReceive()}
>
{confirming ? '提交中…' : '确认收货'}
</View>
) : null}
</View> </View>
) : null} ) : null}
</PageShell> </PageShell>
@@ -1,4 +1,6 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom', navigationStyle: 'custom',
navigationBarTitleText: '我的订单', navigationBarTitleText: '我的订单',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
}); });
+81 -32
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro'; import Taro, { usePullDownRefresh, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
const TABS = [ const TABS = [
{ key: 'all', label: '全部订单' }, { key: 'all', label: '全部订单' },
@@ -30,6 +31,13 @@ function orderStatusLabel(tab: string, status?: string): string {
return STATUS_LABELS[status] || status; return STATUS_LABELS[status] || status;
} }
type OrderItem = {
productName?: string;
productImage?: string;
unitPrice?: number;
quantity?: number;
};
type OrderRow = { type OrderRow = {
id: string; id: string;
orderNo?: string; orderNo?: string;
@@ -37,7 +45,9 @@ type OrderRow = {
payAmount?: number; payAmount?: number;
productName?: string; productName?: string;
qty?: number; qty?: number;
createdAt?: string; quantity?: number;
originOrderId?: string | null;
items?: OrderItem[];
}; };
export default function OrdersPage() { export default function OrdersPage() {
@@ -47,9 +57,9 @@ export default function OrdersPage() {
const [orders, setOrders] = useState<OrderRow[]>([]); const [orders, setOrders] = useState<OrderRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { const loadOrders = useCallback(() => {
setLoading(true); setLoading(true);
request<{ list?: OrderRow[]; items?: OrderRow[]; total?: number } | OrderRow[]>( return request<{ list?: OrderRow[]; items?: OrderRow[]; total?: number } | OrderRow[]>(
`/trade/orders?tab=${encodeURIComponent(tab)}&pageSize=20`, `/trade/orders?tab=${encodeURIComponent(tab)}&pageSize=20`,
) )
.then((data) => { .then((data) => {
@@ -63,6 +73,18 @@ export default function OrdersPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [tab]); }, [tab]);
useEffect(() => {
void loadOrders();
}, [loadOrders]);
usePullDownRefresh(() => {
void loadOrders().finally(() => Taro.stopPullDownRefresh());
});
function goPay(orderId: string) {
Taro.navigateTo({ url: buildPayUrl({ orderId }) });
}
return ( return (
<PageShell variant="sub" className="orders-page"> <PageShell variant="sub" className="orders-page">
<SubPageHeader title="我的订单" /> <SubPageHeader title="我的订单" />
@@ -81,35 +103,62 @@ export default function OrdersPage() {
{loading ? <View className="u-empty"></View> : null} {loading ? <View className="u-empty"></View> : null}
{!loading && orders.length === 0 ? <View className="u-empty"></View> : null} {!loading && orders.length === 0 ? <View className="u-empty"></View> : null}
{!loading && {!loading &&
orders.map((o) => ( orders.map((o) => {
<View const item = o.items?.[0];
key={o.id} const productName = item?.productName || o.productName || '杜康商品';
className="order-list-item" const productImage = (item?.productImage || '').trim();
onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })} const qty = item?.quantity ?? o.quantity ?? o.qty ?? 1;
> const unitPrice = Number(item?.unitPrice ?? 0);
<View className="order-list-head"> const canPay = o.status === 'PENDING_PAY' && !o.originOrderId;
<Text className="order-list-no">{o.orderNo || o.id}</Text>
<Text className="order-list-status"> return (
{orderStatusLabel(tab, o.status)} <View
</Text> key={o.id}
</View> className="order-list-item"
<View className="order-list-body"> onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })}
<View className="order-list-thumb" /> >
<View style={{ flex: 1 }}> <View className="order-list-head">
<Text className="order-list-name">{o.productName || '杜康商品'}</Text> <Text className="order-list-no">{o.orderNo || o.id}</Text>
<Text className="order-list-meta"> <Text className="order-list-status">
{o.qty ?? 1} · {o.createdAt ? String(o.createdAt).slice(0, 10) : ''} {orderStatusLabel(tab, o.status)}
</Text> </Text>
</View> </View>
<View className="order-list-body">
<View className="order-list-thumb">
{productImage ? (
<Image className="order-list-thumb-img" src={productImage} mode="aspectFill" />
) : null}
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text className="order-list-name">{productName}</Text>
<View className="order-list-meta-row">
<Text className="order-list-meta"> {qty}</Text>
<Text className="order-list-meta"> ¥{unitPrice.toFixed(2)}</Text>
</View>
</View>
</View>
<View className="order-list-footer">
<View className="order-list-pay-amount">
<Text className="order-list-meta"></Text>
<Text className="order-product-price">
¥{Number(o.payAmount ?? 0).toFixed(2)}
</Text>
</View>
{canPay ? (
<View
className="order-list-pay-btn"
onClick={(e) => {
e.stopPropagation();
goPay(o.id);
}}
>
</View>
) : null}
</View>
</View> </View>
<View className="order-list-footer"> );
<Text className="order-list-meta"></Text> })}
<Text className="order-product-price">
¥{Number(o.payAmount ?? 0).toFixed(2)}
</Text>
</View>
</View>
))}
</PageShell> </PageShell>
); );
} }
+15 -5
View File
@@ -29,6 +29,7 @@ export default function PayPage() {
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [orderNo, setOrderNo] = useState(''); const [orderNo, setOrderNo] = useState('');
const [payAmount, setPayAmount] = useState('—'); const [payAmount, setPayAmount] = useState('—');
const [deliveryType, setDeliveryType] = useState('');
const returnPath = orderId const returnPath = orderId
? `/pages/pay/index?orderId=${orderId}` ? `/pages/pay/index?orderId=${orderId}`
@@ -62,11 +63,15 @@ export default function PayPage() {
setPayAmount('—'); setPayAmount('—');
return; return;
} }
request<{ orderNo?: string; payAmount?: number | string; totalAmount?: number | string }>( request<{
`/trade/orders/${orderId}`, orderNo?: string;
) payAmount?: number | string;
totalAmount?: number | string;
deliveryType?: string;
}>(`/trade/orders/${orderId}`)
.then((order) => { .then((order) => {
setOrderNo(order.orderNo || ''); setOrderNo(order.orderNo || '');
setDeliveryType(order.deliveryType || '');
const amount = Number(order.payAmount ?? order.totalAmount ?? 0); const amount = Number(order.payAmount ?? order.totalAmount ?? 0);
if (Number.isFinite(amount) && amount > 0) { if (Number.isFinite(amount) && amount > 0) {
setPayAmount(amount.toFixed(2)); setPayAmount(amount.toFixed(2));
@@ -74,6 +79,7 @@ export default function PayPage() {
}) })
.catch((e) => { .catch((e) => {
setOrderNo(''); setOrderNo('');
setDeliveryType('');
toast(e instanceof Error ? e.message : '加载订单失败'); toast(e instanceof Error ? e.message : '加载订单失败');
}); });
}, [orderId]); }, [orderId]);
@@ -137,7 +143,11 @@ export default function PayPage() {
} else { } else {
toast('支付成功', 'success'); toast('支付成功', 'success');
} }
Taro.redirectTo({ url: '/pages/orders/index?tab=paid' }); if (deliveryType === 'ON_SITE_PICKUP') {
Taro.redirectTo({ url: `/pages/pickup-receive/index?id=${orderId}` });
} else {
Taro.redirectTo({ url: '/pages/orders/index?tab=paid' });
}
} catch (e) { } catch (e) {
if (isWechatAuthRequiredError(e)) { if (isWechatAuthRequiredError(e)) {
setNeedsWechatAuth(true); setNeedsWechatAuth(true);
@@ -200,7 +210,7 @@ export default function PayPage() {
</View> </View>
</View> </View>
{msg ? ( {msg ? (
<Text className="pay-wechat-auth-msg" style={{ display: 'block', marginTop: 12 }}> <Text className="pay-wechat-auth-msg" style={{ marginTop: 12 }}>
{msg} {msg}
</Text> </Text>
) : null} ) : null}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '确认收货',
});
@@ -0,0 +1,137 @@
import { useCallback, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images';
type OrderDetail = {
id: string;
orderNo?: string;
status?: string;
payAmount?: number | string;
productName?: string;
productSpec?: string;
quantity?: number;
product?: {
name?: string;
spec?: string;
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
};
imageUrl?: string | null;
mainImageUrl?: string | null;
};
export default function PickupReceivePage() {
const router = useRouter();
const orderId = router.params.id ?? router.params.orderId ?? '';
const [order, setOrder] = useState<OrderDetail | null>(null);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const load = useCallback(() => {
if (!orderId) return;
setLoading(true);
request<OrderDetail>(`/trade/orders/${orderId}`)
.then((data) => setOrder(data))
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, [orderId]);
useDidShow(() => {
load();
});
async function confirmReceive() {
if (!orderId || submitting) return;
setSubmitting(true);
try {
await request(`/trade/orders/${orderId}/confirm-receive`, {
method: 'POST',
data: {},
});
toast('确认收货成功', 'success');
setTimeout(() => {
Taro.redirectTo({ url: '/pages/orders/index?tab=done' });
}, 500);
} catch (e) {
toast(e instanceof Error ? e.message : '确认失败');
} finally {
setSubmitting(false);
}
}
const name = order?.productName || order?.product?.name || '商品';
const spec = order?.productSpec || order?.product?.spec;
const image =
order?.mainImageUrl ||
order?.imageUrl ||
(order?.product ? getProductMainImage(order.product) : '') ||
'';
const amount = Number(order?.payAmount ?? 0);
const canConfirm = order?.status === 'PENDING_RECEIVE' || order?.status === 'DELIVERED';
return (
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
<SubPageHeader title="确认收货" />
<View className="sub-page-body">
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="u-muted"></Text>
</View>
{loading && !order ? (
<View className="order-card">
<Text className="u-muted"></Text>
</View>
) : null}
{order ? (
<>
<View className="order-card">
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">{order.orderNo || '—'}</Text>
</View>
<View className="order-product-row" style={{ marginTop: 12 }}>
<View className="order-product-thumb">
{image ? (
<Image className="order-product-thumb-img" src={image} mode="aspectFill" />
) : null}
</View>
<View style={{ flex: 1 }}>
<Text className="order-product-name">{name}</Text>
{spec ? <Text className="u-muted">{spec}</Text> : null}
<Text className="u-muted">×{order.quantity ?? 1}</Text>
</View>
</View>
</View>
<View className="order-card">
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value order-pay-amount">
¥{Number.isFinite(amount) ? amount.toFixed(2) : '—'}
</Text>
</View>
</View>
</>
) : null}
</View>
<View className="pay-bar">
<View
className="order-confirm-submit"
style={{ flex: 1, opacity: canConfirm && !submitting ? 1 : 0.6 }}
onClick={() => {
if (!canConfirm || submitting) return;
void confirmReceive();
}}
>
<Text>{submitting ? '提交中…' : canConfirm ? '确认收货' : '订单状态不可确认'}</Text>
</View>
</View>
</PageShell>
);
}
+61 -12
View File
@@ -11,17 +11,51 @@ type BenefitSummary = {
maxRedeemAmount: number; maxRedeemAmount: number;
}; };
const MIN_REDEEM_AMOUNT = 0.01;
function formatMoney(amount: number) { function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
/**
* 核销金额输入清洗:
* - 只保留数字与一个小数点
* - 小数最多 2 位(再输入会被截断)
* - 去掉多余前导 0
*/
function sanitizeRedeemAmountInput(raw: string): string {
let next = String(raw ?? '').replace(/[^\d.]/g, '');
if (!next) return '';
const firstDot = next.indexOf('.');
if (firstDot >= 0) {
const intRaw = next.slice(0, firstDot).replace(/\D/g, '');
const decRaw = next
.slice(firstDot + 1)
.replace(/\D/g, '')
.slice(0, 2);
const intPart = intRaw.replace(/^0+(?=\d)/, '') || '0';
if (next.endsWith('.') && decRaw.length === 0) {
return `${intPart}.`;
}
if (decRaw.length > 0) {
return `${intPart}.${decRaw}`;
}
return intPart;
}
return next.replace(/^0+(?=\d)/, '');
}
export default function RedeemPage() { export default function RedeemPage() {
const router = useRouter(); const router = useRouter();
const couponId = router.params.couponId; const couponId = router.params.couponId;
const initialAmount = router.params.amount ?? ''; const initialAmount = router.params.amount ?? '';
const [balance, setBalance] = useState(0); const [balance, setBalance] = useState(0);
const [couponBalance, setCouponBalance] = useState<number | null>(null); const [couponBalance, setCouponBalance] = useState<number | null>(null);
const [amount, setAmount] = useState(initialAmount); const [amount, setAmount] = useState(() => sanitizeRedeemAmountInput(initialAmount));
/** 原生 input 在截断小数后偶发不同步,强制 remount 对齐受控值 */
const [inputKey, setInputKey] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const redeemableMax = couponId ? (couponBalance ?? 0) : balance; const redeemableMax = couponId ? (couponBalance ?? 0) : balance;
@@ -50,14 +84,24 @@ export default function RedeemPage() {
}, [couponId]); }, [couponId]);
function fillMaxAmount() { function fillMaxAmount() {
if (redeemableMax <= 0) return; if (redeemableMax < MIN_REDEEM_AMOUNT) return;
setAmount(String(redeemableMax)); setAmount(sanitizeRedeemAmountInput(redeemableMax.toFixed(2)));
setInputKey((k) => k + 1);
}
function onAmountChange(raw: string) {
const next = sanitizeRedeemAmountInput(raw);
setAmount(next);
// 用户试图输入超过两位小数 / 非法字符时,强制刷新原生框显示
if (next !== raw) {
setInputKey((k) => k + 1);
}
} }
async function submit() { async function submit() {
const value = Math.round(Number(amount) * 100) / 100; const value = Math.round(Number(amount) * 100) / 100;
if (!(value > 0)) { if (!Number.isFinite(value) || value < MIN_REDEEM_AMOUNT) {
toast('请输入核销金额'); toast(`核销金额不能低于 ${MIN_REDEEM_AMOUNT.toFixed(2)}`);
return; return;
} }
if (value > redeemableMax) { if (value > redeemableMax) {
@@ -98,29 +142,34 @@ export default function RedeemPage() {
</View> </View>
<View className="redeem-input-wrap"> <View className="redeem-input-wrap">
<Input <Input
key={inputKey}
className="redeem-input" className="redeem-input"
type="digit" type="digit"
placeholder="输入核销金额" placeholder="输入核销金额"
placeholderClass="redeem-input-placeholder" placeholderClass="redeem-input-placeholder"
value={amount} value={amount}
onInput={(e) => setAmount(e.detail.value)} maxlength={12}
onInput={(e) => onAmountChange(e.detail.value)}
onBlur={(e) => onAmountChange(e.detail.value)}
style={{ textAlign: 'center' }} style={{ textAlign: 'center' }}
/> />
</View> </View>
<View className="redeem-amount-foot"> <View className="redeem-amount-foot">
<Text className="redeem-tips" style={{ margin: 0 }}> <Text className="redeem-amount-hint">
¥{formatMoney(redeemableMax)} ¥{formatMoney(redeemableMax)}
</Text> </Text>
<Text className="redeem-fill-max" onClick={fillMaxAmount}> <Text className="redeem-fill-max" onClick={fillMaxAmount}>
</Text> </Text>
</View> </View>
<Text className="redeem-tips"> <View className="redeem-tips">
0 3 <Text className="redeem-tips-text">
</Text> 0.01 3
</Text>
</View>
<View <View
className={`redeem-submit${loading || redeemableMax <= 0 ? ' redeem-submit--disabled' : ''}`} className={`redeem-submit${loading || redeemableMax < MIN_REDEEM_AMOUNT ? ' redeem-submit--disabled' : ''}`}
onClick={loading || redeemableMax <= 0 ? undefined : submit} onClick={loading || redeemableMax < MIN_REDEEM_AMOUNT ? undefined : submit}
> >
<Text>{loading ? '生成中...' : '生成核销码'}</Text> <Text>{loading ? '生成中...' : '生成核销码'}</Text>
</View> </View>
@@ -1,3 +1,5 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '门店', navigationBarTitleText: '门店',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
}); });
+115 -26
View File
@@ -1,9 +1,15 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, Image, Input } from '@tarojs/components'; import { View, Text, Image, Input } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader'; import TabMainHeader from '../../components/TabMainHeader';
import RegionPicker from '../../components/RegionPicker'; import RegionPicker from '../../components/RegionPicker';
import CategoryPicker, {
EMPTY_CATEGORY,
formatCategoryLabel,
type CategorySelection,
type StoreCategoryNode,
} from '../../components/CategoryPicker';
import { import {
DEFAULT_REGION, DEFAULT_REGION,
formatRegionLabel, formatRegionLabel,
@@ -26,20 +32,36 @@ type Store = {
openTime?: string | null; openTime?: string | null;
closeTime?: string | null; closeTime?: string | null;
status?: string; status?: string;
categoryId?: string | null;
category?: { id?: string; name?: string; parentId?: string | null } | null;
}; };
const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km']; const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
export default function StoresPage() { export default function StoresPage() {
const [stores, setStores] = useState<Store[]>([]); const [stores, setStores] = useState<Store[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [categoryTab, setCategoryTab] = useState<string>('全部'); const [keywordInput, setKeywordInput] = useState('');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION); const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
const [regionOpen, setRegionOpen] = useState(false); const [regionOpen, setRegionOpen] = useState(false);
const [category, setCategory] = useState<CategorySelection>(EMPTY_CATEGORY);
const [categoryOpen, setCategoryOpen] = useState(false);
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const [cityCode, setCityCode] = useState<string>(FALLBACK_CITY_CODE); const [cityCode, setCityCode] = useState<string>(FALLBACK_CITY_CODE);
const regionLabel = formatRegionLabel(region); const regionLabel = formatRegionLabel(region);
const categoryLabel = formatCategoryLabel(category);
const childIdsByParent = useMemo(() => {
const map = new Map<string, string[]>();
for (const root of categoryTree) {
map.set(
root.id,
(root.children ?? []).map((c) => c.id),
);
}
return map;
}, [categoryTree]);
useDidShow(() => { useDidShow(() => {
syncTabBarSelected(1); syncTabBarSelected(1);
@@ -50,21 +72,75 @@ export default function StoresPage() {
}); });
useEffect(() => { useEffect(() => {
void request<StoreCategoryNode[]>('/store-categories')
.then((tree) => setCategoryTree(Array.isArray(tree) ? tree : []))
.catch(() => setCategoryTree([]));
}, []);
const loadStores = useCallback(() => {
setLoading(true); setLoading(true);
const path = cityCode ? `/stores?cityCode=${encodeURIComponent(cityCode)}` : '/stores'; const path = cityCode ? `/stores?cityCode=${encodeURIComponent(cityCode)}` : '/stores';
request<Store[]>(path) return request<Store[]>(path)
.then((list) => setStores(Array.isArray(list) ? list : [])) .then((list) => setStores(Array.isArray(list) ? list : []))
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')) .catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [cityCode]); }, [cityCode]);
useEffect(() => {
void loadStores();
}, [loadStores]);
usePullDownRefresh(() => {
void (async () => {
try {
const resolved = await resolveUserCity();
setRegion(resolved.region);
const nextCode = getCityCodeForCatalog(resolved);
setCityCode(nextCode);
setLoading(true);
const path = nextCode ? `/stores?cityCode=${encodeURIComponent(nextCode)}` : '/stores';
const list = await request<Store[]>(path);
setStores(Array.isArray(list) ? list : []);
} catch (e) {
toast(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
Taro.stopPullDownRefresh();
}
})();
});
function matchesCategory(store: Store): boolean {
if (!category.parentId) return true;
const storeCatId = String(store.categoryId || store.category?.id || '');
const storeParentId = String(store.category?.parentId || '');
if (category.childId) {
return storeCatId === category.childId;
}
if (storeParentId && storeParentId === category.parentId) return true;
const siblings = childIdsByParent.get(category.parentId) ?? [];
return siblings.includes(storeCatId);
}
const filtered = stores.filter((s) => { const filtered = stores.filter((s) => {
if (!matchesRegionFilter(s, region)) return false; if (!matchesRegionFilter(s, region)) return false;
if (!matchesCategory(s)) return false;
if (!keyword.trim()) return true; if (!keyword.trim()) return true;
const q = keyword.trim(); const q = keyword.trim();
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q); return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
}); });
function applySearch() {
setKeyword(keywordInput.trim());
}
function resetFilters() {
setKeywordInput('');
setKeyword('');
setCategory(EMPTY_CATEGORY);
setRegion(DEFAULT_REGION);
}
function formatHours(store: Store) { function formatHours(store: Store) {
if (store.openTime && store.closeTime) { if (store.openTime && store.closeTime) {
return `营业时间: ${store.openTime}-${store.closeTime}`; return `营业时间: ${store.openTime}-${store.closeTime}`;
@@ -75,29 +151,35 @@ export default function StoresPage() {
return ( return (
<PageShell variant="tab" className="store-page no-tab-header"> <PageShell variant="tab" className="store-page no-tab-header">
<TabMainHeader title="门店" /> <TabMainHeader title="门店" />
<View className="store-toolbar">
<View className="store-location" onClick={() => setRegionOpen(true)}>
<View className="store-location-pin" />
<Text className="store-location-text">{regionLabel} </Text>
</View>
<Input
className="store-search"
placeholder="搜索门店"
value={keyword}
onInput={(e) => setKeyword(e.detail.value)}
/>
</View>
<View className="store-category-tabs"> <View className="store-filter">
{CATEGORY_TABS.map((tab) => ( <View className="store-search-row">
<Text <Input
key={tab} className="store-search-input"
className={`store-category-tab${categoryTab === tab ? ' store-category-tab--active' : ''}`} placeholder="搜索门店名称/地址"
onClick={() => setCategoryTab(tab)} value={keywordInput}
> confirmType="search"
{tab} onInput={(e) => setKeywordInput(e.detail.value)}
onConfirm={applySearch}
/>
<View className="store-search-btn" onClick={applySearch} aria-label="搜索">
<View className="store-search-icon" />
</View>
</View>
<View className="store-filter-row">
<View className="store-filter-chip" onClick={() => setRegionOpen(true)}>
<Text className="store-filter-chip-text">{regionLabel}</Text>
<Text className="store-filter-chip-arrow"></Text>
</View>
<View className="store-filter-chip" onClick={() => setCategoryOpen(true)}>
<Text className="store-filter-chip-text">{categoryLabel}</Text>
<Text className="store-filter-chip-arrow"></Text>
</View>
<Text className="store-filter-reset" onClick={resetFilters}>
</Text> </Text>
))} </View>
</View> </View>
<View className="store-list"> <View className="store-list">
@@ -147,6 +229,13 @@ export default function StoresPage() {
onClose={() => setRegionOpen(false)} onClose={() => setRegionOpen(false)}
onConfirm={(next) => setRegion(next)} onConfirm={(next) => setRegion(next)}
/> />
<CategoryPicker
open={categoryOpen}
tree={categoryTree}
value={category}
onClose={() => setCategoryOpen(false)}
onConfirm={(next) => setCategory(next)}
/>
</PageShell> </PageShell>
); );
} }
+106 -68
View File
@@ -31,23 +31,45 @@
background: var(--color-background); background: var(--color-background);
} }
.home-promo-banner {
margin: 12px var(--space-page) 0;
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-card);
box-shadow: var(--shadow-card);
aspect-ratio: 15 / 8;
}
.home-promo-banner-swiper {
width: 100%;
height: 100%;
}
.home-promo-banner-img {
width: 100%;
height: 100%;
display: block;
}
.home-aroma-nav { .home-aroma-nav {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
padding: 8px var(--space-page); margin-top: 4px;
background: rgba(250, 249, 247, 0.95); padding: 6px var(--space-page) 4px;
border-bottom: 1px solid var(--color-surface-container); background: transparent;
border-bottom: none;
position: sticky; position: sticky;
top: var(--nav-bar-height, 56px); top: 0;
z-index: 40; z-index: 40;
background: rgba(250, 249, 247, 0.96);
} }
.home-aroma-tabs { .home-aroma-tabs {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 4px;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
@@ -66,11 +88,11 @@
.home-aroma-tab { .home-aroma-tab {
border: none; border: none;
background: transparent; background: transparent;
padding: 6px 12px; padding: 4px 10px;
font-family: var(--font-headline); font-family: var(--font-headline);
font-size: 16px; font-size: 14px;
font-weight: 600; font-weight: 600;
line-height: 24px; line-height: 22px;
color: var(--color-subtle-gray); color: var(--color-subtle-gray);
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
white-space: nowrap; white-space: nowrap;
@@ -84,68 +106,49 @@
.home-product-list { .home-product-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 10px;
padding: 16px var(--space-page); padding: 8px var(--space-page) 12px;
} }
.home-product-card { .home-product-card {
background: var(--color-card); background: var(--color-card);
border-radius: var(--radius-lg); border-radius: 12px;
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
.home-carousel-wrap { .home-product-card-inner {
position: relative;
width: 100%;
aspect-ratio: 1;
background: var(--color-surface-container);
overflow: hidden;
}
.home-carousel {
width: 100%;
height: 100%;
}
.home-carousel-item,
.home-carousel-image,
.home-carousel-placeholder {
width: 100%;
height: 100%;
}
.home-carousel-placeholder {
background: var(--color-surface-container);
}
.home-carousel-dots {
position: absolute;
bottom: 12px;
left: 0;
right: 0;
display: flex; display: flex;
justify-content: center; align-items: stretch;
gap: 10px;
padding: 10px;
} }
.home-carousel-dot { .home-product-thumb-wrap {
height: 4px; flex-shrink: 0;
width: 6px; width: 88px;
margin: 0 3px; height: 88px;
border-radius: 999px; border-radius: 8px;
background: rgba(153, 153, 153, 0.35); overflow: hidden;
background: var(--color-surface-container);
} }
.home-carousel-dot--active { .home-product-thumb {
width: 20px; width: 100%;
background: var(--color-heritage-red); height: 100%;
display: block;
} }
.home-product-body { .home-product-thumb--empty {
padding: var(--space-gutter); background: var(--color-surface-container);
}
.home-product-main {
flex: 1;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 2px;
} }
.home-product-row { .home-product-row {
@@ -157,9 +160,9 @@
.home-product-name { .home-product-name {
font-family: var(--font-headline); font-family: var(--font-headline);
font-size: 16px; font-size: 14px;
font-weight: 600; font-weight: 600;
line-height: 24px; line-height: 20px;
color: var(--color-on-surface); color: var(--color-on-surface);
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
@@ -168,61 +171,96 @@
} }
.home-product-price { .home-product-price {
font-size: 16px; font-size: 15px;
font-weight: 700; font-weight: 700;
line-height: 20px;
color: var(--color-heritage-red); color: var(--color-heritage-red);
flex-shrink: 0; flex-shrink: 0;
} }
.home-product-sub { .home-product-sub {
font-size: 13px; font-size: 11px;
color: var(--color-subtle-gray); color: var(--color-subtle-gray);
line-height: 18px; line-height: 16px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.home-product-footer { .home-product-footer {
margin-top: 4px; margin-top: 2px;
} }
.home-product-actions { .home-product-actions {
padding: 0 var(--space-gutter) var(--space-gutter); margin-top: auto;
padding-top: 4px;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 6px;
}
.home-pickup-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
border-radius: var(--radius-full);
background: #2e7d32;
color: #fff;
font-size: 11px;
font-weight: 600;
line-height: 16px;
border: none;
} }
.home-buy-btn { .home-buy-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 8px 16px; padding: 4px 12px;
border-radius: var(--radius-full); border-radius: var(--radius-full);
background: var(--color-heritage-red); background: var(--color-heritage-red);
color: #fff; color: #fff;
font-size: 13px; font-size: 11px;
font-weight: 600; font-weight: 600;
line-height: 16px;
border: none; border: none;
} }
.home-promo-footer {
margin: 0 var(--space-page) 8px;
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-card);
box-shadow: var(--shadow-card);
aspect-ratio: 15 / 4;
}
.home-promo-footer-img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.home-empty { .home-empty {
text-align: center; text-align: center;
padding: 48px 24px; padding: 32px 24px;
color: var(--color-subtle-gray); color: var(--color-subtle-gray);
font-size: 14px; font-size: 13px;
} }
.coupon-badge { .home-page .coupon-badge {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
background: var(--color-aged-amber); background: var(--color-aged-amber);
color: var(--color-on-secondary-container); color: var(--color-on-secondary-container);
padding: 4px 12px; padding: 2px 8px;
border-radius: 2px; border-radius: 2px;
font-family: var(--font-label); font-family: var(--font-label);
font-size: 12px; font-size: 11px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.05em; letter-spacing: 0.02em;
line-height: 16px;
} }
+5
View File
@@ -10,6 +10,11 @@
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px)); padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
} }
/* 小程序原生 tabBar 页面窗口已扣除底栏,避免滑到底多余空白 */
.page-shell--tab.page-shell--native-tabbar {
padding-bottom: 12px;
}
.page-shell--scroll, .page-shell--scroll,
.page-shell--sub, .page-shell--sub,
.page-shell--plain { .page-shell--plain {
+43
View File
@@ -68,6 +68,11 @@
flex-shrink: 0; flex-shrink: 0;
} }
.order-confirm-submit--disabled {
opacity: 0.55;
pointer-events: none;
}
.order-card { .order-card {
background: var(--color-card); background: var(--color-card);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -164,6 +169,7 @@
font-size: 13px; font-size: 13px;
color: var(--color-heritage-red); color: var(--color-heritage-red);
line-height: 1.4; line-height: 1.4;
text-align: center;
} }
.order-row { .order-row {
@@ -314,6 +320,13 @@
background: var(--color-surface-container); background: var(--color-surface-container);
margin-right: 12px; margin-right: 12px;
flex-shrink: 0; flex-shrink: 0;
overflow: hidden;
}
.order-list-thumb-img {
width: 100%;
height: 100%;
display: block;
} }
.order-list-name { .order-list-name {
@@ -323,6 +336,12 @@
margin-bottom: 4px; margin-bottom: 4px;
} }
.order-list-meta-row {
display: flex;
align-items: center;
gap: 12px;
}
.order-list-meta { .order-list-meta {
display: block; display: block;
font-size: 12px; font-size: 12px;
@@ -336,6 +355,28 @@
margin-top: 12px; margin-top: 12px;
padding-top: 12px; padding-top: 12px;
border-top: 1px solid var(--color-surface-container); border-top: 1px solid var(--color-surface-container);
gap: 12px;
}
.order-list-pay-amount {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.order-list-pay-btn {
flex-shrink: 0;
height: 32px;
padding: 0 16px;
border-radius: 999px;
background: var(--color-heritage-red);
color: #fff;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
} }
.pay-status { .pay-status {
@@ -398,7 +439,9 @@
} }
.pay-wechat-auth-msg { .pay-wechat-auth-msg {
display: block;
font-size: 13px; font-size: 13px;
color: var(--color-heritage-red, #a02d30); color: var(--color-heritage-red, #a02d30);
line-height: 1.5; line-height: 1.5;
text-align: center;
} }
+18 -2
View File
@@ -75,10 +75,26 @@
} }
.redeem-tips { .redeem-tips {
margin: 0 var(--space-page); display: block;
box-sizing: border-box;
margin: 8px var(--space-page) 0;
padding: 12px 16px;
border-radius: var(--radius-md);
background: rgba(0, 0, 0, 0.03);
}
.redeem-tips-text {
display: block;
font-size: 12px; font-size: 12px;
color: var(--color-subtle-gray); color: var(--color-subtle-gray);
line-height: 1.6; line-height: 1.7;
word-break: break-word;
}
.redeem-amount-hint {
font-size: 12px;
color: var(--color-subtle-gray);
line-height: 1.4;
} }
.redeem-amount-foot { .redeem-amount-foot {
+85 -47
View File
@@ -3,56 +3,34 @@
background: var(--color-background); background: var(--color-background);
} }
.store-toolbar { .store-filter {
display: flex;
align-items: center;
gap: 8px;
padding: 0 var(--space-page) 12px; padding: 0 var(--space-page) 12px;
} }
.store-location { .store-search-row {
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0; gap: 8px;
max-width: 42%; width: 100%;
color: var(--color-on-surface-variant); box-sizing: border-box;
font-size: 12px;
font-weight: 500;
} }
.store-location-pin { .store-search-input {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-heritage-red);
margin-right: 6px;
flex-shrink: 0;
}
.store-location-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-search {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 40px; height: 40px;
padding: 0 12px; padding: 0 14px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-surface-container-low); background: var(--color-surface-container-low);
font-size: 13px; font-size: 13px;
line-height: 40px; line-height: 40px;
color: var(--color-on-surface); color: var(--color-on-surface);
box-sizing: border-box; box-sizing: border-box;
display: flex;
align-items: center;
} }
.store-search input, .store-search-input input,
.store-search .taro-input, .store-search-input .taro-input,
.store-search .weui-input { .store-search-input .weui-input {
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
min-height: 0 !important; min-height: 0 !important;
@@ -66,32 +44,92 @@
color: inherit; color: inherit;
} }
.store-category-tabs { .store-search-btn {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background: var(--color-heritage-red);
color: #fff;
display: flex; display: flex;
padding: 12px var(--space-page); align-items: center;
overflow-x: auto; justify-content: center;
white-space: nowrap;
border-bottom: 1px solid rgba(226, 190, 188, 0.1);
} }
.store-category-tab { .store-search-icon {
flex-shrink: 0; position: relative;
margin-right: 24px; width: 14px;
height: 14px;
border: 2px solid currentColor;
border-radius: 50%;
box-sizing: border-box;
}
.store-search-icon::after {
content: '';
position: absolute;
right: -5px;
bottom: -4px;
width: 7px;
height: 2px;
background: currentColor;
border-radius: 1px;
transform: rotate(45deg);
transform-origin: left center;
}
.store-filter-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.store-filter-chip {
flex: 1;
min-width: 0;
height: 36px;
padding: 0 10px;
border-radius: var(--radius-md);
background: var(--color-surface-container-low);
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
box-sizing: border-box;
}
.store-filter-chip-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--color-on-surface-variant); color: var(--color-on-surface);
padding-bottom: 6px;
border-bottom: 2px solid transparent;
} }
.store-category-tab--active { .store-filter-chip-arrow {
flex-shrink: 0;
font-size: 10px;
color: var(--color-subtle-gray);
}
.store-filter-reset {
flex-shrink: 0;
height: 36px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: var(--color-heritage-red); color: var(--color-heritage-red);
border-bottom-color: var(--color-heritage-red);
font-weight: 700;
} }
.store-list { .store-list {
padding: 16px var(--space-page); padding: 4px var(--space-page) 16px;
} }
.store-card { .store-card {
+11 -3
View File
@@ -61,12 +61,20 @@ describe('validatePartnerCityBinding', () => {
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
}); });
it('rejects overlapping district codes', () => { it('allows overlapping district codes as labels', () => {
const result = validatePartnerCityBinding( const result = validatePartnerCityBinding(
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }], [
{
id: '1',
partnerAccountId: '10',
scopeType: 'DISTRICT',
districtCodes: ['410105'],
companyName: '甲公司',
},
],
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: ['410105'] }, { partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: ['410105'] },
); );
expect(result.ok).toBe(false); expect(result.ok).toBe(true);
}); });
it('allows valid district binding', () => { it('allows valid district binding', () => {
+3 -14
View File
@@ -7,6 +7,8 @@ export interface PartnerCityBindingInput {
scopeType: CityPartnerScopeType; scopeType: CityPartnerScopeType;
districtCodes?: string[] | null; districtCodes?: string[] | null;
bindingStatus?: CityPartnerStatus; bindingStatus?: CityPartnerStatus;
/** 仅用于重合报错文案 */
companyName?: string | null;
} }
export interface PartnerCityResolveRef { export interface PartnerCityResolveRef {
@@ -98,20 +100,7 @@ export function validatePartnerCityBinding(
return { ok: false, message: '区域合伙人须至少选择一个区县' }; return { ok: false, message: '区域合伙人须至少选择一个区县' };
} }
const occupied = new Set<string>(); // 区县仅为标识,允许多个区域合伙人选择相同区县
for (const row of others) {
if (row.scopeType !== 'DISTRICT') continue;
for (const code of normalizeDistrictCodes(row.districtCodes)) {
occupied.add(code);
}
}
for (const code of districts) {
if (occupied.has(code)) {
return { ok: false, message: `区县 ${code} 已被其他区域合伙人占用` };
}
}
return { ok: true }; return { ok: true };
} }
+5
View File
@@ -32,6 +32,11 @@ describe('validateMinPurchase', () => {
expect(validateMinPurchase('CROSS_CITY', 5, 2, 6).ok).toBe(false); expect(validateMinPurchase('CROSS_CITY', 5, 2, 6).ok).toBe(false);
expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true); expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true);
}); });
it('on-site pickup requires at least 1 bottle', () => {
expect(validateMinPurchase('ON_SITE_PICKUP', 0, 2, 6).ok).toBe(false);
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(true);
});
}); });
describe('validateRedeemAmount', () => { describe('validateRedeemAmount', () => {
+7 -1
View File
@@ -8,11 +8,17 @@ export function calcBenefitAmount(product: ProductPricing): number {
} }
export function validateMinPurchase( export function validateMinPurchase(
deliveryType: 'LOCAL' | 'CROSS_CITY', deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
quantity: number, quantity: number,
localMinQty: number, localMinQty: number,
crossMinQty: number, crossMinQty: number,
): { ok: boolean; message?: string } { ): { ok: boolean; message?: string } {
if (deliveryType === 'ON_SITE_PICKUP') {
if (quantity < 1) {
return { ok: false, message: '现场取货至少购买 1 瓶' };
}
return { ok: true };
}
const min = deliveryType === 'LOCAL' ? localMinQty : crossMinQty; const min = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
if (quantity < min) { if (quantity < min) {
return { return {
+2
View File
@@ -25,6 +25,8 @@ export interface ProductDto {
/** 详情长图(bizType=DETAIL 或 detailContent JSON */ /** 详情长图(bizType=DETAIL 或 detailContent JSON */
detailImageUrls?: string[]; detailImageUrls?: string[];
detailContent?: ProductDetailContentDto | null; detailContent?: ProductDetailContentDto | null;
/** 是否允许现场取货下单 */
allowOnSitePickup?: boolean;
} }
export interface ProductDetailFeatureDto { export interface ProductDetailFeatureDto {
+1
View File
@@ -91,6 +91,7 @@ export enum OrderTab {
export enum DeliveryType { export enum DeliveryType {
LOCAL = 'LOCAL', LOCAL = 'LOCAL',
CROSS_CITY = 'CROSS_CITY', CROSS_CITY = 'CROSS_CITY',
ON_SITE_PICKUP = 'ON_SITE_PICKUP',
} }
export enum AromaType { export enum AromaType {
+74 -17
View File
@@ -1,24 +1,69 @@
/** HQ 权限目录(权限分配页勾选源) */
export const HQ_PERMISSION_CATALOG = [ export const HQ_PERMISSION_CATALOG = [
{ key: 'dashboard', label: '概览' }, { key: 'dashboard', label: '概览', group: '业务' },
{ key: 'users', label: '用户管理' }, { key: 'users', label: '用户管理', group: '业务' },
{ key: 'wechat_bindings', label: '微信绑定' }, { key: 'wechat_bindings', label: '微信绑定', group: '业务' },
{ key: 'products', label: '商品管理' }, { key: 'products', label: '商品管理', group: '业务' },
{ key: 'orders', label: '订单管理' }, { key: 'orders', label: '订单管理', group: '业务' },
{ key: 'stores', label: '门店管理' }, { key: 'promo_codes', label: '推广码', group: '业务' },
{ key: 'partners', label: '开城管理' }, { key: 'stores', label: '门店管理', group: '业务' },
{ key: 'benefit', label: '好客权益' }, { key: 'partners', label: '开城管理', group: '业务' },
{ key: 'deliveries', label: '配送单' }, { key: 'finance', label: '财务', group: '业务' },
{ key: 'tickets', label: '工单中心' }, { key: 'benefit', label: '好客权益', group: '业务' },
{ key: 'invoices', label: '发票管理' }, { key: 'deliveries', label: '配送单', group: '业务' },
{ key: 'resources', label: 'OSS 资源库' }, { key: 'tickets', label: '工单中心', group: '业务' },
{ key: 'logs', label: '日志' }, { key: 'invoices', label: '发票管理', group: '业务' },
{ key: 'hq_permissions', label: '权限分配' }, { key: 'resources', label: 'OSS 资源库', group: '业务' },
{ key: 'hq_accounts', label: 'HQ 账户' }, { key: 'logs', label: '日志', group: '业务' },
{ key: 'system_settings', label: '系统设置' }, { key: 'hq_permissions', label: '权限分配', group: '管理' },
{ key: 'hq_accounts', label: 'HQ 账户', group: '管理' },
{ key: 'system_settings_feature', label: '功能开关', group: '系统设置' },
{ key: 'system_settings_sms', label: '短信', group: '系统设置' },
{ key: 'system_settings_wechat', label: '微信', group: '系统设置' },
{ key: 'system_settings_wechat_mini', label: '微信小程序', group: '系统设置' },
{ key: 'system_settings_oss', label: '对象存储 OSS', group: '系统设置' },
{ key: 'system_settings_app', label: '应用链接', group: '系统设置' },
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
{ key: 'system_settings_winery_bank', label: '酒厂银行账户', group: '系统设置' },
] as const; ] as const;
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key']; export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
/** 系统配置 registry group → 权限 key */
export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = {
feature: 'system_settings_feature',
sms: 'system_settings_sms',
wechat: 'system_settings_wechat',
wechat_mini: 'system_settings_wechat_mini',
oss: 'system_settings_oss',
app: 'system_settings_app',
deploy: 'system_settings_deploy',
winery_bank: 'system_settings_winery_bank',
};
export const SYSTEM_SETTINGS_PERMISSION_KEYS = Object.values(
SYSTEM_CONFIG_GROUP_PERMISSION,
) as HqPermissionKey[];
/** 旧版单一 system_settings 权限:视为拥有全部系统设置分组(兼容存量角色配置) */
export const LEGACY_SYSTEM_SETTINGS_KEY = 'system_settings';
export function expandHqPermissionKeys(keys: string[]): HqPermissionKey[] {
const set = new Set<string>(keys);
if (set.has(LEGACY_SYSTEM_SETTINGS_KEY)) {
for (const k of SYSTEM_SETTINGS_PERMISSION_KEYS) set.add(k);
set.delete(LEGACY_SYSTEM_SETTINGS_KEY);
}
return [...set].filter((k): k is HqPermissionKey =>
HQ_PERMISSION_CATALOG.some((p) => p.key === k),
);
}
export function hasAnySystemSettingsPermission(keys: string[]): boolean {
const expanded = expandHqPermissionKeys(keys);
return SYSTEM_SETTINGS_PERMISSION_KEYS.some((k) => expanded.includes(k));
}
export const HQ_ADMIN_ROLES = [ export const HQ_ADMIN_ROLES = [
{ value: 'SUPER_ADMIN', label: '超级管理员' }, { value: 'SUPER_ADMIN', label: '超级管理员' },
{ value: 'OPS', label: '运营' }, { value: 'OPS', label: '运营' },
@@ -34,6 +79,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
'wechat_bindings', 'wechat_bindings',
'products', 'products',
'orders', 'orders',
'promo_codes',
'stores', 'stores',
'partners', 'partners',
'benefit', 'benefit',
@@ -42,7 +88,18 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
'invoices', 'invoices',
'resources', 'resources',
'logs', 'logs',
'system_settings_wechat_mini',
],
FINANCE: [
'dashboard',
'orders',
'stores',
'partners',
'finance',
'benefit',
'invoices',
'logs',
'system_settings_winery_bank',
], ],
FINANCE: ['dashboard', 'orders', 'stores', 'partners', 'benefit', 'invoices', 'logs'],
CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'invoices', 'logs'], CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'invoices', 'logs'],
}; };
+5 -1
View File
@@ -45,12 +45,16 @@ export type PromoCodeItem = {
}; };
export type PromoCodeStats = { export type PromoCodeStats = {
/** 扫码进入次数 */
scanCount: number; scanCount: number;
orderCount: number; orderCount: number;
conversionRate: number; conversionRate: number;
/** 归因用户数(user_promo_attribution */
attributionCount?: number; attributionCount?: number;
/** 用户表 source_ref_id 指向本推广码的用户数 */ /** 扫码注册用户数:用户来源标记为本推广码 */
sourceMarkedCount?: number; sourceMarkedCount?: number;
/** @deprecated 同 sourceMarkedCount,兼容旧字段名 */
registerCount?: number;
}; };
export type PromoCodeAttributedUser = { export type PromoCodeAttributedUser = {
+58 -2
View File
@@ -1,3 +1,5 @@
export type FinancePayStatus = 'UNPAID' | 'PAID';
export interface StorePayoutDto { export interface StorePayoutDto {
id: string; id: string;
redeemAmount: number; redeemAmount: number;
@@ -5,9 +7,28 @@ export interface StorePayoutDto {
status: 'PENDING' | 'PAID'; status: 'PENDING' | 'PAID';
expectedPayAt: string; expectedPayAt: string;
paidAt?: string | null; paidAt?: string | null;
storeBillId?: string | null;
} }
export type PartnerBillStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'REJECTED'; export type PartnerBillStatus =
| 'PENDING_REVIEW'
| 'AWAITING_CONFIRM'
| 'UNPAID'
| 'PAID'
| 'REJECTED';
export const PARTNER_BILL_STATUS_LABELS: Record<PartnerBillStatus, string> = {
PENDING_REVIEW: '待审核',
AWAITING_CONFIRM: '待合伙人确认',
UNPAID: '未打款',
PAID: '已打款',
REJECTED: '已驳回',
};
export const FINANCE_PAY_STATUS_LABELS: Record<FinancePayStatus, string> = {
UNPAID: '未打款',
PAID: '已打款',
};
export interface PartnerBillDto { export interface PartnerBillDto {
id: string; id: string;
@@ -19,6 +40,7 @@ export interface PartnerBillDto {
periodStart: string; periodStart: string;
periodEnd: string; periodEnd: string;
confirmedAt?: string | null; confirmedAt?: string | null;
sentAt?: string | null;
paidAt?: string | null; paidAt?: string | null;
rejectReason?: string | null; rejectReason?: string | null;
} }
@@ -27,6 +49,41 @@ export interface PartnerBillDetailDto extends PartnerBillDto {
partnerId: string; partnerId: string;
} }
export interface StoreBillDto {
id: string;
billNo: string;
storeId: string;
billDate: string;
redeemCount: number;
redeemAmount: number;
settlementRate: number;
payoutAmount: number;
status: FinancePayStatus;
paidAt?: string | null;
}
export interface WineryBillDto {
id: string;
billNo: string;
billDate: string;
orderCount: number;
orderAmount: number;
wineryRate: number;
wineryAmount: number;
status: FinancePayStatus;
paidAt?: string | null;
}
export interface WineryBillItemDto {
id: string;
orderId: string;
orderNo: string;
deliveryType: string;
payAmount: number;
wineryAmount: number;
paidAt: string;
}
/** 门店结算默认比例(核销金额 × 比例) */ /** 门店结算默认比例(核销金额 × 比例) */
export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6; export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6;
@@ -69,4 +126,3 @@ export type WineryOrderBillRow = {
paidAt: string; paidAt: string;
receiverCity?: string; receiverCity?: string;
}; };
+30 -1
View File
@@ -1,4 +1,11 @@
export type SystemConfigFieldType = 'string' | 'boolean' | 'number' | 'password' | 'textarea'; export type SystemConfigFieldType =
| 'string'
| 'boolean'
| 'number'
| 'password'
| 'textarea'
| 'image'
| 'imageList';
export interface SystemConfigFieldMeta { export interface SystemConfigFieldMeta {
key: string; key: string;
@@ -51,3 +58,25 @@ export interface SystemConfigSyncResult {
requiresRestartKeys: string[]; requiresRestartKeys: string[];
message: string; message: string;
} }
/** 解析小程序首页轮播 JSON(最多 8 张) */
export function parseMiniHomeBanners(raw?: string | null): string[] {
if (!raw?.trim()) return [];
const text = raw.trim();
try {
const parsed = JSON.parse(text) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.filter((u): u is string => typeof u === 'string' && !!u.trim())
.map((u) => u.trim())
.slice(0, 8);
} catch {
if (/^https?:\/\//i.test(text)) return [text];
return [];
}
}
export function serializeMiniHomeBanners(urls: string[]): string {
const cleaned = urls.filter((u) => !!u?.trim()).map((u) => u.trim()).slice(0, 8);
return JSON.stringify(cleaned);
}
+5
View File
@@ -31,6 +31,11 @@ export type ClientRuntimeConfig = {
mockWechat?: boolean; mockWechat?: boolean;
/** false 时三端跳过微信 SDK OAuth 授权(由 MOCK_WECHAT 或真实凭证推导) */ /** false 时三端跳过微信 SDK OAuth 授权(由 MOCK_WECHAT 或真实凭证推导) */
wxAuthorize?: boolean; wxAuthorize?: boolean;
/** 小程序首页轮播 / 底部图 */
miniHome?: {
banners: string[];
footerUrl: string | null;
};
}; };
/** 是否展示微信授权入口 */ /** 是否展示微信授权入口 */
+2 -1
View File
@@ -10,7 +10,8 @@
"./PageHeader": "./src/PageHeader.tsx", "./PageHeader": "./src/PageHeader.tsx",
"./CouponBadge": "./src/CouponBadge.tsx", "./CouponBadge": "./src/CouponBadge.tsx",
"./OrderStatusTabs": "./src/OrderStatusTabs.tsx", "./OrderStatusTabs": "./src/OrderStatusTabs.tsx",
"./AppImage": "./src/AppImage.tsx" "./AppImage": "./src/AppImage.tsx",
"./PullToRefresh": "./src/PullToRefresh.tsx"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.3.1" "react": "^18.3.1"
+134
View File
@@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import './pull-to-refresh.css';
const PULL_THRESHOLD = 64;
const MAX_PULL = 96;
type PullToRefreshProps = {
onRefresh: () => void | Promise<void>;
children: ReactNode;
className?: string;
style?: CSSProperties;
disabled?: boolean;
};
function getScrollTop() {
return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
}
export default function PullToRefresh({
onRefresh,
children,
className,
style,
disabled = false,
}: PullToRefreshProps) {
const rootRef = useRef<HTMLDivElement>(null);
const [pull, setPull] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const startY = useRef(0);
const pulling = useRef(false);
const busy = useRef(false);
const pullRef = useRef(0);
const disabledRef = useRef(disabled);
const onRefreshRef = useRef(onRefresh);
disabledRef.current = disabled;
onRefreshRef.current = onRefresh;
pullRef.current = pull;
const finishRefresh = useCallback(async () => {
if (busy.current) return;
busy.current = true;
setRefreshing(true);
setPull(40);
pullRef.current = 40;
try {
await onRefreshRef.current();
} finally {
setRefreshing(false);
setPull(0);
pullRef.current = 0;
busy.current = false;
pulling.current = false;
}
}, []);
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const onTouchStart = (e: TouchEvent) => {
if (disabledRef.current || busy.current) return;
if (getScrollTop() > 2) return;
startY.current = e.touches[0]?.clientY ?? 0;
pulling.current = true;
};
const onTouchMove = (e: TouchEvent) => {
if (!pulling.current || disabledRef.current || busy.current) return;
if (getScrollTop() > 2) {
pulling.current = false;
setPull(0);
pullRef.current = 0;
return;
}
const y = e.touches[0]?.clientY ?? 0;
const delta = y - startY.current;
if (delta <= 0) {
setPull(0);
pullRef.current = 0;
return;
}
const next = Math.min(MAX_PULL, delta * 0.45);
setPull(next);
pullRef.current = next;
if (next > 8 && e.cancelable) e.preventDefault();
};
const onTouchEnd = () => {
if (!pulling.current || disabledRef.current) return;
pulling.current = false;
if (pullRef.current >= PULL_THRESHOLD) {
void finishRefresh();
return;
}
setPull(0);
pullRef.current = 0;
};
el.addEventListener('touchstart', onTouchStart, { passive: true });
el.addEventListener('touchmove', onTouchMove, { passive: false });
el.addEventListener('touchend', onTouchEnd);
el.addEventListener('touchcancel', onTouchEnd);
return () => {
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
el.removeEventListener('touchend', onTouchEnd);
el.removeEventListener('touchcancel', onTouchEnd);
};
}, [finishRefresh]);
const showHint = pull > 8 || refreshing;
const ready = pull >= PULL_THRESHOLD;
const pad = showHint ? Math.max(pull, refreshing ? 40 : 0) : 0;
return (
<div
ref={rootRef}
className={['dukang-ptr', className].filter(Boolean).join(' ')}
style={{ ...style, paddingTop: pad || undefined }}
>
<div
className={`dukang-ptr-indicator${showHint ? ' dukang-ptr-indicator--visible' : ''}`}
style={{ height: pad }}
aria-live="polite"
>
<span className="dukang-ptr-text">
{refreshing ? '刷新中…' : ready ? '松开刷新' : '下拉刷新'}
</span>
</div>
{children}
</div>
);
}
@@ -0,0 +1,26 @@
.dukang-ptr {
position: relative;
min-height: 100%;
box-sizing: border-box;
}
.dukang-ptr-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: flex-end;
justify-content: center;
overflow: hidden;
pointer-events: none;
z-index: 2;
}
.dukang-ptr-text {
padding-bottom: 10px;
font-size: 12px;
line-height: 1;
color: #8a8580;
letter-spacing: 0.02em;
}
+39 -10
View File
@@ -194,19 +194,19 @@ importers:
version: link:../../packages/weixin-sdk version: link:../../packages/weixin-sdk
'@tarojs/components': '@tarojs/components':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)) version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15))
'@tarojs/helper': '@tarojs/helper':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0 version: 4.2.0
'@tarojs/plugin-framework-react': '@tarojs/plugin-framework-react':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(postcss@8.5.15)) version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15))
'@tarojs/plugin-html': '@tarojs/plugin-html':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0) version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)
'@tarojs/plugin-platform-h5': '@tarojs/plugin-platform-h5':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(postcss@8.5.15)) version: 4.2.0(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15))
'@tarojs/plugin-platform-weapp': '@tarojs/plugin-platform-weapp':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/service@4.2.0)(@tarojs/shared@4.2.0) version: 4.2.0(@tarojs/service@4.2.0)(@tarojs/shared@4.2.0)
@@ -215,7 +215,7 @@ importers:
version: 4.2.0(react@18.3.1) version: 4.2.0(react@18.3.1)
'@tarojs/router': '@tarojs/router':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15))) version: 4.2.0(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))
'@tarojs/runtime': '@tarojs/runtime':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0 version: 4.2.0
@@ -224,7 +224,7 @@ importers:
version: 4.2.0 version: 4.2.0
'@tarojs/taro': '@tarojs/taro':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)) version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15))
qrcode: qrcode:
specifier: ^1.5.4 specifier: ^1.5.4
version: 1.5.4 version: 1.5.4
@@ -276,19 +276,19 @@ importers:
version: link:../../packages/weixin-sdk version: link:../../packages/weixin-sdk
'@tarojs/components': '@tarojs/components':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15))
'@tarojs/helper': '@tarojs/helper':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0 version: 4.2.0
'@tarojs/plugin-framework-react': '@tarojs/plugin-framework-react':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(postcss@8.5.15))
'@tarojs/plugin-html': '@tarojs/plugin-html':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0) version: 4.2.0(@tarojs/helper@4.2.0)(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)
'@tarojs/plugin-platform-h5': '@tarojs/plugin-platform-h5':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) version: 4.2.0(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(postcss@8.5.15))
'@tarojs/plugin-platform-weapp': '@tarojs/plugin-platform-weapp':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/service@4.2.0)(@tarojs/shared@4.2.0) version: 4.2.0(@tarojs/service@4.2.0)(@tarojs/shared@4.2.0)
@@ -297,7 +297,7 @@ importers:
version: 4.2.0(react@18.3.1) version: 4.2.0(react@18.3.1)
'@tarojs/router': '@tarojs/router':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15))) version: 4.2.0(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))
'@tarojs/runtime': '@tarojs/runtime':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0 version: 4.2.0
@@ -306,7 +306,7 @@ importers:
version: 4.2.0 version: 4.2.0
'@tarojs/taro': '@tarojs/taro':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)) version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(postcss@8.5.15))
element-china-area-data: element-china-area-data:
specifier: ^6.1.0 specifier: ^6.1.0
version: 6.1.0 version: 6.1.0
@@ -423,6 +423,9 @@ importers:
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
'@nestjs/schedule':
specifier: ^6.1.3
version: 6.1.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
'@prisma/client': '@prisma/client':
specifier: ^5.18.0 specifier: ^5.18.0
version: 5.22.0(prisma@5.22.0) version: 5.22.0(prisma@5.22.0)
@@ -1634,6 +1637,12 @@ packages:
'@nestjs/common': ^10.0.0 '@nestjs/common': ^10.0.0
'@nestjs/core': ^10.0.0 '@nestjs/core': ^10.0.0
'@nestjs/schedule@6.1.3':
resolution: {integrity: sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/schematics@10.2.3': '@nestjs/schematics@10.2.3':
resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==} resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==}
peerDependencies: peerDependencies:
@@ -2564,6 +2573,9 @@ packages:
'@types/lodash@4.17.24': '@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
'@types/luxon@3.7.2':
resolution: {integrity: sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==}
'@types/mime@1.3.5': '@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
@@ -3360,6 +3372,10 @@ packages:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
cron@4.4.0:
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
engines: {node: '>=18.x'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -7980,6 +7996,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@nestjs/schedule@6.1.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)':
dependencies:
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cron: 4.4.0
'@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)': '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)':
dependencies: dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0) '@angular-devkit/core': 17.3.11(chokidar@3.6.0)
@@ -9037,6 +9059,8 @@ snapshots:
'@types/lodash@4.17.24': {} '@types/lodash@4.17.24': {}
'@types/luxon@3.7.2': {}
'@types/mime@1.3.5': {} '@types/mime@1.3.5': {}
'@types/minimatch@6.0.0': '@types/minimatch@6.0.0':
@@ -10082,6 +10106,11 @@ snapshots:
dependencies: dependencies:
luxon: 3.7.2 luxon: 3.7.2
cron@4.4.0:
dependencies:
'@types/luxon': 3.7.2
luxon: 3.7.2
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
+4
View File
@@ -44,6 +44,10 @@ WX_APP_SECRET=
# 未配置时回退 WX_APP_ID,若与小程序 appid 不同会导致 invalid code # 未配置时回退 WX_APP_ID,若与小程序 appid 不同会导致 invalid code
WX_MINI_APP_ID= WX_MINI_APP_ID=
WX_MINI_APP_SECRET= WX_MINI_APP_SECRET=
# 推广码小程序码落地页(getwxacodeunlimit 的 page;勿前导 /
WX_MINI_PROMO_PAGE=pages/home/index
# 小程序码打开版本:release | trial | develop(默认 release
# WX_MINI_ENV_VERSION=release
WX_MCH_ID= WX_MCH_ID=
WX_MCH_SERIAL_NO= WX_MCH_SERIAL_NO=
WX_MCH_PRIVATE_KEY= WX_MCH_PRIVATE_KEY=
@@ -32,6 +32,10 @@ USER_H5_URL=https://user.runxian.top/user
MOCK_WECHAT=false MOCK_WECHAT=false
WX_APP_ID= WX_APP_ID=
WX_APP_SECRET= WX_APP_SECRET=
WX_MINI_APP_ID=
WX_MINI_APP_SECRET=
WX_MINI_PROMO_PAGE=pages/home/index
# WX_MINI_ENV_VERSION=release
WX_MCH_ID= WX_MCH_ID=
WX_MCH_SERIAL_NO= WX_MCH_SERIAL_NO=
WX_MCH_PRIVATE_KEY= WX_MCH_PRIVATE_KEY=
+1
View File
@@ -28,6 +28,7 @@
"@nestjs/core": "^10.4.0", "@nestjs/core": "^10.4.0",
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.4.0", "@nestjs/platform-express": "^10.4.0",
"@nestjs/schedule": "^6.1.3",
"@prisma/client": "^5.18.0", "@prisma/client": "^5.18.0",
"ali-oss": "^6.23.0", "ali-oss": "^6.23.0",
"bullmq": "^5.12.0", "bullmq": "^5.12.0",
+114 -16
View File
@@ -199,12 +199,19 @@ enum HqAdminRole {
} }
enum PartnerBillStatus { enum PartnerBillStatus {
DRAFT PENDING_REVIEW
CONFIRMED AWAITING_CONFIRM
UNPAID
PAID PAID
REJECTED REJECTED
} }
// 酒厂/门店对账单打款状态
enum FinancePayStatus {
UNPAID
PAID
}
enum StoreStatus { enum StoreStatus {
OPEN OPEN
PAUSED PAUSED
@@ -255,6 +262,7 @@ enum PayStatus {
enum DeliveryType { enum DeliveryType {
LOCAL LOCAL
CROSS_CITY CROSS_CITY
ON_SITE_PICKUP
} }
enum FreightPayType { enum FreightPayType {
@@ -444,6 +452,7 @@ model CommonProductItem {
benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2) benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2)
status ProductStatus @default(DRAFT) status ProductStatus @default(DRAFT)
sortOrder Int @default(0) @map("sort_order") sortOrder Int @default(0) @map("sort_order")
allowOnSitePickup Boolean @default(false) @map("allow_on_site_pickup")
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
detailContent Json? @map("detail_content") detailContent Json? @map("detail_content")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@ -477,12 +486,18 @@ model CommonProductDetailTemplate {
} }
model CommonStoreCategory { model CommonStoreCategory {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32) code String @unique @db.VarChar(32)
name String @db.VarChar(64) name String @db.VarChar(64)
sort Int @default(0) sort Int @default(0)
stores Store[] parentId BigInt? @map("parent_id") @db.UnsignedBigInt
status String @default("ACTIVE") @db.VarChar(16)
parent CommonStoreCategory? @relation("StoreCategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
children CommonStoreCategory[] @relation("StoreCategoryTree")
stores Store[]
@@index([parentId, sort])
@@index([status])
@@map("common_store_category") @@map("common_store_category")
} }
@@ -640,8 +655,9 @@ model PartnerBill {
orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2) orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2)
redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2) redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2) totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
status PartnerBillStatus @default(DRAFT) status PartnerBillStatus @default(PENDING_REVIEW)
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3) confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
sentAt DateTime? @map("sent_at") @db.DateTime(3)
paidAt DateTime? @map("paid_at") @db.DateTime(3) paidAt DateTime? @map("paid_at") @db.DateTime(3)
rejectReason String? @map("reject_reason") @db.VarChar(500) rejectReason String? @map("reject_reason") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@ -649,6 +665,7 @@ model PartnerBill {
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict) partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
@@index([partnerAccountId, status]) @@index([partnerAccountId, status])
@@unique([partnerAccountId, periodStart])
@@map("partner_bill") @@map("partner_bill")
} }
@@ -824,6 +841,7 @@ model Store {
redeemPendingRecords RedeemPendingRecord[] redeemPendingRecords RedeemPendingRecord[]
ratings StoreRating[] ratings StoreRating[]
payouts StorePayout[] payouts StorePayout[]
storeBills StoreBill[]
@@index([cityId, status]) @@index([cityId, status])
@@index([partnerAccountId]) @@index([partnerAccountId])
@@ -1022,9 +1040,10 @@ model BenefitCoupon {
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict) user User @relation(fields: [userId], references: [id], onDelete: Restrict)
order Order? @relation(fields: [orderId], references: [id], onDelete: Restrict) order Order? @relation(fields: [orderId], references: [id], onDelete: Restrict)
redeemRecords RedeemRecord[] redeemRecords RedeemRecord[]
redeemAllocations RedeemRecordAllocation[]
@@index([userId, status]) @@index([userId, status])
@@map("user_benefit_coupon") @@map("user_benefit_coupon")
@@ -1040,17 +1059,36 @@ model RedeemRecord {
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2) settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict) user User @relation(fields: [userId], references: [id], onDelete: Restrict)
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict) coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
rating StoreRating? rating StoreRating?
payout StorePayout? payout StorePayout?
pending RedeemPendingRecord? pending RedeemPendingRecord?
allocations RedeemRecordAllocation[]
@@index([storeId, createdAt]) @@index([storeId, createdAt])
@@map("user_redeem_record") @@map("user_redeem_record")
} }
// 核销单跨券 FIFO 分摊明细(含次券可追溯)
model RedeemRecordAllocation {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
redeemRecordId BigInt @map("redeem_record_id") @db.UnsignedBigInt
couponId BigInt @map("coupon_id") @db.UnsignedBigInt
amount Decimal @db.Decimal(10, 2)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Cascade)
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
@@unique([redeemRecordId, couponId])
@@index([couponId])
@@index([redeemRecordId])
@@map("user_redeem_record_allocation")
}
model RedeemPendingRecord { model RedeemPendingRecord {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
pendingNo String @unique @map("pending_no") @db.VarChar(32) pendingNo String @unique @map("pending_no") @db.VarChar(32)
@@ -1098,10 +1136,32 @@ model StoreRating {
@@map("user_store_rating") @@map("user_store_rating")
} }
model StoreBill {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
billNo String @unique @map("bill_no") @db.VarChar(32)
storeId BigInt @map("store_id") @db.UnsignedBigInt
billDate DateTime @map("bill_date") @db.DateTime(3)
redeemCount Int @default(0) @map("redeem_count")
redeemAmount Decimal @map("redeem_amount") @db.Decimal(10, 2)
settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4)
payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2)
status FinancePayStatus @default(UNPAID)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
payouts StorePayout[]
@@unique([storeId, billDate])
@@index([status, billDate])
@@map("store_bill")
}
model StorePayout { model StorePayout {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt storeId BigInt @map("store_id") @db.UnsignedBigInt
storeBillId BigInt? @map("store_bill_id") @db.UnsignedBigInt
redeemAmount Decimal @map("redeem_amount") @db.Decimal(10, 2) redeemAmount Decimal @map("redeem_amount") @db.Decimal(10, 2)
payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2) payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2)
settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4) settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4)
@@ -1113,11 +1173,49 @@ model StorePayout {
redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Restrict) redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Restrict)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
storeBill StoreBill? @relation(fields: [storeBillId], references: [id], onDelete: SetNull)
@@index([storeId, status]) @@index([storeId, status])
@@index([storeBillId])
@@map("store_payout") @@map("store_payout")
} }
model WineryBill {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
billNo String @unique @map("bill_no") @db.VarChar(32)
billDate DateTime @map("bill_date") @db.DateTime(3)
orderCount Int @default(0) @map("order_count")
orderAmount Decimal @map("order_amount") @db.Decimal(10, 2)
wineryRate Decimal @map("winery_rate") @db.Decimal(5, 4)
wineryAmount Decimal @map("winery_amount") @db.Decimal(10, 2)
status FinancePayStatus @default(UNPAID)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
items WineryBillItem[]
@@unique([billDate])
@@index([status, billDate])
@@map("winery_bill")
}
model WineryBillItem {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
wineryBillId BigInt @map("winery_bill_id") @db.UnsignedBigInt
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
orderNo String @map("order_no") @db.VarChar(32)
deliveryType String @map("delivery_type") @db.VarChar(16)
payAmount Decimal @map("pay_amount") @db.Decimal(10, 2)
wineryAmount Decimal @map("winery_amount") @db.Decimal(10, 2)
paidAt DateTime @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
wineryBill WineryBill @relation(fields: [wineryBillId], references: [id], onDelete: Cascade)
@@index([wineryBillId])
@@map("winery_bill_item")
}
// ─── LOG ────────────────────────────────────────────── // ─── LOG ──────────────────────────────────────────────
model LogThirdParty { model LogThirdParty {
+11 -5
View File
@@ -335,12 +335,18 @@ model CommonProductItem {
} }
model CommonStoreCategory { model CommonStoreCategory {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32) code String @unique @db.VarChar(32)
name String @db.VarChar(64) name String @db.VarChar(64)
sort Int @default(0) sort Int @default(0)
stores Store[] parentId BigInt? @map("parent_id") @db.UnsignedBigInt
status String @default("ACTIVE") @db.VarChar(16)
parent CommonStoreCategory? @relation("StoreCategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
children CommonStoreCategory[] @relation("StoreCategoryTree")
stores Store[]
@@index([parentId, sort])
@@index([status])
@@map("common_store_category") @@map("common_store_category")
} }
+1 -1
View File
@@ -218,7 +218,7 @@ async function main() {
orderCommission: 1200, orderCommission: 1200,
redeemCommission: 800, redeemCommission: 800,
totalAmount: 2000, totalAmount: 2000,
status: 'CONFIRMED', status: 'UNPAID',
confirmedAt: now, confirmedAt: now,
}, },
}); });
+31 -8
View File
@@ -266,13 +266,36 @@ async function main() {
const categories = await Promise.all([ const { DEFAULT_STORE_CATEGORY_TREE } = await import('../src/modules/store/store-category.defaults');
const categoryByCode = new Map<string, { id: bigint }>();
prisma.commonStoreCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }), for (const root of DEFAULT_STORE_CATEGORY_TREE) {
const parent = await prisma.commonStoreCategory.create({
prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }), data: {
code: root.code,
]); name: root.name,
sort: root.sort,
parentId: null,
status: 'ACTIVE',
},
});
categoryByCode.set(root.code, parent);
for (const child of root.children) {
const row = await prisma.commonStoreCategory.create({
data: {
code: child.code,
name: child.name,
sort: child.sort,
parentId: parent.id,
status: 'ACTIVE',
},
});
categoryByCode.set(child.code, row);
}
}
const categories = [
categoryByCode.get('HOTPOT')!,
categoryByCode.get('LOCAL')!,
];
@@ -873,7 +896,7 @@ async function main() {
totalAmount: 2000, totalAmount: 2000,
status: 'CONFIRMED', status: 'UNPAID',
confirmedAt: now, confirmedAt: now,
@@ -0,0 +1,7 @@
-- Optional one-time data remap before/after prisma db push when upgrading PartnerBillStatus.
-- Run against MySQL if legacy DRAFT/CONFIRMED rows exist.
-- Step A (before removing old enum values): widen enum if needed via Prisma push,
-- then remap:
UPDATE partner_bill SET status = 'PENDING_REVIEW' WHERE status = 'DRAFT';
UPDATE partner_bill SET status = 'UNPAID' WHERE status = 'CONFIRMED';

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