Compare commits
38 Commits
7fa2c6fbed
...
v3.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 81a6e3674b | |||
| 76270b20bf | |||
| c2914c37e5 | |||
| 9f9b7cb2bd | |||
| de396442a4 | |||
| 02efe6fa14 | |||
| 14b867a3a5 | |||
| f750fea667 | |||
| 55e91158b3 | |||
| 19cb13d26c | |||
| 04253b9120 | |||
| aaf6ee81d5 | |||
| 972bdde650 | |||
| c0d66cfe50 | |||
| 00b52b9aaa | |||
| e9537ce052 | |||
| c204fb0a92 | |||
| c643b0b979 | |||
| ed44e88e93 | |||
| 57172d54d5 | |||
| 608e405468 | |||
| b36f2ea63a | |||
| b4ec9387a0 | |||
| 9e6dc8c85c | |||
| 48995aace4 | |||
| 92ad0e16cf | |||
| d69bfa359a | |||
| 6a5ddcc70a | |||
| b30adfea8e | |||
| 734a15c9ce | |||
| fd62b5bdbe | |||
| e307550ead | |||
| 9c45011bd4 | |||
| d70f2d8f62 | |||
| f6c77e60b6 | |||
| 2d5ecd1263 | |||
| 82435634d7 | |||
| 740bb19489 |
@@ -292,7 +292,7 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
|||||||
subs={detail.children ?? []}
|
subs={detail.children ?? []}
|
||||||
onAdd={() => {
|
onAdd={() => {
|
||||||
subForm.resetFields();
|
subForm.resetFields();
|
||||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||||
setSubOpen(true);
|
setSubOpen(true);
|
||||||
}}
|
}}
|
||||||
onEdit={(row) => {
|
onEdit={(row) => {
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ export type DashboardStats = {
|
|||||||
partnersTotal: number;
|
partnersTotal: number;
|
||||||
redeemToday: number;
|
redeemToday: number;
|
||||||
deliveriesTotal: number;
|
deliveriesTotal: number;
|
||||||
|
pendingPayouts?: number;
|
||||||
|
/** 合伙人已确认、待总部审核打款 */
|
||||||
|
pendingBills?: number;
|
||||||
|
pendingPartnerDraftBills?: number;
|
||||||
|
openTickets?: number;
|
||||||
ordersByStatus: Array<{ status: string; count: number }>;
|
ordersByStatus: Array<{ status: string; count: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,15 +121,36 @@ export type AdminUserRow = {
|
|||||||
orderCount: number;
|
orderCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminOrderItem = {
|
||||||
|
productName: string;
|
||||||
|
productSpec: string;
|
||||||
|
productImage: string;
|
||||||
|
unitPrice: number;
|
||||||
|
quantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminOrderRow = {
|
export type AdminOrderRow = {
|
||||||
id: string;
|
id: string;
|
||||||
orderNo: string;
|
orderNo: string;
|
||||||
status: string;
|
status: string;
|
||||||
deliveryType: string;
|
deliveryType: string;
|
||||||
payAmount: number;
|
payAmount: number;
|
||||||
|
productName?: string;
|
||||||
|
productSpec?: string;
|
||||||
|
quantity?: number;
|
||||||
receiverName: string;
|
receiverName: string;
|
||||||
receiverPhone: string;
|
receiverPhone: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
cityId?: string;
|
||||||
|
city?: { id: string; name: string; code: string };
|
||||||
|
fulfillmentWarehouseId?: string | null;
|
||||||
|
fulfillmentWarehouse?: { id: string; name: string } | null;
|
||||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||||
delivery?: { provider: string; trackingNo: string | null; providerOrderNo: string | null };
|
delivery?: {
|
||||||
|
provider: string;
|
||||||
|
trackingNo: string | null;
|
||||||
|
providerOrderNo: string | null;
|
||||||
|
logisticsCompany?: string | null;
|
||||||
|
manualQueryUrl?: string | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export default function CityPartnersPage() {
|
|||||||
async function openAddSubAccount(parentId: string) {
|
async function openAddSubAccount(parentId: string) {
|
||||||
await openPartner(parentId);
|
await openPartner(parentId);
|
||||||
subForm.resetFields();
|
subForm.resetFields();
|
||||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||||
setSubOpen(true);
|
setSubOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +482,7 @@ export default function CityPartnersPage() {
|
|||||||
subs={detail.children ?? []}
|
subs={detail.children ?? []}
|
||||||
onAdd={() => {
|
onAdd={() => {
|
||||||
subForm.resetFields();
|
subForm.resetFields();
|
||||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||||
setSubOpen(true);
|
setSubOpen(true);
|
||||||
}}
|
}}
|
||||||
onEdit={(sub) => openSubEdit(sub, detail.id)}
|
onEdit={(sub) => openSubEdit(sub, detail.id)}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Popconfirm,
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -59,29 +60,54 @@ type PartnerOption = { id: string; companyName: string };
|
|||||||
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
||||||
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||||
|
|
||||||
const FULFILLMENT_MODE_OPTIONS = Object.entries(WAREHOUSE_FULFILLMENT_MODE_LABELS).map(([value, label]) => ({
|
|
||||||
value,
|
|
||||||
label,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function FulfillmentFields({
|
function FulfillmentFields({
|
||||||
mode,
|
mode,
|
||||||
providerOptions,
|
providerOptions,
|
||||||
|
onModeChange,
|
||||||
}: {
|
}: {
|
||||||
mode: WarehouseFulfillmentMode;
|
mode: WarehouseFulfillmentMode;
|
||||||
providerOptions: FulfillmentProviderDto[];
|
providerOptions: FulfillmentProviderDto[];
|
||||||
|
onModeChange: (mode: WarehouseFulfillmentMode) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mode === WarehouseFulfillmentMode.API_AUTO && (
|
<Form.Item
|
||||||
<Form.Item name="fulfillmentProviderId" label="仓配承运商" rules={[{ required: true }]}>
|
name="fulfillmentMode"
|
||||||
|
label="支付后自动发货"
|
||||||
|
extra={
|
||||||
|
autoShip
|
||||||
|
? '同城订单支付成功后,自动推送仓配承运商(如小飞侠)并进入配送中'
|
||||||
|
: '支付后仅分配仓库,保持待发货,由仓管或总部手工填运单'
|
||||||
|
}
|
||||||
|
getValueProps={(v) => ({ checked: v === WarehouseFulfillmentMode.API_AUTO })}
|
||||||
|
getValueFromEvent={(checked: boolean) =>
|
||||||
|
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="开"
|
||||||
|
unCheckedChildren="关"
|
||||||
|
onChange={(checked) => {
|
||||||
|
onModeChange(
|
||||||
|
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{autoShip && (
|
||||||
|
<Form.Item
|
||||||
|
name="fulfillmentProviderId"
|
||||||
|
label="仓配承运商"
|
||||||
|
rules={[{ required: true, message: '自动发货须选择承运商' }]}
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
placeholder={providerOptions.length ? '选择已注册承运商' : '请先在仓配管理注册'}
|
placeholder={providerOptions.length ? '选择已注册承运商' : '请先在仓配管理注册'}
|
||||||
options={providerOptions.map((p) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
|
options={providerOptions.map((p) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
{mode === WarehouseFulfillmentMode.MANUAL && (
|
{!autoShip && (
|
||||||
<>
|
<>
|
||||||
<Form.Item name="manualCarrierLabel" label="默认承运商名称">
|
<Form.Item name="manualCarrierLabel" label="默认承运商名称">
|
||||||
<Input placeholder="如 顺丰速运" />
|
<Input placeholder="如 顺丰速运" />
|
||||||
@@ -183,18 +209,17 @@ export default function CityWarehousesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onManagerTypeChange(
|
function onManagerTypeChange(
|
||||||
type: WarehouseManagerType,
|
v: WarehouseManagerType,
|
||||||
form: typeof createForm | typeof editForm,
|
form: typeof createForm,
|
||||||
setType: (v: WarehouseManagerType) => void,
|
setType: (t: WarehouseManagerType) => void,
|
||||||
) {
|
) {
|
||||||
setType(type);
|
setType(v);
|
||||||
if (type !== WarehouseManagerType.PARTNER) {
|
if (v !== WarehouseManagerType.PARTNER) {
|
||||||
form.setFieldValue('partnerAccountId', undefined);
|
form.setFieldValue('partnerAccountId', undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 },
|
|
||||||
{
|
{
|
||||||
title: '城市',
|
title: '城市',
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -204,17 +229,17 @@ export default function CityWarehousesPage() {
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '地址', dataIndex: 'address', ellipsis: true },
|
{ title: '仓库', dataIndex: 'name', width: 140, ellipsis: true },
|
||||||
|
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true },
|
||||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||||
{
|
{
|
||||||
title: '管仓类型',
|
title: '管仓',
|
||||||
dataIndex: 'managerType',
|
width: 90,
|
||||||
width: 100,
|
render: (_, row) => WAREHOUSE_MANAGER_LABELS[row.managerType] || row.managerType,
|
||||||
render: (v) => WAREHOUSE_MANAGER_LABELS[v as WarehouseManagerType] || v,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '管仓合伙人',
|
title: '合伙人',
|
||||||
dataIndex: 'partnerCompanyName',
|
dataIndex: 'partnerCompanyName',
|
||||||
width: 120,
|
width: 120,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
@@ -226,12 +251,14 @@ export default function CityWarehousesPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '履约',
|
title: '自动发货',
|
||||||
width: 120,
|
width: 130,
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.fulfillmentMode === 'API_AUTO'
|
row.fulfillmentMode === 'API_AUTO' ? (
|
||||||
? row.fulfillmentProviderName || 'API'
|
<Tag color="blue">{row.fulfillmentProviderName || '自动'}</Tag>
|
||||||
: WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL],
|
) : (
|
||||||
|
<Tag>{WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL]}</Tag>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
@@ -274,21 +301,23 @@ export default function CityWarehousesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<Typography.Title level={4} style={{ margin: 0 }}>仓库</Typography.Title>
|
||||||
仓库管理
|
|
||||||
</Typography.Title>
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
createForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: WarehouseStatus.ACTIVE });
|
|
||||||
setCreateManagerType(WarehouseManagerType.HQ);
|
setCreateManagerType(WarehouseManagerType.HQ);
|
||||||
setCreateCityId(undefined);
|
setCreateCityId(undefined);
|
||||||
setPartners([]);
|
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
|
||||||
|
createForm.setFieldsValue({
|
||||||
|
managerType: WarehouseManagerType.HQ,
|
||||||
|
status: WarehouseStatus.ACTIVE,
|
||||||
|
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
|
||||||
|
});
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
新建仓库
|
新增仓库
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
@@ -296,7 +325,7 @@ export default function CityWarehousesPage() {
|
|||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
message="管仓合伙人仅可在此页面分配;城市合伙人详情中的管仓仓库为只读展示。"
|
message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
@@ -309,7 +338,7 @@ export default function CityWarehousesPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item name="name" label="仓库名">
|
<Form.Item name="name" label="仓库名">
|
||||||
<Input allowClear placeholder="仓库名" />
|
<Input allowClear />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="cityId" label="城市">
|
<Form.Item name="cityId" label="城市">
|
||||||
<Select
|
<Select
|
||||||
@@ -317,47 +346,33 @@ export default function CityWarehousesPage() {
|
|||||||
showSearch
|
showSearch
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
style={{ width: 140 }}
|
style={{ width: 140 }}
|
||||||
placeholder="全部城市"
|
|
||||||
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="managerType" label="管仓类型">
|
<Form.Item name="managerType" label="管仓">
|
||||||
<Select allowClear style={{ width: 120 }} placeholder="全部" options={MANAGER_OPTIONS} />
|
<Select allowClear style={{ width: 120 }} options={MANAGER_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select allowClear style={{ width: 100 }} placeholder="全部" options={STATUS_OPTIONS} />
|
<Select allowClear style={{ width: 100 }} options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Button type="primary" htmlType="submit">
|
||||||
<Button type="primary" htmlType="submit">
|
查询
|
||||||
查询
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
filterForm.resetFields();
|
|
||||||
setFilters({});
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
重置
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
className="admin-table-nowrap"
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1400 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
total: data?.total ?? 0,
|
total: data?.total ?? 0,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
|
||||||
onChange: (p, ps) => {
|
onChange: (p, ps) => {
|
||||||
setPage(p);
|
setPage(p);
|
||||||
setPageSize(ps);
|
setPageSize(ps);
|
||||||
@@ -366,7 +381,7 @@ export default function CityWarehousesPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="新建仓库"
|
title="新增仓库"
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
width={520}
|
width={520}
|
||||||
onCancel={() => setCreateOpen(false)}
|
onCancel={() => setCreateOpen(false)}
|
||||||
@@ -425,13 +440,11 @@ export default function CityWarehousesPage() {
|
|||||||
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
|
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
|
||||||
<Select options={STATUS_OPTIONS} />
|
<Select options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="fulfillmentMode" label="履约方式" initialValue={WarehouseFulfillmentMode.MANUAL}>
|
<FulfillmentFields
|
||||||
<Select
|
mode={createFulfillmentMode}
|
||||||
options={FULFILLMENT_MODE_OPTIONS}
|
providerOptions={providerOptions}
|
||||||
onChange={(v) => setCreateFulfillmentMode(v)}
|
onModeChange={setCreateFulfillmentMode}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
|
||||||
<FulfillmentFields mode={createFulfillmentMode} providerOptions={providerOptions} />
|
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@@ -490,13 +503,11 @@ export default function CityWarehousesPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select options={STATUS_OPTIONS} />
|
<Select options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="fulfillmentMode" label="履约方式">
|
<FulfillmentFields
|
||||||
<Select
|
mode={editFulfillmentMode}
|
||||||
options={FULFILLMENT_MODE_OPTIONS}
|
providerOptions={providerOptions}
|
||||||
onChange={(v) => setEditFulfillmentMode(v)}
|
onModeChange={setEditFulfillmentMode}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
|
||||||
<FulfillmentFields mode={editFulfillmentMode} providerOptions={providerOptions} />
|
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message,
|
Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -164,6 +165,47 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card
|
||||||
|
loading={loading}
|
||||||
|
title="待审核合伙人打款"
|
||||||
|
extra={<Link to="/finance/partner-bills">去处理</Link>}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
value={stats?.pendingBills ?? 0}
|
||||||
|
suffix="笔"
|
||||||
|
valueStyle={{ color: (stats?.pendingBills ?? 0) > 0 ? '#fa8c16' : undefined }}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
合伙人已确认申请,待总部通过或驳回
|
||||||
|
{(stats?.pendingPartnerDraftBills ?? 0) > 0
|
||||||
|
? `(另有 ${stats?.pendingPartnerDraftBills} 笔待合伙人确认)`
|
||||||
|
: ''}
|
||||||
|
</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card
|
||||||
|
loading={loading}
|
||||||
|
title="待打款门店结算"
|
||||||
|
extra={<Link to="/finance/store-bills">去处理</Link>}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
value={stats?.pendingPayouts ?? 0}
|
||||||
|
suffix="笔"
|
||||||
|
valueStyle={{ color: (stats?.pendingPayouts ?? 0) > 0 ? '#fa8c16' : undefined }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card loading={loading} title="待处理工单">
|
||||||
|
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card title="已合并访客账号" loading={loading}>
|
<Card title="已合并访客账号" loading={loading}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Radio,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -17,8 +18,14 @@ import {
|
|||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type AdminOrderRow, type Paginated } from '../lib/api';
|
import { request, type AdminOrderItem, type AdminOrderRow, type Paginated } from '../lib/api';
|
||||||
import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import {
|
||||||
|
ADMIN_OPTIONS_PAGE_SIZE,
|
||||||
|
DELIVERY_PROVIDER_LABELS,
|
||||||
|
ORDER_STATUS_COLORS,
|
||||||
|
ORDER_STATUS_LABELS,
|
||||||
|
fmtTime,
|
||||||
|
} from '../lib/constants';
|
||||||
|
|
||||||
type ShipDefaults = {
|
type ShipDefaults = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -33,6 +40,21 @@ type ShipDefaults = {
|
|||||||
payMode: string;
|
payMode: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CityOption = { id: string; name: string; code: string };
|
||||||
|
|
||||||
|
type WarehouseOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cityId?: string;
|
||||||
|
cityName?: string;
|
||||||
|
status?: string;
|
||||||
|
contactName: string;
|
||||||
|
contactPhone: string;
|
||||||
|
address: string;
|
||||||
|
lng?: number | null;
|
||||||
|
lat?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
type OrderDetail = AdminOrderRow & {
|
type OrderDetail = AdminOrderRow & {
|
||||||
receiverAddress?: string;
|
receiverAddress?: string;
|
||||||
receiverProvince?: string;
|
receiverProvince?: string;
|
||||||
@@ -51,17 +73,58 @@ type OrderDetail = AdminOrderRow & {
|
|||||||
productAmount?: number;
|
productAmount?: number;
|
||||||
freightAmount?: number;
|
freightAmount?: number;
|
||||||
benefitAmount?: number;
|
benefitAmount?: number;
|
||||||
deliveryType?: string;
|
|
||||||
fulfillmentWarehouseId?: string | null;
|
|
||||||
paidAt?: string | null;
|
paidAt?: string | null;
|
||||||
payExpireAt?: string | null;
|
payExpireAt?: string | null;
|
||||||
items?: Array<Record<string, unknown>>;
|
productImage?: string;
|
||||||
|
listUnitPrice?: number;
|
||||||
|
items?: AdminOrderItem[];
|
||||||
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>>;
|
||||||
city?: { name: string; code: string };
|
fulfillmentWarehouse?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
contactName?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
address?: string;
|
||||||
|
lng?: number | null;
|
||||||
|
lat?: number | null;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
|
||||||
|
|
||||||
|
function orderProductRows(detail: OrderDetail): AdminOrderItem[] {
|
||||||
|
if (detail.items?.length) return detail.items;
|
||||||
|
if (!detail.productName) return [];
|
||||||
|
return [{
|
||||||
|
productName: detail.productName,
|
||||||
|
productSpec: detail.productSpec ?? '',
|
||||||
|
productImage: detail.productImage ?? '',
|
||||||
|
unitPrice: Number(detail.listUnitPrice ?? 0),
|
||||||
|
quantity: detail.quantity ?? 1,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
function canShip(row: { status: string; delivery?: { trackingNo?: string | null } | null }) {
|
||||||
|
return ['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(row.status) && !row.delivery?.trackingNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): ShipDefaults {
|
||||||
|
return {
|
||||||
|
provider: 'XFX',
|
||||||
|
providerLabel: '小飞侠',
|
||||||
|
fromName: wh.contactName || base?.fromName || '杜康仓库',
|
||||||
|
fromMobile: wh.contactPhone || base?.fromMobile || '13800000000',
|
||||||
|
fromAddress: wh.address || base?.fromAddress || '',
|
||||||
|
fromAddressDetail: wh.name || base?.fromAddressDetail || '',
|
||||||
|
fromLng: wh.lng != null ? Number(wh.lng) : (base?.fromLng ?? 113.665),
|
||||||
|
fromLat: wh.lat != null ? Number(wh.lat) : (base?.fromLat ?? 34.757),
|
||||||
|
weight: base?.weight ?? 2,
|
||||||
|
payMode: base?.payMode ?? '1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [shipForm] = Form.useForm();
|
const [shipForm] = Form.useForm();
|
||||||
@@ -78,6 +141,11 @@ export default function OrdersPage() {
|
|||||||
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
|
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
|
||||||
const [shipping, setShipping] = useState(false);
|
const [shipping, setShipping] = useState(false);
|
||||||
const [logisticsShipping, setLogisticsShipping] = useState(false);
|
const [logisticsShipping, setLogisticsShipping] = useState(false);
|
||||||
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
|
const [shipModalOpen, setShipModalOpen] = useState(false);
|
||||||
|
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
|
||||||
|
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
||||||
|
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||||
|
|
||||||
const selectedOrders = useMemo(
|
const selectedOrders = useMemo(
|
||||||
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
||||||
@@ -91,6 +159,7 @@ export default function OrdersPage() {
|
|||||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||||
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
||||||
if (values.status) qs.set('status', values.status);
|
if (values.status) qs.set('status', values.status);
|
||||||
|
if (values.cityId) qs.set('cityId', values.cityId);
|
||||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||||
setData(res);
|
setData(res);
|
||||||
@@ -105,40 +174,100 @@ export default function OrdersPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void request<ShipDefaults>('/admin/orders/ship-defaults').then(setShipDefaults).catch(() => {});
|
void request<ShipDefaults>('/admin/orders/ship-defaults').then(setShipDefaults).catch(() => {});
|
||||||
|
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setCities(res.items ?? []))
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) {
|
||||||
|
shipForm.setFieldsValue({
|
||||||
|
warehouseId: warehouseId || undefined,
|
||||||
|
provider: defaults.provider,
|
||||||
|
weight: defaults.weight,
|
||||||
|
payMode: defaults.payMode,
|
||||||
|
fromName: defaults.fromName,
|
||||||
|
fromMobile: defaults.fromMobile,
|
||||||
|
fromAddress: defaults.fromAddress,
|
||||||
|
fromAddressDetail: defaults.fromAddressDetail,
|
||||||
|
fromLng: defaults.fromLng,
|
||||||
|
fromLat: defaults.fromLat,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWarehouses() {
|
||||||
|
const res = await request<Paginated<WarehouseOption>>(
|
||||||
|
`/admin/city-warehouses?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&status=ACTIVE`,
|
||||||
|
).catch(() => null);
|
||||||
|
const rows = (res?.items ?? []).filter((w) => !w.status || w.status === 'ACTIVE');
|
||||||
|
setWarehouses(rows);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
async function openDetail(id: string) {
|
||||||
const res = await request<OrderDetail>(`/admin/orders/${id}`);
|
const res = await request<OrderDetail>(`/admin/orders/${id}`);
|
||||||
setDetail(res);
|
setDetail(res);
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
const defaults = shipDefaults ?? await request<ShipDefaults>('/admin/orders/ship-defaults').catch(() => null);
|
}
|
||||||
|
|
||||||
|
async function openShipModal(id: string) {
|
||||||
|
const res = await request<OrderDetail>(`/admin/orders/${id}`);
|
||||||
|
setShipTarget(res);
|
||||||
|
setDetail(res);
|
||||||
|
logisticsForm.setFieldsValue({
|
||||||
|
logisticsCompany: '',
|
||||||
|
trackingNo: '',
|
||||||
|
manualQueryUrl: '',
|
||||||
|
});
|
||||||
|
const rows = await loadWarehouses();
|
||||||
|
const preferredWarehouseId = res.fulfillmentWarehouseId || res.fulfillmentWarehouse?.id;
|
||||||
|
const preferred =
|
||||||
|
rows.find((w) => w.id === preferredWarehouseId) ||
|
||||||
|
rows.find((w) => w.cityId === (res.city?.id || res.cityId)) ||
|
||||||
|
rows[0];
|
||||||
|
const defaults = preferred
|
||||||
|
? warehouseToDefaults(preferred, shipDefaults)
|
||||||
|
: (shipDefaults ?? await request<ShipDefaults>('/admin/orders/ship-defaults').catch(() => null));
|
||||||
if (defaults) {
|
if (defaults) {
|
||||||
setShipDefaults(defaults);
|
setShipDefaults(defaults);
|
||||||
shipForm.setFieldsValue({
|
applyShipDefaults(defaults, preferred?.id);
|
||||||
provider: defaults.provider,
|
} else {
|
||||||
weight: defaults.weight,
|
shipForm.setFieldsValue({ warehouseId: preferred?.id, provider: 'XFX' });
|
||||||
payMode: defaults.payMode,
|
|
||||||
fromName: defaults.fromName,
|
|
||||||
fromMobile: defaults.fromMobile,
|
|
||||||
fromAddress: defaults.fromAddress,
|
|
||||||
fromAddressDetail: defaults.fromAddressDetail,
|
|
||||||
fromLng: defaults.fromLng,
|
|
||||||
fromLat: defaults.fromLat,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
setShipMode(preferred ? 'WAREHOUSE' : 'EXPRESS');
|
||||||
|
setShipModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onWarehouseChange(warehouseId: string) {
|
||||||
|
const wh = warehouses.find((w) => w.id === warehouseId);
|
||||||
|
if (!wh) return;
|
||||||
|
applyShipDefaults(warehouseToDefaults(wh, shipDefaults), warehouseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitShip() {
|
async function submitShip() {
|
||||||
if (!detail) return;
|
if (!shipTarget) return;
|
||||||
const values = await shipForm.validateFields();
|
const values = await shipForm.validateFields();
|
||||||
setShipping(true);
|
setShipping(true);
|
||||||
try {
|
try {
|
||||||
const res = await request<OrderDetail>(`/admin/orders/${detail.id}/ship`, {
|
const res = await request<OrderDetail>(`/admin/orders/${shipTarget.id}/ship`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(values),
|
body: JSON.stringify({
|
||||||
|
provider: values.provider || 'XFX',
|
||||||
|
warehouseId: values.warehouseId,
|
||||||
|
weight: values.weight,
|
||||||
|
payMode: values.payMode,
|
||||||
|
remark: values.remark,
|
||||||
|
fromName: values.fromName,
|
||||||
|
fromMobile: values.fromMobile,
|
||||||
|
fromAddress: values.fromAddress,
|
||||||
|
fromAddressDetail: values.fromAddressDetail,
|
||||||
|
fromLng: values.fromLng,
|
||||||
|
fromLat: values.fromLat,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
message.success('发货成功');
|
message.success('选仓发货成功');
|
||||||
|
setShipTarget(res);
|
||||||
setDetail(res);
|
setDetail(res);
|
||||||
|
setShipModalOpen(false);
|
||||||
void load();
|
void load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '发货失败');
|
message.error(e instanceof Error ? e.message : '发货失败');
|
||||||
@@ -148,16 +277,28 @@ export default function OrdersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitLogisticsShip() {
|
async function submitLogisticsShip() {
|
||||||
if (!detail) return;
|
if (!shipTarget) return;
|
||||||
const values = await logisticsForm.validateFields();
|
const values = await logisticsForm.validateFields(['logisticsCompany', 'trackingNo', 'manualQueryUrl']);
|
||||||
|
const logisticsCompany = String(values.logisticsCompany ?? '').trim();
|
||||||
|
const trackingNo = String(values.trackingNo ?? '').trim();
|
||||||
|
if (!logisticsCompany || !trackingNo) {
|
||||||
|
message.error('请填写快递公司与运单号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLogisticsShipping(true);
|
setLogisticsShipping(true);
|
||||||
try {
|
try {
|
||||||
const res = await request<OrderDetail>(`/admin/orders/${detail.id}/logistics-ship`, {
|
const res = await request<OrderDetail>(`/admin/orders/${shipTarget.id}/logistics-ship`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(values),
|
body: JSON.stringify({
|
||||||
|
logisticsCompany,
|
||||||
|
trackingNo,
|
||||||
|
manualQueryUrl: String(values.manualQueryUrl ?? '').trim() || undefined,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
message.success('快递单已录入');
|
message.success('快递单已录入');
|
||||||
|
setShipTarget(res);
|
||||||
setDetail(res);
|
setDetail(res);
|
||||||
|
setShipModalOpen(false);
|
||||||
void load();
|
void load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '填单失败');
|
message.error(e instanceof Error ? e.message : '填单失败');
|
||||||
@@ -191,6 +332,25 @@ export default function OrdersPage() {
|
|||||||
|
|
||||||
const columns: ColumnsType<AdminOrderRow> = [
|
const columns: ColumnsType<AdminOrderRow> = [
|
||||||
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
||||||
|
{
|
||||||
|
title: '城市',
|
||||||
|
width: 90,
|
||||||
|
render: (_, row) => row.city?.name || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品',
|
||||||
|
width: 180,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, row) => {
|
||||||
|
const name = row.productName || '—';
|
||||||
|
const qty = row.quantity != null ? ` ×${row.quantity}` : '';
|
||||||
|
return (
|
||||||
|
<span title={row.productSpec ? `${name}(${row.productSpec})` : name}>
|
||||||
|
{name}{qty}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -227,11 +387,19 @@ export default function OrdersPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 140,
|
||||||
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
<Space size={0}>
|
||||||
详情
|
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||||
</Button>
|
详情
|
||||||
|
</Button>
|
||||||
|
{canShip(row) && (
|
||||||
|
<Button type="link" size="small" onClick={() => void openShipModal(row.id)}>
|
||||||
|
配送
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -255,6 +423,16 @@ export default function OrdersPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="cityId" label="城市">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
placeholder="全部"
|
||||||
|
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="receiverPhone" label="收货手机">
|
<Form.Item name="receiverPhone" label="收货手机">
|
||||||
<Input allowClear />
|
<Input allowClear />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -271,7 +449,7 @@ export default function OrdersPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1500 }}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||||
@@ -295,6 +473,11 @@ export default function OrdersPage() {
|
|||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={detail && (
|
extra={detail && (
|
||||||
<Space>
|
<Space>
|
||||||
|
{canShip(detail) && (
|
||||||
|
<Button type="primary" onClick={() => void openShipModal(detail.id)}>
|
||||||
|
配送发货
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Typography.Text type="secondary">调试改状态</Typography.Text>
|
<Typography.Text type="secondary">调试改状态</Typography.Text>
|
||||||
<Select
|
<Select
|
||||||
value={detail.status}
|
value={detail.status}
|
||||||
@@ -318,6 +501,13 @@ export default function OrdersPage() {
|
|||||||
<>
|
<>
|
||||||
<Descriptions column={1} bordered size="small" title="基本信息">
|
<Descriptions column={1} bordered size="small" title="基本信息">
|
||||||
<Descriptions.Item label="订单号">{detail.orderNo}</Descriptions.Item>
|
<Descriptions.Item label="订单号">{detail.orderNo}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开城城市">
|
||||||
|
{detail.city?.name || '—'}
|
||||||
|
{detail.city?.code ? `(${detail.city.code})` : ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="履约仓">
|
||||||
|
{detail.fulfillmentWarehouse?.name || '未分配'}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
|
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
|
||||||
{ORDER_STATUS_LABELS[detail.status] || detail.status}
|
{ORDER_STATUS_LABELS[detail.status] || detail.status}
|
||||||
@@ -330,6 +520,51 @@ export default function OrdersPage() {
|
|||||||
<Descriptions.Item label="下单时间">{new Date(detail.createdAt).toLocaleString('zh-CN')}</Descriptions.Item>
|
<Descriptions.Item label="下单时间">{new Date(detail.createdAt).toLocaleString('zh-CN')}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>商品</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey={(_, i) => String(i)}
|
||||||
|
pagination={false}
|
||||||
|
dataSource={orderProductRows(detail)}
|
||||||
|
locale={{ emptyText: '无商品信息' }}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '商品',
|
||||||
|
dataIndex: 'productName',
|
||||||
|
render: (name: string, row) => (
|
||||||
|
<Space>
|
||||||
|
{row.productImage ? (
|
||||||
|
<img
|
||||||
|
src={row.productImage}
|
||||||
|
alt=""
|
||||||
|
style={{ width: 40, height: 40, objectFit: 'cover', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span>
|
||||||
|
{name || '—'}
|
||||||
|
{row.productSpec ? (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||||
|
{row.productSpec}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单价',
|
||||||
|
dataIndex: 'unitPrice',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number) => `¥${v}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数量',
|
||||||
|
dataIndex: 'quantity',
|
||||||
|
width: 70,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<Descriptions column={1} bordered size="small" title="收货信息" style={{ marginTop: 16 }}>
|
<Descriptions column={1} bordered size="small" title="收货信息" style={{ marginTop: 16 }}>
|
||||||
<Descriptions.Item label="收货人">{detail.receiverName} {detail.receiverPhone}</Descriptions.Item>
|
<Descriptions.Item label="收货人">{detail.receiverName} {detail.receiverPhone}</Descriptions.Item>
|
||||||
<Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item>
|
<Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item>
|
||||||
@@ -363,99 +598,6 @@ export default function OrdersPage() {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(detail.status) && !detail.delivery?.trackingNo && (
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
{(detail.deliveryType === 'CROSS_CITY' || !detail.fulfillmentWarehouseId) && (
|
|
||||||
<>
|
|
||||||
<Typography.Title level={5}>总部快递填单</Typography.Title>
|
|
||||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
适用于跨城订单或同城无仓订单
|
|
||||||
</Typography.Paragraph>
|
|
||||||
<Form form={logisticsForm} layout="vertical" size="small">
|
|
||||||
<Form.Item name="logisticsCompany" label="快递公司" rules={[{ required: true }]}>
|
|
||||||
<Input placeholder="如 顺丰速运、京东物流" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="trackingNo" label="运单号" rules={[{ required: true }]}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="manualQueryUrl" label="物流查询链接(可选)">
|
|
||||||
<Input placeholder="https://..." />
|
|
||||||
</Form.Item>
|
|
||||||
<Button type="primary" loading={logisticsShipping} onClick={() => void submitLogisticsShip()}>
|
|
||||||
提交快递单
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detail.fulfillmentWarehouseId && (
|
|
||||||
<>
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
|
||||||
仓配小飞侠重试
|
|
||||||
</Typography.Title>
|
|
||||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
仓配订单通常支付后自动推单;失败时可手动重试
|
|
||||||
</Typography.Paragraph>
|
|
||||||
<Form form={shipForm} layout="vertical" size="small">
|
|
||||||
<Form.Item name="provider" label="快递公司" rules={[{ required: true }]}>
|
|
||||||
<Select
|
|
||||||
options={[{ value: 'XFX', label: '小飞侠' }]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="weight" label="重量(kg)" rules={[{ required: true }]}>
|
|
||||||
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="payMode" label="付费方式" rules={[{ required: true }]}>
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ value: '1', label: '寄付' },
|
|
||||||
{ value: '2', label: '到付' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="remark" label="备注">
|
|
||||||
<Input.TextArea rows={2} placeholder="可选" />
|
|
||||||
</Form.Item>
|
|
||||||
<Collapse
|
|
||||||
ghost
|
|
||||||
items={[{
|
|
||||||
key: 'from',
|
|
||||||
label: '寄件信息(默认仓库,可修改)',
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<Form.Item name="fromName" label="寄件人" rules={[{ required: true }]}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="fromMobile" label="寄件手机" rules={[{ required: true }]}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="fromAddress" label="寄件区域" rules={[{ required: true }]}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="fromAddressDetail" label="寄件详细地址" rules={[{ required: true }]}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Space>
|
|
||||||
<Form.Item name="fromLng" label="经度">
|
|
||||||
<InputNumber step={0.001} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="fromLat" label="纬度">
|
|
||||||
<InputNumber step={0.001} />
|
|
||||||
</Form.Item>
|
|
||||||
</Space>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}]}
|
|
||||||
/>
|
|
||||||
<Button type="primary" loading={shipping} onClick={() => void submitShip()}>
|
|
||||||
调用小飞侠发货
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detail.statusLogs && detail.statusLogs.length > 0 && (
|
{detail.statusLogs && detail.statusLogs.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
|
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
|
||||||
@@ -476,6 +618,150 @@ export default function OrdersPage() {
|
|||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={shipTarget ? `配送发货 · ${shipTarget.orderNo}` : '配送发货'}
|
||||||
|
open={shipModalOpen}
|
||||||
|
onCancel={() => setShipModalOpen(false)}
|
||||||
|
width={560}
|
||||||
|
destroyOnClose={false}
|
||||||
|
forceRender
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => setShipModalOpen(false)}>取消</Button>
|
||||||
|
{shipMode === 'WAREHOUSE' ? (
|
||||||
|
<Button type="primary" loading={shipping} onClick={() => void submitShip()}>
|
||||||
|
确认选仓发货
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="primary" loading={logisticsShipping} onClick={() => void submitLogisticsShip()}>
|
||||||
|
提交快递单
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{shipTarget && (
|
||||||
|
<>
|
||||||
|
<Descriptions size="small" column={1} style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions.Item label="城市">{shipTarget.city?.name || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="收货">
|
||||||
|
{shipTarget.receiverName} {shipTarget.receiverPhone}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="商品">
|
||||||
|
{shipTarget.productName || '—'}
|
||||||
|
{shipTarget.quantity != null ? ` ×${shipTarget.quantity}` : ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Radio.Group
|
||||||
|
value={shipMode}
|
||||||
|
onChange={(e) => setShipMode(e.target.value as ShipMode)}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'WAREHOUSE', label: '选仓配送' },
|
||||||
|
{ value: 'EXPRESS', label: '填写快递单号' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: shipMode === 'WAREHOUSE' ? 'block' : 'none' }}>
|
||||||
|
<Form form={shipForm} layout="vertical" size="small" preserve>
|
||||||
|
<Form.Item
|
||||||
|
name="warehouseId"
|
||||||
|
label="选择仓库"
|
||||||
|
rules={[{ required: true, message: '请选择仓库' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder={warehouses.length ? '选择仓库' : '暂无可用仓库'}
|
||||||
|
options={warehouses.map((w) => ({
|
||||||
|
value: w.id,
|
||||||
|
label: w.cityName ? `${w.cityName} · ${w.name}` : w.name,
|
||||||
|
}))}
|
||||||
|
onChange={(id) => void onWarehouseChange(id)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="provider" label="承运商" rules={[{ required: true }]} initialValue="XFX">
|
||||||
|
<Select options={[{ value: 'XFX', label: '小飞侠' }]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="weight" label="重量(kg)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="payMode" label="付费方式" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: '1', label: '寄付' },
|
||||||
|
{ value: '2', label: '到付' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={2} placeholder="可选" />
|
||||||
|
</Form.Item>
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
items={[{
|
||||||
|
key: 'from',
|
||||||
|
label: '寄件信息(默认仓库,可修改)',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form.Item name="fromName" label="寄件人" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fromMobile" label="寄件手机" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fromAddress" label="寄件区域" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fromAddressDetail" label="寄件详细地址" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Form.Item name="fromLng" label="经度">
|
||||||
|
<InputNumber step={0.001} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fromLat" label="纬度">
|
||||||
|
<InputNumber step={0.001} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: shipMode === 'EXPRESS' ? 'block' : 'none' }}>
|
||||||
|
<Form form={logisticsForm} layout="vertical" size="small" preserve>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 0 }}>
|
||||||
|
填写快递公司与运单号即可完成发货
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Form.Item
|
||||||
|
name="logisticsCompany"
|
||||||
|
label="快递公司"
|
||||||
|
rules={[{ required: true, message: '请填写快递公司' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="如 顺丰速运、京东物流" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="trackingNo"
|
||||||
|
label="运单号"
|
||||||
|
rules={[{ required: true, message: '请填写运单号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入运单号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="manualQueryUrl" label="物流查询链接(可选)">
|
||||||
|
<Input placeholder="https://..." />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={`确认批量删除(${selectedOrders.length} 笔)`}
|
title={`确认批量删除(${selectedOrders.length} 笔)`}
|
||||||
open={batchDeleteOpen}
|
open={batchDeleteOpen}
|
||||||
@@ -501,13 +787,27 @@ export default function OrdersPage() {
|
|||||||
dataSource={selectedOrders}
|
dataSource={selectedOrders}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '订单号', dataIndex: 'orderNo', width: 170 },
|
{ title: '订单号', dataIndex: 'orderNo', width: 170 },
|
||||||
|
{
|
||||||
|
title: '城市',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) => row.city?.name || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.productName
|
||||||
|
? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}`
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (s) => (
|
render: (s) => (
|
||||||
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
||||||
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
|
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export default function PartnerAccountsPage() {
|
|||||||
function openAddSub(parent: AccountTreeRow) {
|
function openAddSub(parent: AccountTreeRow) {
|
||||||
setSubParent(parent);
|
setSubParent(parent);
|
||||||
subForm.resetFields();
|
subForm.resetFields();
|
||||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||||
setSubOpen(true);
|
setSubOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Form,
|
Form,
|
||||||
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
@@ -29,6 +30,7 @@ type Row = {
|
|||||||
status: string;
|
status: string;
|
||||||
periodStart: string;
|
periodStart: string;
|
||||||
periodEnd: string;
|
periodEnd: string;
|
||||||
|
rejectReason?: string | null;
|
||||||
partner?: { companyName?: string; phone?: string };
|
partner?: { companyName?: string; phone?: string };
|
||||||
partnerAccount?: { companyName?: string; phone?: string };
|
partnerAccount?: { companyName?: string; phone?: string };
|
||||||
};
|
};
|
||||||
@@ -36,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: '草稿',
|
DRAFT: '待合伙人确认',
|
||||||
CONFIRMED: '已确认',
|
CONFIRMED: '待打款审核',
|
||||||
PAID: '已打款',
|
PAID: '已打款',
|
||||||
|
REJECTED: '已驳回',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
DRAFT: 'default',
|
DRAFT: 'default',
|
||||||
CONFIRMED: 'blue',
|
CONFIRMED: 'orange',
|
||||||
PAID: 'green',
|
PAID: 'green',
|
||||||
|
REJECTED: 'red',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PartnerBillsPage() {
|
export default function PartnerBillsPage() {
|
||||||
@@ -66,6 +70,10 @@ export default function PartnerBillsPage() {
|
|||||||
const [genOpen, setGenOpen] = useState(false);
|
const [genOpen, setGenOpen] = useState(false);
|
||||||
const [genForm] = Form.useForm();
|
const [genForm] = Form.useForm();
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
|
||||||
|
const [rejectForm] = Form.useForm();
|
||||||
|
const [rejecting, setRejecting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
@@ -95,7 +103,7 @@ export default function PartnerBillsPage() {
|
|||||||
|
|
||||||
async function confirmBill(id: string) {
|
async function confirmBill(id: string) {
|
||||||
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
|
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
|
||||||
message.success('已确认');
|
message.success('已代确认,进入待打款审核');
|
||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,10 +112,33 @@ export default function PartnerBillsPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
||||||
});
|
});
|
||||||
message.success('已标记打款');
|
message.success('已通过并标记打款');
|
||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openReject(row: Row) {
|
||||||
|
setRejectTarget(row);
|
||||||
|
rejectForm.resetFields();
|
||||||
|
setRejectOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReject(values: { reason: string }) {
|
||||||
|
if (!rejectTarget) return;
|
||||||
|
setRejecting(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/partner-bills/${rejectTarget.id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ reason: values.reason.trim() }),
|
||||||
|
});
|
||||||
|
message.success('已驳回打款申请');
|
||||||
|
setRejectOpen(false);
|
||||||
|
setRejectTarget(null);
|
||||||
|
reload();
|
||||||
|
} finally {
|
||||||
|
setRejecting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function exportExcel() {
|
async function exportExcel() {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
@@ -163,24 +194,38 @@ export default function PartnerBillsPage() {
|
|||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 90,
|
width: 120,
|
||||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
render: (s, row) => (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
|
||||||
|
{s === 'REJECTED' && row.rejectReason ? (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{row.rejectReason}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 160,
|
width: 220,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0}>
|
<Space size={0} wrap>
|
||||||
{row.status === 'DRAFT' && (
|
{row.status === 'DRAFT' && (
|
||||||
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
|
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
|
||||||
确认
|
代确认
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{row.status === 'CONFIRMED' && (
|
{row.status === 'CONFIRMED' && (
|
||||||
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
|
<>
|
||||||
标记打款
|
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
|
||||||
</Button>
|
通过打款
|
||||||
|
</Button>
|
||||||
|
<Button type="link" size="small" danger onClick={() => openReject(row)}>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -194,7 +239,7 @@ export default function PartnerBillsPage() {
|
|||||||
合伙人账单
|
合伙人账单
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
T+30 结算:按自然月汇总酒单佣金(酒单价 × 酒单佣金比例)与门店核销佣金(核销额 × 核销佣金比例)
|
T+30 结算:合伙人确认并申请打款后,总部在此审核通过或驳回(驳回须填写理由)
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
@@ -242,7 +287,7 @@ export default function PartnerBillsPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 120 }}
|
style={{ width: 140 }}
|
||||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -280,7 +325,7 @@ export default function PartnerBillsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1280 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -320,6 +365,38 @@ export default function PartnerBillsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="驳回打款申请"
|
||||||
|
open={rejectOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setRejectOpen(false);
|
||||||
|
setRejectTarget(null);
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
|
账单 {rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''}
|
||||||
|
<br />
|
||||||
|
驳回理由将展示在合伙人 H5 账单页。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}>
|
||||||
|
<Form.Item
|
||||||
|
name="reason"
|
||||||
|
label="驳回理由"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请填写驳回理由' },
|
||||||
|
{ max: 500, message: '不超过 500 字' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={4} placeholder="请说明驳回原因,便于合伙人核对后重新申请" maxLength={500} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" danger htmlType="submit" block loading={rejecting}>
|
||||||
|
确认驳回
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import CenterPage from './pages/CenterPage';
|
|||||||
import PartnerMePage from './pages/PartnerMePage';
|
import PartnerMePage from './pages/PartnerMePage';
|
||||||
import BillsPage from './pages/BillsPage';
|
import BillsPage from './pages/BillsPage';
|
||||||
import SettlementPage from './pages/SettlementPage';
|
import SettlementPage from './pages/SettlementPage';
|
||||||
import ReshipPage from './pages/ReshipPage';
|
|
||||||
import WeeklyReportPage from './pages/WeeklyReportPage';
|
import WeeklyReportPage from './pages/WeeklyReportPage';
|
||||||
import ProxyOrderPage from './pages/ProxyOrderPage';
|
import ProxyOrderPage from './pages/ProxyOrderPage';
|
||||||
import StaffListPage from './pages/StaffListPage';
|
import StaffListPage from './pages/StaffListPage';
|
||||||
@@ -34,7 +33,6 @@ function PrimaryRoutes() {
|
|||||||
<Route path="/center/staff" element={<StaffListPage />} />
|
<Route path="/center/staff" element={<StaffListPage />} />
|
||||||
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
||||||
<Route path="/proxy-order" element={<ProxyOrderPage />} />
|
<Route path="/proxy-order" element={<ProxyOrderPage />} />
|
||||||
<Route path="/reshipments" element={<ReshipPage />} />
|
|
||||||
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
||||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||||
@@ -65,9 +63,9 @@ function SubAccountRoutes() {
|
|||||||
<>
|
<>
|
||||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||||
{isWarehouse && <Route path="/orders/:id" element={<OrderDetailPage />} />}
|
{isWarehouse && <Route path="/orders/:id" element={<OrderDetailPage />} />}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
|
|||||||
hasWechat: profile.hasWechat,
|
hasWechat: profile.hasWechat,
|
||||||
wxNickname: profile.wxNickname,
|
wxNickname: profile.wxNickname,
|
||||||
wxAvatarUrl: profile.wxAvatarUrl,
|
wxAvatarUrl: profile.wxAvatarUrl,
|
||||||
|
managedWarehouseId: profile.managedWarehouseId,
|
||||||
|
hasWarehouseAccess: profile.hasWarehouseAccess,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export type PartnerSessionProfile = Pick<
|
|||||||
| 'hasWechat'
|
| 'hasWechat'
|
||||||
| 'wxNickname'
|
| 'wxNickname'
|
||||||
| 'wxAvatarUrl'
|
| 'wxAvatarUrl'
|
||||||
|
| 'managedWarehouseId'
|
||||||
|
| 'hasWarehouseAccess'
|
||||||
> & {
|
> & {
|
||||||
staffRole?: PartnerStaffRole;
|
staffRole?: PartnerStaffRole;
|
||||||
};
|
};
|
||||||
@@ -125,6 +127,8 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile {
|
|||||||
hasWechat: me.hasWechat,
|
hasWechat: me.hasWechat,
|
||||||
wxNickname: me.wxNickname,
|
wxNickname: me.wxNickname,
|
||||||
wxAvatarUrl: me.wxAvatarUrl,
|
wxAvatarUrl: me.wxAvatarUrl,
|
||||||
|
managedWarehouseId: me.managedWarehouseId,
|
||||||
|
hasWarehouseAccess: me.hasWarehouseAccess,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export function fetchPartnerLeaderboard(period: PartnerLeaderboardPeriod = 'tota
|
|||||||
return request<PartnerLeaderboardResponse>(
|
return request<PartnerLeaderboardResponse>(
|
||||||
'PARTNER_H5',
|
'PARTNER_H5',
|
||||||
`/partner/dashboard/leaderboard?period=${period}`,
|
`/partner/dashboard/leaderboard?period=${period}`,
|
||||||
|
{ silent: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,52 @@ export function hasPartnerPermission(
|
|||||||
return account.permissions?.includes(permission) ?? false;
|
return account.permissions?.includes(permission) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasAnyPartnerPermission(
|
||||||
|
account: PartnerMe | null | undefined,
|
||||||
|
permissions: PartnerPermissionKey[],
|
||||||
|
): boolean {
|
||||||
|
return permissions.some((p) => hasPartnerPermission(account, p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端 GET /partner/dashboard 权限点一致 */
|
||||||
|
export function canAccessPartnerDashboard(account: PartnerMe | null | undefined): boolean {
|
||||||
|
return hasAnyPartnerPermission(account, [
|
||||||
|
'store:create',
|
||||||
|
'store:manage',
|
||||||
|
'order:view',
|
||||||
|
'warehouse:manage',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 门店列表/拓店统计所需权限 */
|
||||||
|
export function canAccessPartnerStores(account: PartnerMe | null | undefined): boolean {
|
||||||
|
return hasAnyPartnerPermission(account, ['store:create', 'store:manage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑资料 / 开闭店 / 重新上传:主账号、门店权限,或历史未配权限的门店类子账号 */
|
||||||
|
export function canManagePartnerStore(account: PartnerMe | null | undefined): boolean {
|
||||||
|
if (!account) return false;
|
||||||
|
if (isPrimaryAccount(account)) return true;
|
||||||
|
if (isWarehouseStaff(account)) return false;
|
||||||
|
if (hasAnyPartnerPermission(account, ['store:manage', 'store:create'])) return true;
|
||||||
|
// 合伙人端早期创建的子账号可能 permissions 为空,按门店员工放开
|
||||||
|
return !Array.isArray(account.permissions) || account.permissions.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 录入新店 */
|
||||||
|
export function canCreatePartnerStore(account: PartnerMe | null | undefined): boolean {
|
||||||
|
if (!account) return false;
|
||||||
|
if (isPrimaryAccount(account)) return true;
|
||||||
|
if (isWarehouseStaff(account)) return false;
|
||||||
|
if (hasPartnerPermission(account, 'store:create')) return true;
|
||||||
|
return !Array.isArray(account.permissions) || account.permissions.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端 GET /partner/orders 权限点一致 */
|
||||||
|
export function canAccessPartnerOrders(account: PartnerMe | null | undefined): boolean {
|
||||||
|
return hasAnyPartnerPermission(account, ['order:view', 'warehouse:manage']);
|
||||||
|
}
|
||||||
|
|
||||||
/** 仓库管理员:warehouse:manage,或仅有 order:view(无门店权限) */
|
/** 仓库管理员:warehouse:manage,或仅有 order:view(无门店权限) */
|
||||||
export function isWarehouseStaff(account: PartnerMe | null | undefined): boolean {
|
export function isWarehouseStaff(account: PartnerMe | null | undefined): boolean {
|
||||||
if (!account || isPrimaryAccount(account)) return false;
|
if (!account || isPrimaryAccount(account)) return false;
|
||||||
@@ -35,12 +81,21 @@ export function getPartnerNavKind(account: PartnerMe | null | undefined): Partne
|
|||||||
return 'store_staff';
|
return 'store_staff';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 是否已配置管仓:主账号看 hasWarehouseAccess;子账号还需有仓库相关权限 */
|
||||||
|
export function hasWarehouseAccess(account: PartnerMe | null | undefined): boolean {
|
||||||
|
if (!account) return false;
|
||||||
|
if (account.hasWarehouseAccess === false) return false;
|
||||||
|
if (account.hasWarehouseAccess === true) return true;
|
||||||
|
// 旧缓存无字段时保守:主账号未知视为无,避免误展示全城订单
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function partnerHomePath(account: PartnerMe | null | undefined): string {
|
export function partnerHomePath(account: PartnerMe | null | undefined): string {
|
||||||
return '/';
|
return '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/login'];
|
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/leaderboard', '/login'];
|
||||||
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/login'];
|
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/leaderboard', '/login'];
|
||||||
|
|
||||||
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
|
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
|
||||||
if (pathname === '/login') return true;
|
if (pathname === '/login') return true;
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import {
|
import { PartnerStaffRole, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
|
||||||
PartnerStaffRole,
|
import type { CreatePartnerStaffRequest, PartnerStaffItem, UpdatePartnerStaffRequest } from '@dukang/shared-types';
|
||||||
type CreatePartnerStaffRequest,
|
|
||||||
type PartnerStaffItem,
|
|
||||||
type UpdatePartnerStaffRequest,
|
|
||||||
} from '@dukang/shared-types';
|
|
||||||
import { request } from './api';
|
import { request } from './api';
|
||||||
|
|
||||||
export function listPartnerStaff() {
|
export function listPartnerStaff() {
|
||||||
@@ -25,7 +21,10 @@ export function createPartnerStaff(body: CreatePartnerStaffRequest) {
|
|||||||
name: body.name,
|
name: body.name,
|
||||||
phone: body.phone,
|
phone: body.phone,
|
||||||
smsCode: body.smsCode,
|
smsCode: body.smsCode,
|
||||||
staffRole: PartnerStaffRole.INTERNAL,
|
staffRole: body.staffRole ?? PartnerStaffRole.INTERNAL,
|
||||||
|
permissions: body.permissions?.length
|
||||||
|
? body.permissions
|
||||||
|
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,96 @@
|
|||||||
import { useEffect, 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 type { PartnerBillDto } from '@dukang/shared-types';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
|
|
||||||
|
function fmtMoney(n: number) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function billStatusLabel(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'DRAFT': return '待确认';
|
||||||
|
case 'CONFIRMED': return '审核中';
|
||||||
|
case 'PAID': return '已结算';
|
||||||
|
case 'REJECTED': return '已驳回';
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canApplyPayment(status: string) {
|
||||||
|
return status === 'DRAFT' || status === 'REJECTED';
|
||||||
|
}
|
||||||
|
|
||||||
export default function BillsPage() {
|
export default function BillsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
|
const [bills, setBills] = useState<PartnerBillDto[]>([]);
|
||||||
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);
|
||||||
|
|
||||||
|
async function loadBills() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const list = await request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills');
|
||||||
|
setBills(Array.isArray(list) ? list : []);
|
||||||
|
} catch {
|
||||||
|
setBills([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
void loadBills();
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const billId = searchParams.get('id');
|
const billId = searchParams.get('id');
|
||||||
|
const actionable = useMemo(
|
||||||
|
() => bills.filter((b) => canApplyPayment(b.status)),
|
||||||
|
[bills],
|
||||||
|
);
|
||||||
const bill = billId
|
const bill = billId
|
||||||
? bills.find((b) => String(b.id) === billId) ?? bills[0]
|
? bills.find((b) => String(b.id) === billId) ?? actionable[0] ?? bills[0]
|
||||||
: bills[0];
|
: actionable[0] ?? bills[0];
|
||||||
|
|
||||||
function confirmBill() {
|
const status = String(bill?.status || '');
|
||||||
if (!confirmed) return;
|
const showApplyForm = !!bill && canApplyPayment(status);
|
||||||
|
const isReviewing = status === 'CONFIRMED';
|
||||||
|
const isRejected = status === 'REJECTED';
|
||||||
|
const isPaid = status === 'PAID';
|
||||||
|
|
||||||
|
async function confirmBill() {
|
||||||
|
if (!bill || !confirmed || !canApplyPayment(String(bill.status))) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setTimeout(() => {
|
try {
|
||||||
|
await request('PARTNER_H5', `/partner/settlement/bills/${bill.id}/confirm`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
toastSuccess('申请已提交,请耐心等待总部打款审核');
|
||||||
|
setConfirmed(false);
|
||||||
|
await loadBills();
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '提交失败');
|
||||||
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
alert('申请已提交,请耐心等待打款审核。(preV1 演示)');
|
}
|
||||||
}, 1500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="partner-bills-page">
|
<div className="partner-bills-page">
|
||||||
<PageHeader title="待确认账单" onBack={() => navigate('/center/settlement')} />
|
<PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
|
||||||
|
|
||||||
<div className="partner-bill-stepper">
|
<div className="partner-bill-stepper">
|
||||||
<div className="partner-stepper-inner">
|
<div className="partner-stepper-inner">
|
||||||
<div className="partner-stepper-line" aria-hidden>
|
<div className="partner-stepper-line" aria-hidden>
|
||||||
<div className="partner-stepper-line-fill" style={{ width: '50%' }} />
|
<div
|
||||||
|
className="partner-stepper-line-fill"
|
||||||
|
style={{ width: isPaid ? '100%' : isReviewing || isRejected ? '75%' : '50%' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-step">
|
<div className="partner-step">
|
||||||
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--done">
|
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--done">
|
||||||
@@ -45,17 +99,28 @@ 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 partner-step-circle--active">2</div>
|
<div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isReviewing || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
|
||||||
|
{showApplyForm && !confirmed ? '2' : (
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<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">3</div>
|
<div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isReviewing || isRejected ? ' partner-step-circle--active' : ''}`}>
|
||||||
<span className="partner-step-label">申请打款</span>
|
{isPaid ? (
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
|
||||||
|
) : '3'}
|
||||||
|
</div>
|
||||||
|
<span className={`partner-step-label${isReviewing || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
|
||||||
|
{isRejected ? '已驳回' : isPaid ? '已打款' : '申请打款'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!bill && <div className="empty">暂无账单</div>}
|
{loading && <div className="empty">加载中…</div>}
|
||||||
|
{!loading && !bill && <div className="empty">暂无账单</div>}
|
||||||
|
|
||||||
{bill && (
|
{bill && (
|
||||||
<section className="partner-bill-card">
|
<section className="partner-bill-card">
|
||||||
@@ -66,25 +131,46 @@ 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 partner-status-pill--paused">{String(bill.status || '待确认')}</span>
|
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isReviewing ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
|
||||||
|
{billStatusLabel(status)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isRejected && bill.rejectReason && (
|
||||||
|
<div className="partner-info-banner" style={{ marginBottom: 16, background: 'rgba(166,29,36,0.06)' }}>
|
||||||
|
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>error</span>
|
||||||
|
<div>
|
||||||
|
<p className="body-md text-primary" style={{ fontWeight: 600, marginBottom: 4 }}>打款申请已驳回</p>
|
||||||
|
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>{bill.rejectReason}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isReviewing && (
|
||||||
|
<div className="partner-info-banner" style={{ marginBottom: 16 }}>
|
||||||
|
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>hourglass_top</span>
|
||||||
|
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
|
||||||
|
您已提交打款申请,总部审核中,请耐心等待。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="partner-bill-amount">
|
<div className="partner-bill-amount">
|
||||||
<p className="text-muted body-md" style={{ marginBottom: 8 }}>应结总金额</p>
|
<p className="text-muted body-md" style={{ marginBottom: 8 }}>应结总金额</p>
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
|
||||||
<span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span>
|
<span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span>
|
||||||
<span className="amount-xl">{Number(bill.totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
<span className="amount-xl">{fmtMoney(Number(bill.totalAmount || 0))}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<div className="partner-info-row">
|
<div className="partner-info-row">
|
||||||
<span className="text-variant body-md">订单分佣收入</span>
|
<span className="text-variant body-md">订单分佣收入</span>
|
||||||
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.82).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.orderCommission || 0))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-info-row">
|
<div className="partner-info-row">
|
||||||
<span className="text-variant body-md">核销权益分佣</span>
|
<span className="text-variant body-md">核销权益分佣</span>
|
||||||
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.18).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.redeemCommission || 0))}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,45 +179,58 @@ export default function BillsPage() {
|
|||||||
|
|
||||||
{bills.length > 1 && (
|
{bills.length > 1 && (
|
||||||
<div style={{ padding: '0 20px' }}>
|
<div style={{ padding: '0 20px' }}>
|
||||||
<h3 className="headline-md" style={{ marginBottom: 12 }}>历史账单</h3>
|
<h3 className="headline-md" style={{ marginBottom: 12 }}>全部账单</h3>
|
||||||
{bills.slice(1).map((b) => (
|
{bills.map((b) => (
|
||||||
<div key={String(b.id)} className="partner-store-card" style={{ margin: '0 0 12px' }}>
|
<button
|
||||||
|
key={String(b.id)}
|
||||||
|
type="button"
|
||||||
|
className="partner-store-card"
|
||||||
|
style={{ margin: '0 0 12px', width: '100%', textAlign: 'left', cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/center/bills?id=${b.id}`)}
|
||||||
|
>
|
||||||
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
|
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
|
||||||
<div>
|
<div>
|
||||||
<p className="body-md">{String(b.billNo)}</p>
|
<p className="body-md">{b.billNo}</p>
|
||||||
<p className="label-md text-muted">{String(b.status)}</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>
|
</div>
|
||||||
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
|
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="partner-info-banner" style={{ marginTop: 16 }}>
|
{showApplyForm && (
|
||||||
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
|
<>
|
||||||
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
|
<div className="partner-info-banner" style={{ marginTop: 16 }}>
|
||||||
账单确认后将正式进入打款流程。如有异议,请在确认前联系城市运营经理核实数据。
|
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
|
||||||
</p>
|
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
|
||||||
</div>
|
{isRejected
|
||||||
|
? '请根据驳回理由核对后重新确认并申请打款。'
|
||||||
|
: '账单确认后将正式进入打款审核。如有异议,请在确认前联系城市运营经理核实数据。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{bill && (
|
<footer className="partner-bill-footer">
|
||||||
<footer className="partner-bill-footer">
|
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
|
||||||
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
|
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
|
||||||
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
|
<span>我已核对数据无误,同意根据此账单进行结算</span>
|
||||||
<span>我已核对数据无误,同意根据此账单进行结算</span>
|
</label>
|
||||||
</label>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
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={confirmBill}
|
>
|
||||||
>
|
{submitting ? '正在提交...' : isRejected ? '重新申请打款' : '确认并申请打款'}
|
||||||
{submitting ? '正在提交...' : '确认并申请打款'}
|
{!submitting && <span className="material-symbols-outlined">payments</span>}
|
||||||
{!submitting && <span className="material-symbols-outlined">payments</span>}
|
</button>
|
||||||
</button>
|
</footer>
|
||||||
</footer>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ function fmtMoney(n: number) {
|
|||||||
export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) {
|
export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) {
|
||||||
const { account, logout, refresh, applySession } = usePartnerSession();
|
const { account, logout, refresh, applySession } = usePartnerSession();
|
||||||
const isPrimary = variant === 'primary' && isPrimaryAccount(account);
|
const isPrimary = variant === 'primary' && isPrimaryAccount(account);
|
||||||
const [name, setName] = useState(account?.name ?? '');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [authorizing, setAuthorizing] = useState(false);
|
const [authorizing, setAuthorizing] = useState(false);
|
||||||
const [bills, setBills] = useState<PartnerBillDto[]>([]);
|
const [bills, setBills] = useState<PartnerBillDto[]>([]);
|
||||||
const [staffCount, setStaffCount] = useState(0);
|
const [staffCount, setStaffCount] = useState(0);
|
||||||
@@ -38,10 +36,6 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
|||||||
document.title = isPrimary ? '合伙人中心' : '个人中心';
|
document.title = isPrimary ? '合伙人中心' : '个人中心';
|
||||||
}, [isPrimary]);
|
}, [isPrimary]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setName(account?.name ?? '');
|
|
||||||
}, [account?.name]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPrimary) return;
|
if (!isPrimary) return;
|
||||||
void request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
|
void request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
|
||||||
@@ -53,37 +47,22 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
|||||||
}, [isPrimary]);
|
}, [isPrimary]);
|
||||||
|
|
||||||
const finance = useMemo(() => {
|
const finance = useMemo(() => {
|
||||||
const pending = bills.filter((b) => b.status === 'DRAFT');
|
const pending = bills.filter((b) => b.status === 'DRAFT' || b.status === 'REJECTED');
|
||||||
const settled = bills.filter((b) => b.status === 'PAID');
|
const settled = bills.filter((b) => b.status === 'PAID');
|
||||||
const paidCount = settled.length;
|
const paidCount = settled.length;
|
||||||
|
const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length;
|
||||||
return {
|
return {
|
||||||
pendingBillCount: pending.length,
|
pendingBillCount: pending.length,
|
||||||
pendingTotal: pending.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
rejectedCount,
|
||||||
|
pendingTotal: bills
|
||||||
|
.filter((b) => b.status === 'DRAFT' || b.status === 'CONFIRMED' || b.status === 'REJECTED')
|
||||||
|
.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),
|
||||||
paidCount,
|
paidCount,
|
||||||
};
|
};
|
||||||
}, [bills]);
|
}, [bills]);
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
const trimmed = name.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
toastError('请输入姓名');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (trimmed === account?.name) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await request('PARTNER_H5', '/partner/me', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ name: trimmed }),
|
|
||||||
});
|
|
||||||
await refresh();
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleContactPartner() {
|
function handleContactPartner() {
|
||||||
if (isPrimary) {
|
if (isPrimary) {
|
||||||
if (!contactSupport()) toastError('暂无客服电话');
|
if (!contactSupport()) toastError('暂无客服电话');
|
||||||
@@ -180,9 +159,13 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
|||||||
<span className="material-symbols-outlined">receipt_long</span>
|
<span className="material-symbols-outlined">receipt_long</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="body-md text-primary" style={{ fontWeight: 600 }}>待确认账单</p>
|
<p className="body-md text-primary" style={{ fontWeight: 600 }}>
|
||||||
|
{finance.rejectedCount > 0 ? '待处理账单' : '待确认账单'}
|
||||||
|
</p>
|
||||||
<p className="label-md text-primary" style={{ opacity: 0.85 }}>
|
<p className="label-md text-primary" style={{ opacity: 0.85 }}>
|
||||||
您有 {finance.pendingBillCount} 笔账单待确认
|
{finance.rejectedCount > 0
|
||||||
|
? `您有 ${finance.pendingBillCount} 笔账单待处理(含 ${finance.rejectedCount} 笔已驳回)`
|
||||||
|
: `您有 ${finance.pendingBillCount} 笔账单待确认`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,59 +230,52 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
|||||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-menu-item"
|
||||||
|
onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}
|
||||||
|
>
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon">
|
||||||
|
<span className="material-symbols-outlined">support_agent</span>
|
||||||
|
</div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>联系客服</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isPrimary && (
|
{!isPrimary && (
|
||||||
<>
|
<section className="partner-menu-section">
|
||||||
<section className="partner-menu-section">
|
<div className="partner-menu-card">
|
||||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>个人信息设置</h3>
|
<Link to="/leaderboard" className="partner-menu-item">
|
||||||
<div className="partner-menu-card" style={{ padding: '16px' }}>
|
<div className="partner-menu-item-left">
|
||||||
<label className="partner-form-label" htmlFor="center-name">姓名</label>
|
<div className="partner-menu-icon">
|
||||||
<input
|
<span className="material-symbols-outlined">emoji_events</span>
|
||||||
id="center-name"
|
|
||||||
className="partner-form-input"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="请输入姓名"
|
|
||||||
maxLength={32}
|
|
||||||
/>
|
|
||||||
<label className="partner-form-label" style={{ marginTop: 16 }}>手机号</label>
|
|
||||||
<input className="partner-form-input" value={account?.phone ?? ''} readOnly disabled />
|
|
||||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>手机号由管理员维护,如需修改请联系总部</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="partner-btn-primary"
|
|
||||||
style={{ width: '100%', marginTop: 16 }}
|
|
||||||
disabled={saving}
|
|
||||||
onClick={() => void handleSave()}
|
|
||||||
>
|
|
||||||
{saving ? '保存中…' : '保存资料'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="partner-menu-section">
|
|
||||||
<div className="partner-menu-card">
|
|
||||||
<button type="button" className="partner-menu-item" onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}>
|
|
||||||
<div className="partner-menu-item-left">
|
|
||||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">support_agent</span></div>
|
|
||||||
<span className="body-md" style={{ fontSize: 16 }}>联系客服</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
<span className="body-md" style={{ fontSize: 16 }}>团队贡献榜</span>
|
||||||
</button>
|
</div>
|
||||||
<button type="button" className="partner-menu-item" onClick={handleContactPartner}>
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
<div className="partner-menu-item-left">
|
</Link>
|
||||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">call</span></div>
|
<button type="button" className="partner-menu-item" onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}>
|
||||||
<span className="body-md" style={{ fontSize: 16 }}>联系合伙人</span>
|
<div className="partner-menu-item-left">
|
||||||
</div>
|
<div className="partner-menu-icon"><span className="material-symbols-outlined">support_agent</span></div>
|
||||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
<span className="body-md" style={{ fontSize: 16 }}>联系客服</span>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
</section>
|
</button>
|
||||||
</>
|
<button type="button" className="partner-menu-item" onClick={handleContactPartner}>
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon"><span className="material-symbols-outlined">call</span></div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>联系合伙人</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button type="button" className="partner-logout-btn" onClick={logout}>
|
<button type="button" className="partner-logout-btn" onClick={logout}>
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { Link, useNavigate } from 'react-router-dom';
|
|||||||
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||||
import { getPartnerNavKind } from '../lib/partnerAccess';
|
import {
|
||||||
|
canAccessPartnerDashboard,
|
||||||
|
canAccessPartnerOrders,
|
||||||
|
canAccessPartnerStores,
|
||||||
|
getPartnerNavKind,
|
||||||
|
hasWarehouseAccess,
|
||||||
|
} from '../lib/partnerAccess';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
|
|
||||||
function fmtMoney(n: number) {
|
function fmtMoney(n: number) {
|
||||||
@@ -96,7 +102,7 @@ function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] })
|
|||||||
<div className="partner-leaderboard-header">
|
<div className="partner-leaderboard-header">
|
||||||
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 20 }}>emoji_events</span>
|
<span className="material-symbols-outlined text-primary" style={{ fontSize: 20 }}>emoji_events</span>
|
||||||
合伙人贡献榜
|
团队贡献榜
|
||||||
</h3>
|
</h3>
|
||||||
<Link to="/leaderboard" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Link to="/leaderboard" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
查看全部 <span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
|
查看全部 <span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
|
||||||
@@ -135,6 +141,10 @@ export default function HomePage() {
|
|||||||
const navKind = getPartnerNavKind(account);
|
const navKind = getPartnerNavKind(account);
|
||||||
const isPrimary = navKind === 'primary';
|
const isPrimary = navKind === 'primary';
|
||||||
const isWarehouse = navKind === 'warehouse_staff';
|
const isWarehouse = navKind === 'warehouse_staff';
|
||||||
|
const warehouseOk = hasWarehouseAccess(account);
|
||||||
|
const canOrders = canAccessPartnerOrders(account) && warehouseOk && (isPrimary || isWarehouse);
|
||||||
|
const canDashboard = canAccessPartnerDashboard(account) && !isWarehouse;
|
||||||
|
const canStores = canAccessPartnerStores(account) && !isWarehouse;
|
||||||
|
|
||||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||||
@@ -147,25 +157,42 @@ export default function HomePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||||
if (isWarehouse || isPrimary) {
|
// 等 session 带上账号后再按权限发请求,避免无权限接口弹错
|
||||||
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders')
|
if (!account) return;
|
||||||
|
|
||||||
|
if (canOrders) {
|
||||||
|
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
|
||||||
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
|
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
|
||||||
.catch(() => setOrders([]));
|
.catch(() => setOrders([]));
|
||||||
}
|
|
||||||
if (!isWarehouse) {
|
|
||||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {});
|
|
||||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores).catch(() => []);
|
|
||||||
fetchPartnerLeaderboard('month')
|
|
||||||
.then((data) => {
|
|
||||||
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setLeaderboardEntries([]);
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {});
|
setOrders([]);
|
||||||
}
|
}
|
||||||
}, [navigate, isWarehouse, isPrimary]);
|
|
||||||
|
if (canDashboard) {
|
||||||
|
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
|
||||||
|
.then(setDash)
|
||||||
|
.catch(() => setDash(null));
|
||||||
|
} else {
|
||||||
|
setDash(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canStores) {
|
||||||
|
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
|
||||||
|
.then((data) => setStores(Array.isArray(data) ? data : []))
|
||||||
|
.catch(() => setStores([]));
|
||||||
|
} else {
|
||||||
|
setStores([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主账号与全部子账号均可查看同团队贡献榜(后端不校验业务权限点)
|
||||||
|
fetchPartnerLeaderboard('month')
|
||||||
|
.then((data) => {
|
||||||
|
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLeaderboardEntries([]);
|
||||||
|
});
|
||||||
|
}, [navigate, account, canOrders, canDashboard, canStores]);
|
||||||
|
|
||||||
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]);
|
||||||
@@ -190,7 +217,6 @@ export default function HomePage() {
|
|||||||
const orderCount = Number(dash?.orderCount || orderStats.todayCount || 0);
|
const orderCount = Number(dash?.orderCount || orderStats.todayCount || 0);
|
||||||
const revenue = orderCount * 128.45;
|
const revenue = orderCount * 128.45;
|
||||||
const profit = revenue * 0.25;
|
const profit = revenue * 0.25;
|
||||||
const pendingShipBadge = orderStats.pendingShip;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page partner-home partner-home--flush-top">
|
<div className="page partner-home partner-home--flush-top">
|
||||||
@@ -224,13 +250,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="partner-quick-action-label">录入新店</span>
|
<span className="partner-quick-action-label">录入新店</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/reshipments" className="partner-quick-action">
|
|
||||||
<div className="partner-quick-action-icon partner-quick-action-icon--amber">
|
|
||||||
<span className="material-symbols-outlined">assignment_return</span>
|
|
||||||
{pendingShipBadge > 0 && <span className="partner-quick-badge-count">{pendingShipBadge}</span>}
|
|
||||||
</div>
|
|
||||||
<span className="partner-quick-action-label">补发处理</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/center/settlement" className="partner-quick-action">
|
<Link to="/center/settlement" className="partner-quick-action">
|
||||||
<div className="partner-quick-action-icon partner-quick-action-icon--green">
|
<div className="partner-quick-action-icon partner-quick-action-icon--green">
|
||||||
<span className="material-symbols-outlined">account_balance</span>
|
<span className="material-symbols-outlined">account_balance</span>
|
||||||
@@ -252,7 +271,7 @@ export default function HomePage() {
|
|||||||
<p className="headline-md" style={{ marginBottom: 8 }}>订单管理</p>
|
<p className="headline-md" style={{ marginBottom: 8 }}>订单管理</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(isPrimary || isWarehouse) && (
|
{canOrders && (
|
||||||
<OrderSummarySection
|
<OrderSummarySection
|
||||||
title={isWarehouse ? '今日订单' : undefined}
|
title={isWarehouse ? '今日订单' : undefined}
|
||||||
todayCount={orderStats.todayCount}
|
todayCount={orderStats.todayCount}
|
||||||
@@ -262,7 +281,19 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isWarehouse && (
|
{(isPrimary || isWarehouse) && !warehouseOk && (
|
||||||
|
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="partner-warehouse-denied">
|
||||||
|
<span className="material-symbols-outlined text-primary">warehouse</span>
|
||||||
|
<p className="body-md" style={{ fontWeight: 600, marginTop: 8 }}>未配置仓库管理权限</p>
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 4, lineHeight: 1.5 }}>
|
||||||
|
购酒订单由总部履约,不会推送到本账号。如需管仓发货,请联系总部配置仓库。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canStores && (
|
||||||
<>
|
<>
|
||||||
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
|
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
|
||||||
<div className="partner-bento-header">
|
<div className="partner-bento-header">
|
||||||
@@ -310,10 +341,10 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<LeaderboardPreview entries={leaderboardEntries} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<LeaderboardPreview entries={leaderboardEntries} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export default function LeaderboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-no-tab">
|
<div className="page-no-tab">
|
||||||
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} />
|
<PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
|
||||||
|
|
||||||
<div className="partner-leaderboard-tabs">
|
<div className="partner-leaderboard-tabs">
|
||||||
{PERIOD_TABS.map((tab) => (
|
{PERIOD_TABS.map((tab) => (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||||
@@ -24,6 +24,32 @@ import { isWechatEnv } from '../lib/weixin';
|
|||||||
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
||||||
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
||||||
|
|
||||||
|
function AgreementCheckbox({
|
||||||
|
agreed,
|
||||||
|
onChange,
|
||||||
|
inputRef,
|
||||||
|
}: {
|
||||||
|
agreed: boolean;
|
||||||
|
onChange: (next: boolean) => void;
|
||||||
|
inputRef?: RefObject<HTMLLabelElement | null>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}>
|
||||||
|
<input type="checkbox" checked={agreed} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
<span>
|
||||||
|
我已阅读并同意{' '}
|
||||||
|
<Link to="/legal/user-agreement" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
《用户协议》
|
||||||
|
</Link>{' '}
|
||||||
|
与{' '}
|
||||||
|
<Link to="/legal/privacy-policy" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
《隐私政策》
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function maskPhone(phone: string) {
|
function maskPhone(phone: string) {
|
||||||
if (phone.length < 7) return phone;
|
if (phone.length < 7) return phone;
|
||||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
||||||
@@ -74,6 +100,7 @@ export default function LoginPage() {
|
|||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||||
|
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchClientConfig()
|
fetchClientConfig()
|
||||||
@@ -88,6 +115,7 @@ export default function LoginPage() {
|
|||||||
function ensureAgreed() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setMsg('请先勾选并同意用户协议');
|
setMsg('请先勾选并同意用户协议');
|
||||||
|
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -230,10 +258,16 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
|
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
|
||||||
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
|
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
|
||||||
|
<AgreementCheckbox
|
||||||
|
agreed={agreed}
|
||||||
|
onChange={setAgreed}
|
||||||
|
inputRef={agreementRef}
|
||||||
|
/>
|
||||||
{canWechatQuick ? (
|
{canWechatQuick ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="partner-btn-primary"
|
className="partner-btn-primary"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
disabled={wxLoading}
|
disabled={wxLoading}
|
||||||
onClick={() => void wechatLogin()}
|
onClick={() => void wechatLogin()}
|
||||||
>
|
>
|
||||||
@@ -241,7 +275,7 @@ export default function LoginPage() {
|
|||||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<p className="partner-auth-msg" style={{ textAlign: 'center', marginBottom: 12 }}>
|
<p className="partner-auth-msg" style={{ textAlign: 'center', marginTop: 12, marginBottom: 12 }}>
|
||||||
{wxAuthorize && !isWechatEnv()
|
{wxAuthorize && !isWechatEnv()
|
||||||
? '请在微信内打开以使用一键登录'
|
? '请在微信内打开以使用一键登录'
|
||||||
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
||||||
@@ -321,6 +355,12 @@ export default function LoginPage() {
|
|||||||
<span>记住账号</span>
|
<span>记住账号</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<AgreementCheckbox
|
||||||
|
agreed={agreed}
|
||||||
|
onChange={setAgreed}
|
||||||
|
inputRef={agreementRef}
|
||||||
|
/>
|
||||||
|
|
||||||
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
|
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
|
||||||
|
|
||||||
<button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}>
|
<button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}>
|
||||||
@@ -339,20 +379,6 @@ export default function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="partner-checkbox-row">
|
|
||||||
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
|
|
||||||
<span>
|
|
||||||
我已阅读并同意{' '}
|
|
||||||
<Link to="/legal/user-agreement" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
|
|
||||||
《用户协议》
|
|
||||||
</Link>{' '}
|
|
||||||
与{' '}
|
|
||||||
<Link to="/legal/privacy-policy" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
|
|
||||||
《隐私政策》
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
|
import { hasWarehouseAccess } from '../lib/partnerAccess';
|
||||||
|
|
||||||
type DateFilter = 'today' | '7d' | '30d';
|
type DateFilter = 'today' | '7d' | '30d';
|
||||||
type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL';
|
type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL';
|
||||||
@@ -34,15 +36,24 @@ type OrderListPageProps = {
|
|||||||
tabRoot?: boolean;
|
tabRoot?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OrdersResponse = {
|
||||||
|
list: Array<Record<string, unknown>>;
|
||||||
|
hasWarehouseAccess?: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [data, setData] = useState<{ list: Array<Record<string, unknown>> }>({ list: [] });
|
const { account } = usePartnerSession();
|
||||||
|
const warehouseOk = hasWarehouseAccess(account);
|
||||||
|
const [data, setData] = useState<OrdersResponse>({ list: [] });
|
||||||
const [tab, setTab] = useState<'orders' | 'coupons'>('orders');
|
const [tab, setTab] = useState<'orders' | 'coupons'>('orders');
|
||||||
const [dateFilter, setDateFilter] = useState<DateFilter>('today');
|
const [dateFilter, setDateFilter] = useState<DateFilter>('today');
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||||
const [shippingId, setShippingId] = useState<string | null>(null);
|
const [shippingId, setShippingId] = useState<string | null>(null);
|
||||||
const [shipForm, setShipForm] = useState({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
|
const [shipForm, setShipForm] = useState({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
|
||||||
const [shipModalId, setShipModalId] = useState<string | null>(null);
|
const [shipModalId, setShipModalId] = useState<string | null>(null);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = '订单管理';
|
document.title = '订单管理';
|
||||||
@@ -50,8 +61,22 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||||
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders').then(setData);
|
if (!warehouseOk) {
|
||||||
}, [navigate]);
|
setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' });
|
||||||
|
setLoaded(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request<OrdersResponse>('PARTNER_H5', '/partner/orders')
|
||||||
|
.then((res) => {
|
||||||
|
setData({
|
||||||
|
list: Array.isArray(res.list) ? res.list : [],
|
||||||
|
hasWarehouseAccess: res.hasWarehouseAccess,
|
||||||
|
message: res.message,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => setData({ list: [] }))
|
||||||
|
.finally(() => setLoaded(true));
|
||||||
|
}, [navigate, warehouseOk]);
|
||||||
|
|
||||||
const filtered = useMemo(() => data.list.filter((o) => {
|
const filtered = useMemo(() => data.list.filter((o) => {
|
||||||
if (statusFilter === 'ALL') return true;
|
if (statusFilter === 'ALL') return true;
|
||||||
@@ -83,8 +108,12 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
|||||||
body: JSON.stringify(shipForm),
|
body: JSON.stringify(shipForm),
|
||||||
});
|
});
|
||||||
setShipModalId(null);
|
setShipModalId(null);
|
||||||
const next = await request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders');
|
const next = await request<OrdersResponse>('PARTNER_H5', '/partner/orders');
|
||||||
setData(next);
|
setData({
|
||||||
|
list: Array.isArray(next.list) ? next.list : [],
|
||||||
|
hasWarehouseAccess: next.hasWarehouseAccess,
|
||||||
|
message: next.message,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.alert(err instanceof Error ? err.message : '发货失败');
|
window.alert(err instanceof Error ? err.message : '发货失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -97,6 +126,21 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
|||||||
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
|
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loaded && !warehouseOk) {
|
||||||
|
return (
|
||||||
|
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
|
||||||
|
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
|
||||||
|
<div className="partner-warehouse-denied" style={{ margin: 20, padding: 32, textAlign: 'center' }}>
|
||||||
|
<span className="material-symbols-outlined text-primary" style={{ fontSize: 40 }}>warehouse</span>
|
||||||
|
<p className="headline-md" style={{ marginTop: 12 }}>未配置仓库管理权限</p>
|
||||||
|
<p className="body-md text-muted" style={{ marginTop: 8, lineHeight: 1.6 }}>
|
||||||
|
购酒订单由总部履约,不会推送到本账号。如需管仓发货,请联系总部配置仓库。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
|
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
|
||||||
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
|
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import PageHeader from '@dukang/shared-ui/PageHeader';
|
|||||||
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';
|
||||||
|
|
||||||
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing';
|
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing' | 'rejected';
|
||||||
|
|
||||||
function fmtMoney(n: number) {
|
function fmtMoney(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
@@ -15,6 +15,7 @@ function billStatusLabel(status: string) {
|
|||||||
case 'DRAFT': return '待结算';
|
case 'DRAFT': return '待结算';
|
||||||
case 'CONFIRMED': return '审核中';
|
case 'CONFIRMED': return '审核中';
|
||||||
case 'PAID': return '已结算';
|
case 'PAID': return '已结算';
|
||||||
|
case 'REJECTED': return '已驳回';
|
||||||
default: return status;
|
default: return status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +25,7 @@ function billStatusClass(status: string) {
|
|||||||
case 'DRAFT': return 'partner-settlement-status--pending';
|
case 'DRAFT': return 'partner-settlement-status--pending';
|
||||||
case 'CONFIRMED': return 'partner-settlement-status--reviewing';
|
case 'CONFIRMED': return 'partner-settlement-status--reviewing';
|
||||||
case 'PAID': return 'partner-settlement-status--settled';
|
case 'PAID': return 'partner-settlement-status--settled';
|
||||||
|
case 'REJECTED': return 'partner-settlement-status--rejected';
|
||||||
default: return '';
|
default: return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,9 +72,10 @@ 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';
|
if (statusFilter === 'pending') return b.status === 'DRAFT' || b.status === 'REJECTED';
|
||||||
if (statusFilter === 'settled') return b.status === 'PAID';
|
if (statusFilter === 'settled') return b.status === 'PAID';
|
||||||
if (statusFilter === 'reviewing') return b.status === 'CONFIRMED';
|
if (statusFilter === 'reviewing') return b.status === 'CONFIRMED';
|
||||||
|
if (statusFilter === 'rejected') return b.status === 'REJECTED';
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [bills, month, statusFilter]);
|
}, [bills, month, statusFilter]);
|
||||||
@@ -93,8 +96,9 @@ 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: 'settled', label: '已结算' },
|
|
||||||
{ key: 'reviewing', label: '审核中' },
|
{ key: 'reviewing', label: '审核中' },
|
||||||
|
{ key: 'rejected', label: '已驳回' },
|
||||||
|
{ key: 'settled', label: '已结算' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -188,7 +192,12 @@ export default function SettlementPage() {
|
|||||||
<span className="partner-settlement-dot" />
|
<span className="partner-settlement-dot" />
|
||||||
订单分佣 ¥{fmtMoney(Number(bill.orderCommission || 0))}
|
订单分佣 ¥{fmtMoney(Number(bill.orderCommission || 0))}
|
||||||
</p>
|
</p>
|
||||||
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' ? ' text-primary' : ''}`}>
|
{bill.status === 'REJECTED' && bill.rejectReason ? (
|
||||||
|
<p className="label-md text-primary" style={{ marginTop: 6 }}>
|
||||||
|
驳回:{bill.rejectReason}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
|
||||||
¥{fmtMoney(Number(bill.totalAmount || 0))}
|
¥{fmtMoney(Number(bill.totalAmount || 0))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -523,7 +523,9 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean);
|
const envPhotoUrls = Array.from(
|
||||||
|
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
|
||||||
|
).slice(0, 3);
|
||||||
|
|
||||||
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ 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';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { isSubAccount } from '../lib/partnerAccess';
|
import { canManagePartnerStore } from '../lib/partnerAccess';
|
||||||
|
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
|
||||||
|
import OssUploadField from '../components/OssUploadField';
|
||||||
import {
|
import {
|
||||||
canPartnerOpenStore,
|
canPartnerOpenStore,
|
||||||
storeAuditLabel,
|
storeAuditLabel,
|
||||||
@@ -16,19 +18,36 @@ import {
|
|||||||
} from '../lib/storeStatus';
|
} from '../lib/storeStatus';
|
||||||
|
|
||||||
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
||||||
|
const ENV_SLOT_COUNT = 3;
|
||||||
|
|
||||||
|
function uniqueEnvUrls(urls: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const raw of urls) {
|
||||||
|
const url = raw.trim();
|
||||||
|
if (!url || seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
out.push(url);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export default function StoreDetailPage() {
|
export default function StoreDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { account } = usePartnerSession();
|
const { account } = usePartnerSession();
|
||||||
const subReadonly = isSubAccount(account);
|
const canMutate = canManagePartnerStore(account);
|
||||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
|
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
|
||||||
|
const [coverUrl, setCoverUrl] = useState('');
|
||||||
|
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
|
||||||
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
||||||
const [statusSaving, setStatusSaving] = useState(false);
|
const [statusSaving, setStatusSaving] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [mediaSaving, setMediaSaving] = useState(false);
|
||||||
const [actionError, setActionError] = useState('');
|
const [actionError, setActionError] = useState('');
|
||||||
|
const [wechatReady, setWechatReady] = useState(false);
|
||||||
|
|
||||||
function applyStore(data: Record<string, unknown>) {
|
function applyStore(data: Record<string, unknown>) {
|
||||||
setStore(data);
|
setStore(data);
|
||||||
@@ -39,6 +58,15 @@ export default function StoreDetailPage() {
|
|||||||
intro: String(data.intro || ''),
|
intro: String(data.intro || ''),
|
||||||
});
|
});
|
||||||
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
|
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
|
||||||
|
setCoverUrl(String(data.coverUrl || ''));
|
||||||
|
const envFromMedia = Array.isArray(data.media)
|
||||||
|
? uniqueEnvUrls(
|
||||||
|
(data.media as Array<{ url?: string; bizType?: string }>)
|
||||||
|
.filter((m) => m.bizType === 'ENV')
|
||||||
|
.map((m) => String(m.url || '')),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
setEnvPhotoUrls(normalizeStringArray(envFromMedia, ENV_SLOT_COUNT));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -113,6 +141,42 @@ export default function StoreDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveMedia() {
|
||||||
|
if (!id || mediaSaving || status === 'CLOSED') return;
|
||||||
|
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
||||||
|
if (auditStatus === 'PENDING') {
|
||||||
|
setActionError('门店审核中,暂不可修改资料');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextCover = coverUrl.trim();
|
||||||
|
const nextEnv = uniqueEnvUrls(envPhotoUrls);
|
||||||
|
if (!nextCover) {
|
||||||
|
setActionError('请上传门头照');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextEnv.length < ENV_SLOT_COUNT) {
|
||||||
|
setActionError(`请上传至少 ${ENV_SLOT_COUNT} 张环境照片`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMediaSaving(true);
|
||||||
|
setActionError('');
|
||||||
|
try {
|
||||||
|
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
coverUrl: nextCover,
|
||||||
|
envPhotoUrls: nextEnv,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
applyStore(data);
|
||||||
|
toastSuccess(auditStatus === 'REJECTED' ? '照片已更新并重新提交审核' : '照片已更新');
|
||||||
|
} catch (e) {
|
||||||
|
setActionError(e instanceof Error ? e.message : '照片更新失败');
|
||||||
|
} finally {
|
||||||
|
setMediaSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loadError) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div className="partner-detail-page">
|
<div className="partner-detail-page">
|
||||||
@@ -124,13 +188,17 @@ export default function StoreDetailPage() {
|
|||||||
|
|
||||||
if (!store) return <div className="empty">加载中...</div>;
|
if (!store) return <div className="empty">加载中...</div>;
|
||||||
|
|
||||||
const envPhotos = Array.isArray(store.media)
|
const envPhotos = uniqueEnvUrls(
|
||||||
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
|
Array.isArray(store.media)
|
||||||
: [];
|
? (store.media as Array<{ url?: string; bizType?: string }>)
|
||||||
|
.filter((m) => m.bizType === 'ENV')
|
||||||
|
.map((m) => String(m.url || ''))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
|
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
|
||||||
const auditPending = auditStatus === 'PENDING';
|
const auditPending = auditStatus === 'PENDING';
|
||||||
const auditRejected = auditStatus === 'REJECTED';
|
const auditRejected = auditStatus === 'REJECTED';
|
||||||
const readOnly = subReadonly || status === 'CLOSED' || auditPending;
|
const readOnly = !canMutate || status === 'CLOSED' || auditPending;
|
||||||
const canOpen = canPartnerOpenStore(auditStatus);
|
const canOpen = canPartnerOpenStore(auditStatus);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -170,7 +238,7 @@ export default function StoreDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{!subReadonly && (
|
{canMutate && (
|
||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>运营状态</h3>
|
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>运营状态</h3>
|
||||||
@@ -200,13 +268,29 @@ export default function StoreDetailPage() {
|
|||||||
|
|
||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}>基本信息</h3>
|
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}>基本信息</h3>
|
||||||
<div className="partner-cover">
|
{!canMutate || readOnly ? (
|
||||||
<AppImage
|
<div className="partner-cover">
|
||||||
src={store.coverUrl ? String(store.coverUrl) : null}
|
<AppImage
|
||||||
alt={form.name}
|
src={coverUrl || null}
|
||||||
wrapperClassName="app-image--fill"
|
alt={form.name}
|
||||||
/>
|
wrapperClassName="app-image--fill"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 8 }}>门头照</p>
|
||||||
|
<OssUploadField
|
||||||
|
wide
|
||||||
|
bizType="STORE_TITLE"
|
||||||
|
mediaType="IMAGE"
|
||||||
|
value={coverUrl}
|
||||||
|
wechatReady={wechatReady}
|
||||||
|
onWechatReadyChange={setWechatReady}
|
||||||
|
onChange={setCoverUrl}
|
||||||
|
label="点击更换门头照"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>门店名称</label>
|
<label>门店名称</label>
|
||||||
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
|
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
|
||||||
@@ -234,13 +318,48 @@ export default function StoreDetailPage() {
|
|||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
|
||||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}>店内环境</h3>
|
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}>店内环境</h3>
|
||||||
<span className="label-md text-muted">{envPhotos.length ? `已上传 ${envPhotos.length} 张` : '暂无照片'}</span>
|
<span className="label-md text-muted">
|
||||||
|
{canMutate && !readOnly
|
||||||
|
? `需 ${ENV_SLOT_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length} 张`
|
||||||
|
: envPhotos.length
|
||||||
|
? `已上传 ${envPhotos.length} 张`
|
||||||
|
: '暂无照片'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{envPhotos.length > 0 ? (
|
{canMutate && !readOnly ? (
|
||||||
|
<>
|
||||||
|
<div className="partner-upload-grid">
|
||||||
|
{envPhotoUrls.map((url, index) => (
|
||||||
|
<OssUploadField
|
||||||
|
key={index}
|
||||||
|
compact
|
||||||
|
bizType="STORE_ENV"
|
||||||
|
mediaType="IMAGE"
|
||||||
|
value={url}
|
||||||
|
wechatReady={wechatReady}
|
||||||
|
onWechatReadyChange={setWechatReady}
|
||||||
|
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-outline"
|
||||||
|
style={{ width: '100%', marginTop: 16 }}
|
||||||
|
disabled={mediaSaving}
|
||||||
|
onClick={() => void saveMedia()}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18, verticalAlign: 'middle', marginRight: 4 }}>
|
||||||
|
upload
|
||||||
|
</span>
|
||||||
|
{mediaSaving ? '上传中…' : '重新上传照片'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : envPhotos.length > 0 ? (
|
||||||
<div className="partner-photo-grid">
|
<div className="partner-photo-grid">
|
||||||
{envPhotos.map((photo, index) => (
|
{envPhotos.map((url, index) => (
|
||||||
<div key={index} className="partner-cover" style={{ aspectRatio: '1' }}>
|
<div key={`${url}-${index}`} className="partner-cover" style={{ aspectRatio: '1', marginBottom: 0 }}>
|
||||||
<AppImage src={photo.url ? String(photo.url) : null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
|
<AppImage src={url || null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -264,7 +383,7 @@ export default function StoreDetailPage() {
|
|||||||
|
|
||||||
<footer className="partner-save-footer">
|
<footer className="partner-save-footer">
|
||||||
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
||||||
{!subReadonly && (
|
{canMutate && (
|
||||||
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
||||||
<span className="material-symbols-outlined">save</span>
|
<span className="material-symbols-outlined">save</span>
|
||||||
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
|
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { isSubAccount } from '../lib/partnerAccess';
|
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
|
||||||
import {
|
import {
|
||||||
canPartnerOpenStore,
|
canPartnerOpenStore,
|
||||||
storeAuditLabel,
|
storeAuditLabel,
|
||||||
@@ -27,7 +27,8 @@ export default function StoreListPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { account } = usePartnerSession();
|
const { account } = usePartnerSession();
|
||||||
const readonly = isSubAccount(account);
|
const canMutate = canManagePartnerStore(account);
|
||||||
|
const canCreate = canCreatePartnerStore(account);
|
||||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
|
const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
|
||||||
@@ -45,8 +46,8 @@ export default function StoreListPage() {
|
|||||||
}, [navigate, loadStores]);
|
}, [navigate, loadStores]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = readonly ? '我的门店' : '门店管理';
|
document.title = canMutate ? '门店管理' : '我的门店';
|
||||||
}, [readonly]);
|
}, [canMutate]);
|
||||||
|
|
||||||
const filtered = useMemo(() => stores.filter((s) => {
|
const filtered = useMemo(() => stores.filter((s) => {
|
||||||
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
||||||
@@ -101,12 +102,14 @@ export default function StoreListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canCreate && (
|
||||||
<Link to="/stores/new" className="partner-fab-link">
|
<Link to="/stores/new" className="partner-fab-link">
|
||||||
<button type="button" className="partner-btn-primary">
|
<button type="button" className="partner-btn-primary">
|
||||||
<span className="material-symbols-outlined">add_business</span>
|
<span className="material-symbols-outlined">add_business</span>
|
||||||
录入新门店
|
录入新门店
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
||||||
|
|
||||||
@@ -146,7 +149,7 @@ export default function StoreListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
{!readonly && (
|
{canMutate && (
|
||||||
<div className="partner-store-card-actions">
|
<div className="partner-store-card-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -13,13 +13,23 @@ html {
|
|||||||
/* ── Partner auth ── */
|
/* ── Partner auth ── */
|
||||||
.partner-auth-page {
|
.partner-auth-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
padding: 48px var(--space-page) var(--space-page);
|
padding: 48px var(--space-page) calc(24px + env(safe-area-inset-bottom, 0px));
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
|
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-height: 720px) {
|
||||||
|
.partner-auth-page {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-auth-page--quick {
|
.partner-auth-page--quick {
|
||||||
@@ -166,14 +176,27 @@ html {
|
|||||||
|
|
||||||
.partner-checkbox-row {
|
.partner-checkbox-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: var(--space-md);
|
margin-top: var(--space-md);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
color: var(--color-on-surface-variant);
|
color: var(--color-on-surface-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-checkbox-row input { margin-top: 0; accent-color: var(--color-heritage-red); }
|
.partner-checkbox-row input {
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-checkbox-row--agreement {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.partner-auth-footer {
|
.partner-auth-footer {
|
||||||
margin-top: var(--space-lg);
|
margin-top: var(--space-lg);
|
||||||
@@ -2713,14 +2736,28 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
border: none;
|
||||||
border-bottom: 1px solid var(--color-surface-container);
|
border-bottom: 1px solid var(--color-surface-container);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-menu-item:last-child { border-bottom: none; }
|
.partner-menu-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.partner-menu-item:active {
|
||||||
|
background: rgba(166, 29, 36, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
.partner-menu-item-left {
|
.partner-menu-item-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2985,6 +3022,11 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
color: var(--color-success-green);
|
color: var(--color-success-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.partner-settlement-status--rejected {
|
||||||
|
background: rgba(166, 29, 36, 0.12);
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
.partner-settlement-item-meta {
|
.partner-settlement-item-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3273,8 +3315,17 @@ body {
|
|||||||
border-top: 1px dashed rgba(166, 29, 36, 0.15);
|
border-top: 1px dashed rgba(166, 29, 36, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-home--flush-top .partner-home-body {
|
.partner-warehouse-denied {
|
||||||
padding-top: 20px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-warehouse-denied .material-symbols-outlined {
|
||||||
|
font-size: 36px;
|
||||||
|
font-variation-settings: 'FILL' 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-home--flush-top.partner-store-page,
|
.partner-home--flush-top.partner-store-page,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||||
@@ -28,6 +28,36 @@ function formatWechatError(e: unknown): string {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ShopAgreementCheckbox({
|
||||||
|
agreed,
|
||||||
|
onChange,
|
||||||
|
labelRef,
|
||||||
|
}: {
|
||||||
|
agreed: boolean;
|
||||||
|
onChange: (next: boolean) => void;
|
||||||
|
labelRef?: RefObject<HTMLLabelElement | null>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="shop-login-agreement" ref={labelRef}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={agreed}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
我已阅读并同意
|
||||||
|
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
|
||||||
|
《用户协议》
|
||||||
|
</Link>
|
||||||
|
与
|
||||||
|
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
|
||||||
|
《隐私政策》
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { applySession } = useStoreSession();
|
const { applySession } = useStoreSession();
|
||||||
@@ -42,6 +72,7 @@ export default function LoginPage() {
|
|||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||||
|
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchClientConfig()
|
fetchClientConfig()
|
||||||
@@ -71,6 +102,7 @@ export default function LoginPage() {
|
|||||||
function ensureAgreed() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setMsg('请先阅读并同意用户协议');
|
setMsg('请先阅读并同意用户协议');
|
||||||
|
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -180,6 +212,11 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<div className="shop-quick-actions">
|
<div className="shop-quick-actions">
|
||||||
{msg && <p className="shop-login-msg">{msg}</p>}
|
{msg && <p className="shop-login-msg">{msg}</p>}
|
||||||
|
<ShopAgreementCheckbox
|
||||||
|
agreed={agreed}
|
||||||
|
onChange={setAgreed}
|
||||||
|
labelRef={agreementRef}
|
||||||
|
/>
|
||||||
{canWechatQuick ? (
|
{canWechatQuick ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -270,6 +307,12 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
{msg && <p className="shop-login-msg">{msg}</p>}
|
{msg && <p className="shop-login-msg">{msg}</p>}
|
||||||
|
|
||||||
|
<ShopAgreementCheckbox
|
||||||
|
agreed={agreed}
|
||||||
|
onChange={setAgreed}
|
||||||
|
labelRef={agreementRef}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shop-login-submit"
|
className="shop-login-submit"
|
||||||
@@ -300,24 +343,6 @@ export default function LoginPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="shop-login-agreement">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={agreed}
|
|
||||||
onChange={(e) => setAgreed(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
我已阅读并同意
|
|
||||||
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
|
|
||||||
《用户协议》
|
|
||||||
</Link>
|
|
||||||
与
|
|
||||||
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
|
|
||||||
《隐私政策》
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="shop-login-footer">
|
<footer className="shop-login-footer">
|
||||||
|
|||||||
@@ -188,7 +188,7 @@
|
|||||||
|
|
||||||
.shop-login-submit {
|
.shop-login-submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 32px;
|
margin-top: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -248,14 +248,18 @@
|
|||||||
|
|
||||||
.shop-login-agreement {
|
.shop-login-agreement {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-width: 280px;
|
max-width: 100%;
|
||||||
margin: 32px auto 0;
|
margin: 16px 0 0;
|
||||||
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shop-login-agreement input {
|
.shop-login-agreement input {
|
||||||
margin-top: 0;
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
accent-color: var(--color-heritage-red);
|
accent-color: var(--color-heritage-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +459,15 @@
|
|||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-quick-actions .shop-login-agreement {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-quick-actions .shop-quick-login-btn {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.shop-quick-login-btn {
|
.shop-quick-login-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import type { WechatLoginResult } from '@dukang/shared-types';
|
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||||
@@ -31,6 +31,7 @@ export default function LoginPage() {
|
|||||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
||||||
const [bindMode, setBindMode] = useState(false);
|
const [bindMode, setBindMode] = useState(false);
|
||||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||||
|
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||||
const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } =
|
const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } =
|
||||||
useSmsCode();
|
useSmsCode();
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ export default function LoginPage() {
|
|||||||
function ensureAgreed() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setMsg('请先勾选并同意用户协议');
|
setMsg('请先勾选并同意用户协议');
|
||||||
|
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -194,6 +196,23 @@ export default function LoginPage() {
|
|||||||
{displayMsg || sentHint}
|
{displayMsg || sentHint}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<label className="login-agreement" ref={agreementRef}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={agreed}
|
||||||
|
onChange={(e) => setAgreed(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
我已阅读并同意
|
||||||
|
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
|
||||||
|
《用户协议》
|
||||||
|
</Link>
|
||||||
|
和
|
||||||
|
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
|
||||||
|
《隐私政策》
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="login-sms-btn"
|
className="login-sms-btn"
|
||||||
@@ -219,26 +238,6 @@ export default function LoginPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="login-footer">
|
|
||||||
<label className="login-agreement">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={agreed}
|
|
||||||
onChange={(e) => setAgreed(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
我已阅读并同意
|
|
||||||
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
|
|
||||||
《用户协议》
|
|
||||||
</Link>
|
|
||||||
和
|
|
||||||
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
|
|
||||||
《隐私政策》
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1558,8 +1558,9 @@
|
|||||||
|
|
||||||
.login-agreement {
|
.login-agreement {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
margin: 12px 0 16px;
|
||||||
font-family: var(--font-label);
|
font-family: var(--font-label);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -1571,8 +1572,8 @@
|
|||||||
|
|
||||||
.login-agreement input {
|
.login-agreement input {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
width: 16px;
|
width: 18px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
accent-color: var(--color-heritage-red);
|
accent-color: var(--color-heritage-red);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,11 @@
|
|||||||
color: var(--hq-muted);
|
color: var(--hq-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-agreement {
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.login-remember {
|
.login-remember {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
|
|||||||
@@ -212,24 +212,6 @@ export default function LoginPage() {
|
|||||||
<Text>记住账号</Text>
|
<Text>记住账号</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button className="hq-btn hq-btn--primary hq-btn--block login-submit" loading={loading} onClick={smsLogin}>
|
|
||||||
登录
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{msg ? <Text className="login-msg">{msg}</Text> : null}
|
|
||||||
|
|
||||||
<View className="login-divider">
|
|
||||||
<Text>其他登录方式</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
className={`login-wechat-btn${wxLoading ? ' is-disabled' : ''}`}
|
|
||||||
onClick={wxLoading ? undefined : wechatLogin}
|
|
||||||
>
|
|
||||||
<WechatIcon />
|
|
||||||
<Text>{wxLoading ? '登录中...' : '微信一键授权'}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
||||||
<View className={`login-checkbox${agreed ? ' is-checked' : ''}`} />
|
<View className={`login-checkbox${agreed ? ' is-checked' : ''}`} />
|
||||||
<Text className="login-agreement-text">
|
<Text className="login-agreement-text">
|
||||||
@@ -255,6 +237,24 @@ export default function LoginPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{msg ? <Text className="login-msg">{msg}</Text> : null}
|
||||||
|
|
||||||
|
<Button className="hq-btn hq-btn--primary hq-btn--block login-submit" loading={loading} onClick={smsLogin}>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<View className="login-divider">
|
||||||
|
<Text>其他登录方式</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className={`login-wechat-btn${wxLoading ? ' is-disabled' : ''}`}
|
||||||
|
onClick={wxLoading ? undefined : wechatLogin}
|
||||||
|
>
|
||||||
|
<WechatIcon />
|
||||||
|
<Text>{wxLoading ? '登录中...' : '微信一键授权'}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="login-footer">
|
<View className="login-footer">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pnpm --filter @dukang/mini-user dev:weapp
|
|||||||
|
|
||||||
| 环节 | 文件 | 说明 |
|
| 环节 | 文件 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | 默认 `https://dkapi.runxian.top`;可用环境变量 `VITE_API_TARGET` 覆盖 |
|
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | 本地默认 `http://localhost:3000`;`NODE_ENV=production` 默认 `https://dkapi.runxian.top`;可用 `VITE_API_TARGET` 覆盖 |
|
||||||
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
||||||
|
|
||||||
### 微信登录 `invalid code`
|
### 微信登录 `invalid code`
|
||||||
@@ -30,17 +30,25 @@ pnpm --filter @dukang/mini-user dev:weapp
|
|||||||
| 小程序 appid | `project.config.json` → `wxda31c8e8e85051e7` |
|
| 小程序 appid | `project.config.json` → `wxda31c8e8e85051e7` |
|
||||||
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
|
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
|
||||||
|
|
||||||
**本地联调(不接真实微信)**:API 指到本机且开启 Mock:
|
**本地联调(不接真实微信)**:保持默认即可(API → `localhost:3000`),并开启 Mock:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 终端 1
|
# 终端 1
|
||||||
pnpm dev:api # server/.env 保持 MOCK_WECHAT=true
|
pnpm dev:api # server/.env 保持 MOCK_SMS=true / MOCK_WECHAT=true
|
||||||
|
|
||||||
# 终端 2
|
# 终端 2
|
||||||
$env:VITE_API_TARGET="http://localhost:3000"; pnpm dev:mini-user:weapp
|
pnpm dev:mini-user # H5 预览 :5177
|
||||||
|
# 或
|
||||||
|
pnpm --filter @dukang/mini-user dev:weapp
|
||||||
```
|
```
|
||||||
|
|
||||||
**连远程 API**:在 `dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret,并部署含 `code2Session` 小程序凭证逻辑的后端。
|
**连远程 API**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$env:VITE_API_TARGET="https://dkapi.runxian.top"; pnpm --filter @dukang/mini-user dev
|
||||||
|
```
|
||||||
|
|
||||||
|
并在 `dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。
|
||||||
|
|
||||||
## 页面结构(18 页)
|
## 页面结构(18 页)
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { createRequire } from 'node:module';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { defineConfig } from '@tarojs/cli';
|
import { defineConfig } from '@tarojs/cli';
|
||||||
|
|
||||||
/** 小程序/H5 请求的后端 origin(不含 /api);本地联调可用 VITE_API_TARGET 覆盖 */
|
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
||||||
const API_ORIGIN = process.env.VITE_API_TARGET ?? 'https://dkapi.runxian.top';
|
const API_ORIGIN =
|
||||||
|
process.env.VITE_API_TARGET ??
|
||||||
|
(process.env.NODE_ENV === 'production' ? 'https://dkapi.runxian.top' : 'http://localhost:3000');
|
||||||
|
|
||||||
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
||||||
|
|
||||||
|
|||||||
@@ -329,6 +329,33 @@ export default function LoginPage() {
|
|||||||
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
|
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
|
||||||
使用微信支付前需授权微信账号
|
使用微信支付前需授权微信账号
|
||||||
</Text>
|
</Text>
|
||||||
|
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
||||||
|
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
||||||
|
{agreed ? <Text>✓</Text> : null}
|
||||||
|
</View>
|
||||||
|
<Text className="login-agreement-text">
|
||||||
|
我已阅读并同意
|
||||||
|
<Text
|
||||||
|
className="login-agreement-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
《用户协议》
|
||||||
|
</Text>
|
||||||
|
和
|
||||||
|
<Text
|
||||||
|
className="login-agreement-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
《隐私政策》
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
{showWechatLogin ? (
|
{showWechatLogin ? (
|
||||||
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
|
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -376,6 +403,34 @@ export default function LoginPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
||||||
|
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
||||||
|
{agreed ? <Text>✓</Text> : null}
|
||||||
|
</View>
|
||||||
|
<Text className="login-agreement-text">
|
||||||
|
我已阅读并同意
|
||||||
|
<Text
|
||||||
|
className="login-agreement-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
《用户协议》
|
||||||
|
</Text>
|
||||||
|
和
|
||||||
|
<Text
|
||||||
|
className="login-agreement-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
《隐私政策》
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
||||||
onClick={loading ? undefined : () => void login()}
|
onClick={loading ? undefined : () => void login()}
|
||||||
@@ -415,36 +470,6 @@ export default function LoginPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="login-footer">
|
|
||||||
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
|
||||||
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
|
||||||
{agreed ? <Text>✓</Text> : null}
|
|
||||||
</View>
|
|
||||||
<Text className="login-agreement-text">
|
|
||||||
我已阅读并同意
|
|
||||||
<Text
|
|
||||||
className="login-agreement-link"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
《用户协议》
|
|
||||||
</Text>
|
|
||||||
和
|
|
||||||
<Text
|
|
||||||
className="login-agreement-link"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
《隐私政策》
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ type OrderPreview = {
|
|||||||
payAmount: number;
|
payAmount: number;
|
||||||
benefitAmount: number;
|
benefitAmount: number;
|
||||||
city?: { localMinQty: number; crossMinQty: number };
|
city?: { localMinQty: number; crossMinQty: number };
|
||||||
|
quantityOk?: boolean;
|
||||||
|
quantityMessage?: string | null;
|
||||||
|
minQty?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatAddress(a: Address) {
|
function formatAddress(a: Address) {
|
||||||
@@ -53,7 +56,7 @@ export default function OrderConfirmPage() {
|
|||||||
const checkoutCtx = readCheckoutContext(router.params);
|
const checkoutCtx = readCheckoutContext(router.params);
|
||||||
const productId = checkoutCtx.productId ?? '';
|
const productId = checkoutCtx.productId ?? '';
|
||||||
const forceCross = checkoutCtx.cross === true;
|
const forceCross = checkoutCtx.cross === true;
|
||||||
const [quantity, setQuantity] = useState(Math.max(2, Number(checkoutCtx.qty || 2)));
|
const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2)));
|
||||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||||
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
|
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
|
||||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||||
@@ -91,7 +94,7 @@ export default function OrderConfirmPage() {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setPreview(data);
|
setPreview(data);
|
||||||
setMsg('');
|
setMsg(data.quantityOk === false ? (data.quantityMessage || '') : '');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -115,18 +118,14 @@ export default function OrderConfirmPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
|
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
|
||||||
const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2);
|
const minQty =
|
||||||
|
preview?.minQty ??
|
||||||
|
(isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2));
|
||||||
|
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
|
||||||
|
const canSubmit = !!addressId && !!preview && quantityOk && !loading && !previewLoading;
|
||||||
|
|
||||||
function updateQuantity(next: number) {
|
function updateQuantity(next: number) {
|
||||||
if (next < minQty) {
|
if (next < 1) return;
|
||||||
setMsg(
|
|
||||||
!isCross
|
|
||||||
? `同城配送至少购买 ${minQty} 瓶`
|
|
||||||
: `跨城配送至少购买 ${minQty} 瓶(1箱)`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMsg('');
|
|
||||||
setQuantity(next);
|
setQuantity(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,8 +157,18 @@ export default function OrderConfirmPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!addressId) {
|
if (!canSubmit) {
|
||||||
setMsg('请选择收货地址');
|
if (!addressId) {
|
||||||
|
setMsg('请选择收货地址');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!quantityOk) {
|
||||||
|
setMsg(
|
||||||
|
isCross
|
||||||
|
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
|
||||||
|
: `同城配送至少购买 ${minQty} 瓶`,
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +215,13 @@ export default function OrderConfirmPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
|
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
|
||||||
|
const submitLabel = loading
|
||||||
|
? '提交中…'
|
||||||
|
: !addressId
|
||||||
|
? '请选择地址'
|
||||||
|
: !quantityOk
|
||||||
|
? `至少购买 ${minQty} 瓶`
|
||||||
|
: '提交订单';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
|
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
|
||||||
@@ -241,7 +257,8 @@ export default function OrderConfirmPage() {
|
|||||||
{isCross ? (
|
{isCross ? (
|
||||||
<View className="order-card">
|
<View className="order-card">
|
||||||
<Text className="u-muted">
|
<Text className="u-muted">
|
||||||
该地址超出同城配送范围,将由总部物流发货,运费到付。
|
该地址超出同城配送范围,将由总部物流发货,运费到付
|
||||||
|
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}。
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -269,7 +286,7 @@ export default function OrderConfirmPage() {
|
|||||||
<Text>购买数量</Text>
|
<Text>购买数量</Text>
|
||||||
<View className="order-qty-controls">
|
<View className="order-qty-controls">
|
||||||
<View
|
<View
|
||||||
className="order-qty-btn"
|
className={`order-qty-btn${quantity <= 1 ? ' order-qty-btn--disabled' : ''}`}
|
||||||
onClick={() => updateQuantity(quantity - 1)}
|
onClick={() => updateQuantity(quantity - 1)}
|
||||||
>
|
>
|
||||||
<Text>−</Text>
|
<Text>−</Text>
|
||||||
@@ -283,6 +300,13 @@ export default function OrderConfirmPage() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
{!quantityOk ? (
|
||||||
|
<Text className="order-qty-hint">
|
||||||
|
{isCross
|
||||||
|
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
|
||||||
|
: `同城配送至少购买 ${minQty} 瓶,请调整数量`}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="order-card">
|
<View className="order-card">
|
||||||
@@ -320,10 +344,13 @@ export default function OrderConfirmPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
className="order-confirm-submit"
|
className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
|
||||||
onClick={() => !loading && void submit()}
|
onClick={() => {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text>{loading ? '提交中…' : !addressId ? '请选择地址' : '提交订单'}</Text>
|
<Text>{submitLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -306,8 +306,9 @@
|
|||||||
|
|
||||||
.login-agreement {
|
.login-agreement {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
margin: 12px 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-agreement-check {
|
.login-agreement-check {
|
||||||
|
|||||||
@@ -93,6 +93,11 @@
|
|||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-qty-btn--disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.order-qty-value {
|
.order-qty-value {
|
||||||
margin: 0 14px;
|
margin: 0 14px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -101,6 +106,14 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-qty-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.order-row {
|
.order-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -180,6 +193,13 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-confirm-submit--disabled {
|
||||||
|
background: #c9c5c0;
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0.85;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.order-tabs {
|
.order-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0 var(--space-page);
|
padding: 0 var(--space-page);
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=8192}"
|
|||||||
# 生产部署 path:nginx 入口为 /user/
|
# 生产部署 path:nginx 入口为 /user/
|
||||||
export TARO_H5_PUBLIC_PATH="${TARO_H5_PUBLIC_PATH:-/user/}"
|
export TARO_H5_PUBLIC_PATH="${TARO_H5_PUBLIC_PATH:-/user/}"
|
||||||
export TARO_H5_ROUTER_BASENAME="${TARO_H5_ROUTER_BASENAME:-/user}"
|
export TARO_H5_ROUTER_BASENAME="${TARO_H5_ROUTER_BASENAME:-/user}"
|
||||||
|
# C 端 H5 编译期注入的 API origin(勿落到 localhost)
|
||||||
|
export VITE_API_TARGET="${VITE_API_TARGET:-https://dkapi.runxian.top}"
|
||||||
pnpm approve-builds --all 2>/dev/null || true
|
pnpm approve-builds --all 2>/dev/null || true
|
||||||
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||||
|
|
||||||
|
|||||||
@@ -89,3 +89,9 @@ export const PARTNER_PERMISSION_LABELS: Record<PartnerPermissionKey, string> = {
|
|||||||
'store:create': '开店管理',
|
'store:create': '开店管理',
|
||||||
'order:view': '订单查看',
|
'order:view': '订单查看',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 门店类子账号默认权限:录入、开闭店、维护资料 */
|
||||||
|
export const DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS: PartnerPermissionKey[] = [
|
||||||
|
'store:create',
|
||||||
|
'store:manage',
|
||||||
|
];
|
||||||
|
|||||||
@@ -194,8 +194,8 @@ export const FULFILLMENT_PROVIDER_STATUS_LABELS: Record<FulfillmentProviderStatu
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const WAREHOUSE_FULFILLMENT_MODE_LABELS: Record<WarehouseFulfillmentMode, string> = {
|
export const WAREHOUSE_FULFILLMENT_MODE_LABELS: Record<WarehouseFulfillmentMode, string> = {
|
||||||
[WarehouseFulfillmentMode.API_AUTO]: 'API 自动推单',
|
[WarehouseFulfillmentMode.API_AUTO]: '自动发货',
|
||||||
[WarehouseFulfillmentMode.MANUAL]: '自管手工填单',
|
[WarehouseFulfillmentMode.MANUAL]: '关闭(手工填单)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum AccountStatus {
|
export enum AccountStatus {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly s
|
|||||||
'partner_store_audit_rejected',
|
'partner_store_audit_rejected',
|
||||||
],
|
],
|
||||||
shipping: ['partner_order_ship', 'partner_delivery_advance'],
|
shipping: ['partner_order_ship', 'partner_delivery_advance'],
|
||||||
settlement: ['partner_bill_view', 'partner_bill_detail_view'],
|
settlement: ['partner_bill_view', 'partner_bill_detail_view', 'partner_bill_confirm'],
|
||||||
warehouse_ops: ['partner_warehouse_view', 'partner_warehouse_update'],
|
warehouse_ops: ['partner_warehouse_view', 'partner_warehouse_update'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export interface PartnerMe {
|
|||||||
/** 子账号联系主账号用 */
|
/** 子账号联系主账号用 */
|
||||||
primaryPhone?: string;
|
primaryPhone?: string;
|
||||||
primaryName?: string;
|
primaryName?: string;
|
||||||
|
/** 主账号绑定的管仓 ID(有则本城酒单推送至该仓) */
|
||||||
|
managedWarehouseId?: string | null;
|
||||||
|
/** 是否已配置仓库管理(无则不展示/不推送酒订单) */
|
||||||
|
hasWarehouseAccess?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdatePartnerMeRequest {
|
export interface UpdatePartnerMeRequest {
|
||||||
|
|||||||
@@ -7,21 +7,24 @@ export interface StorePayoutDto {
|
|||||||
paidAt?: string | null;
|
paidAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PartnerBillStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'REJECTED';
|
||||||
|
|
||||||
export interface PartnerBillDto {
|
export interface PartnerBillDto {
|
||||||
id: string;
|
id: string;
|
||||||
billNo: string;
|
billNo: string;
|
||||||
orderCommission: number;
|
orderCommission: number;
|
||||||
redeemCommission: number;
|
redeemCommission: number;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
status: 'DRAFT' | 'CONFIRMED' | 'PAID';
|
status: PartnerBillStatus;
|
||||||
periodStart: string;
|
periodStart: string;
|
||||||
periodEnd: string;
|
periodEnd: string;
|
||||||
|
confirmedAt?: string | null;
|
||||||
|
paidAt?: string | null;
|
||||||
|
rejectReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartnerBillDetailDto extends PartnerBillDto {
|
export interface PartnerBillDetailDto extends PartnerBillDto {
|
||||||
partnerId: string;
|
partnerId: string;
|
||||||
confirmedAt?: string | null;
|
|
||||||
paidAt?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 门店结算默认比例(核销金额 × 比例) */
|
/** 门店结算默认比例(核销金额 × 比例) */
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const APP_ROOT = path.resolve(__dirname, '../apps/mini-user');
|
const APP_ROOT = path.resolve(__dirname, '../apps/mini-user');
|
||||||
const DIST = path.resolve(APP_ROOT, 'dist');
|
const DIST = path.resolve(APP_ROOT, 'dist');
|
||||||
const PORT = Number(process.env.PORT || 5177);
|
const PORT = Number(process.env.PORT || 5177);
|
||||||
const API_ORIGIN = (process.env.VITE_API_TARGET ?? 'https://dkapi.runxian.top').replace(/\/$/, '');
|
const API_ORIGIN = (process.env.VITE_API_TARGET ?? 'http://localhost:3000').replace(/\/$/, '');
|
||||||
const TARO_BIN = path.resolve(APP_ROOT, 'node_modules/@tarojs/cli/bin/taro');
|
const TARO_BIN = path.resolve(APP_ROOT, 'node_modules/@tarojs/cli/bin/taro');
|
||||||
|
|
||||||
const MIME = {
|
const MIME = {
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ enum PartnerBillStatus {
|
|||||||
DRAFT
|
DRAFT
|
||||||
CONFIRMED
|
CONFIRMED
|
||||||
PAID
|
PAID
|
||||||
|
REJECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum StoreStatus {
|
enum StoreStatus {
|
||||||
@@ -623,6 +624,7 @@ model PartnerBill {
|
|||||||
status PartnerBillStatus @default(DRAFT)
|
status PartnerBillStatus @default(DRAFT)
|
||||||
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
|
confirmedAt DateTime? @map("confirmed_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)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
|
||||||
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
|
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ async function main() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const xfxProvider = await prisma.fulfillmentProvider.create({
|
await prisma.fulfillmentProvider.create({
|
||||||
data: {
|
data: {
|
||||||
code: 'XFX',
|
code: 'XFX',
|
||||||
name: '小飞侠',
|
name: '小飞侠',
|
||||||
@@ -218,9 +218,9 @@ async function main() {
|
|||||||
|
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
|
||||||
fulfillmentMode: 'API_AUTO',
|
fulfillmentMode: 'MANUAL',
|
||||||
|
|
||||||
fulfillmentProviderId: xfxProvider.id,
|
fulfillmentProviderId: null,
|
||||||
|
|
||||||
lng: 113.665,
|
lng: 113.665,
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const HqOperationAction = {
|
|||||||
PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE',
|
PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE',
|
||||||
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_REJECT: 'PARTNER_BILL_REJECT',
|
||||||
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',
|
||||||
@@ -108,6 +109,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单',
|
[HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单',
|
||||||
[HqOperationAction.PARTNER_BILL_CONFIRM]: '确认合伙人账单',
|
[HqOperationAction.PARTNER_BILL_CONFIRM]: '确认合伙人账单',
|
||||||
[HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算',
|
[HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算',
|
||||||
|
[HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请',
|
||||||
[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]: '创建推广码',
|
||||||
|
|||||||
@@ -103,10 +103,16 @@ export class PartnerCityService {
|
|||||||
if (!primary.cityId) return { id: -1n };
|
if (!primary.cityId) return { id: -1n };
|
||||||
|
|
||||||
const warehouseIds = await this.resolveManagedWarehouseIds(primary.id);
|
const warehouseIds = await this.resolveManagedWarehouseIds(primary.id);
|
||||||
if (warehouseIds.length > 0) {
|
// 未配置管仓:不推送/不展示酒订单(同城无仓由总部履约)
|
||||||
return { fulfillmentWarehouseId: { in: warehouseIds } };
|
if (warehouseIds.length === 0) {
|
||||||
|
return { id: -1n };
|
||||||
}
|
}
|
||||||
return { cityId: primary.cityId };
|
return { fulfillmentWarehouseId: { in: warehouseIds } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasManagedWarehouse(partnerAccountId: bigint): Promise<boolean> {
|
||||||
|
const ids = await this.resolveManagedWarehouseIds(partnerAccountId);
|
||||||
|
return ids.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 合伙人可管仓库:主账号 managedWarehouseId + 绑定为管仓合伙人的仓 */
|
/** 合伙人可管仓库:主账号 managedWarehouseId + 绑定为管仓合伙人的仓 */
|
||||||
|
|||||||
@@ -178,11 +178,7 @@ export class FulfillmentService {
|
|||||||
});
|
});
|
||||||
if (!order) throw new NotFoundException('订单不存在');
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
|
|
||||||
const isHqQueue =
|
// HQ 可对任意待发货单填快递单号(含仓配单手动填单)
|
||||||
order.deliveryType === 'CROSS_CITY' ||
|
|
||||||
(order.deliveryType === 'LOCAL' && !order.fulfillmentWarehouseId);
|
|
||||||
|
|
||||||
if (!isHqQueue) throw new BadRequestException('该订单由仓配履约,请使用仓配发货');
|
|
||||||
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
||||||
throw new BadRequestException('当前订单状态不可发货');
|
throw new BadRequestException('当前订单状态不可发货');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,240 +1,245 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ClientApp, PartnerStaffRole, SmsScene } from '@dukang/shared-types';
|
import { ClientApp, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS, PartnerStaffRole, SmsScene } 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 type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
import { AnalyticsService } from '../analytics/analytics.service';
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
|
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PartnerStaffService {
|
export class PartnerStaffService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly analytics: AnalyticsService,
|
private readonly analytics: AnalyticsService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listStaff(parentAccountId: bigint) {
|
async listStaff(parentAccountId: bigint) {
|
||||||
const rows = await this.prisma.partnerAccount.findMany({
|
const rows = await this.prisma.partnerAccount.findMany({
|
||||||
where: { parentAccountId },
|
where: { parentAccountId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
return rows.map((row) => this.toStaffItem(row));
|
return rows.map((row) => this.toStaffItem(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendStaffPhoneSms(actor: AuthUser, phone: string) {
|
async sendStaffPhoneSms(actor: AuthUser, phone: string) {
|
||||||
const parentAccountId = actor.actorId;
|
const parentAccountId = actor.actorId;
|
||||||
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||||
where: { id: parentAccountId },
|
where: { id: parentAccountId },
|
||||||
});
|
});
|
||||||
if (parent.isPrimary !== 1) {
|
if (parent.isPrimary !== 1) {
|
||||||
throw new BadRequestException('仅主账号可添加子账号');
|
throw new BadRequestException('仅主账号可添加子账号');
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = phone.trim();
|
const normalized = phone.trim();
|
||||||
if (!/^1[3-9]\d{9}$/.test(normalized)) {
|
if (!/^1[3-9]\d{9}$/.test(normalized)) {
|
||||||
throw new BadRequestException('请输入正确的手机号码');
|
throw new BadRequestException('请输入正确的手机号码');
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } });
|
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } });
|
||||||
if (existing) throw new BadRequestException('该手机号已被使用');
|
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||||
|
|
||||||
const masked = this.maskPhone(normalized);
|
const masked = this.maskPhone(normalized);
|
||||||
try {
|
try {
|
||||||
await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, {
|
await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, {
|
||||||
clientApp: ClientApp.PARTNER_H5,
|
clientApp: ClientApp.PARTNER_H5,
|
||||||
});
|
});
|
||||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
|
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
|
||||||
phone: masked,
|
phone: masked,
|
||||||
scene: SmsScene.PARTNER_STAFF_ADD,
|
scene: SmsScene.PARTNER_STAFF_ADD,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof BadRequestException) {
|
if (err instanceof BadRequestException) {
|
||||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
|
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
|
||||||
phone: masked,
|
phone: masked,
|
||||||
scene: SmsScene.PARTNER_STAFF_ADD,
|
scene: SmsScene.PARTNER_STAFF_ADD,
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
reason: err.message,
|
reason: err.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, maskedPhone: masked };
|
return { ok: true, maskedPhone: masked };
|
||||||
}
|
}
|
||||||
|
|
||||||
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
|
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
|
||||||
const parentAccountId = actor.actorId;
|
const parentAccountId = actor.actorId;
|
||||||
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||||
where: { id: parentAccountId },
|
where: { id: parentAccountId },
|
||||||
});
|
});
|
||||||
if (parent.isPrimary !== 1) {
|
if (parent.isPrimary !== 1) {
|
||||||
throw new BadRequestException('仅主账号可添加子账号');
|
throw new BadRequestException('仅主账号可添加子账号');
|
||||||
}
|
}
|
||||||
|
|
||||||
const phone = dto.phone.trim();
|
const phone = dto.phone.trim();
|
||||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||||
throw new BadRequestException('请输入正确的手机号码');
|
throw new BadRequestException('请输入正确的手机号码');
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
||||||
if (existing) throw new BadRequestException('该手机号已被使用');
|
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||||
|
|
||||||
const smsCode = dto.smsCode.trim();
|
const smsCode = dto.smsCode.trim();
|
||||||
if (!smsCode) throw new BadRequestException('请输入手机号验证码');
|
if (!smsCode) throw new BadRequestException('请输入手机号验证码');
|
||||||
try {
|
try {
|
||||||
await this.authService.verifySmsCode(phone, smsCode, SmsScene.PARTNER_STAFF_ADD);
|
await this.authService.verifySmsCode(phone, smsCode, SmsScene.PARTNER_STAFF_ADD);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_verify_fail', parentAccountId, {
|
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_verify_fail', parentAccountId, {
|
||||||
phone: this.maskPhone(phone),
|
phone: this.maskPhone(phone),
|
||||||
reason: err instanceof BadRequestException ? err.message : '验证码错误',
|
reason: err instanceof BadRequestException ? err.message : '验证码错误',
|
||||||
});
|
});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = dto.name.trim();
|
const name = dto.name.trim();
|
||||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||||
|
|
||||||
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL;
|
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL;
|
||||||
|
const permissions =
|
||||||
const account = await this.prisma.partnerAccount.create({
|
dto.permissions && dto.permissions.length > 0
|
||||||
data: {
|
? dto.permissions
|
||||||
phone,
|
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS];
|
||||||
name,
|
|
||||||
staffRole,
|
const account = await this.prisma.partnerAccount.create({
|
||||||
permissions: dto.permissions ?? undefined,
|
data: {
|
||||||
isPrimary: 0,
|
phone,
|
||||||
parentAccountId: parent.id,
|
name,
|
||||||
status: 'DISABLED',
|
staffRole,
|
||||||
},
|
permissions,
|
||||||
});
|
isPrimary: 0,
|
||||||
|
parentAccountId: parent.id,
|
||||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
|
status: 'DISABLED',
|
||||||
name,
|
},
|
||||||
phone: this.maskPhone(phone),
|
});
|
||||||
staffRole,
|
|
||||||
status: account.status,
|
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
|
||||||
phoneVerified: true,
|
name,
|
||||||
});
|
phone: this.maskPhone(phone),
|
||||||
|
staffRole,
|
||||||
return this.toStaffItem(account);
|
permissions,
|
||||||
}
|
status: account.status,
|
||||||
|
phoneVerified: true,
|
||||||
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdatePartnerStaffDto) {
|
});
|
||||||
const parentAccountId = actor.actorId;
|
|
||||||
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
return this.toStaffItem(account);
|
||||||
const before = {
|
}
|
||||||
name: staff.name,
|
|
||||||
staffRole: staff.staffRole,
|
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdatePartnerStaffDto) {
|
||||||
status: staff.status,
|
const parentAccountId = actor.actorId;
|
||||||
};
|
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
||||||
const data: Record<string, unknown> = {};
|
const before = {
|
||||||
if (dto.name !== undefined) {
|
name: staff.name,
|
||||||
const name = dto.name.trim();
|
staffRole: staff.staffRole,
|
||||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
status: staff.status,
|
||||||
data.name = name;
|
};
|
||||||
}
|
const data: Record<string, unknown> = {};
|
||||||
if (dto.staffRole !== undefined) {
|
if (dto.name !== undefined) {
|
||||||
data.staffRole = dto.staffRole as PartnerStaffRole;
|
const name = dto.name.trim();
|
||||||
}
|
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||||
if (dto.permissions !== undefined) {
|
data.name = name;
|
||||||
data.permissions = dto.permissions;
|
}
|
||||||
}
|
if (dto.staffRole !== undefined) {
|
||||||
if (dto.status !== undefined) {
|
data.staffRole = dto.staffRole as PartnerStaffRole;
|
||||||
data.status = dto.status;
|
}
|
||||||
}
|
if (dto.permissions !== undefined) {
|
||||||
const updated = await this.prisma.partnerAccount.update({
|
data.permissions = dto.permissions;
|
||||||
where: { id: staff.id },
|
}
|
||||||
data,
|
if (dto.status !== undefined) {
|
||||||
});
|
data.status = dto.status;
|
||||||
|
}
|
||||||
const onlyRoleChange =
|
const updated = await this.prisma.partnerAccount.update({
|
||||||
(dto.staffRole !== undefined || dto.permissions !== undefined) &&
|
where: { id: staff.id },
|
||||||
dto.name === undefined &&
|
data,
|
||||||
dto.status === undefined;
|
});
|
||||||
const eventName = onlyRoleChange ? 'partner_staff_permission_update' : 'partner_staff_update';
|
|
||||||
|
const onlyRoleChange =
|
||||||
const primaryId = parentAccountId;
|
(dto.staffRole !== undefined || dto.permissions !== undefined) &&
|
||||||
this.trackStaffEvent(actor, primaryId, eventName, staff.id, {
|
dto.name === undefined &&
|
||||||
before,
|
dto.status === undefined;
|
||||||
after: {
|
const eventName = onlyRoleChange ? 'partner_staff_permission_update' : 'partner_staff_update';
|
||||||
name: updated.name,
|
|
||||||
staffRole: updated.staffRole,
|
const primaryId = parentAccountId;
|
||||||
status: updated.status,
|
this.trackStaffEvent(actor, primaryId, eventName, staff.id, {
|
||||||
},
|
before,
|
||||||
});
|
after: {
|
||||||
|
name: updated.name,
|
||||||
return this.toStaffItem(updated);
|
staffRole: updated.staffRole,
|
||||||
}
|
status: updated.status,
|
||||||
|
},
|
||||||
async deleteStaff(actor: AuthUser, staffId: bigint) {
|
});
|
||||||
const parentAccountId = actor.actorId;
|
|
||||||
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
return this.toStaffItem(updated);
|
||||||
|
}
|
||||||
this.trackStaffEvent(actor, parentAccountId, 'partner_staff_delete', staff.id, {
|
|
||||||
name: staff.name,
|
async deleteStaff(actor: AuthUser, staffId: bigint) {
|
||||||
phone: this.maskPhone(staff.phone),
|
const parentAccountId = actor.actorId;
|
||||||
staffRole: staff.staffRole,
|
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
||||||
status: staff.status,
|
|
||||||
});
|
this.trackStaffEvent(actor, parentAccountId, 'partner_staff_delete', staff.id, {
|
||||||
|
name: staff.name,
|
||||||
await this.prisma.partnerAccount.delete({ where: { id: staff.id } });
|
phone: this.maskPhone(staff.phone),
|
||||||
return { ok: true };
|
staffRole: staff.staffRole,
|
||||||
}
|
status: staff.status,
|
||||||
|
});
|
||||||
private trackStaffEvent(
|
|
||||||
actor: AuthUser,
|
await this.prisma.partnerAccount.delete({ where: { id: staff.id } });
|
||||||
primaryAccountId: bigint,
|
return { ok: true };
|
||||||
eventName: string,
|
}
|
||||||
refId: bigint,
|
|
||||||
extraJson?: Record<string, unknown>,
|
private trackStaffEvent(
|
||||||
) {
|
actor: AuthUser,
|
||||||
this.analytics.trackPartnerOneSafe(actor.actorId, actor.clientApp, {
|
primaryAccountId: bigint,
|
||||||
partnerAccountId: primaryAccountId,
|
eventName: string,
|
||||||
eventName,
|
refId: bigint,
|
||||||
refType: 'PARTNER_ACCOUNT',
|
extraJson?: Record<string, unknown>,
|
||||||
refId,
|
) {
|
||||||
extraJson,
|
this.analytics.trackPartnerOneSafe(actor.actorId, actor.clientApp, {
|
||||||
});
|
partnerAccountId: primaryAccountId,
|
||||||
}
|
eventName,
|
||||||
|
refType: 'PARTNER_ACCOUNT',
|
||||||
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
|
refId,
|
||||||
const staff = await this.prisma.partnerAccount.findFirst({
|
extraJson,
|
||||||
where: { id: staffId, parentAccountId },
|
});
|
||||||
});
|
}
|
||||||
if (!staff) throw new NotFoundException('子账号不存在');
|
|
||||||
return staff;
|
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
|
||||||
}
|
const staff = await this.prisma.partnerAccount.findFirst({
|
||||||
|
where: { id: staffId, parentAccountId },
|
||||||
private toStaffItem(row: {
|
});
|
||||||
id: bigint;
|
if (!staff) throw new NotFoundException('子账号不存在');
|
||||||
name: string;
|
return staff;
|
||||||
phone: string;
|
}
|
||||||
staffRole: string | null;
|
|
||||||
permissions?: unknown;
|
private toStaffItem(row: {
|
||||||
status: string;
|
id: bigint;
|
||||||
lastLoginAt: Date | null;
|
name: string;
|
||||||
}) {
|
phone: string;
|
||||||
return serializeBigInt({
|
staffRole: string | null;
|
||||||
id: row.id.toString(),
|
permissions?: unknown;
|
||||||
name: row.name,
|
status: string;
|
||||||
phone: this.maskPhone(row.phone),
|
lastLoginAt: Date | null;
|
||||||
staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL,
|
}) {
|
||||||
permissions: Array.isArray(row.permissions) ? row.permissions : undefined,
|
return serializeBigInt({
|
||||||
status: row.status,
|
id: row.id.toString(),
|
||||||
lastLoginAt: row.lastLoginAt?.toISOString(),
|
name: row.name,
|
||||||
});
|
phone: this.maskPhone(row.phone),
|
||||||
}
|
staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL,
|
||||||
|
permissions: Array.isArray(row.permissions) ? row.permissions : undefined,
|
||||||
private maskPhone(phone: string): string {
|
status: row.status,
|
||||||
if (phone.length !== 11) return phone;
|
lastLoginAt: row.lastLoginAt?.toISOString(),
|
||||||
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private maskPhone(phone: string): string {
|
||||||
|
if (phone.length !== 11) return phone;
|
||||||
|
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export class AdminDashboardService {
|
|||||||
deliveriesTotal,
|
deliveriesTotal,
|
||||||
pendingPayouts,
|
pendingPayouts,
|
||||||
pendingBills,
|
pendingBills,
|
||||||
|
pendingPartnerDraftBills,
|
||||||
openTickets,
|
openTickets,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
|
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
|
||||||
@@ -42,7 +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: { in: ['DRAFT', 'CONFIRMED'] } } }),
|
this.prisma.partnerBill.count({ where: { status: 'CONFIRMED' } }),
|
||||||
|
this.prisma.partnerBill.count({ where: { status: 'DRAFT' } }),
|
||||||
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
|
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -57,7 +59,9 @@ export class AdminDashboardService {
|
|||||||
redeemToday,
|
redeemToday,
|
||||||
deliveriesTotal,
|
deliveriesTotal,
|
||||||
pendingPayouts,
|
pendingPayouts,
|
||||||
|
/** 合伙人已确认、待总部审核打款 */
|
||||||
pendingBills,
|
pendingBills,
|
||||||
|
pendingPartnerDraftBills,
|
||||||
openTickets,
|
openTickets,
|
||||||
ordersByStatus: ordersByStatus.map((row) => ({
|
ordersByStatus: ordersByStatus.map((row) => ({
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export class AdminOrdersService {
|
|||||||
if (query.orderNo) where.orderNo = { contains: query.orderNo };
|
if (query.orderNo) where.orderNo = { contains: query.orderNo };
|
||||||
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
|
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
|
||||||
if (query.userId) where.userId = BigInt(query.userId);
|
if (query.userId) where.userId = BigInt(query.userId);
|
||||||
|
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||||
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
|
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
|
||||||
if (query.createdFrom || query.createdTo) {
|
if (query.createdFrom || query.createdTo) {
|
||||||
where.createdAt = {};
|
where.createdAt = {};
|
||||||
@@ -46,6 +47,8 @@ export class AdminOrdersService {
|
|||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||||
delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } },
|
delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } },
|
||||||
|
city: { select: { id: true, name: true, code: true } },
|
||||||
|
fulfillmentWarehouse: { select: { id: true, name: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.order.count({ where }),
|
this.prisma.order.count({ where }),
|
||||||
@@ -75,6 +78,18 @@ export class AdminOrdersService {
|
|||||||
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: { id: true, url: true } },
|
||||||
|
fulfillmentWarehouse: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
contactName: true,
|
||||||
|
contactPhone: true,
|
||||||
|
address: true,
|
||||||
|
lng: true,
|
||||||
|
lat: true,
|
||||||
|
fulfillmentMode: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!order) throw new NotFoundException('订单不存在');
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
@@ -101,7 +116,7 @@ export class AdminOrdersService {
|
|||||||
throw new BadRequestException('暂仅支持小飞侠配送');
|
throw new BadRequestException('暂仅支持小飞侠配送');
|
||||||
}
|
}
|
||||||
|
|
||||||
const order = await this.prisma.order.findUnique({
|
let order = await this.prisma.order.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
delivery: true,
|
delivery: true,
|
||||||
@@ -117,7 +132,30 @@ export class AdminOrdersService {
|
|||||||
throw new BadRequestException('该订单已有运单号,请勿重复发货');
|
throw new BadRequestException('该订单已有运单号,请勿重复发货');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.warehouseId) {
|
||||||
|
const warehouseId = BigInt(dto.warehouseId);
|
||||||
|
const warehouseRow = await this.prisma.cityWarehouse.findFirst({
|
||||||
|
where: { id: warehouseId, status: 'ACTIVE' },
|
||||||
|
});
|
||||||
|
if (!warehouseRow) {
|
||||||
|
throw new BadRequestException('仓库不存在或已停用');
|
||||||
|
}
|
||||||
|
if (order.fulfillmentWarehouseId !== warehouseId) {
|
||||||
|
await this.prisma.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: { fulfillmentWarehouseId: warehouseId },
|
||||||
|
});
|
||||||
|
order = await this.prisma.order.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: { delivery: true, fulfillmentWarehouse: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const warehouse = order.fulfillmentWarehouse;
|
const warehouse = order.fulfillmentWarehouse;
|
||||||
|
if (!warehouse) {
|
||||||
|
throw new BadRequestException('请先选择履约仓库');
|
||||||
|
}
|
||||||
const providerId =
|
const providerId =
|
||||||
order.delivery?.fulfillmentProviderId ??
|
order.delivery?.fulfillmentProviderId ??
|
||||||
warehouse?.fulfillmentProviderId ??
|
warehouse?.fulfillmentProviderId ??
|
||||||
|
|||||||
@@ -711,6 +711,11 @@ export class AdminShipOrderDto {
|
|||||||
@IsIn(['XFX'])
|
@IsIn(['XFX'])
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|
||||||
|
/** 可选:指定/改派履约仓后再推仓配 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
warehouseId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
fromName?: string;
|
fromName?: string;
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cityId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
receiverPhone?: string;
|
receiverPhone?: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
import { DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
|
||||||
import { SettlementService } from './settlement.service';
|
import { SettlementService } from './settlement.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';
|
||||||
@@ -9,6 +10,7 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
|||||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
|
|
||||||
class UpdatePartnerMeDto {
|
class UpdatePartnerMeDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -25,6 +27,11 @@ export class SettlementController {
|
|||||||
bills(@CurrentUser() user: AuthUser) {
|
bills(@CurrentUser() user: AuthUser) {
|
||||||
return this.settlementService.listPartnerBills(user.actorId);
|
return this.settlementService.listPartnerBills(user.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('bills/:id/confirm')
|
||||||
|
confirm(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.settlementService.partnerConfirmBill(user.actorId, BigInt(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('shop/payouts')
|
@Controller('shop/payouts')
|
||||||
@@ -193,6 +200,20 @@ export class AdminPartnerBillController {
|
|||||||
markPaid(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
|
markPaid(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
|
||||||
return this.settlementService.markPartnerBillPaid(BigInt(id), body);
|
return this.settlementService.markPartnerBillPaid(BigInt(id), body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/reject')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.PARTNER_BILL_REJECT,
|
||||||
|
refType: 'PARTNER_BILL',
|
||||||
|
refIdParam: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
reject(@Param('id') id: string, @Body() body: { reason?: string }) {
|
||||||
|
if (!body?.reason?.trim()) {
|
||||||
|
throw new BadRequestException('请填写驳回理由');
|
||||||
|
}
|
||||||
|
return this.settlementService.rejectPartnerBill(BigInt(id), body.reason);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('admin/winery-bills')
|
@Controller('admin/winery-bills')
|
||||||
@@ -224,7 +245,10 @@ export class AdminWineryBillController {
|
|||||||
@Controller('partner/me')
|
@Controller('partner/me')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class PartnerMeController {
|
export class PartnerMeController {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly partnerCityService: PartnerCityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async me(@CurrentUser() user: AuthUser) {
|
async me(@CurrentUser() user: AuthUser) {
|
||||||
@@ -246,7 +270,7 @@ export class PartnerMeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async buildPartnerMe(actorId: bigint) {
|
private async buildPartnerMe(actorId: bigint) {
|
||||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
let account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||||
where: { id: actorId },
|
where: { id: actorId },
|
||||||
});
|
});
|
||||||
let primary = account;
|
let primary = account;
|
||||||
@@ -255,6 +279,24 @@ export class PartnerMeController {
|
|||||||
where: { id: account.parentAccountId },
|
where: { id: account.parentAccountId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 门店类子账号若未配置权限,补齐开店/门店管理,便于开闭店与重传资料
|
||||||
|
if (account.isPrimary !== 1) {
|
||||||
|
const perms = Array.isArray(account.permissions) ? (account.permissions as string[]) : [];
|
||||||
|
const hasStorePerm = perms.includes('store:create') || perms.includes('store:manage');
|
||||||
|
const warehouseOnly =
|
||||||
|
!hasStorePerm &&
|
||||||
|
perms.length > 0 &&
|
||||||
|
(perms.includes('warehouse:manage') || perms.includes('order:view'));
|
||||||
|
if (!hasStorePerm && !warehouseOnly) {
|
||||||
|
account = await this.prisma.partnerAccount.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: { permissions: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasWarehouseAccess = await this.partnerCityService.hasManagedWarehouse(primary.id);
|
||||||
return {
|
return {
|
||||||
id: account.id.toString(),
|
id: account.id.toString(),
|
||||||
name: account.name,
|
name: account.name,
|
||||||
@@ -269,6 +311,8 @@ export class PartnerMeController {
|
|||||||
hasWechat: !!account.wxOpenId,
|
hasWechat: !!account.wxOpenId,
|
||||||
wxNickname: account.wxNickname ?? undefined,
|
wxNickname: account.wxNickname ?? undefined,
|
||||||
wxAvatarUrl: account.wxAvatarUrl ?? undefined,
|
wxAvatarUrl: account.wxAvatarUrl ?? undefined,
|
||||||
|
managedWarehouseId: primary.managedWarehouseId?.toString() ?? null,
|
||||||
|
hasWarehouseAccess,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -529,11 +529,70 @@ export class SettlementService {
|
|||||||
async confirmPartnerBill(id: bigint) {
|
async confirmPartnerBill(id: bigint) {
|
||||||
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
|
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
|
||||||
if (!bill) throw new NotFoundException('账单不存在');
|
if (!bill) throw new NotFoundException('账单不存在');
|
||||||
if (bill.status !== 'DRAFT') throw new BadRequestException('仅草稿可确认');
|
if (bill.status !== 'DRAFT' && bill.status !== 'REJECTED') {
|
||||||
|
throw new BadRequestException('仅待确认或已驳回账单可确认');
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await this.prisma.partnerBill.update({
|
const updated = await this.prisma.partnerBill.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'CONFIRMED', confirmedAt: new Date() },
|
data: {
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
confirmedAt: new Date(),
|
||||||
|
rejectReason: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return serializeBigInt(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合伙人端:确认账单并申请打款(DRAFT/REJECTED → CONFIRMED) */
|
||||||
|
async partnerConfirmBill(partnerAccountId: bigint, billId: bigint) {
|
||||||
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
|
const bill = await this.prisma.partnerBill.findUnique({ where: { id: billId } });
|
||||||
|
if (!bill || bill.partnerAccountId !== primary.id) {
|
||||||
|
throw new NotFoundException('账单不存在');
|
||||||
|
}
|
||||||
|
if (bill.status !== 'DRAFT' && bill.status !== 'REJECTED') {
|
||||||
|
throw new BadRequestException('当前账单不可再次申请打款');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.partnerBill.update({
|
||||||
|
where: { id: billId },
|
||||||
|
data: {
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
confirmedAt: new Date(),
|
||||||
|
rejectReason: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||||
|
partnerAccountId: primary.id,
|
||||||
|
eventName: 'partner_bill_confirm',
|
||||||
|
refType: 'PARTNER_BILL',
|
||||||
|
refId: billId,
|
||||||
|
extraJson: { billNo: bill.billNo },
|
||||||
|
});
|
||||||
|
|
||||||
|
return serializeBigInt(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectPartnerBill(id: bigint, reason: string) {
|
||||||
|
const rejectReason = reason.trim();
|
||||||
|
if (!rejectReason) throw new BadRequestException('请填写驳回理由');
|
||||||
|
if (rejectReason.length > 500) throw new BadRequestException('驳回理由不能超过 500 字');
|
||||||
|
|
||||||
|
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
|
||||||
|
if (!bill) throw new NotFoundException('账单不存在');
|
||||||
|
if (bill.status !== 'CONFIRMED') {
|
||||||
|
throw new BadRequestException('仅待打款审核的账单可驳回');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.partnerBill.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'REJECTED',
|
||||||
|
rejectReason,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return serializeBigInt(updated);
|
return serializeBigInt(updated);
|
||||||
@@ -542,11 +601,11 @@ export class SettlementService {
|
|||||||
async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string }) {
|
async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string }) {
|
||||||
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
|
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
|
||||||
if (!bill) throw new NotFoundException('账单不存在');
|
if (!bill) throw new NotFoundException('账单不存在');
|
||||||
if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅已确认账单可标记打款');
|
if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅待打款审核的账单可标记打款');
|
||||||
|
|
||||||
const updated = await this.prisma.partnerBill.update({
|
const updated = await this.prisma.partnerBill.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'PAID', paidAt: new Date() },
|
data: { status: 'PAID', paidAt: new Date(), rejectReason: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
return serializeBigInt(updated);
|
return serializeBigInt(updated);
|
||||||
@@ -577,6 +636,7 @@ export class SettlementService {
|
|||||||
'状态',
|
'状态',
|
||||||
'确认时间',
|
'确认时间',
|
||||||
'打款时间',
|
'打款时间',
|
||||||
|
'驳回理由',
|
||||||
].join(',');
|
].join(',');
|
||||||
const rows = bills.map((b) =>
|
const rows = bills.map((b) =>
|
||||||
[
|
[
|
||||||
@@ -591,6 +651,7 @@ export class SettlementService {
|
|||||||
b.status,
|
b.status,
|
||||||
b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '',
|
b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '',
|
||||||
b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '',
|
b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '',
|
||||||
|
csvEscape(b.rejectReason ?? ''),
|
||||||
].join(','),
|
].join(','),
|
||||||
);
|
);
|
||||||
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length };
|
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length };
|
||||||
|
|||||||
@@ -75,19 +75,29 @@ export class PartnerStoreController {
|
|||||||
) {
|
) {
|
||||||
return this.storeService.partnerUpdateStoreBasic(user.actorId, BigInt(id), body);
|
return this.storeService.partnerUpdateStoreBasic(user.actorId, BigInt(id), body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':id/media')
|
||||||
|
updateMedia(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
return this.storeService.partnerUpdateStoreMedia(user.actorId, BigInt(id), body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('partner/dashboard')
|
@Controller('partner/dashboard')
|
||||||
@UseGuards(JwtAuthGuard, PartnerPermissionGuard)
|
@UseGuards(JwtAuthGuard, PartnerPermissionGuard)
|
||||||
@RequirePartnerPermissions('store:create', 'store:manage', 'order:view', 'warehouse:manage')
|
|
||||||
export class PartnerDashboardController {
|
export class PartnerDashboardController {
|
||||||
constructor(private readonly storeService: StoreService) {}
|
constructor(private readonly storeService: StoreService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequirePartnerPermissions('store:create', 'store:manage', 'order:view', 'warehouse:manage')
|
||||||
dashboard(@CurrentUser() user: AuthUser) {
|
dashboard(@CurrentUser() user: AuthUser) {
|
||||||
return this.storeService.partnerDashboard(user.actorId);
|
return this.storeService.partnerDashboard(user.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 任意合伙人子账号可看同主账号排行(激励),不校验业务权限点 */
|
||||||
@Get('leaderboard')
|
@Get('leaderboard')
|
||||||
leaderboard(
|
leaderboard(
|
||||||
@CurrentUser() user: AuthUser,
|
@CurrentUser() user: AuthUser,
|
||||||
|
|||||||
@@ -85,15 +85,7 @@ export class StoreService {
|
|||||||
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
|
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const media = await this.prisma.commonResource.findMany({
|
const media = await this.loadPartnerStoreMedia(storeId);
|
||||||
where: {
|
|
||||||
ownerType: 'STORE',
|
|
||||||
ownerId: storeId,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
bizType: { in: ['ENV', 'CONTRACT'] },
|
|
||||||
},
|
|
||||||
orderBy: { sortOrder: 'asc' },
|
|
||||||
});
|
|
||||||
return serializeBigInt(mapStoreCompat({ ...store, media }));
|
return serializeBigInt(mapStoreCompat({ ...store, media }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,9 +161,7 @@ export class StoreService {
|
|||||||
|
|
||||||
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
||||||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||||||
const envPhotoUrls = Array.isArray(body.envPhotoUrls)
|
const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls);
|
||||||
? body.envPhotoUrls.map((u) => String(u).trim()).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
|
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
|
||||||
|
|
||||||
if (!coverUrl) throw new BadRequestException('请上传门头照');
|
if (!coverUrl) throw new BadRequestException('请上传门头照');
|
||||||
@@ -317,7 +307,7 @@ export class StoreService {
|
|||||||
status: 'OPEN' | 'PAUSED' | 'CLOSED',
|
status: 'OPEN' | 'PAUSED' | 'CLOSED',
|
||||||
) {
|
) {
|
||||||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||||
this.assertPrimaryAccount(account);
|
await this.assertCanMutateStore(account, partnerAccountId, storeId);
|
||||||
const store = await this.prisma.store.findFirst({
|
const store = await this.prisma.store.findFirst({
|
||||||
where: { id: storeId, partnerAccountId: primaryId },
|
where: { id: storeId, partnerAccountId: primaryId },
|
||||||
});
|
});
|
||||||
@@ -365,7 +355,7 @@ export class StoreService {
|
|||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||||
this.assertPrimaryAccount(account);
|
await this.assertCanMutateStore(account, partnerAccountId, storeId);
|
||||||
const store = await this.prisma.store.findFirst({
|
const store = await this.prisma.store.findFirst({
|
||||||
where: { id: storeId, partnerAccountId: primaryId },
|
where: { id: storeId, partnerAccountId: primaryId },
|
||||||
});
|
});
|
||||||
@@ -429,6 +419,115 @@ export class StoreService {
|
|||||||
return this.partnerGetStore(partnerAccountId, storeId);
|
return this.partnerGetStore(partnerAccountId, storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 重新上传门头照 / 环境照:先软删旧 ENV,再写入去重后的新图(最多 3 张) */
|
||||||
|
async partnerUpdateStoreMedia(
|
||||||
|
partnerAccountId: bigint,
|
||||||
|
storeId: bigint,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||||
|
await this.assertCanMutateStore(account, partnerAccountId, storeId);
|
||||||
|
const store = await this.prisma.store.findFirst({
|
||||||
|
where: { id: storeId, partnerAccountId: primaryId },
|
||||||
|
});
|
||||||
|
if (!store) throw new NotFoundException('门店不存在');
|
||||||
|
if (store.status === 'CLOSED') {
|
||||||
|
throw new BadRequestException('门店已关闭,不可编辑');
|
||||||
|
}
|
||||||
|
if (store.auditStatus === 'PENDING') {
|
||||||
|
throw new BadRequestException('门店审核中,暂不可修改资料');
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverUrl = body.coverUrl !== undefined ? String(body.coverUrl ?? '').trim() : undefined;
|
||||||
|
const hasEnv = body.envPhotoUrls !== undefined;
|
||||||
|
const envPhotoUrls = hasEnv ? this.normalizeEnvPhotoUrls(body.envPhotoUrls) : undefined;
|
||||||
|
if (coverUrl !== undefined && !coverUrl) {
|
||||||
|
throw new BadRequestException('请上传门头照');
|
||||||
|
}
|
||||||
|
if (envPhotoUrls !== undefined && envPhotoUrls.length < 3) {
|
||||||
|
throw new BadRequestException('请上传至少 3 张环境照片');
|
||||||
|
}
|
||||||
|
if (coverUrl === undefined && envPhotoUrls === undefined) {
|
||||||
|
throw new BadRequestException('请至少更新门头照或环境照片');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
|
||||||
|
|
||||||
|
if (coverUrl !== undefined) {
|
||||||
|
if (store.coverResourceId) {
|
||||||
|
await this.prisma.commonResource.update({
|
||||||
|
where: { id: store.coverResourceId },
|
||||||
|
data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const cover = await this.prisma.commonResource.create({
|
||||||
|
data: {
|
||||||
|
ownerType: 'STORE',
|
||||||
|
ownerId: storeId,
|
||||||
|
bizType: 'COVER',
|
||||||
|
mediaType: 'IMAGE',
|
||||||
|
ossBucket,
|
||||||
|
ossKey: coverUrl,
|
||||||
|
url: coverUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.store.update({
|
||||||
|
where: { id: storeId },
|
||||||
|
data: { coverResourceId: cover.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envPhotoUrls !== undefined) {
|
||||||
|
await this.prisma.commonResource.updateMany({
|
||||||
|
where: { ownerType: 'STORE', ownerId: storeId, bizType: 'ENV', status: 'ACTIVE' },
|
||||||
|
data: { status: 'DELETED' },
|
||||||
|
});
|
||||||
|
for (let i = 0; i < envPhotoUrls.length; i++) {
|
||||||
|
await this.prisma.commonResource.create({
|
||||||
|
data: {
|
||||||
|
ownerType: 'STORE',
|
||||||
|
ownerId: storeId,
|
||||||
|
bizType: 'ENV',
|
||||||
|
mediaType: 'IMAGE',
|
||||||
|
ossBucket,
|
||||||
|
ossKey: envPhotoUrls[i],
|
||||||
|
url: envPhotoUrls[i],
|
||||||
|
sortOrder: i,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resubmitAudit = store.auditStatus === 'REJECTED';
|
||||||
|
if (resubmitAudit) {
|
||||||
|
await this.prisma.store.update({
|
||||||
|
where: { id: storeId },
|
||||||
|
data: {
|
||||||
|
auditStatus: 'PENDING',
|
||||||
|
rejectReason: null,
|
||||||
|
auditedAt: null,
|
||||||
|
status: store.status === 'OPEN' ? 'PAUSED' : store.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.commonEvent.create({
|
||||||
|
data: {
|
||||||
|
eventType: 'STORE_AUDIT',
|
||||||
|
refType: 'STORE',
|
||||||
|
refId: storeId,
|
||||||
|
actorType: 'PARTNER',
|
||||||
|
actorId: partnerAccountId,
|
||||||
|
status: 'PENDING',
|
||||||
|
param1: 'RESUBMIT',
|
||||||
|
param1Desc: 'audit_type',
|
||||||
|
remark: '合伙人重新上传资料后重新提交审核',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.partnerGetStore(partnerAccountId, storeId);
|
||||||
|
}
|
||||||
|
|
||||||
async getShopStore(storeAccountId: bigint, storeId: bigint) {
|
async getShopStore(storeAccountId: bigint, storeId: bigint) {
|
||||||
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||||
@@ -889,6 +988,88 @@ export class StoreService {
|
|||||||
return account.isPrimary !== 1;
|
return account.isPrimary !== 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private partnerPermissionList(account: { permissions?: unknown }): string[] {
|
||||||
|
return Array.isArray(account.permissions) ? (account.permissions as string[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主账号,或门店类子账号(含历史空权限)可改门店 */
|
||||||
|
private async assertCanMutateStore(
|
||||||
|
account: { isPrimary: number; permissions?: unknown },
|
||||||
|
partnerAccountId: bigint,
|
||||||
|
storeId: bigint,
|
||||||
|
) {
|
||||||
|
if (!this.isSubAccount(account)) return;
|
||||||
|
const perms = this.partnerPermissionList(account);
|
||||||
|
const canManage = perms.includes('store:manage');
|
||||||
|
const canCreate = perms.includes('store:create');
|
||||||
|
const legacyStoreStaff = perms.length === 0;
|
||||||
|
const warehouseOnly =
|
||||||
|
!canManage &&
|
||||||
|
!canCreate &&
|
||||||
|
!legacyStoreStaff &&
|
||||||
|
(perms.includes('warehouse:manage') ||
|
||||||
|
(perms.includes('order:view') && !perms.includes('store:create') && !perms.includes('store:manage')));
|
||||||
|
if (warehouseOnly || (!canManage && !canCreate && !legacyStoreStaff)) {
|
||||||
|
throw new ForbiddenException('子账号无门店管理权限');
|
||||||
|
}
|
||||||
|
// store:manage 可管团队门店;仅 store:create / 历史空权限只能改自己录入的店
|
||||||
|
if (canManage) return;
|
||||||
|
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeEnvPhotoUrls(raw: unknown, max = 3): string[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
const url = String(item ?? '').trim();
|
||||||
|
if (!url || seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
urls.push(url);
|
||||||
|
if (urls.length >= max) break;
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取门店媒体;对重复 ENV URL 软删并只返回一份,修复历史 3→6 脏数据 */
|
||||||
|
private async loadPartnerStoreMedia(storeId: bigint) {
|
||||||
|
const media = await this.prisma.commonResource.findMany({
|
||||||
|
where: {
|
||||||
|
ownerType: 'STORE',
|
||||||
|
ownerId: storeId,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
bizType: { in: ['ENV', 'CONTRACT'] },
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const seenEnv = new Set<string>();
|
||||||
|
const duplicateEnvIds: bigint[] = [];
|
||||||
|
const kept: typeof media = [];
|
||||||
|
for (const row of media) {
|
||||||
|
if (row.bizType !== 'ENV') {
|
||||||
|
kept.push(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = row.url.trim();
|
||||||
|
if (seenEnv.has(key)) {
|
||||||
|
duplicateEnvIds.push(row.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenEnv.add(key);
|
||||||
|
kept.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicateEnvIds.length > 0) {
|
||||||
|
await this.prisma.commonResource.updateMany({
|
||||||
|
where: { id: { in: duplicateEnvIds } },
|
||||||
|
data: { status: 'DELETED' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
private assertPrimaryAccount(account: { isPrimary: number }) {
|
private assertPrimaryAccount(account: { isPrimary: number }) {
|
||||||
if (this.isSubAccount(account)) {
|
if (this.isSubAccount(account)) {
|
||||||
throw new ForbiddenException('子账号无权执行此操作');
|
throw new ForbiddenException('子账号无权执行此操作');
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export class TradeService {
|
|||||||
city.localMinQty,
|
city.localMinQty,
|
||||||
city.crossMinQty,
|
city.crossMinQty,
|
||||||
);
|
);
|
||||||
if (!check.ok) throw new BadRequestException(check.message);
|
|
||||||
|
|
||||||
const unitPrice = Number(product.price);
|
const unitPrice = Number(product.price);
|
||||||
const productAmount = unitPrice * body.quantity;
|
const productAmount = unitPrice * body.quantity;
|
||||||
@@ -96,6 +95,10 @@ export class TradeService {
|
|||||||
payAmount: productAmount,
|
payAmount: productAmount,
|
||||||
benefitAmount: benefitPerUnit * body.quantity,
|
benefitAmount: benefitPerUnit * body.quantity,
|
||||||
city: serializeBigInt(city),
|
city: serializeBigInt(city),
|
||||||
|
/** 起购未满足时仍返回预览,供确认页改数量;下单接口仍会硬校验 */
|
||||||
|
quantityOk: check.ok,
|
||||||
|
quantityMessage: check.ok ? null : (check.message ?? null),
|
||||||
|
minQty: deliveryType === 'LOCAL' ? city.localMinQty : city.crossMinQty,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +113,9 @@ export class TradeService {
|
|||||||
req: Request,
|
req: Request,
|
||||||
) {
|
) {
|
||||||
const preview = await this.preview(userId, body);
|
const preview = await this.preview(userId, body);
|
||||||
|
if (preview.quantityOk === false) {
|
||||||
|
throw new BadRequestException(preview.quantityMessage || '购买数量不满足起购要求');
|
||||||
|
}
|
||||||
const address = await this.prisma.userAddress.findFirst({
|
const address = await this.prisma.userAddress.findFirst({
|
||||||
where: { id: BigInt(body.addressId), userId },
|
where: { id: BigInt(body.addressId), userId },
|
||||||
});
|
});
|
||||||
@@ -509,6 +515,7 @@ export class TradeService {
|
|||||||
|
|
||||||
async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
|
async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
|
const hasWarehouseAccess = await this.partnerCityService.hasManagedWarehouse(primary.id);
|
||||||
const where = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
const where = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||||
const [list, total] = await Promise.all([
|
const [list, total] = await Promise.all([
|
||||||
this.prisma.order.findMany({
|
this.prisma.order.findMany({
|
||||||
@@ -520,7 +527,14 @@ export class TradeService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.order.count({ where }),
|
this.prisma.order.count({ where }),
|
||||||
]);
|
]);
|
||||||
return { list: serializeBigInt(list.map(mapOrderCompat)), total, page, pageSize };
|
return {
|
||||||
|
list: serializeBigInt(list.map(mapOrderCompat)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
hasWarehouseAccess,
|
||||||
|
message: hasWarehouseAccess ? undefined : '未配置仓库管理权限,购酒订单由总部履约',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPartnerOrder(partnerAccountId: bigint, orderId: bigint) {
|
async getPartnerOrder(partnerAccountId: bigint, orderId: bigint) {
|
||||||
|
|||||||
Reference in New Issue
Block a user