Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 047ffd7b16 | |||
| bca1d8afea | |||
| 0418370c31 | |||
| cd206d6baa | |||
| aeb72154a7 | |||
| e0d3c840a2 | |||
| f2303f7fb8 | |||
| fb530e2ff5 | |||
| 8c4419e04e | |||
| 85f2c156b0 | |||
| addfcfb0b1 | |||
| b392c28787 | |||
| b534a569f8 | |||
| b0f565103f | |||
| 5e33dd9847 | |||
| 9a550cbaaf | |||
| 823e439101 | |||
| 6c299f1a1d | |||
| 7c9827875f | |||
| ae7c63c08d | |||
| 270cebc79a | |||
| ba21cc89c6 |
@@ -20,6 +20,7 @@ import CityPartnersPage from './pages/CityPartnersPage';
|
|||||||
import CityWarehousesPage from './pages/CityWarehousesPage';
|
import 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">
|
||||||
|
|||||||
@@ -44,6 +44,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: '门店资源' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
|||||||
@@ -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: '删除商品' },
|
||||||
@@ -36,9 +40,16 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
|||||||
{ value: 'TICKET_REJECT', label: '工单驳回' },
|
{ value: 'TICKET_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(
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}>
|
||||||
确认驳回
|
确认驳回
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -121,10 +121,10 @@ export default function PromoCodesPage() {
|
|||||||
[filters],
|
[filters],
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function loadScenes() {
|
async function loadScenes() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ export default function PromoCodesPage() {
|
|||||||
<Space size="small" wrap>
|
<Space size="small" wrap>
|
||||||
|
|
||||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
|
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
|
||||||
|
|
||||||
详情
|
详情
|
||||||
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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%' }}>
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Statistic,
|
Statistic,
|
||||||
@@ -21,17 +24,34 @@ import { request } 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> = {
|
||||||
@@ -42,29 +62,64 @@ const DELIVERY_LABELS: Record<string, string> = {
|
|||||||
export default function WineryBillsPage() {
|
export default function WineryBillsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
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);
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -73,23 +128,22 @@ export default function WineryBillsPage() {
|
|||||||
|
|
||||||
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,10 +159,27 @@ 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>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -116,19 +187,19 @@ export default function WineryBillsPage() {
|
|||||||
<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+30 结算:按自然月列出同城/跨城已付酒单,应付 = 酒单实付 × {ratePct}%(暂定)
|
每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
||||||
</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.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 +208,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 +253,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 +287,56 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,25 +74,28 @@ export default function PromoCodeDetailPage() {
|
|||||||
<>
|
<>
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24} lg={8}>
|
<Col xs={24} lg={8}>
|
||||||
<Card title="推广二维码" size="small">
|
<Card title="小程序码" size="small">
|
||||||
{detail.qrcodeUrl ? (
|
{detail.qrcodeUrl ? (
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<img
|
<img
|
||||||
src={detail.qrcodeUrl}
|
src={detail.qrcodeUrl}
|
||||||
alt="推广二维码"
|
alt="推广小程序码"
|
||||||
style={{ width: 200, height: 200, marginBottom: 12 }}
|
style={{ width: 200, height: 200, marginBottom: 12 }}
|
||||||
/>
|
/>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12, fontSize: 12 }}>
|
||||||
|
微信扫码进入小程序(scene=活动 ID {detail.id})
|
||||||
|
</Typography.Paragraph>
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
<Button
|
<Button
|
||||||
block
|
block
|
||||||
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-qrcode.png`)}
|
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-wxacode.png`)}
|
||||||
>
|
>
|
||||||
下载二维码
|
下载小程序码
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Typography.Text type="secondary">暂无二维码</Typography.Text>
|
<Typography.Text type="secondary">暂无小程序码</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -144,13 +147,18 @@ 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}>
|
||||||
|
<Typography.Text copyable={{ text: String(detail.id) }}>
|
||||||
|
{detail.id}(小程序码 scene)
|
||||||
|
</Typography.Text>
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="二维码 ID" span={2}>
|
<Descriptions.Item label="二维码 ID" span={2}>
|
||||||
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text>
|
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="落地链接" span={2}>
|
<Descriptions.Item label="H5 落地链接" span={2}>
|
||||||
<Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</Typography.Text>
|
<Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</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>
|
||||||
{detail.qrcodeUrl}
|
{detail.qrcodeUrl}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:00–22: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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, 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';
|
||||||
@@ -44,9 +44,9 @@ export default function BenefitPage() {
|
|||||||
syncTabBarSelected(2);
|
syncTabBarSelected(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const loadBenefit = useCallback(() => {
|
||||||
if (!loggedIn) return;
|
if (!loggedIn) return Promise.resolve();
|
||||||
Promise.all([
|
return Promise.all([
|
||||||
request<BenefitSummary>('/benefit/summary'),
|
request<BenefitSummary>('/benefit/summary'),
|
||||||
request<CouponItem[]>('/benefit/coupons'),
|
request<CouponItem[]>('/benefit/coupons'),
|
||||||
])
|
])
|
||||||
@@ -57,6 +57,14 @@ export default function BenefitPage() {
|
|||||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||||
}, [loggedIn]);
|
}, [loggedIn]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadBenefit();
|
||||||
|
}, [loadBenefit]);
|
||||||
|
|
||||||
|
usePullDownRefresh(() => {
|
||||||
|
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');
|
||||||
const visible = tab === 'available' ? available : history;
|
const visible = tab === 'available' ? available : history;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '杜康好客',
|
navigationBarTitleText: '杜康好客',
|
||||||
|
enablePullDownRefresh: true,
|
||||||
|
backgroundTextStyle: 'dark',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, 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 TabMainHeader from '../../components/TabMainHeader';
|
import TabMainHeader from '../../components/TabMainHeader';
|
||||||
import CouponBadge from '../../components/CouponBadge';
|
import CouponBadge from '../../components/CouponBadge';
|
||||||
@@ -41,14 +41,39 @@ export default function HomePage() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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 request<Product[]>(
|
||||||
|
`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`,
|
||||||
|
);
|
||||||
|
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) =>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '我的',
|
navigationBarTitleText: '我的',
|
||||||
|
enablePullDownRefresh: true,
|
||||||
|
backgroundTextStyle: 'dark',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -47,13 +47,25 @@ 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: '退款中',
|
||||||
REFUNDED: '已退款',
|
REFUNDED: '已退款',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 已付款未完成:可选现场取货并确认收货 */
|
||||||
|
const ON_SITE_PICKUP_STATUSES = new Set([
|
||||||
|
'PENDING_SHIP',
|
||||||
|
'OUT_WAREHOUSE',
|
||||||
|
'SHIPPING',
|
||||||
|
'SHIPPED',
|
||||||
|
'PENDING_RECEIVE',
|
||||||
|
'DELIVERED',
|
||||||
|
]);
|
||||||
|
|
||||||
function fullReceiverAddress(order: OrderDetail) {
|
function fullReceiverAddress(order: OrderDetail) {
|
||||||
const detail = (order.receiverAddress || '').trim();
|
const detail = (order.receiverAddress || '').trim();
|
||||||
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
||||||
@@ -68,6 +80,8 @@ 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 [onSitePickup, setOnSitePickup] = useState(false);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!orderId) return;
|
if (!orderId) return;
|
||||||
@@ -76,7 +90,16 @@ 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 canOnSitePickup =
|
||||||
|
!!order && !isReship && ON_SITE_PICKUP_STATUSES.has(order.status || '');
|
||||||
|
const canConfirmReceive =
|
||||||
|
!!order &&
|
||||||
|
!isReship &&
|
||||||
|
(['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '') ||
|
||||||
|
(onSitePickup && canOnSitePickup));
|
||||||
|
|
||||||
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 +134,41 @@ 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 useOnSite =
|
||||||
|
onSitePickup || !['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
|
||||||
|
const { confirm } = await Taro.showModal({
|
||||||
|
title: useOnSite ? '确认现场取货?' : '确认收货?',
|
||||||
|
content: useOnSite
|
||||||
|
? '请确认您已在现场拿到商品。确认后订单将完成,好客权益即时可用,无法再安排配送。若尚未取到酒,请勿确认。'
|
||||||
|
: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
|
||||||
|
confirmText: '确认收货',
|
||||||
|
cancelText: '再想想',
|
||||||
|
});
|
||||||
|
if (!confirm) return;
|
||||||
|
|
||||||
|
setConfirming(true);
|
||||||
|
try {
|
||||||
|
const updated = await request<OrderDetail>(`/trade/orders/${order.id}/confirm-receive`, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { onSitePickup: useOnSite },
|
||||||
|
});
|
||||||
|
setOrder(updated);
|
||||||
|
setOnSitePickup(false);
|
||||||
|
toast(useOnSite ? '现场取货已确认,订单完成' : '已确认收货');
|
||||||
|
} 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(' ');
|
||||||
@@ -171,6 +225,27 @@ export default function OrderDetailPage() {
|
|||||||
<Text className="u-muted">地址信息待完善</Text>
|
<Text className="u-muted">地址信息待完善</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
{canOnSitePickup ? (
|
||||||
|
<View className="order-card">
|
||||||
|
<Text className="order-card-title">取货方式</Text>
|
||||||
|
<View
|
||||||
|
className="order-pickup-option"
|
||||||
|
onClick={() => setOnSitePickup((v) => !v)}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`order-pickup-check${onSitePickup ? ' order-pickup-check--on' : ''}`}
|
||||||
|
>
|
||||||
|
{onSitePickup ? <Text className="order-pickup-check-mark">✓</Text> : null}
|
||||||
|
</View>
|
||||||
|
<View className="order-pickup-copy">
|
||||||
|
<Text className="order-pickup-title">现场取货</Text>
|
||||||
|
<Text className="order-pickup-desc">
|
||||||
|
已在活动现场或门店拿到商品时勾选,确认后订单直接完成
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
<View className="order-card">
|
<View className="order-card">
|
||||||
<Text className="order-card-title">订单信息</Text>
|
<Text className="order-card-title">订单信息</Text>
|
||||||
<View className="order-row">
|
<View className="order-row">
|
||||||
@@ -189,7 +264,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 +298,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 ? '提交中…' : onSitePickup ? '确认现场取货' : '确认收货'}
|
||||||
|
</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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,45 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 核销金额输入:最多两位小数;去掉前导 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, '')
|
||||||
|
.replace(/\./g, '')
|
||||||
|
.slice(0, 2);
|
||||||
|
const intPart = intRaw.replace(/^0+(?=\d)/, '') || '0';
|
||||||
|
// 正在输入小数点或小数位时保留点
|
||||||
|
if (decRaw.length > 0 || next.endsWith('.')) {
|
||||||
|
return `${intPart}.${decRaw}`;
|
||||||
|
}
|
||||||
|
return intPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 纯整数:忽略前导 0(保留单个 0)
|
||||||
|
next = next.replace(/^0+(?=\d)/, '');
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
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));
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const redeemableMax = couponId ? (couponBalance ?? 0) : balance;
|
const redeemableMax = couponId ? (couponBalance ?? 0) : balance;
|
||||||
@@ -50,14 +78,18 @@ 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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAmountChange(raw: string) {
|
||||||
|
setAmount(sanitizeRedeemAmountInput(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -103,24 +135,27 @@ export default function RedeemPage() {
|
|||||||
placeholder="输入核销金额"
|
placeholder="输入核销金额"
|
||||||
placeholderClass="redeem-input-placeholder"
|
placeholderClass="redeem-input-placeholder"
|
||||||
value={amount}
|
value={amount}
|
||||||
onInput={(e) => setAmount(e.detail.value)}
|
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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,63 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-confirm-submit--disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-pickup-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-pickup-check {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1.5px solid var(--color-outline, #c8c4be);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-pickup-check--on {
|
||||||
|
border-color: var(--color-primary, #8b1a1a);
|
||||||
|
background: var(--color-primary, #8b1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-pickup-check-mark {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-pickup-copy {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-pickup-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-pickup-desc {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-on-surface-variant, #78716c);
|
||||||
|
}
|
||||||
|
|
||||||
.order-card {
|
.order-card {
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -314,6 +371,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 +387,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 +406,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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Generated
+39
-10
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -477,12 +484,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 +653,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 +663,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 +839,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 +1038,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 +1057,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 +1134,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 +1171,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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -31,6 +31,10 @@ export const HqOperationAction = {
|
|||||||
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
|
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
|
||||||
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
|
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
|
||||||
STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE',
|
STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE',
|
||||||
|
STORE_CATEGORY_CREATE: 'STORE_CATEGORY_CREATE',
|
||||||
|
STORE_CATEGORY_UPDATE: 'STORE_CATEGORY_UPDATE',
|
||||||
|
STORE_CATEGORY_DELETE: 'STORE_CATEGORY_DELETE',
|
||||||
|
STORE_CATEGORY_ENSURE: 'STORE_CATEGORY_ENSURE',
|
||||||
PRODUCT_CREATE: 'PRODUCT_CREATE',
|
PRODUCT_CREATE: 'PRODUCT_CREATE',
|
||||||
PRODUCT_UPDATE: 'PRODUCT_UPDATE',
|
PRODUCT_UPDATE: 'PRODUCT_UPDATE',
|
||||||
PRODUCT_DELETE: 'PRODUCT_DELETE',
|
PRODUCT_DELETE: 'PRODUCT_DELETE',
|
||||||
@@ -43,10 +47,17 @@ export const HqOperationAction = {
|
|||||||
TICKET_REJECT: 'TICKET_REJECT',
|
TICKET_REJECT: 'TICKET_REJECT',
|
||||||
STORE_PAYOUT_CONFIRM: 'STORE_PAYOUT_CONFIRM',
|
STORE_PAYOUT_CONFIRM: 'STORE_PAYOUT_CONFIRM',
|
||||||
STORE_PAYOUT_BATCH_CONFIRM: 'STORE_PAYOUT_BATCH_CONFIRM',
|
STORE_PAYOUT_BATCH_CONFIRM: 'STORE_PAYOUT_BATCH_CONFIRM',
|
||||||
|
STORE_BILL_CONFIRM: 'STORE_BILL_CONFIRM',
|
||||||
|
STORE_BILL_BATCH_CONFIRM: 'STORE_BILL_BATCH_CONFIRM',
|
||||||
PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE',
|
PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE',
|
||||||
|
PARTNER_BILL_SEND: 'PARTNER_BILL_SEND',
|
||||||
|
PARTNER_BILL_BATCH_SEND: 'PARTNER_BILL_BATCH_SEND',
|
||||||
PARTNER_BILL_CONFIRM: 'PARTNER_BILL_CONFIRM',
|
PARTNER_BILL_CONFIRM: 'PARTNER_BILL_CONFIRM',
|
||||||
PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID',
|
PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID',
|
||||||
|
PARTNER_BILL_BATCH_MARK_PAID: 'PARTNER_BILL_BATCH_MARK_PAID',
|
||||||
PARTNER_BILL_REJECT: 'PARTNER_BILL_REJECT',
|
PARTNER_BILL_REJECT: 'PARTNER_BILL_REJECT',
|
||||||
|
WINERY_BILL_CONFIRM: 'WINERY_BILL_CONFIRM',
|
||||||
|
WINERY_BILL_BATCH_CONFIRM: 'WINERY_BILL_BATCH_CONFIRM',
|
||||||
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
|
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
|
||||||
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
|
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
|
||||||
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
|
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
|
||||||
@@ -94,6 +105,10 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
|
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
|
||||||
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
|
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
|
||||||
[HqOperationAction.STORE_MEDIA_DELETE]: '删除门店资源',
|
[HqOperationAction.STORE_MEDIA_DELETE]: '删除门店资源',
|
||||||
|
[HqOperationAction.STORE_CATEGORY_CREATE]: '新增门店分类',
|
||||||
|
[HqOperationAction.STORE_CATEGORY_UPDATE]: '编辑门店分类',
|
||||||
|
[HqOperationAction.STORE_CATEGORY_DELETE]: '删除门店分类',
|
||||||
|
[HqOperationAction.STORE_CATEGORY_ENSURE]: '初始化默认门店分类',
|
||||||
[HqOperationAction.PRODUCT_CREATE]: '新增商品',
|
[HqOperationAction.PRODUCT_CREATE]: '新增商品',
|
||||||
[HqOperationAction.PRODUCT_UPDATE]: '编辑商品',
|
[HqOperationAction.PRODUCT_UPDATE]: '编辑商品',
|
||||||
[HqOperationAction.PRODUCT_DELETE]: '删除商品',
|
[HqOperationAction.PRODUCT_DELETE]: '删除商品',
|
||||||
@@ -106,10 +121,17 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.TICKET_REJECT]: '工单驳回',
|
[HqOperationAction.TICKET_REJECT]: '工单驳回',
|
||||||
[HqOperationAction.STORE_PAYOUT_CONFIRM]: '门店打款确认',
|
[HqOperationAction.STORE_PAYOUT_CONFIRM]: '门店打款确认',
|
||||||
[HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM]: '批量门店打款',
|
[HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM]: '批量门店打款',
|
||||||
|
[HqOperationAction.STORE_BILL_CONFIRM]: '门店对账单确认打款',
|
||||||
|
[HqOperationAction.STORE_BILL_BATCH_CONFIRM]: '批量门店对账单打款',
|
||||||
[HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单',
|
[HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单',
|
||||||
|
[HqOperationAction.PARTNER_BILL_SEND]: '发送合伙人账单',
|
||||||
|
[HqOperationAction.PARTNER_BILL_BATCH_SEND]: '批量发送合伙人账单',
|
||||||
[HqOperationAction.PARTNER_BILL_CONFIRM]: '确认合伙人账单',
|
[HqOperationAction.PARTNER_BILL_CONFIRM]: '确认合伙人账单',
|
||||||
[HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算',
|
[HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算',
|
||||||
|
[HqOperationAction.PARTNER_BILL_BATCH_MARK_PAID]: '批量合伙人账单结算',
|
||||||
[HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请',
|
[HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请',
|
||||||
|
[HqOperationAction.WINERY_BILL_CONFIRM]: '酒厂对账单确认打款',
|
||||||
|
[HqOperationAction.WINERY_BILL_BATCH_CONFIRM]: '批量酒厂对账单打款',
|
||||||
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
|
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
|
||||||
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
|
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
|
||||||
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
|
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { BadRequestException, Injectable, InternalServerErrorException, Logger }
|
|||||||
import { loadAppConfig } from '@dukang/shared-types';
|
import { loadAppConfig } from '@dukang/shared-types';
|
||||||
import { RedisService } from '../../common/redis/redis.service';
|
import { RedisService } from '../../common/redis/redis.service';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface';
|
import type {
|
||||||
|
IWechatProvider,
|
||||||
|
WechatCodeSession,
|
||||||
|
WechatOAuthSession,
|
||||||
|
WechatWxaCodeUnlimitedInput,
|
||||||
|
} from './wechat.interface';
|
||||||
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
|
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
|
||||||
import {
|
import {
|
||||||
decryptPayResource,
|
decryptPayResource,
|
||||||
@@ -252,6 +257,52 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
||||||
|
const scene = (input.scene ?? '').trim();
|
||||||
|
if (!scene || scene.length > 32) {
|
||||||
|
throw new BadRequestException('小程序码 scene 须为 1~32 个可见字符');
|
||||||
|
}
|
||||||
|
const accessToken = await this.getMiniAccessToken();
|
||||||
|
const page = (input.page ?? process.env.WX_MINI_PROMO_PAGE ?? 'pages/home/index').replace(
|
||||||
|
/^\//,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const envFromCfg = process.env.WX_MINI_ENV_VERSION;
|
||||||
|
const envVersion: 'release' | 'trial' | 'develop' =
|
||||||
|
input.envVersion ??
|
||||||
|
(envFromCfg === 'trial' || envFromCfg === 'develop' || envFromCfg === 'release'
|
||||||
|
? envFromCfg
|
||||||
|
: 'release');
|
||||||
|
const body = {
|
||||||
|
scene,
|
||||||
|
page,
|
||||||
|
width: input.width ?? 430,
|
||||||
|
check_path: input.checkPath ?? false,
|
||||||
|
env_version: envVersion,
|
||||||
|
is_hyaline: input.isHyaline ?? false,
|
||||||
|
};
|
||||||
|
const apiUrl = `https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=${accessToken}`;
|
||||||
|
const res = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
// 失败时微信返回 JSON(以 { 开头),成功为 PNG 二进制
|
||||||
|
if (buf.length >= 1 && buf[0] === 0x7b /* '{' */) {
|
||||||
|
let errMsg = '生成小程序码失败';
|
||||||
|
try {
|
||||||
|
const err = JSON.parse(buf.toString('utf8')) as { errcode?: number; errmsg?: string };
|
||||||
|
errMsg = err.errmsg || errMsg;
|
||||||
|
this.logger.error(`getwxacodeunlimit failed: ${err.errcode} ${err.errmsg}`);
|
||||||
|
} catch {
|
||||||
|
this.logger.error(`getwxacodeunlimit non-image response: ${buf.toString('utf8').slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
throw new InternalServerErrorException(errMsg);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5', actorRef?: WechatActorRef): Promise<string> {
|
async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5', actorRef?: WechatActorRef): Promise<string> {
|
||||||
if (platform === 'h5') {
|
if (platform === 'h5') {
|
||||||
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
|
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, NotImplementedException } from '@nestjs/common';
|
import { Injectable, NotImplementedException } from '@nestjs/common';
|
||||||
import type { IWechatProvider } from './wechat.interface';
|
import type { IWechatProvider, WechatWxaCodeUnlimitedInput } from './wechat.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WechatDisabledProvider implements IWechatProvider {
|
export class WechatDisabledProvider implements IWechatProvider {
|
||||||
@@ -54,4 +54,8 @@ export class WechatDisabledProvider implements IWechatProvider {
|
|||||||
parsePayNotification() {
|
parsePayNotification() {
|
||||||
return this.disabled();
|
return this.disabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getWxaCodeUnlimited(_input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
||||||
|
return this.disabled();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ export type WechatPayNotifyResult = {
|
|||||||
amountFen: number;
|
amountFen: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WechatWxaCodeUnlimitedInput = {
|
||||||
|
/** 最大 32 可见字符,扫码后小程序 onLaunch.options.scene */
|
||||||
|
scene: string;
|
||||||
|
/** 小程序页面路径,如 pages/home/index(不要前导 /) */
|
||||||
|
page?: string;
|
||||||
|
width?: number;
|
||||||
|
checkPath?: boolean;
|
||||||
|
envVersion?: 'release' | 'trial' | 'develop';
|
||||||
|
isHyaline?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export interface IWechatProvider {
|
export interface IWechatProvider {
|
||||||
isEnabled(): boolean;
|
isEnabled(): boolean;
|
||||||
|
|
||||||
@@ -81,4 +92,7 @@ export interface IWechatProvider {
|
|||||||
headers: Record<string, string | string[] | undefined>,
|
headers: Record<string, string | string[] | undefined>,
|
||||||
rawBody: string,
|
rawBody: string,
|
||||||
): Promise<WechatPayNotifyResult>;
|
): Promise<WechatPayNotifyResult>;
|
||||||
|
|
||||||
|
/** 获取不限制的小程序码(PNG Buffer),须服务端调用 */
|
||||||
|
getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { Injectable, NotImplementedException } from '@nestjs/common';
|
import { Injectable, NotImplementedException } from '@nestjs/common';
|
||||||
|
import * as QRCode from 'qrcode';
|
||||||
import type {
|
import type {
|
||||||
IWechatProvider,
|
IWechatProvider,
|
||||||
WechatCodeSession,
|
WechatCodeSession,
|
||||||
WechatOAuthSession,
|
WechatOAuthSession,
|
||||||
|
WechatWxaCodeUnlimitedInput,
|
||||||
} from './wechat.interface';
|
} from './wechat.interface';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,4 +83,15 @@ export class WechatMockProvider implements IWechatProvider {
|
|||||||
parsePayNotification(): never {
|
parsePayNotification(): never {
|
||||||
throw new NotImplementedException('FEATURE_DISABLED');
|
throw new NotImplementedException('FEATURE_DISABLED');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mock:用普通二维码 PNG 占位,内容含 scene,便于本地联调上传 OSS */
|
||||||
|
async getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
||||||
|
const scene = (input.scene ?? '').trim() || 'mock';
|
||||||
|
return QRCode.toBuffer(`mock-wxa://promo?scene=${encodeURIComponent(scene)}`, {
|
||||||
|
width: input.width ?? 430,
|
||||||
|
margin: 1,
|
||||||
|
type: 'png',
|
||||||
|
color: { dark: '#1f1a17', light: '#ffffff' },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,4 +86,8 @@ export class WechatRouterProvider implements IWechatProvider {
|
|||||||
) {
|
) {
|
||||||
return this.resolve().parsePayNotification(headers, rawBody);
|
return this.resolve().parsePayNotification(headers, rawBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getWxaCodeUnlimited(input: Parameters<IWechatProvider['getWxaCodeUnlimited']>[0]) {
|
||||||
|
return this.resolve().getWxaCodeUnlimited(input);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import { TradeModule } from '../modules/trade/trade.module';
|
import { TradeModule } from '../modules/trade/trade.module';
|
||||||
|
import { SettlementModule } from '../modules/settlement/settlement.module';
|
||||||
import { DeliveryProcessor } from './delivery.processor';
|
import { DeliveryProcessor } from './delivery.processor';
|
||||||
|
import { SettlementScheduler } from './settlement.scheduler';
|
||||||
import { DELIVERY_QUEUE } from './jobs.constants';
|
import { DELIVERY_QUEUE } from './jobs.constants';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), TradeModule],
|
imports: [
|
||||||
providers: [DeliveryProcessor],
|
ScheduleModule.forRoot(),
|
||||||
|
BullModule.registerQueue({ name: DELIVERY_QUEUE }),
|
||||||
|
TradeModule,
|
||||||
|
SettlementModule,
|
||||||
|
],
|
||||||
|
providers: [DeliveryProcessor, SettlementScheduler],
|
||||||
})
|
})
|
||||||
export class JobsModule {}
|
export class JobsModule {}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { SettlementService } from '../modules/settlement/settlement.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 财务对账单定时任务(Asia/Shanghai)
|
||||||
|
* - 每日 08:00:酒厂日账单 + 门店日账单(统计昨日 00:00~今日 00:00)
|
||||||
|
* - 每月 1 日 08:00:合伙人上一自然月账单
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SettlementScheduler {
|
||||||
|
private readonly logger = new Logger(SettlementScheduler.name);
|
||||||
|
|
||||||
|
constructor(private readonly settlementService: SettlementService) {}
|
||||||
|
|
||||||
|
@Cron('0 8 * * *', { timeZone: 'Asia/Shanghai' })
|
||||||
|
async handleDailyBills() {
|
||||||
|
this.logger.log('Daily settlement bills job start');
|
||||||
|
try {
|
||||||
|
const winery = await this.settlementService.generateWineryBillForDay();
|
||||||
|
this.logger.log(`Winery bill: ${JSON.stringify(winery)}`);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('Winery bill job failed', e instanceof Error ? e.stack : e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const store = await this.settlementService.generateStoreBillsForDay();
|
||||||
|
this.logger.log(`Store bills: ${JSON.stringify(store)}`);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('Store bill job failed', e instanceof Error ? e.stack : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron('0 8 1 * *', { timeZone: 'Asia/Shanghai' })
|
||||||
|
async handleMonthlyPartnerBills() {
|
||||||
|
this.logger.log('Monthly partner bills job start');
|
||||||
|
try {
|
||||||
|
const result = await this.settlementService.generatePreviousMonthPartnerBills();
|
||||||
|
this.logger.log(
|
||||||
|
`Partner bills: total=${result.total} success=${result.success} failed=${result.failed}`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('Partner bill job failed', e instanceof Error ? e.stack : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ export class PromoController {
|
|||||||
touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
|
touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
|
||||||
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
|
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
|
||||||
return this.promoCodeService.touch(
|
return this.promoCodeService.touch(
|
||||||
{ promoCode: dto.promoCode, qrcodeId: dto.qrcodeId },
|
{ promoCode: dto.promoCode, qrcodeId: dto.qrcodeId, promoId: dto.promoId },
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class PromoTouchDto {
|
export class PromoTouchDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -8,4 +8,9 @@ export class PromoTouchDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
qrcodeId?: string;
|
qrcodeId?: string;
|
||||||
|
|
||||||
|
/** 小程序码 scene 中的推广活动 ID */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
promoId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export class PartnerCityService {
|
|||||||
partnerAccountId: r.id.toString(),
|
partnerAccountId: r.id.toString(),
|
||||||
scopeType: r.scopeType as CityPartnerScopeType,
|
scopeType: r.scopeType as CityPartnerScopeType,
|
||||||
districtCodes: this.parseDistrictCodes(r.districtCodes),
|
districtCodes: this.parseDistrictCodes(r.districtCodes),
|
||||||
|
companyName: r.companyName,
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
partnerAccountId: input.partnerAccountId ?? 'new',
|
partnerAccountId: input.partnerAccountId ?? 'new',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
|||||||
import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers';
|
import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers';
|
||||||
import { mapBenefitLedgerCompat } from '../../common/compat/v31-compat';
|
import { mapBenefitLedgerCompat } from '../../common/compat/v31-compat';
|
||||||
import { BenefitService } from '../benefit/benefit.service';
|
import { BenefitService } from '../benefit/benefit.service';
|
||||||
|
import { AdminRedeemService } from './admin-redeem.service';
|
||||||
import type { AdminBenefitCouponsQueryDto, AdminBenefitLedgersQueryDto } from './dto/admin-query.dto';
|
import type { AdminBenefitCouponsQueryDto, AdminBenefitLedgersQueryDto } from './dto/admin-query.dto';
|
||||||
import type { AdminBenefitGrantDto } from './dto/admin-mutate.dto';
|
import type { AdminBenefitGrantDto } from './dto/admin-mutate.dto';
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export class AdminBenefitService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly benefitService: BenefitService,
|
private readonly benefitService: BenefitService,
|
||||||
|
private readonly adminRedeemService: AdminRedeemService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listCoupons(query: AdminBenefitCouponsQueryDto) {
|
async listCoupons(query: AdminBenefitCouponsQueryDto) {
|
||||||
@@ -45,16 +47,23 @@ export class AdminBenefitService {
|
|||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||||
order: { select: { id: true, orderNo: true, status: true, payAmount: true } },
|
order: { select: { id: true, orderNo: true, status: true, payAmount: true } },
|
||||||
redeemRecords: { orderBy: { createdAt: 'desc' }, take: 10, include: { store: { select: { id: true, name: true } } } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!coupon) throw new NotFoundException('权益券不存在');
|
if (!coupon) throw new NotFoundException('权益券不存在');
|
||||||
const ledgers = await this.prisma.commonEvent.findMany({
|
const [ledgers, redeemTrace] = await Promise.all([
|
||||||
where: benefitLedgerWhere(undefined, id),
|
this.prisma.commonEvent.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
where: benefitLedgerWhere(undefined, id),
|
||||||
take: 20,
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
}),
|
||||||
|
this.adminRedeemService.buildCouponRedeemTrace(coupon),
|
||||||
|
]);
|
||||||
|
return serializeBigInt({
|
||||||
|
...coupon,
|
||||||
|
ledgers,
|
||||||
|
redeemSummary: redeemTrace.redeemSummary,
|
||||||
|
redeemRecords: redeemTrace.redeemRecords,
|
||||||
});
|
});
|
||||||
return serializeBigInt({ ...coupon, ledgers });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async voidCoupon(id: bigint) {
|
async voidCoupon(id: bigint) {
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ export class AdminCitiesService {
|
|||||||
const cityPartners = await this.partnerCityService.listByCity(id);
|
const cityPartners = await this.partnerCityService.listByCity(id);
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
...city,
|
...city,
|
||||||
|
// Decimal 经 JSON 会变成字符串,显式 Number 避免前端 `"0.05" + 1e-9` 字符串拼接误判
|
||||||
|
maxPartnerCommissionRate:
|
||||||
|
city.maxPartnerCommissionRate != null ? Number(city.maxPartnerCommissionRate) : null,
|
||||||
cityPartners,
|
cityPartners,
|
||||||
storeCount: city._count.stores,
|
storeCount: city._count.stores,
|
||||||
orderCount: city._count.orders,
|
orderCount: city._count.orders,
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ export class AdminDashboardService {
|
|||||||
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
|
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
|
||||||
this.prisma.orderDelivery.count(),
|
this.prisma.orderDelivery.count(),
|
||||||
this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
|
this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
|
||||||
this.prisma.partnerBill.count({ where: { status: 'CONFIRMED' } }),
|
this.prisma.partnerBill.count({ where: { status: 'UNPAID' } }),
|
||||||
this.prisma.partnerBill.count({ where: { status: 'DRAFT' } }),
|
this.prisma.partnerBill.count({ where: { status: 'PENDING_REVIEW' } }),
|
||||||
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
|
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.d
|
|||||||
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
|
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
|
||||||
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
||||||
|
import { AdminRedeemService } from './admin-redeem.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminOrdersService {
|
export class AdminOrdersService {
|
||||||
@@ -20,6 +21,7 @@ export class AdminOrdersService {
|
|||||||
private readonly xiaofeixiaService: AdminXiaofeixiaService,
|
private readonly xiaofeixiaService: AdminXiaofeixiaService,
|
||||||
private readonly fulfillmentService: FulfillmentService,
|
private readonly fulfillmentService: FulfillmentService,
|
||||||
private readonly fulfillmentProviderService: FulfillmentProviderService,
|
private readonly fulfillmentProviderService: FulfillmentProviderService,
|
||||||
|
private readonly adminRedeemService: AdminRedeemService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(query: AdminOrdersQueryDto) {
|
async list(query: AdminOrdersQueryDto) {
|
||||||
@@ -73,11 +75,18 @@ export class AdminOrdersService {
|
|||||||
},
|
},
|
||||||
delivery: true,
|
delivery: true,
|
||||||
benefitCoupon: {
|
benefitCoupon: {
|
||||||
select: { id: true, couponNo: true, balance: true, status: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
couponNo: true,
|
||||||
|
totalAmount: true,
|
||||||
|
usedAmount: true,
|
||||||
|
balance: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
city: { select: { id: true, name: true, code: true } },
|
city: { select: { id: true, name: true, code: true } },
|
||||||
product: { select: { id: true, name: true, skuCode: true, barcode69: true } },
|
product: { select: { id: true, name: true, skuCode: true, barcode69: true } },
|
||||||
imageResource: { select: { id: true, url: true } },
|
imageResource: { select: { url: true } },
|
||||||
fulfillmentWarehouse: {
|
fulfillmentWarehouse: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -97,11 +106,34 @@ export class AdminOrdersService {
|
|||||||
where: orderStatusLogWhere(id),
|
where: orderStatusLogWhere(id),
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
});
|
});
|
||||||
return serializeBigInt(mapOrderCompat({
|
|
||||||
...order,
|
const coupon = order.benefitCoupon;
|
||||||
statusLogs: mapStatusLogCompat(statusLogs),
|
const redeemTrace = coupon
|
||||||
benefitCoupons: order.benefitCoupon ? [order.benefitCoupon] : [],
|
? await this.adminRedeemService.buildCouponRedeemTrace(coupon)
|
||||||
}));
|
: { redeemSummary: null, redeemRecords: [] };
|
||||||
|
|
||||||
|
const { benefitCoupon: _coupon, ...orderRest } = order;
|
||||||
|
|
||||||
|
return serializeBigInt(
|
||||||
|
mapOrderCompat({
|
||||||
|
...orderRest,
|
||||||
|
statusLogs: mapStatusLogCompat(statusLogs),
|
||||||
|
benefitCoupons: coupon
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: coupon.id,
|
||||||
|
couponNo: coupon.couponNo,
|
||||||
|
totalAmount: Number(coupon.totalAmount),
|
||||||
|
usedAmount: Number(coupon.usedAmount),
|
||||||
|
balance: Number(coupon.balance),
|
||||||
|
status: coupon.status,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
redeemSummary: redeemTrace.redeemSummary,
|
||||||
|
redeemRecords: redeemTrace.redeemRecords,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateStatusDebug(id: bigint, status: string) {
|
async updateStatusDebug(id: bigint, status: string) {
|
||||||
@@ -309,7 +341,12 @@ export class AdminOrdersService {
|
|||||||
if (couponIds.length) {
|
if (couponIds.length) {
|
||||||
const redeemIds = (
|
const redeemIds = (
|
||||||
await tx.redeemRecord.findMany({
|
await tx.redeemRecord.findMany({
|
||||||
where: { couponId: { in: couponIds } },
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ couponId: { in: couponIds } },
|
||||||
|
{ allocations: { some: { couponId: { in: couponIds } } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
).map((r) => r.id);
|
).map((r) => r.id);
|
||||||
@@ -317,6 +354,7 @@ export class AdminOrdersService {
|
|||||||
if (redeemIds.length) {
|
if (redeemIds.length) {
|
||||||
await tx.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
await tx.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
||||||
await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
||||||
|
await tx.redeemRecordAllocation.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
||||||
await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
|
await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export class AdminPartnersService {
|
|||||||
maxPartnerCommissionRate:
|
maxPartnerCommissionRate:
|
||||||
p.city?.maxPartnerCommissionRate != null ? Number(p.city.maxPartnerCommissionRate) : null,
|
p.city?.maxPartnerCommissionRate != null ? Number(p.city.maxPartnerCommissionRate) : null,
|
||||||
scopeType: p.scopeType,
|
scopeType: p.scopeType,
|
||||||
|
districtCodes: this.partnerCityService.parseDistrictCodes(p.districtCodes),
|
||||||
orderCommissionRate: Number(p.orderCommissionRate ?? 0),
|
orderCommissionRate: Number(p.orderCommissionRate ?? 0),
|
||||||
redeemCommissionRate: Number(p.redeemCommissionRate ?? 0.03),
|
redeemCommissionRate: Number(p.redeemCommissionRate ?? 0.03),
|
||||||
bindingStatus: p.bindingStatus,
|
bindingStatus: p.bindingStatus,
|
||||||
|
|||||||
@@ -6,6 +6,28 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
|||||||
import type { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto';
|
import type { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto';
|
||||||
import type { UpdateDeliveryDto } from './dto/admin-mutate.dto';
|
import type { UpdateDeliveryDto } from './dto/admin-mutate.dto';
|
||||||
|
|
||||||
|
export type CouponRedeemTrace = {
|
||||||
|
redeemSummary: {
|
||||||
|
couponNo: string;
|
||||||
|
totalAmount: number;
|
||||||
|
usedAmount: number;
|
||||||
|
balance: number;
|
||||||
|
status: string;
|
||||||
|
redeemCount: number;
|
||||||
|
redeemRecordSum: number;
|
||||||
|
};
|
||||||
|
redeemRecords: Array<{
|
||||||
|
id: bigint;
|
||||||
|
redeemNo: string;
|
||||||
|
amount: number;
|
||||||
|
settleAmount: number;
|
||||||
|
couponAmount: number;
|
||||||
|
role: 'PRIMARY' | 'SECONDARY';
|
||||||
|
createdAt: Date;
|
||||||
|
store: { id: bigint; name: string; cityName: string | null } | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminRedeemService {
|
export class AdminRedeemService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -39,14 +61,257 @@ export class AdminRedeemService {
|
|||||||
const record = await this.prisma.redeemRecord.findUnique({
|
const record = await this.prisma.redeemRecord.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
user: true,
|
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||||
store: { include: { partnerAccount: { select: { id: true, companyName: true } } } },
|
store: {
|
||||||
coupon: true,
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
cityName: true,
|
||||||
|
address: true,
|
||||||
|
partnerAccount: { select: { id: true, companyName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
coupon: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
couponNo: true,
|
||||||
|
totalAmount: true,
|
||||||
|
usedAmount: true,
|
||||||
|
balance: true,
|
||||||
|
status: true,
|
||||||
|
orderId: true,
|
||||||
|
order: { select: { id: true, orderNo: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allocations: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: {
|
||||||
|
coupon: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
couponNo: true,
|
||||||
|
order: { select: { id: true, orderNo: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
payout: true,
|
payout: true,
|
||||||
|
rating: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!record) throw new NotFoundException('核销记录不存在');
|
if (!record) throw new NotFoundException('核销记录不存在');
|
||||||
return serializeBigInt(record);
|
return serializeBigInt({
|
||||||
|
...record,
|
||||||
|
allocations: record.allocations.map((a) => ({
|
||||||
|
couponId: a.couponId,
|
||||||
|
amount: Number(a.amount),
|
||||||
|
sortOrder: a.sortOrder,
|
||||||
|
couponNo: a.coupon.couponNo,
|
||||||
|
orderId: a.coupon.order?.id ?? null,
|
||||||
|
orderNo: a.coupon.order?.orderNo ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按权益券聚合核销追溯(含跨券 FIFO 次券) */
|
||||||
|
async buildCouponRedeemTrace(coupon: {
|
||||||
|
id: bigint;
|
||||||
|
couponNo: string;
|
||||||
|
totalAmount: Prisma.Decimal | number;
|
||||||
|
usedAmount: Prisma.Decimal | number;
|
||||||
|
balance: Prisma.Decimal | number;
|
||||||
|
status: string;
|
||||||
|
}): Promise<CouponRedeemTrace> {
|
||||||
|
await this.ensureRedeemAllocationsForCoupon(coupon.id);
|
||||||
|
|
||||||
|
const redeemRows = await this.prisma.redeemRecord.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ couponId: coupon.id },
|
||||||
|
{ allocations: { some: { couponId: coupon.id } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
store: { select: { id: true, name: true, cityName: true } },
|
||||||
|
allocations: {
|
||||||
|
where: { couponId: coupon.id },
|
||||||
|
select: { amount: true, sortOrder: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const redeemRecords = redeemRows.map((r) => {
|
||||||
|
const couponAmount =
|
||||||
|
r.allocations[0] != null
|
||||||
|
? Number(r.allocations[0].amount)
|
||||||
|
: r.couponId === coupon.id
|
||||||
|
? Number(r.amount)
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
redeemNo: r.redeemNo,
|
||||||
|
amount: Number(r.amount),
|
||||||
|
settleAmount: Number(r.settleAmount),
|
||||||
|
couponAmount,
|
||||||
|
role: (r.couponId === coupon.id ? 'PRIMARY' : 'SECONDARY') as 'PRIMARY' | 'SECONDARY',
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
store: r.store,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const redeemRecordSum = redeemRecords.reduce((sum, r) => sum + r.couponAmount, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
redeemSummary: {
|
||||||
|
couponNo: coupon.couponNo,
|
||||||
|
totalAmount: Number(coupon.totalAmount),
|
||||||
|
usedAmount: Number(coupon.usedAmount),
|
||||||
|
balance: Number(coupon.balance),
|
||||||
|
status: coupon.status,
|
||||||
|
redeemCount: redeemRecords.length,
|
||||||
|
redeemRecordSum,
|
||||||
|
},
|
||||||
|
redeemRecords,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureRedeemAllocationsForCoupon(couponId: bigint) {
|
||||||
|
const coupon = await this.prisma.benefitCoupon.findUnique({
|
||||||
|
where: { id: couponId },
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
});
|
||||||
|
if (!coupon) return;
|
||||||
|
|
||||||
|
const pendings = await this.prisma.redeemPendingRecord.findMany({
|
||||||
|
where: { userId: coupon.userId, redeemRecordId: { not: null } },
|
||||||
|
select: { redeemRecordId: true, allocationsJson: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const pending of pendings) {
|
||||||
|
if (!pending.redeemRecordId) continue;
|
||||||
|
const allocs = this.parseAllocationsJson(pending.allocationsJson);
|
||||||
|
if (!allocs.some((a) => a.couponId === couponId.toString())) continue;
|
||||||
|
|
||||||
|
const existing = await this.prisma.redeemRecordAllocation.count({
|
||||||
|
where: { redeemRecordId: pending.redeemRecordId },
|
||||||
|
});
|
||||||
|
if (existing > 0) continue;
|
||||||
|
|
||||||
|
await this.prisma.redeemRecordAllocation.createMany({
|
||||||
|
data: allocs.map((a, index) => ({
|
||||||
|
redeemRecordId: pending.redeemRecordId!,
|
||||||
|
couponId: BigInt(a.couponId),
|
||||||
|
amount: a.amount,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ledgers = await this.prisma.commonEvent.findMany({
|
||||||
|
where: {
|
||||||
|
eventType: 'BENEFIT_LEDGER',
|
||||||
|
param1: 'REDEEM',
|
||||||
|
param2: couponId.toString(),
|
||||||
|
actorType: 'USER',
|
||||||
|
actorId: coupon.userId,
|
||||||
|
refType: 'STORE',
|
||||||
|
},
|
||||||
|
select: { id: true, refId: true, amount1: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const ledger of ledgers) {
|
||||||
|
if (ledger.refId == null || ledger.amount1 == null) continue;
|
||||||
|
const allocAmount = Math.abs(Number(ledger.amount1));
|
||||||
|
if (!(allocAmount > 0)) continue;
|
||||||
|
|
||||||
|
const already = await this.prisma.redeemRecordAllocation.findFirst({
|
||||||
|
where: {
|
||||||
|
couponId,
|
||||||
|
amount: allocAmount,
|
||||||
|
redeemRecord: {
|
||||||
|
userId: coupon.userId,
|
||||||
|
storeId: ledger.refId,
|
||||||
|
createdAt: {
|
||||||
|
gte: new Date(ledger.createdAt.getTime() - 8000),
|
||||||
|
lte: new Date(ledger.createdAt.getTime() + 8000),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (already) continue;
|
||||||
|
|
||||||
|
const candidates = await this.prisma.redeemRecord.findMany({
|
||||||
|
where: {
|
||||||
|
userId: coupon.userId,
|
||||||
|
storeId: ledger.refId,
|
||||||
|
createdAt: {
|
||||||
|
gte: new Date(ledger.createdAt.getTime() - 8000),
|
||||||
|
lte: new Date(ledger.createdAt.getTime() + 8000),
|
||||||
|
},
|
||||||
|
amount: { gte: allocAmount },
|
||||||
|
allocations: { none: { couponId } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
if (!candidates.length) continue;
|
||||||
|
|
||||||
|
const target =
|
||||||
|
candidates.find((r) => r.couponId !== couponId) ??
|
||||||
|
(candidates.length === 1 ? candidates[0] : null);
|
||||||
|
if (!target) continue;
|
||||||
|
|
||||||
|
await this.prisma.redeemRecordAllocation
|
||||||
|
.create({
|
||||||
|
data: {
|
||||||
|
redeemRecordId: target.id,
|
||||||
|
couponId,
|
||||||
|
amount: allocAmount,
|
||||||
|
sortOrder: target.couponId === couponId ? 0 : 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* unique 冲突忽略 */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryWithoutAlloc = await this.prisma.redeemRecord.findMany({
|
||||||
|
where: {
|
||||||
|
couponId,
|
||||||
|
allocations: { none: {} },
|
||||||
|
},
|
||||||
|
select: { id: true, amount: true },
|
||||||
|
});
|
||||||
|
if (primaryWithoutAlloc.length) {
|
||||||
|
await this.prisma.redeemRecordAllocation.createMany({
|
||||||
|
data: primaryWithoutAlloc.map((r) => ({
|
||||||
|
redeemRecordId: r.id,
|
||||||
|
couponId,
|
||||||
|
amount: r.amount,
|
||||||
|
sortOrder: 0,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAllocationsJson(
|
||||||
|
value: Prisma.JsonValue,
|
||||||
|
): Array<{ couponId: string; amount: number }> {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object') return null;
|
||||||
|
const row = item as { couponId?: unknown; amount?: unknown };
|
||||||
|
const id = row.couponId != null ? String(row.couponId) : '';
|
||||||
|
const amount = Number(row.amount);
|
||||||
|
if (!id || !Number.isFinite(amount) || amount <= 0) return null;
|
||||||
|
return { couponId: id, amount };
|
||||||
|
})
|
||||||
|
.filter((item): item is { couponId: string; amount: number } => !!item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||||
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
|
import { StoreCategoryService } from '../store/store-category.service';
|
||||||
|
import {
|
||||||
|
CreateStoreCategoryDto,
|
||||||
|
UpdateStoreCategoryDto,
|
||||||
|
} from './dto/admin-mutate.dto';
|
||||||
|
|
||||||
|
@Controller('admin/store-categories')
|
||||||
|
@UseGuards(HqAuthGuard)
|
||||||
|
export class AdminStoreCategoriesController {
|
||||||
|
constructor(private readonly categories: StoreCategoryService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.categories.listTree({ includeDisabled: true, ensure: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('flat')
|
||||||
|
listFlat() {
|
||||||
|
return this.categories.listFlat({ includeDisabled: true, ensure: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('ensure-defaults')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.STORE_CATEGORY_ENSURE,
|
||||||
|
refType: 'STORE_CATEGORY',
|
||||||
|
includeBody: false,
|
||||||
|
})
|
||||||
|
async ensureDefaults() {
|
||||||
|
await this.categories.ensureDefaults();
|
||||||
|
return this.categories.listTree({ includeDisabled: true, ensure: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.STORE_CATEGORY_CREATE,
|
||||||
|
refType: 'STORE_CATEGORY',
|
||||||
|
refIdField: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
create(@Body() dto: CreateStoreCategoryDto) {
|
||||||
|
return this.categories.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.STORE_CATEGORY_UPDATE,
|
||||||
|
refType: 'STORE_CATEGORY',
|
||||||
|
refIdParam: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateStoreCategoryDto) {
|
||||||
|
return this.categories.update(BigInt(id), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.STORE_CATEGORY_DELETE,
|
||||||
|
refType: 'STORE_CATEGORY',
|
||||||
|
refIdParam: 'id',
|
||||||
|
})
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.categories.remove(BigInt(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1150,3 +1150,53 @@ export class AdminBenefitGrantDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CreateStoreCategoryDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
sort?: number;
|
||||||
|
|
||||||
|
/** 空/不传 = 一级分类;传入一级 id = 二级分类 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['ACTIVE', 'DISABLED'])
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateStoreCategoryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
sort?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, v) => v !== null && v !== undefined)
|
||||||
|
@IsString()
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['ACTIVE', 'DISABLED'])
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CityScopeModule } from '../city-scope/city-scope.module';
|
|||||||
import { IamModule } from '../iam/iam.module';
|
import { IamModule } from '../iam/iam.module';
|
||||||
import { TradeModule } from '../trade/trade.module';
|
import { TradeModule } from '../trade/trade.module';
|
||||||
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
|
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
|
||||||
|
import { StoreModule } from '../store/store.module';
|
||||||
import { AdminDashboardController } from './admin-dashboard.controller';
|
import { AdminDashboardController } from './admin-dashboard.controller';
|
||||||
import { AdminDashboardService } from './admin-dashboard.service';
|
import { AdminDashboardService } from './admin-dashboard.service';
|
||||||
import { AdminUsersController } from './admin-users.controller';
|
import { AdminUsersController } from './admin-users.controller';
|
||||||
@@ -10,6 +11,7 @@ import { AdminUsersService } from './admin-users.service';
|
|||||||
import { AdminOrdersController } from './admin-orders.controller';
|
import { AdminOrdersController } from './admin-orders.controller';
|
||||||
import { AdminOrdersService } from './admin-orders.service';
|
import { AdminOrdersService } from './admin-orders.service';
|
||||||
import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller';
|
import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller';
|
||||||
|
import { AdminStoreCategoriesController } from './admin-store-categories.controller';
|
||||||
import { AdminStoresService } from './admin-stores.service';
|
import { AdminStoresService } from './admin-stores.service';
|
||||||
import { AdminPartnersController, AdminPartnerAccountsController } from './admin-partners.controller';
|
import { AdminPartnersController, AdminPartnerAccountsController } from './admin-partners.controller';
|
||||||
import { AdminPartnersService } from './admin-partners.service';
|
import { AdminPartnersService } from './admin-partners.service';
|
||||||
@@ -59,7 +61,7 @@ import { AdminSystemConfigController } from './admin-system-config.controller';
|
|||||||
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
|
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule, StoreModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
AdminDashboardController,
|
AdminDashboardController,
|
||||||
AdminDeployController,
|
AdminDeployController,
|
||||||
@@ -68,6 +70,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
|||||||
AdminStoresController,
|
AdminStoresController,
|
||||||
AdminStoreAccountsController,
|
AdminStoreAccountsController,
|
||||||
AdminStoreMediaController,
|
AdminStoreMediaController,
|
||||||
|
AdminStoreCategoriesController,
|
||||||
AdminPartnersController,
|
AdminPartnersController,
|
||||||
AdminPartnerAccountsController,
|
AdminPartnerAccountsController,
|
||||||
AdminCitiesController,
|
AdminCitiesController,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import * as QRCode from 'qrcode';
|
|
||||||
import {
|
import {
|
||||||
PROMO_CODE_SCENE_LABELS,
|
PROMO_CODE_SCENE_LABELS,
|
||||||
PromoCodeScene,
|
PromoCodeScene,
|
||||||
@@ -14,8 +13,9 @@ import {
|
|||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { OSS_PROVIDER } from '../../integrations/integrations.constants';
|
import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
||||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||||
|
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||||
import type {
|
import type {
|
||||||
CreatePromoCodeDto,
|
CreatePromoCodeDto,
|
||||||
PromoCodeListQueryDto,
|
PromoCodeListQueryDto,
|
||||||
@@ -70,6 +70,7 @@ export class PromoCodeService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
||||||
|
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
listScenes() {
|
listScenes() {
|
||||||
@@ -231,18 +232,25 @@ export class PromoCodeService {
|
|||||||
throw new BadRequestException('生成二维码 ID 失败,请重试');
|
throw new BadRequestException('生成二维码 ID 失败,请重试');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createQrcodeResource(promoId: bigint, code: string, qrcodeId: string) {
|
/**
|
||||||
const landingUrl = buildLandingUrl(code, qrcodeId);
|
* 调用微信 getwxacodeunlimit,scene=推广活动 ID,PNG 上传 OSS uploads/qrcode/
|
||||||
const pngBuffer = await QRCode.toBuffer(landingUrl, {
|
*/
|
||||||
width: 512,
|
private async createQrcodeResource(promoId: bigint, code: string) {
|
||||||
margin: 1,
|
const scene = promoId.toString();
|
||||||
type: 'png',
|
if (scene.length > 32) {
|
||||||
color: { dark: '#1f1a17', light: '#ffffff' },
|
throw new BadRequestException('推广活动 ID 过长,无法写入小程序码 scene');
|
||||||
|
}
|
||||||
|
const pngBuffer = await this.wechat.getWxaCodeUnlimited({
|
||||||
|
scene,
|
||||||
|
page: process.env.WX_MINI_PROMO_PAGE || 'pages/home/index',
|
||||||
|
width: 430,
|
||||||
|
checkPath: false,
|
||||||
});
|
});
|
||||||
|
const fileName = `promo-${code}-${scene}.png`;
|
||||||
const uploaded = await this.oss.putObject({
|
const uploaded = await this.oss.putObject({
|
||||||
bizType: 'QRCODE',
|
bizType: 'QRCODE',
|
||||||
mediaType: 'IMAGE',
|
mediaType: 'IMAGE',
|
||||||
fileName: `promo-${code}.png`,
|
fileName,
|
||||||
buffer: pngBuffer,
|
buffer: pngBuffer,
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
});
|
});
|
||||||
@@ -255,7 +263,7 @@ export class PromoCodeService {
|
|||||||
ossBucket: uploaded.bucket,
|
ossBucket: uploaded.bucket,
|
||||||
ossKey: uploaded.ossKey,
|
ossKey: uploaded.ossKey,
|
||||||
url: uploaded.url,
|
url: uploaded.url,
|
||||||
fileName: `promo-${code}.png`,
|
fileName,
|
||||||
fileSize: BigInt(pngBuffer.length),
|
fileSize: BigInt(pngBuffer.length),
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
},
|
},
|
||||||
@@ -281,13 +289,18 @@ export class PromoCodeService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const resource = await this.createQrcodeResource(row.id, code, qrcodeId);
|
try {
|
||||||
const updated = await this.prisma.commonPromoCode.update({
|
const resource = await this.createQrcodeResource(row.id, code);
|
||||||
where: { id: row.id },
|
const updated = await this.prisma.commonPromoCode.update({
|
||||||
data: { qrcodeResourceId: resource.id },
|
where: { id: row.id },
|
||||||
include: this.includeRelations,
|
data: { qrcodeResourceId: resource.id },
|
||||||
});
|
include: this.includeRelations,
|
||||||
return this.mapRow(updated);
|
});
|
||||||
|
return this.mapRow(updated);
|
||||||
|
} catch (err) {
|
||||||
|
await this.prisma.commonPromoCode.delete({ where: { id: row.id } }).catch(() => undefined);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: bigint, dto: UpdatePromoCodeDto) {
|
async update(id: bigint, dto: UpdatePromoCodeDto) {
|
||||||
@@ -349,9 +362,13 @@ export class PromoCodeService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByCodeOrQrcodeId(input: { code?: string; qrcodeId?: string }) {
|
async findByCodeOrQrcodeId(input: { code?: string; qrcodeId?: string; promoId?: string }) {
|
||||||
const code = input.code?.trim().toUpperCase();
|
const code = input.code?.trim().toUpperCase();
|
||||||
const qrcodeId = input.qrcodeId?.trim();
|
const qrcodeId = input.qrcodeId?.trim();
|
||||||
|
const promoId = input.promoId?.trim();
|
||||||
|
if (promoId && /^\d+$/.test(promoId)) {
|
||||||
|
return this.prisma.commonPromoCode.findUnique({ where: { id: BigInt(promoId) } });
|
||||||
|
}
|
||||||
if (code) {
|
if (code) {
|
||||||
return this.prisma.commonPromoCode.findUnique({ where: { code } });
|
return this.prisma.commonPromoCode.findUnique({ where: { code } });
|
||||||
}
|
}
|
||||||
@@ -362,14 +379,18 @@ export class PromoCodeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** C 端扫码/带参进入:累加 scan_count、归因、标记用户来源 */
|
/** C 端扫码/带参进入:累加 scan_count、归因、标记用户来源 */
|
||||||
async touch(input: { promoCode?: string; qrcodeId?: string }, userId?: bigint) {
|
async touch(
|
||||||
|
input: { promoCode?: string; qrcodeId?: string; promoId?: string },
|
||||||
|
userId?: bigint,
|
||||||
|
) {
|
||||||
const promoCode = input.promoCode?.trim().toUpperCase();
|
const promoCode = input.promoCode?.trim().toUpperCase();
|
||||||
const qrcodeId = input.qrcodeId?.trim();
|
const qrcodeId = input.qrcodeId?.trim();
|
||||||
if (!promoCode && !qrcodeId) {
|
const promoId = input.promoId?.trim();
|
||||||
throw new BadRequestException('请提供 promoCode 或 qrcodeId');
|
if (!promoCode && !qrcodeId && !promoId) {
|
||||||
|
throw new BadRequestException('请提供 promoId、promoCode 或 qrcodeId');
|
||||||
}
|
}
|
||||||
|
|
||||||
const promo = await this.findByCodeOrQrcodeId({ code: promoCode, qrcodeId });
|
const promo = await this.findByCodeOrQrcodeId({ code: promoCode, qrcodeId, promoId });
|
||||||
if (!promo || promo.status !== 'ACTIVE') {
|
if (!promo || promo.status !== 'ACTIVE') {
|
||||||
throw new NotFoundException('推广码无效或已停用');
|
throw new NotFoundException('推广码无效或已停用');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,13 @@ export class RedeemService {
|
|||||||
storeId: account.storeId,
|
storeId: account.storeId,
|
||||||
amount,
|
amount,
|
||||||
settleAmount,
|
settleAmount,
|
||||||
|
allocations: {
|
||||||
|
create: normalizedAllocations.map((item, index) => ({
|
||||||
|
couponId: BigInt(item.couponId),
|
||||||
|
amount: item.amount,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export class SettlementController {
|
|||||||
return this.settlementService.listPartnerBills(user.actorId);
|
return this.settlementService.listPartnerBills(user.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('bills/batch-confirm')
|
||||||
|
batchConfirm(@CurrentUser() user: AuthUser, @Body() body: { ids: string[] }) {
|
||||||
|
return this.settlementService.batchPartnerConfirmBills(user.actorId, body.ids ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('bills/:id/confirm')
|
@Post('bills/:id/confirm')
|
||||||
confirm(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
confirm(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
return this.settlementService.partnerConfirmBill(user.actorId, BigInt(id));
|
return this.settlementService.partnerConfirmBill(user.actorId, BigInt(id));
|
||||||
@@ -126,6 +131,65 @@ export class AdminStorePayoutController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('admin/store-bills')
|
||||||
|
@UseGuards(HqAuthGuard)
|
||||||
|
export class AdminStoreBillController {
|
||||||
|
constructor(private readonly settlementService: SettlementService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: Record<string, string>) {
|
||||||
|
return this.settlementService.listAdminStoreBills({
|
||||||
|
page: query.page ? Number(query.page) : 1,
|
||||||
|
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
||||||
|
status: query.status,
|
||||||
|
storeId: query.storeId,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('export')
|
||||||
|
export(@Query() query: Record<string, string>) {
|
||||||
|
return this.settlementService.exportAdminStoreBills({
|
||||||
|
status: query.status,
|
||||||
|
storeId: query.storeId,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('generate')
|
||||||
|
generate() {
|
||||||
|
return this.settlementService.generateStoreBillsForDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('batch-confirm')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.STORE_BILL_BATCH_CONFIRM,
|
||||||
|
refType: 'STORE_BILL',
|
||||||
|
batch: true,
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
batchConfirm(@Body() body: { ids: string[] }) {
|
||||||
|
return this.settlementService.batchConfirmStoreBills(body.ids ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detail(@Param('id') id: string) {
|
||||||
|
return this.settlementService.getAdminStoreBill(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/confirm')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.STORE_BILL_CONFIRM,
|
||||||
|
refType: 'STORE_BILL',
|
||||||
|
refIdParam: 'id',
|
||||||
|
})
|
||||||
|
confirm(@Param('id') id: string) {
|
||||||
|
return this.settlementService.confirmStoreBill(BigInt(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('admin/partner-bills')
|
@Controller('admin/partner-bills')
|
||||||
@UseGuards(HqAuthGuard)
|
@UseGuards(HqAuthGuard)
|
||||||
export class AdminPartnerBillController {
|
export class AdminPartnerBillController {
|
||||||
@@ -175,11 +239,43 @@ export class AdminPartnerBillController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('batch-send')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.PARTNER_BILL_BATCH_SEND,
|
||||||
|
refType: 'PARTNER_BILL',
|
||||||
|
batch: true,
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
batchSend(@Body() body: { ids: string[] }) {
|
||||||
|
return this.settlementService.batchSendPartnerBills(body.ids ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('batch-mark-paid')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.PARTNER_BILL_BATCH_MARK_PAID,
|
||||||
|
refType: 'PARTNER_BILL',
|
||||||
|
batch: true,
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
batchMarkPaid(@Body() body: { ids: string[] }) {
|
||||||
|
return this.settlementService.batchMarkPartnerBillsPaid(body.ids ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
detail(@Param('id') id: string) {
|
detail(@Param('id') id: string) {
|
||||||
return this.settlementService.getAdminPartnerBill(BigInt(id));
|
return this.settlementService.getAdminPartnerBill(BigInt(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/send')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.PARTNER_BILL_SEND,
|
||||||
|
refType: 'PARTNER_BILL',
|
||||||
|
refIdParam: 'id',
|
||||||
|
})
|
||||||
|
send(@Param('id') id: string) {
|
||||||
|
return this.settlementService.sendPartnerBill(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/confirm')
|
@Post(':id/confirm')
|
||||||
@HqOperation({
|
@HqOperation({
|
||||||
action: HqOperationAction.PARTNER_BILL_CONFIRM,
|
action: HqOperationAction.PARTNER_BILL_CONFIRM,
|
||||||
@@ -226,20 +322,55 @@ export class AdminWineryBillController {
|
|||||||
return this.settlementService.listAdminWineryBills({
|
return this.settlementService.listAdminWineryBills({
|
||||||
page: query.page ? Number(query.page) : 1,
|
page: query.page ? Number(query.page) : 1,
|
||||||
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
||||||
|
status: query.status,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo,
|
||||||
year: query.year ? Number(query.year) : undefined,
|
year: query.year ? Number(query.year) : undefined,
|
||||||
month: query.month ? Number(query.month) : undefined,
|
month: query.month ? Number(query.month) : undefined,
|
||||||
deliveryType: query.deliveryType,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
export(@Query() query: Record<string, string>) {
|
export(@Query() query: Record<string, string>) {
|
||||||
return this.settlementService.exportAdminWineryBills({
|
return this.settlementService.exportAdminWineryBills({
|
||||||
|
status: query.status,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo,
|
||||||
year: query.year ? Number(query.year) : undefined,
|
year: query.year ? Number(query.year) : undefined,
|
||||||
month: query.month ? Number(query.month) : undefined,
|
month: query.month ? Number(query.month) : undefined,
|
||||||
deliveryType: query.deliveryType,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('generate')
|
||||||
|
generate() {
|
||||||
|
return this.settlementService.generateWineryBillForDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('batch-confirm')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.WINERY_BILL_BATCH_CONFIRM,
|
||||||
|
refType: 'WINERY_BILL',
|
||||||
|
batch: true,
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
batchConfirm(@Body() body: { ids: string[] }) {
|
||||||
|
return this.settlementService.batchConfirmWineryBills(body.ids ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detail(@Param('id') id: string) {
|
||||||
|
return this.settlementService.getAdminWineryBill(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/confirm')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.WINERY_BILL_CONFIRM,
|
||||||
|
refType: 'WINERY_BILL',
|
||||||
|
refIdParam: 'id',
|
||||||
|
})
|
||||||
|
confirm(@Param('id') id: string) {
|
||||||
|
return this.settlementService.confirmWineryBill(BigInt(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('partner/me')
|
@Controller('partner/me')
|
||||||
@@ -280,7 +411,6 @@ export class PartnerMeController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 门店类子账号若未配置权限,补齐开店/门店管理,便于开闭店与重传资料
|
|
||||||
if (account.isPrimary !== 1) {
|
if (account.isPrimary !== 1) {
|
||||||
const perms = Array.isArray(account.permissions) ? (account.permissions as string[]) : [];
|
const perms = Array.isArray(account.permissions) ? (account.permissions as string[]) : [];
|
||||||
const hasStorePerm = perms.includes('store:create') || perms.includes('store:manage');
|
const hasStorePerm = perms.includes('store:create') || perms.includes('store:manage');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CityScopeModule } from '../city-scope/city-scope.module';
|
|||||||
import { SettlementService } from './settlement.service';
|
import { SettlementService } from './settlement.service';
|
||||||
import {
|
import {
|
||||||
AdminPartnerBillController,
|
AdminPartnerBillController,
|
||||||
|
AdminStoreBillController,
|
||||||
AdminStorePayoutController,
|
AdminStorePayoutController,
|
||||||
AdminWineryBillController,
|
AdminWineryBillController,
|
||||||
PartnerMeController,
|
PartnerMeController,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
PartnerMeController,
|
PartnerMeController,
|
||||||
ShopPayoutController,
|
ShopPayoutController,
|
||||||
AdminStorePayoutController,
|
AdminStorePayoutController,
|
||||||
|
AdminStoreBillController,
|
||||||
AdminPartnerBillController,
|
AdminPartnerBillController,
|
||||||
AdminWineryBillController,
|
AdminWineryBillController,
|
||||||
],
|
],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
/** 门店两级分类默认树(一级 + 二级) */
|
||||||
|
export const DEFAULT_STORE_CATEGORY_TREE = [
|
||||||
|
{
|
||||||
|
code: 'DINING',
|
||||||
|
name: '餐饮',
|
||||||
|
sort: 1,
|
||||||
|
children: [
|
||||||
|
{ code: 'LOCAL', name: '地方菜', sort: 1 },
|
||||||
|
{ code: 'WESTERN', name: '西餐', sort: 2 },
|
||||||
|
{ code: 'BBQ', name: '烧烤', sort: 3 },
|
||||||
|
{ code: 'HOTPOT', name: '火锅', sort: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'LODGING',
|
||||||
|
name: '住宿',
|
||||||
|
sort: 2,
|
||||||
|
children: [
|
||||||
|
{ code: 'BUDGET_HOTEL', name: '快捷酒店', sort: 1 },
|
||||||
|
{ code: 'INN', name: '旅馆', sort: 2 },
|
||||||
|
{ code: 'LUXURY_HOTEL', name: '豪华酒店', sort: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ENTERTAINMENT',
|
||||||
|
name: '娱乐',
|
||||||
|
sort: 3,
|
||||||
|
children: [
|
||||||
|
{ code: 'KTV', name: 'KTV', sort: 1 },
|
||||||
|
{ code: 'CLUB', name: '会所', sort: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { DEFAULT_STORE_CATEGORY_TREE } from './store-category.defaults';
|
||||||
|
|
||||||
|
export type StoreCategoryNode = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
sort: number;
|
||||||
|
parentId: string | null;
|
||||||
|
status: string;
|
||||||
|
children: StoreCategoryNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StoreCategoryService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅补齐缺失的默认分类,绝不 update 已有行(避免改动线上历史数据)。
|
||||||
|
* 已存在的 HOTPOT/LOCAL 等保持原样;缺的一级/二级才 create。
|
||||||
|
*/
|
||||||
|
async ensureDefaults() {
|
||||||
|
for (const root of DEFAULT_STORE_CATEGORY_TREE) {
|
||||||
|
let parent = await this.prisma.commonStoreCategory.findUnique({
|
||||||
|
where: { code: root.code },
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
parent = await this.prisma.commonStoreCategory.create({
|
||||||
|
data: {
|
||||||
|
code: root.code,
|
||||||
|
name: root.name,
|
||||||
|
sort: root.sort,
|
||||||
|
parentId: null,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const child of root.children) {
|
||||||
|
const existing = await this.prisma.commonStoreCategory.findUnique({
|
||||||
|
where: { code: child.code },
|
||||||
|
});
|
||||||
|
if (existing) continue;
|
||||||
|
await this.prisma.commonStoreCategory.create({
|
||||||
|
data: {
|
||||||
|
code: child.code,
|
||||||
|
name: child.name,
|
||||||
|
sort: child.sort,
|
||||||
|
parentId: parent.id,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapNode(
|
||||||
|
row: {
|
||||||
|
id: bigint;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
sort: number;
|
||||||
|
parentId: bigint | null;
|
||||||
|
status: string;
|
||||||
|
children?: Array<{
|
||||||
|
id: bigint;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
sort: number;
|
||||||
|
parentId: bigint | null;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
includeDisabledChildren: boolean,
|
||||||
|
): StoreCategoryNode {
|
||||||
|
const children = (row.children ?? [])
|
||||||
|
.filter((c) => includeDisabledChildren || c.status === 'ACTIVE')
|
||||||
|
.sort((a, b) => a.sort - b.sort || Number(a.id - b.id))
|
||||||
|
.map((c) => this.mapNode(c, includeDisabledChildren));
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
sort: row.sort,
|
||||||
|
parentId: row.parentId != null ? String(row.parentId) : null,
|
||||||
|
status: row.status,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTree(options?: { includeDisabled?: boolean; ensure?: boolean }) {
|
||||||
|
if (options?.ensure !== false) {
|
||||||
|
await this.ensureDefaults();
|
||||||
|
}
|
||||||
|
const includeDisabled = options?.includeDisabled === true;
|
||||||
|
const roots = await this.prisma.commonStoreCategory.findMany({
|
||||||
|
where: {
|
||||||
|
parentId: null,
|
||||||
|
...(includeDisabled ? {} : { status: 'ACTIVE' }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
children: {
|
||||||
|
orderBy: [{ sort: 'asc' }, { id: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ sort: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
return serializeBigInt(roots.map((r) => this.mapNode(r, includeDisabled)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFlat(options?: { includeDisabled?: boolean; ensure?: boolean }) {
|
||||||
|
if (options?.ensure !== false) {
|
||||||
|
await this.ensureDefaults();
|
||||||
|
}
|
||||||
|
const includeDisabled = options?.includeDisabled === true;
|
||||||
|
const rows = await this.prisma.commonStoreCategory.findMany({
|
||||||
|
where: includeDisabled ? undefined : { status: 'ACTIVE' },
|
||||||
|
orderBy: [{ parentId: 'asc' }, { sort: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
return serializeBigInt(
|
||||||
|
rows.map((row) => ({
|
||||||
|
id: String(row.id),
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
sort: row.sort,
|
||||||
|
parentId: row.parentId != null ? String(row.parentId) : null,
|
||||||
|
status: row.status,
|
||||||
|
level: row.parentId == null ? 1 : 2,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertLeafCategoryId(categoryId: bigint) {
|
||||||
|
const row = await this.prisma.commonStoreCategory.findUnique({ where: { id: categoryId } });
|
||||||
|
if (!row || row.status !== 'ACTIVE') {
|
||||||
|
throw new BadRequestException('店铺类型不存在或已停用');
|
||||||
|
}
|
||||||
|
if (row.parentId == null) {
|
||||||
|
throw new BadRequestException('请选择二级店铺类型');
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
sort?: number;
|
||||||
|
parentId?: string | null;
|
||||||
|
status?: string;
|
||||||
|
}) {
|
||||||
|
const code = dto.code.trim().toUpperCase();
|
||||||
|
const name = dto.name.trim();
|
||||||
|
if (!code) throw new BadRequestException('请填写分类编码');
|
||||||
|
if (!name) throw new BadRequestException('请填写分类名称');
|
||||||
|
let parentId: bigint | null = null;
|
||||||
|
if (dto.parentId) {
|
||||||
|
const parent = await this.prisma.commonStoreCategory.findUnique({
|
||||||
|
where: { id: BigInt(dto.parentId) },
|
||||||
|
});
|
||||||
|
if (!parent || parent.parentId != null) {
|
||||||
|
throw new BadRequestException('父级必须是一级分类');
|
||||||
|
}
|
||||||
|
parentId = parent.id;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.commonStoreCategory.create({
|
||||||
|
data: {
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
sort: dto.sort ?? 0,
|
||||||
|
parentId,
|
||||||
|
status: dto.status === 'DISABLED' ? 'DISABLED' : 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt(row);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new BadRequestException('分类编码已存在');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: bigint,
|
||||||
|
dto: {
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
sort?: number;
|
||||||
|
parentId?: string | null;
|
||||||
|
status?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const existing = await this.prisma.commonStoreCategory.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('分类不存在');
|
||||||
|
|
||||||
|
let parentId: bigint | null | undefined = undefined;
|
||||||
|
if (dto.parentId !== undefined) {
|
||||||
|
if (dto.parentId == null || dto.parentId === '') {
|
||||||
|
parentId = null;
|
||||||
|
} else {
|
||||||
|
if (BigInt(dto.parentId) === id) {
|
||||||
|
throw new BadRequestException('不能将分类设为自己的子级');
|
||||||
|
}
|
||||||
|
const parent = await this.prisma.commonStoreCategory.findUnique({
|
||||||
|
where: { id: BigInt(dto.parentId) },
|
||||||
|
});
|
||||||
|
if (!parent || parent.parentId != null) {
|
||||||
|
throw new BadRequestException('父级必须是一级分类');
|
||||||
|
}
|
||||||
|
// 一级分类若已有子级,不允许变成二级
|
||||||
|
if (existing.parentId == null) {
|
||||||
|
const childCount = await this.prisma.commonStoreCategory.count({
|
||||||
|
where: { parentId: id },
|
||||||
|
});
|
||||||
|
if (childCount > 0) {
|
||||||
|
throw new BadRequestException('该一级分类下仍有二级分类,不能改为二级');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parentId = parent.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.commonStoreCategory.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.code !== undefined ? { code: dto.code.trim().toUpperCase() } : {}),
|
||||||
|
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||||
|
...(dto.sort !== undefined ? { sort: dto.sort } : {}),
|
||||||
|
...(parentId !== undefined ? { parentId } : {}),
|
||||||
|
...(dto.status !== undefined
|
||||||
|
? { status: dto.status === 'DISABLED' ? 'DISABLED' : 'ACTIVE' }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt(row);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new BadRequestException('分类编码已存在');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: bigint) {
|
||||||
|
const existing = await this.prisma.commonStoreCategory.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('分类不存在');
|
||||||
|
const childCount = await this.prisma.commonStoreCategory.count({ where: { parentId: id } });
|
||||||
|
if (childCount > 0) {
|
||||||
|
throw new BadRequestException('请先删除或停用下级分类');
|
||||||
|
}
|
||||||
|
const storeCount = await this.prisma.store.count({ where: { categoryId: id } });
|
||||||
|
if (storeCount > 0) {
|
||||||
|
// 软停用,避免破坏已有门店关联
|
||||||
|
const row = await this.prisma.commonStoreCategory.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'DISABLED' },
|
||||||
|
});
|
||||||
|
return serializeBigInt({ ...row, softDisabled: true });
|
||||||
|
}
|
||||||
|
await this.prisma.commonStoreCategory.delete({ where: { id } });
|
||||||
|
return { id: String(id), deleted: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||||
import { StoreService } from './store.service';
|
import { StoreService } from './store.service';
|
||||||
|
import { StoreCategoryService } from './store-category.service';
|
||||||
import { RedeemService } from '../redeem/redeem.service';
|
import { RedeemService } from '../redeem/redeem.service';
|
||||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||||
@@ -23,6 +24,27 @@ export class PublicStoreController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('store-categories')
|
||||||
|
export class PublicStoreCategoriesController {
|
||||||
|
constructor(private readonly categories: StoreCategoryService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.categories.listTree({ includeDisabled: false, ensure: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('partner/store-categories')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class PartnerStoreCategoriesController {
|
||||||
|
constructor(private readonly categories: StoreCategoryService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.categories.listTree({ includeDisabled: false, ensure: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('partner/stores')
|
@Controller('partner/stores')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class PartnerStoreController {
|
export class PartnerStoreController {
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { RedeemModule } from '../redeem/redeem.module';
|
|||||||
import { AnalyticsModule } from '../analytics/analytics.module';
|
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||||
import { StoreService } from './store.service';
|
import { StoreService } from './store.service';
|
||||||
|
import { StoreCategoryService } from './store-category.service';
|
||||||
import {
|
import {
|
||||||
PartnerDashboardController,
|
PartnerDashboardController,
|
||||||
PartnerReportController,
|
PartnerReportController,
|
||||||
|
PartnerStoreCategoriesController,
|
||||||
PartnerStoreController,
|
PartnerStoreController,
|
||||||
|
PublicStoreCategoriesController,
|
||||||
PublicStoreController,
|
PublicStoreController,
|
||||||
ShopDashboardController,
|
ShopDashboardController,
|
||||||
ShopStoreController,
|
ShopStoreController,
|
||||||
@@ -17,13 +20,15 @@ import {
|
|||||||
imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)],
|
imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)],
|
||||||
controllers: [
|
controllers: [
|
||||||
PublicStoreController,
|
PublicStoreController,
|
||||||
|
PublicStoreCategoriesController,
|
||||||
|
PartnerStoreCategoriesController,
|
||||||
PartnerStoreController,
|
PartnerStoreController,
|
||||||
PartnerDashboardController,
|
PartnerDashboardController,
|
||||||
PartnerReportController,
|
PartnerReportController,
|
||||||
ShopStoreController,
|
ShopStoreController,
|
||||||
ShopDashboardController,
|
ShopDashboardController,
|
||||||
],
|
],
|
||||||
providers: [StoreService],
|
providers: [StoreService, StoreCategoryService],
|
||||||
exports: [StoreService],
|
exports: [StoreService, StoreCategoryService],
|
||||||
})
|
})
|
||||||
export class StoreModule {}
|
export class StoreModule {}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user