Compare commits
36 Commits
608e405468
...
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 | |||
| 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 ?? []}
|
||||
onAdd={() => {
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||
setSubOpen(true);
|
||||
}}
|
||||
onEdit={(row) => {
|
||||
|
||||
@@ -121,15 +121,36 @@ export type AdminUserRow = {
|
||||
orderCount: number;
|
||||
};
|
||||
|
||||
export type AdminOrderItem = {
|
||||
productName: string;
|
||||
productSpec: string;
|
||||
productImage: string;
|
||||
unitPrice: number;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type AdminOrderRow = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
deliveryType: string;
|
||||
payAmount: number;
|
||||
productName?: string;
|
||||
productSpec?: string;
|
||||
quantity?: number;
|
||||
receiverName: string;
|
||||
receiverPhone: 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 };
|
||||
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) {
|
||||
await openPartner(parentId);
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||
setSubOpen(true);
|
||||
}
|
||||
|
||||
@@ -482,7 +482,7 @@ export default function CityPartnersPage() {
|
||||
subs={detail.children ?? []}
|
||||
onAdd={() => {
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||
setSubOpen(true);
|
||||
}}
|
||||
onEdit={(sub) => openSubEdit(sub, detail.id)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
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 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({
|
||||
mode,
|
||||
providerOptions,
|
||||
onModeChange,
|
||||
}: {
|
||||
mode: WarehouseFulfillmentMode;
|
||||
providerOptions: FulfillmentProviderDto[];
|
||||
onModeChange: (mode: WarehouseFulfillmentMode) => void;
|
||||
}) {
|
||||
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
|
||||
return (
|
||||
<>
|
||||
{mode === WarehouseFulfillmentMode.API_AUTO && (
|
||||
<Form.Item name="fulfillmentProviderId" label="仓配承运商" rules={[{ required: true }]}>
|
||||
<Form.Item
|
||||
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
|
||||
placeholder={providerOptions.length ? '选择已注册承运商' : '请先在仓配管理注册'}
|
||||
options={providerOptions.map((p) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{mode === WarehouseFulfillmentMode.MANUAL && (
|
||||
{!autoShip && (
|
||||
<>
|
||||
<Form.Item name="manualCarrierLabel" label="默认承运商名称">
|
||||
<Input placeholder="如 顺丰速运" />
|
||||
@@ -183,18 +209,17 @@ export default function CityWarehousesPage() {
|
||||
}
|
||||
|
||||
function onManagerTypeChange(
|
||||
type: WarehouseManagerType,
|
||||
form: typeof createForm | typeof editForm,
|
||||
setType: (v: WarehouseManagerType) => void,
|
||||
v: WarehouseManagerType,
|
||||
form: typeof createForm,
|
||||
setType: (t: WarehouseManagerType) => void,
|
||||
) {
|
||||
setType(type);
|
||||
if (type !== WarehouseManagerType.PARTNER) {
|
||||
setType(v);
|
||||
if (v !== WarehouseManagerType.PARTNER) {
|
||||
form.setFieldValue('partnerAccountId', undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 },
|
||||
{
|
||||
title: '城市',
|
||||
width: 100,
|
||||
@@ -204,17 +229,17 @@ export default function CityWarehousesPage() {
|
||||
</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: 'contactPhone', width: 120 },
|
||||
{
|
||||
title: '管仓类型',
|
||||
dataIndex: 'managerType',
|
||||
width: 100,
|
||||
render: (v) => WAREHOUSE_MANAGER_LABELS[v as WarehouseManagerType] || v,
|
||||
title: '管仓',
|
||||
width: 90,
|
||||
render: (_, row) => WAREHOUSE_MANAGER_LABELS[row.managerType] || row.managerType,
|
||||
},
|
||||
{
|
||||
title: '管仓合伙人',
|
||||
title: '合伙人',
|
||||
dataIndex: 'partnerCompanyName',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
@@ -226,12 +251,14 @@ export default function CityWarehousesPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '履约',
|
||||
width: 120,
|
||||
title: '自动发货',
|
||||
width: 130,
|
||||
render: (_, row) =>
|
||||
row.fulfillmentMode === 'API_AUTO'
|
||||
? row.fulfillmentProviderName || 'API'
|
||||
: WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL],
|
||||
row.fulfillmentMode === 'API_AUTO' ? (
|
||||
<Tag color="blue">{row.fulfillmentProviderName || '自动'}</Tag>
|
||||
) : (
|
||||
<Tag>{WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
@@ -274,21 +301,23 @@ export default function CityWarehousesPage() {
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
仓库管理
|
||||
</Typography.Title>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>仓库</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: WarehouseStatus.ACTIVE });
|
||||
setCreateManagerType(WarehouseManagerType.HQ);
|
||||
setCreateCityId(undefined);
|
||||
setPartners([]);
|
||||
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
|
||||
createForm.setFieldsValue({
|
||||
managerType: WarehouseManagerType.HQ,
|
||||
status: WarehouseStatus.ACTIVE,
|
||||
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
|
||||
});
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建仓库
|
||||
新增仓库
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
@@ -296,7 +325,7 @@ export default function CityWarehousesPage() {
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="管仓合伙人仅可在此页面分配;城市合伙人详情中的管仓仓库为只读展示。"
|
||||
message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。"
|
||||
/>
|
||||
|
||||
<Form
|
||||
@@ -309,7 +338,7 @@ export default function CityWarehousesPage() {
|
||||
}}
|
||||
>
|
||||
<Form.Item name="name" label="仓库名">
|
||||
<Input allowClear placeholder="仓库名" />
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="cityId" label="城市">
|
||||
<Select
|
||||
@@ -317,47 +346,33 @@ export default function CityWarehousesPage() {
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部城市"
|
||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="managerType" label="管仓类型">
|
||||
<Select allowClear style={{ width: 120 }} placeholder="全部" options={MANAGER_OPTIONS} />
|
||||
<Form.Item name="managerType" label="管仓">
|
||||
<Select allowClear style={{ width: 120 }} options={MANAGER_OPTIONS} />
|
||||
</Form.Item>
|
||||
<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>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
filterForm.resetFields();
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
@@ -366,7 +381,7 @@ export default function CityWarehousesPage() {
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="新建仓库"
|
||||
title="新增仓库"
|
||||
open={createOpen}
|
||||
width={520}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
@@ -425,13 +440,11 @@ export default function CityWarehousesPage() {
|
||||
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="fulfillmentMode" label="履约方式" initialValue={WarehouseFulfillmentMode.MANUAL}>
|
||||
<Select
|
||||
options={FULFILLMENT_MODE_OPTIONS}
|
||||
onChange={(v) => setCreateFulfillmentMode(v)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FulfillmentFields mode={createFulfillmentMode} providerOptions={providerOptions} />
|
||||
<FulfillmentFields
|
||||
mode={createFulfillmentMode}
|
||||
providerOptions={providerOptions}
|
||||
onModeChange={setCreateFulfillmentMode}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -490,13 +503,11 @@ export default function CityWarehousesPage() {
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="fulfillmentMode" label="履约方式">
|
||||
<Select
|
||||
options={FULFILLMENT_MODE_OPTIONS}
|
||||
onChange={(v) => setEditFulfillmentMode(v)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FulfillmentFields mode={editFulfillmentMode} providerOptions={providerOptions} />
|
||||
<FulfillmentFields
|
||||
mode={editFulfillmentMode}
|
||||
providerOptions={providerOptions}
|
||||
onModeChange={setEditFulfillmentMode}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
@@ -17,8 +18,14 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type AdminOrderRow, type Paginated } from '../lib/api';
|
||||
import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { request, type AdminOrderItem, type AdminOrderRow, type Paginated } from '../lib/api';
|
||||
import {
|
||||
ADMIN_OPTIONS_PAGE_SIZE,
|
||||
DELIVERY_PROVIDER_LABELS,
|
||||
ORDER_STATUS_COLORS,
|
||||
ORDER_STATUS_LABELS,
|
||||
fmtTime,
|
||||
} from '../lib/constants';
|
||||
|
||||
type ShipDefaults = {
|
||||
provider: string;
|
||||
@@ -33,6 +40,21 @@ type ShipDefaults = {
|
||||
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 & {
|
||||
receiverAddress?: string;
|
||||
receiverProvince?: string;
|
||||
@@ -51,17 +73,58 @@ type OrderDetail = AdminOrderRow & {
|
||||
productAmount?: number;
|
||||
freightAmount?: number;
|
||||
benefitAmount?: number;
|
||||
deliveryType?: string;
|
||||
fulfillmentWarehouseId?: string | null;
|
||||
paidAt?: string | null;
|
||||
payExpireAt?: string | null;
|
||||
items?: Array<Record<string, unknown>>;
|
||||
productImage?: string;
|
||||
listUnitPrice?: number;
|
||||
items?: AdminOrderItem[];
|
||||
payment?: Record<string, unknown> | null;
|
||||
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
|
||||
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() {
|
||||
const [form] = Form.useForm();
|
||||
const [shipForm] = Form.useForm();
|
||||
@@ -78,6 +141,11 @@ export default function OrdersPage() {
|
||||
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
|
||||
const [shipping, setShipping] = 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(
|
||||
() => (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) });
|
||||
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
||||
if (values.status) qs.set('status', values.status);
|
||||
if (values.cityId) qs.set('cityId', values.cityId);
|
||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||
setData(res);
|
||||
@@ -105,40 +174,100 @@ export default function OrdersPage() {
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
const res = await request<OrderDetail>(`/admin/orders/${id}`);
|
||||
setDetail(res);
|
||||
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) {
|
||||
setShipDefaults(defaults);
|
||||
shipForm.setFieldsValue({
|
||||
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,
|
||||
});
|
||||
applyShipDefaults(defaults, preferred?.id);
|
||||
} else {
|
||||
shipForm.setFieldsValue({ warehouseId: preferred?.id, provider: 'XFX' });
|
||||
}
|
||||
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() {
|
||||
if (!detail) return;
|
||||
if (!shipTarget) return;
|
||||
const values = await shipForm.validateFields();
|
||||
setShipping(true);
|
||||
try {
|
||||
const res = await request<OrderDetail>(`/admin/orders/${detail.id}/ship`, {
|
||||
const res = await request<OrderDetail>(`/admin/orders/${shipTarget.id}/ship`, {
|
||||
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);
|
||||
setShipModalOpen(false);
|
||||
void load();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '发货失败');
|
||||
@@ -148,16 +277,28 @@ export default function OrdersPage() {
|
||||
}
|
||||
|
||||
async function submitLogisticsShip() {
|
||||
if (!detail) return;
|
||||
const values = await logisticsForm.validateFields();
|
||||
if (!shipTarget) return;
|
||||
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);
|
||||
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',
|
||||
body: JSON.stringify(values),
|
||||
body: JSON.stringify({
|
||||
logisticsCompany,
|
||||
trackingNo,
|
||||
manualQueryUrl: String(values.manualQueryUrl ?? '').trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('快递单已录入');
|
||||
setShipTarget(res);
|
||||
setDetail(res);
|
||||
setShipModalOpen(false);
|
||||
void load();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '填单失败');
|
||||
@@ -191,6 +332,25 @@ export default function OrdersPage() {
|
||||
|
||||
const columns: ColumnsType<AdminOrderRow> = [
|
||||
{ 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: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -227,11 +387,19 @@ export default function OrdersPage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||
详情
|
||||
</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="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</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="收货手机">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
@@ -271,7 +449,7 @@ export default function OrdersPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 1500 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||
@@ -295,6 +473,11 @@ export default function OrdersPage() {
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Space>
|
||||
{canShip(detail) && (
|
||||
<Button type="primary" onClick={() => void openShipModal(detail.id)}>
|
||||
配送发货
|
||||
</Button>
|
||||
)}
|
||||
<Typography.Text type="secondary">调试改状态</Typography.Text>
|
||||
<Select
|
||||
value={detail.status}
|
||||
@@ -318,6 +501,13 @@ export default function OrdersPage() {
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" title="基本信息">
|
||||
<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="状态">
|
||||
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
|
||||
{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>
|
||||
|
||||
<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.Item label="收货人">{detail.receiverName} {detail.receiverPhone}</Descriptions.Item>
|
||||
<Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item>
|
||||
@@ -363,99 +598,6 @@ export default function OrdersPage() {
|
||||
</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 && (
|
||||
<>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
|
||||
@@ -476,6 +618,150 @@ export default function OrdersPage() {
|
||||
)}
|
||||
</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
|
||||
title={`确认批量删除(${selectedOrders.length} 笔)`}
|
||||
open={batchDeleteOpen}
|
||||
@@ -501,13 +787,27 @@ export default function OrdersPage() {
|
||||
dataSource={selectedOrders}
|
||||
columns={[
|
||||
{ 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: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
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: 'receiverName', width: 90 },
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function PartnerAccountsPage() {
|
||||
function openAddSub(parent: AccountTreeRow) {
|
||||
setSubParent(parent);
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||
setSubOpen(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,9 +63,9 @@ function SubAccountRoutes() {
|
||||
<>
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
</>
|
||||
)}
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
{isWarehouse && <Route path="/orders/:id" element={<OrderDetailPage />} />}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -5,6 +5,7 @@ export function fetchPartnerLeaderboard(period: PartnerLeaderboardPeriod = 'tota
|
||||
return request<PartnerLeaderboardResponse>(
|
||||
'PARTNER_H5',
|
||||
`/partner/dashboard/leaderboard?period=${period}`,
|
||||
{ silent: true },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,52 @@ export function hasPartnerPermission(
|
||||
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(无门店权限) */
|
||||
export function isWarehouseStaff(account: PartnerMe | null | undefined): boolean {
|
||||
if (!account || isPrimaryAccount(account)) return false;
|
||||
@@ -48,8 +94,8 @@ export function partnerHomePath(account: PartnerMe | null | undefined): string {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/login'];
|
||||
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/login'];
|
||||
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/leaderboard', '/login'];
|
||||
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/leaderboard', '/login'];
|
||||
|
||||
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
|
||||
if (pathname === '/login') return true;
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import {
|
||||
PartnerStaffRole,
|
||||
type CreatePartnerStaffRequest,
|
||||
type PartnerStaffItem,
|
||||
type UpdatePartnerStaffRequest,
|
||||
} from '@dukang/shared-types';
|
||||
import { PartnerStaffRole, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
|
||||
import type { CreatePartnerStaffRequest, PartnerStaffItem, UpdatePartnerStaffRequest } from '@dukang/shared-types';
|
||||
import { request } from './api';
|
||||
|
||||
export function listPartnerStaff() {
|
||||
@@ -25,7 +21,10 @@ export function createPartnerStaff(body: CreatePartnerStaffRequest) {
|
||||
name: body.name,
|
||||
phone: body.phone,
|
||||
smsCode: body.smsCode,
|
||||
staffRole: PartnerStaffRole.INTERNAL,
|
||||
staffRole: body.staffRole ?? PartnerStaffRole.INTERNAL,
|
||||
permissions: body.permissions?.length
|
||||
? body.permissions
|
||||
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ function fmtMoney(n: number) {
|
||||
export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) {
|
||||
const { account, logout, refresh, applySession } = usePartnerSession();
|
||||
const isPrimary = variant === 'primary' && isPrimaryAccount(account);
|
||||
const [name, setName] = useState(account?.name ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [authorizing, setAuthorizing] = useState(false);
|
||||
const [bills, setBills] = useState<PartnerBillDto[]>([]);
|
||||
const [staffCount, setStaffCount] = useState(0);
|
||||
@@ -38,10 +36,6 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
document.title = isPrimary ? '合伙人中心' : '个人中心';
|
||||
}, [isPrimary]);
|
||||
|
||||
useEffect(() => {
|
||||
setName(account?.name ?? '');
|
||||
}, [account?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPrimary) return;
|
||||
void request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
|
||||
@@ -69,25 +63,6 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
};
|
||||
}, [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() {
|
||||
if (isPrimary) {
|
||||
if (!contactSupport()) toastError('暂无客服电话');
|
||||
@@ -255,59 +230,52 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isPrimary && (
|
||||
<>
|
||||
<section className="partner-menu-section">
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>个人信息设置</h3>
|
||||
<div className="partner-menu-card" style={{ padding: '16px' }}>
|
||||
<label className="partner-form-label" htmlFor="center-name">姓名</label>
|
||||
<input
|
||||
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>
|
||||
<section className="partner-menu-section">
|
||||
<div className="partner-menu-card">
|
||||
<Link to="/leaderboard" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon">
|
||||
<span className="material-symbols-outlined">emoji_events</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</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>
|
||||
</>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>团队贡献榜</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</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>
|
||||
<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}>
|
||||
|
||||
@@ -3,7 +3,13 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||
import { getPartnerNavKind, hasWarehouseAccess } from '../lib/partnerAccess';
|
||||
import {
|
||||
canAccessPartnerDashboard,
|
||||
canAccessPartnerOrders,
|
||||
canAccessPartnerStores,
|
||||
getPartnerNavKind,
|
||||
hasWarehouseAccess,
|
||||
} from '../lib/partnerAccess';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
@@ -96,7 +102,7 @@ function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] })
|
||||
<div className="partner-leaderboard-header">
|
||||
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 20 }}>emoji_events</span>
|
||||
合伙人贡献榜
|
||||
团队贡献榜
|
||||
</h3>
|
||||
<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>
|
||||
@@ -136,6 +142,9 @@ export default function HomePage() {
|
||||
const isPrimary = navKind === 'primary';
|
||||
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 [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
@@ -148,27 +157,42 @@ export default function HomePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
if ((isWarehouse || isPrimary) && warehouseOk) {
|
||||
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders')
|
||||
// 等 session 带上账号后再按权限发请求,避免无权限接口弹错
|
||||
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 : []))
|
||||
.catch(() => setOrders([]));
|
||||
} else {
|
||||
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([]);
|
||||
});
|
||||
|
||||
if (canDashboard) {
|
||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
|
||||
.then(setDash)
|
||||
.catch(() => setDash(null));
|
||||
} else {
|
||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {});
|
||||
setDash(null);
|
||||
}
|
||||
}, [navigate, isWarehouse, isPrimary, warehouseOk]);
|
||||
|
||||
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 orderStats = useMemo(() => summarizeOrders(orders), [orders]);
|
||||
@@ -247,7 +271,7 @@ export default function HomePage() {
|
||||
<p className="headline-md" style={{ marginBottom: 8 }}>订单管理</p>
|
||||
)}
|
||||
|
||||
{(isPrimary || isWarehouse) && warehouseOk && (
|
||||
{canOrders && (
|
||||
<OrderSummarySection
|
||||
title={isWarehouse ? '今日订单' : undefined}
|
||||
todayCount={orderStats.todayCount}
|
||||
@@ -269,7 +293,7 @@ export default function HomePage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!isWarehouse && (
|
||||
{canStores && (
|
||||
<>
|
||||
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
|
||||
<div className="partner-bento-header">
|
||||
@@ -317,10 +341,10 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LeaderboardPreview entries={leaderboardEntries} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<LeaderboardPreview entries={leaderboardEntries} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function LeaderboardPage() {
|
||||
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} />
|
||||
<PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
|
||||
|
||||
<div className="partner-leaderboard-tabs">
|
||||
{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 { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
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_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) {
|
||||
if (phone.length < 7) return phone;
|
||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
||||
@@ -74,6 +100,7 @@ export default function LoginPage() {
|
||||
const [msg, setMsg] = useState('');
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClientConfig()
|
||||
@@ -88,6 +115,7 @@ export default function LoginPage() {
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
setMsg('请先勾选并同意用户协议');
|
||||
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -230,10 +258,16 @@ export default function LoginPage() {
|
||||
|
||||
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
|
||||
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
|
||||
<AgreementCheckbox
|
||||
agreed={agreed}
|
||||
onChange={setAgreed}
|
||||
inputRef={agreementRef}
|
||||
/>
|
||||
{canWechatQuick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
style={{ marginTop: 16 }}
|
||||
disabled={wxLoading}
|
||||
onClick={() => void wechatLogin()}
|
||||
>
|
||||
@@ -241,7 +275,7 @@ export default function LoginPage() {
|
||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
||||
</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()
|
||||
? '请在微信内打开以使用一键登录'
|
||||
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
||||
@@ -321,6 +355,12 @@ export default function LoginPage() {
|
||||
<span>记住账号</span>
|
||||
</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>}
|
||||
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}>
|
||||
@@ -339,20 +379,6 @@ export default function LoginPage() {
|
||||
</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>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -523,7 +523,9 @@ export default function StoreCreatePage() {
|
||||
|
||||
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', {
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { toastSuccess } from '../lib/toast';
|
||||
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 {
|
||||
canPartnerOpenStore,
|
||||
storeAuditLabel,
|
||||
@@ -16,19 +18,36 @@ import {
|
||||
} from '../lib/storeStatus';
|
||||
|
||||
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() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { account } = usePartnerSession();
|
||||
const subReadonly = isSubAccount(account);
|
||||
const canMutate = canManagePartnerStore(account);
|
||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
|
||||
const [coverUrl, setCoverUrl] = useState('');
|
||||
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
|
||||
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
||||
const [statusSaving, setStatusSaving] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [mediaSaving, setMediaSaving] = useState(false);
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [wechatReady, setWechatReady] = useState(false);
|
||||
|
||||
function applyStore(data: Record<string, unknown>) {
|
||||
setStore(data);
|
||||
@@ -39,6 +58,15 @@ export default function StoreDetailPage() {
|
||||
intro: String(data.intro || ''),
|
||||
});
|
||||
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(() => {
|
||||
@@ -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) {
|
||||
return (
|
||||
<div className="partner-detail-page">
|
||||
@@ -124,13 +188,17 @@ export default function StoreDetailPage() {
|
||||
|
||||
if (!store) return <div className="empty">加载中...</div>;
|
||||
|
||||
const envPhotos = Array.isArray(store.media)
|
||||
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
|
||||
: [];
|
||||
const envPhotos = uniqueEnvUrls(
|
||||
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 auditPending = auditStatus === 'PENDING';
|
||||
const auditRejected = auditStatus === 'REJECTED';
|
||||
const readOnly = subReadonly || status === 'CLOSED' || auditPending;
|
||||
const readOnly = !canMutate || status === 'CLOSED' || auditPending;
|
||||
const canOpen = canPartnerOpenStore(auditStatus);
|
||||
|
||||
return (
|
||||
@@ -170,7 +238,7 @@ export default function StoreDetailPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!subReadonly && (
|
||||
{canMutate && (
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<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' }}>
|
||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}>基本信息</h3>
|
||||
<div className="partner-cover">
|
||||
<AppImage
|
||||
src={store.coverUrl ? String(store.coverUrl) : null}
|
||||
alt={form.name}
|
||||
wrapperClassName="app-image--fill"
|
||||
/>
|
||||
</div>
|
||||
{!canMutate || readOnly ? (
|
||||
<div className="partner-cover">
|
||||
<AppImage
|
||||
src={coverUrl || null}
|
||||
alt={form.name}
|
||||
wrapperClassName="app-image--fill"
|
||||
/>
|
||||
</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">
|
||||
<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 }} />
|
||||
@@ -234,13 +318,48 @@ export default function StoreDetailPage() {
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<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>
|
||||
<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>
|
||||
{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">
|
||||
{envPhotos.map((photo, index) => (
|
||||
<div key={index} className="partner-cover" style={{ aspectRatio: '1' }}>
|
||||
<AppImage src={photo.url ? String(photo.url) : null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
|
||||
{envPhotos.map((url, index) => (
|
||||
<div key={`${url}-${index}`} className="partner-cover" style={{ aspectRatio: '1', marginBottom: 0 }}>
|
||||
<AppImage src={url || null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -264,7 +383,7 @@ export default function StoreDetailPage() {
|
||||
|
||||
<footer className="partner-save-footer">
|
||||
<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}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { isSubAccount } from '../lib/partnerAccess';
|
||||
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
|
||||
import {
|
||||
canPartnerOpenStore,
|
||||
storeAuditLabel,
|
||||
@@ -27,7 +27,8 @@ export default function StoreListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { account } = usePartnerSession();
|
||||
const readonly = isSubAccount(account);
|
||||
const canMutate = canManagePartnerStore(account);
|
||||
const canCreate = canCreatePartnerStore(account);
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [q, setQ] = useState('');
|
||||
const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
|
||||
@@ -45,8 +46,8 @@ export default function StoreListPage() {
|
||||
}, [navigate, loadStores]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = readonly ? '我的门店' : '门店管理';
|
||||
}, [readonly]);
|
||||
document.title = canMutate ? '门店管理' : '我的门店';
|
||||
}, [canMutate]);
|
||||
|
||||
const filtered = useMemo(() => stores.filter((s) => {
|
||||
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
||||
@@ -101,12 +102,14 @@ export default function StoreListPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canCreate && (
|
||||
<Link to="/stores/new" className="partner-fab-link">
|
||||
<button type="button" className="partner-btn-primary">
|
||||
<span className="material-symbols-outlined">add_business</span>
|
||||
录入新门店
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
||||
|
||||
@@ -146,7 +149,7 @@ export default function StoreListPage() {
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{!readonly && (
|
||||
{canMutate && (
|
||||
<div className="partner-store-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -13,13 +13,23 @@ html {
|
||||
/* ── Partner auth ── */
|
||||
.partner-auth-page {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px var(--space-page) var(--space-page);
|
||||
justify-content: flex-start;
|
||||
padding: 48px var(--space-page) calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
background-color: var(--color-background);
|
||||
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 {
|
||||
@@ -166,14 +176,27 @@ html {
|
||||
|
||||
.partner-checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-top: var(--space-md);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
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 {
|
||||
margin-top: var(--space-lg);
|
||||
@@ -2713,14 +2736,28 @@ nav.app-tabbar .app-tabbar-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-surface-container);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
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:active {
|
||||
background: rgba(166, 29, 36, 0.04);
|
||||
}
|
||||
|
||||
.partner-menu-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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 AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
@@ -28,6 +28,36 @@ function formatWechatError(e: unknown): string {
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const { applySession } = useStoreSession();
|
||||
@@ -42,6 +72,7 @@ export default function LoginPage() {
|
||||
const [msg, setMsg] = useState('');
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClientConfig()
|
||||
@@ -71,6 +102,7 @@ export default function LoginPage() {
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
setMsg('请先阅读并同意用户协议');
|
||||
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -180,6 +212,11 @@ export default function LoginPage() {
|
||||
|
||||
<div className="shop-quick-actions">
|
||||
{msg && <p className="shop-login-msg">{msg}</p>}
|
||||
<ShopAgreementCheckbox
|
||||
agreed={agreed}
|
||||
onChange={setAgreed}
|
||||
labelRef={agreementRef}
|
||||
/>
|
||||
{canWechatQuick ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -270,6 +307,12 @@ export default function LoginPage() {
|
||||
|
||||
{msg && <p className="shop-login-msg">{msg}</p>}
|
||||
|
||||
<ShopAgreementCheckbox
|
||||
agreed={agreed}
|
||||
onChange={setAgreed}
|
||||
labelRef={agreementRef}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shop-login-submit"
|
||||
@@ -300,24 +343,6 @@ export default function LoginPage() {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<footer className="shop-login-footer">
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
|
||||
.shop-login-submit {
|
||||
width: 100%;
|
||||
margin-top: 32px;
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -248,14 +248,18 @@
|
||||
|
||||
.shop-login-agreement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
max-width: 280px;
|
||||
margin: 32px auto 0;
|
||||
max-width: 100%;
|
||||
margin: 16px 0 0;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.shop-login-agreement input {
|
||||
margin-top: 0;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
@@ -455,6 +459,15 @@
|
||||
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 {
|
||||
width: 100%;
|
||||
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 AppImage from '@dukang/shared-ui/AppImage';
|
||||
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||
@@ -31,6 +31,7 @@ export default function LoginPage() {
|
||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
||||
const [bindMode, setBindMode] = useState(false);
|
||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||
const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } =
|
||||
useSmsCode();
|
||||
|
||||
@@ -73,6 +74,7 @@ export default function LoginPage() {
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
setMsg('请先勾选并同意用户协议');
|
||||
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -194,6 +196,23 @@ export default function LoginPage() {
|
||||
{displayMsg || sentHint}
|
||||
</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
|
||||
type="button"
|
||||
className="login-sms-btn"
|
||||
@@ -219,26 +238,6 @@ export default function LoginPage() {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1558,8 +1558,9 @@
|
||||
|
||||
.login-agreement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 12px 0 16px;
|
||||
font-family: var(--font-label);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
@@ -1571,8 +1572,8 @@
|
||||
|
||||
.login-agreement input {
|
||||
margin-top: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
@@ -177,6 +177,11 @@
|
||||
color: var(--hq-muted);
|
||||
}
|
||||
|
||||
.login-agreement {
|
||||
margin: 8px 0 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.login-remember {
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
|
||||
@@ -212,24 +212,6 @@ export default function LoginPage() {
|
||||
<Text>记住账号</Text>
|
||||
</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-checkbox${agreed ? ' is-checked' : ''}`} />
|
||||
<Text className="login-agreement-text">
|
||||
@@ -255,6 +237,24 @@ export default function LoginPage() {
|
||||
</Text>
|
||||
</Text>
|
||||
</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 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` |
|
||||
|
||||
### 微信登录 `invalid code`
|
||||
@@ -30,17 +30,25 @@ pnpm --filter @dukang/mini-user dev:weapp
|
||||
| 小程序 appid | `project.config.json` → `wxda31c8e8e85051e7` |
|
||||
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
|
||||
|
||||
**本地联调(不接真实微信)**:API 指到本机且开启 Mock:
|
||||
**本地联调(不接真实微信)**:保持默认即可(API → `localhost:3000`),并开启 Mock:
|
||||
|
||||
```bash
|
||||
# 终端 1
|
||||
pnpm dev:api # server/.env 保持 MOCK_WECHAT=true
|
||||
pnpm dev:api # server/.env 保持 MOCK_SMS=true / MOCK_WECHAT=true
|
||||
|
||||
# 终端 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 页)
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { defineConfig } from '@tarojs/cli';
|
||||
|
||||
/** 小程序/H5 请求的后端 origin(不含 /api);本地联调可用 VITE_API_TARGET 覆盖 */
|
||||
const API_ORIGIN = process.env.VITE_API_TARGET ?? 'https://dkapi.runxian.top';
|
||||
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
||||
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'));
|
||||
|
||||
|
||||
@@ -329,6 +329,33 @@ export default function LoginPage() {
|
||||
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
|
||||
使用微信支付前需授权微信账号
|
||||
</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 ? (
|
||||
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
|
||||
) : null}
|
||||
@@ -376,6 +403,34 @@ export default function LoginPage() {
|
||||
</Text>
|
||||
) : 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
|
||||
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
||||
onClick={loading ? undefined : () => void login()}
|
||||
@@ -415,36 +470,6 @@ export default function LoginPage() {
|
||||
</>
|
||||
) : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ type OrderPreview = {
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
city?: { localMinQty: number; crossMinQty: number };
|
||||
quantityOk?: boolean;
|
||||
quantityMessage?: string | null;
|
||||
minQty?: number;
|
||||
};
|
||||
|
||||
function formatAddress(a: Address) {
|
||||
@@ -53,7 +56,7 @@ export default function OrderConfirmPage() {
|
||||
const checkoutCtx = readCheckoutContext(router.params);
|
||||
const productId = checkoutCtx.productId ?? '';
|
||||
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 [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
|
||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||
@@ -91,7 +94,7 @@ export default function OrderConfirmPage() {
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setPreview(data);
|
||||
setMsg('');
|
||||
setMsg(data.quantityOk === false ? (data.quantityMessage || '') : '');
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -115,18 +118,14 @@ export default function OrderConfirmPage() {
|
||||
);
|
||||
|
||||
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) {
|
||||
if (next < minQty) {
|
||||
setMsg(
|
||||
!isCross
|
||||
? `同城配送至少购买 ${minQty} 瓶`
|
||||
: `跨城配送至少购买 ${minQty} 瓶(1箱)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setMsg('');
|
||||
if (next < 1) return;
|
||||
setQuantity(next);
|
||||
}
|
||||
|
||||
@@ -158,8 +157,18 @@ export default function OrderConfirmPage() {
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!addressId) {
|
||||
setMsg('请选择收货地址');
|
||||
if (!canSubmit) {
|
||||
if (!addressId) {
|
||||
setMsg('请选择收货地址');
|
||||
return;
|
||||
}
|
||||
if (!quantityOk) {
|
||||
setMsg(
|
||||
isCross
|
||||
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
|
||||
: `同城配送至少购买 ${minQty} 瓶`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,6 +215,13 @@ export default function OrderConfirmPage() {
|
||||
}
|
||||
|
||||
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
|
||||
const submitLabel = loading
|
||||
? '提交中…'
|
||||
: !addressId
|
||||
? '请选择地址'
|
||||
: !quantityOk
|
||||
? `至少购买 ${minQty} 瓶`
|
||||
: '提交订单';
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
|
||||
@@ -241,7 +257,8 @@ export default function OrderConfirmPage() {
|
||||
{isCross ? (
|
||||
<View className="order-card">
|
||||
<Text className="u-muted">
|
||||
该地址超出同城配送范围,将由总部物流发货,运费到付。
|
||||
该地址超出同城配送范围,将由总部物流发货,运费到付
|
||||
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}。
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -269,7 +286,7 @@ export default function OrderConfirmPage() {
|
||||
<Text>购买数量</Text>
|
||||
<View className="order-qty-controls">
|
||||
<View
|
||||
className="order-qty-btn"
|
||||
className={`order-qty-btn${quantity <= 1 ? ' order-qty-btn--disabled' : ''}`}
|
||||
onClick={() => updateQuantity(quantity - 1)}
|
||||
>
|
||||
<Text>−</Text>
|
||||
@@ -283,6 +300,13 @@ export default function OrderConfirmPage() {
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{!quantityOk ? (
|
||||
<Text className="order-qty-hint">
|
||||
{isCross
|
||||
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
|
||||
: `同城配送至少购买 ${minQty} 瓶,请调整数量`}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className="order-card">
|
||||
@@ -320,10 +344,13 @@ export default function OrderConfirmPage() {
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className="order-confirm-submit"
|
||||
onClick={() => !loading && void submit()}
|
||||
className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
|
||||
onClick={() => {
|
||||
if (!canSubmit) return;
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<Text>{loading ? '提交中…' : !addressId ? '请选择地址' : '提交订单'}</Text>
|
||||
<Text>{submitLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
|
||||
@@ -306,8 +306,9 @@
|
||||
|
||||
.login-agreement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
|
||||
.login-agreement-check {
|
||||
|
||||
@@ -93,6 +93,11 @@
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.order-qty-btn--disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.order-qty-value {
|
||||
margin: 0 14px;
|
||||
font-size: 16px;
|
||||
@@ -101,6 +106,14 @@
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -180,6 +193,13 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.order-confirm-submit--disabled {
|
||||
background: #c9c5c0;
|
||||
color: #fff;
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.order-tabs {
|
||||
display: flex;
|
||||
padding: 0 var(--space-page);
|
||||
|
||||
@@ -46,6 +46,8 @@ export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=8192}"
|
||||
# 生产部署 path:nginx 入口为 /user/
|
||||
export TARO_H5_PUBLIC_PATH="${TARO_H5_PUBLIC_PATH:-/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 install --frozen-lockfile 2>/dev/null || pnpm install
|
||||
|
||||
|
||||
@@ -89,3 +89,9 @@ export const PARTNER_PERMISSION_LABELS: Record<PartnerPermissionKey, string> = {
|
||||
'store:create': '开店管理',
|
||||
'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> = {
|
||||
[WarehouseFulfillmentMode.API_AUTO]: 'API 自动推单',
|
||||
[WarehouseFulfillmentMode.MANUAL]: '自管手工填单',
|
||||
[WarehouseFulfillmentMode.API_AUTO]: '自动发货',
|
||||
[WarehouseFulfillmentMode.MANUAL]: '关闭(手工填单)',
|
||||
};
|
||||
|
||||
export enum AccountStatus {
|
||||
|
||||
@@ -16,7 +16,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const APP_ROOT = path.resolve(__dirname, '../apps/mini-user');
|
||||
const DIST = path.resolve(APP_ROOT, 'dist');
|
||||
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 MIME = {
|
||||
|
||||
@@ -177,7 +177,7 @@ async function main() {
|
||||
|
||||
|
||||
|
||||
const xfxProvider = await prisma.fulfillmentProvider.create({
|
||||
await prisma.fulfillmentProvider.create({
|
||||
data: {
|
||||
code: 'XFX',
|
||||
name: '小飞侠',
|
||||
@@ -218,9 +218,9 @@ async function main() {
|
||||
|
||||
status: 'ACTIVE',
|
||||
|
||||
fulfillmentMode: 'API_AUTO',
|
||||
fulfillmentMode: 'MANUAL',
|
||||
|
||||
fulfillmentProviderId: xfxProvider.id,
|
||||
fulfillmentProviderId: null,
|
||||
|
||||
lng: 113.665,
|
||||
|
||||
|
||||
@@ -178,11 +178,7 @@ export class FulfillmentService {
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
|
||||
const isHqQueue =
|
||||
order.deliveryType === 'CROSS_CITY' ||
|
||||
(order.deliveryType === 'LOCAL' && !order.fulfillmentWarehouseId);
|
||||
|
||||
if (!isHqQueue) throw new BadRequestException('该订单由仓配履约,请使用仓配发货');
|
||||
// HQ 可对任意待发货单填快递单号(含仓配单手动填单)
|
||||
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
||||
throw new BadRequestException('当前订单状态不可发货');
|
||||
}
|
||||
|
||||
@@ -1,240 +1,245 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ClientApp, PartnerStaffRole, SmsScene } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PartnerStaffService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly analytics: AnalyticsService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
async listStaff(parentAccountId: bigint) {
|
||||
const rows = await this.prisma.partnerAccount.findMany({
|
||||
where: { parentAccountId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((row) => this.toStaffItem(row));
|
||||
}
|
||||
|
||||
async sendStaffPhoneSms(actor: AuthUser, phone: string) {
|
||||
const parentAccountId = actor.actorId;
|
||||
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: parentAccountId },
|
||||
});
|
||||
if (parent.isPrimary !== 1) {
|
||||
throw new BadRequestException('仅主账号可添加子账号');
|
||||
}
|
||||
|
||||
const normalized = phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(normalized)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
}
|
||||
|
||||
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } });
|
||||
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||
|
||||
const masked = this.maskPhone(normalized);
|
||||
try {
|
||||
await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, {
|
||||
clientApp: ClientApp.PARTNER_H5,
|
||||
});
|
||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
|
||||
phone: masked,
|
||||
scene: SmsScene.PARTNER_STAFF_ADD,
|
||||
status: 'success',
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof BadRequestException) {
|
||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
|
||||
phone: masked,
|
||||
scene: SmsScene.PARTNER_STAFF_ADD,
|
||||
status: 'failed',
|
||||
reason: err.message,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return { ok: true, maskedPhone: masked };
|
||||
}
|
||||
|
||||
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
|
||||
const parentAccountId = actor.actorId;
|
||||
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: parentAccountId },
|
||||
});
|
||||
if (parent.isPrimary !== 1) {
|
||||
throw new BadRequestException('仅主账号可添加子账号');
|
||||
}
|
||||
|
||||
const phone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
}
|
||||
|
||||
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
||||
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||
|
||||
const smsCode = dto.smsCode.trim();
|
||||
if (!smsCode) throw new BadRequestException('请输入手机号验证码');
|
||||
try {
|
||||
await this.authService.verifySmsCode(phone, smsCode, SmsScene.PARTNER_STAFF_ADD);
|
||||
} catch (err) {
|
||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_verify_fail', parentAccountId, {
|
||||
phone: this.maskPhone(phone),
|
||||
reason: err instanceof BadRequestException ? err.message : '验证码错误',
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||
|
||||
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL;
|
||||
|
||||
const account = await this.prisma.partnerAccount.create({
|
||||
data: {
|
||||
phone,
|
||||
name,
|
||||
staffRole,
|
||||
permissions: dto.permissions ?? undefined,
|
||||
isPrimary: 0,
|
||||
parentAccountId: parent.id,
|
||||
status: 'DISABLED',
|
||||
},
|
||||
});
|
||||
|
||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
|
||||
name,
|
||||
phone: this.maskPhone(phone),
|
||||
staffRole,
|
||||
status: account.status,
|
||||
phoneVerified: true,
|
||||
});
|
||||
|
||||
return this.toStaffItem(account);
|
||||
}
|
||||
|
||||
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdatePartnerStaffDto) {
|
||||
const parentAccountId = actor.actorId;
|
||||
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
||||
const before = {
|
||||
name: staff.name,
|
||||
staffRole: staff.staffRole,
|
||||
status: staff.status,
|
||||
};
|
||||
const data: Record<string, unknown> = {};
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||
data.name = name;
|
||||
}
|
||||
if (dto.staffRole !== undefined) {
|
||||
data.staffRole = dto.staffRole as PartnerStaffRole;
|
||||
}
|
||||
if (dto.permissions !== undefined) {
|
||||
data.permissions = dto.permissions;
|
||||
}
|
||||
if (dto.status !== undefined) {
|
||||
data.status = dto.status;
|
||||
}
|
||||
const updated = await this.prisma.partnerAccount.update({
|
||||
where: { id: staff.id },
|
||||
data,
|
||||
});
|
||||
|
||||
const onlyRoleChange =
|
||||
(dto.staffRole !== undefined || dto.permissions !== undefined) &&
|
||||
dto.name === undefined &&
|
||||
dto.status === undefined;
|
||||
const eventName = onlyRoleChange ? 'partner_staff_permission_update' : 'partner_staff_update';
|
||||
|
||||
const primaryId = parentAccountId;
|
||||
this.trackStaffEvent(actor, primaryId, eventName, staff.id, {
|
||||
before,
|
||||
after: {
|
||||
name: updated.name,
|
||||
staffRole: updated.staffRole,
|
||||
status: updated.status,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toStaffItem(updated);
|
||||
}
|
||||
|
||||
async deleteStaff(actor: AuthUser, staffId: bigint) {
|
||||
const parentAccountId = actor.actorId;
|
||||
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
||||
|
||||
this.trackStaffEvent(actor, parentAccountId, 'partner_staff_delete', staff.id, {
|
||||
name: staff.name,
|
||||
phone: this.maskPhone(staff.phone),
|
||||
staffRole: staff.staffRole,
|
||||
status: staff.status,
|
||||
});
|
||||
|
||||
await this.prisma.partnerAccount.delete({ where: { id: staff.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private trackStaffEvent(
|
||||
actor: AuthUser,
|
||||
primaryAccountId: bigint,
|
||||
eventName: string,
|
||||
refId: bigint,
|
||||
extraJson?: Record<string, unknown>,
|
||||
) {
|
||||
this.analytics.trackPartnerOneSafe(actor.actorId, actor.clientApp, {
|
||||
partnerAccountId: primaryAccountId,
|
||||
eventName,
|
||||
refType: 'PARTNER_ACCOUNT',
|
||||
refId,
|
||||
extraJson,
|
||||
});
|
||||
}
|
||||
|
||||
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
|
||||
const staff = await this.prisma.partnerAccount.findFirst({
|
||||
where: { id: staffId, parentAccountId },
|
||||
});
|
||||
if (!staff) throw new NotFoundException('子账号不存在');
|
||||
return staff;
|
||||
}
|
||||
|
||||
private toStaffItem(row: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
phone: string;
|
||||
staffRole: string | null;
|
||||
permissions?: unknown;
|
||||
status: string;
|
||||
lastLoginAt: Date | null;
|
||||
}) {
|
||||
return serializeBigInt({
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
phone: this.maskPhone(row.phone),
|
||||
staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL,
|
||||
permissions: Array.isArray(row.permissions) ? row.permissions : undefined,
|
||||
status: row.status,
|
||||
lastLoginAt: row.lastLoginAt?.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private maskPhone(phone: string): string {
|
||||
if (phone.length !== 11) return phone;
|
||||
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
|
||||
}
|
||||
}
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ClientApp, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS, PartnerStaffRole, SmsScene } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PartnerStaffService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly analytics: AnalyticsService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
async listStaff(parentAccountId: bigint) {
|
||||
const rows = await this.prisma.partnerAccount.findMany({
|
||||
where: { parentAccountId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((row) => this.toStaffItem(row));
|
||||
}
|
||||
|
||||
async sendStaffPhoneSms(actor: AuthUser, phone: string) {
|
||||
const parentAccountId = actor.actorId;
|
||||
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: parentAccountId },
|
||||
});
|
||||
if (parent.isPrimary !== 1) {
|
||||
throw new BadRequestException('仅主账号可添加子账号');
|
||||
}
|
||||
|
||||
const normalized = phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(normalized)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
}
|
||||
|
||||
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } });
|
||||
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||
|
||||
const masked = this.maskPhone(normalized);
|
||||
try {
|
||||
await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, {
|
||||
clientApp: ClientApp.PARTNER_H5,
|
||||
});
|
||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
|
||||
phone: masked,
|
||||
scene: SmsScene.PARTNER_STAFF_ADD,
|
||||
status: 'success',
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof BadRequestException) {
|
||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
|
||||
phone: masked,
|
||||
scene: SmsScene.PARTNER_STAFF_ADD,
|
||||
status: 'failed',
|
||||
reason: err.message,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return { ok: true, maskedPhone: masked };
|
||||
}
|
||||
|
||||
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
|
||||
const parentAccountId = actor.actorId;
|
||||
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: parentAccountId },
|
||||
});
|
||||
if (parent.isPrimary !== 1) {
|
||||
throw new BadRequestException('仅主账号可添加子账号');
|
||||
}
|
||||
|
||||
const phone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
}
|
||||
|
||||
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
||||
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||
|
||||
const smsCode = dto.smsCode.trim();
|
||||
if (!smsCode) throw new BadRequestException('请输入手机号验证码');
|
||||
try {
|
||||
await this.authService.verifySmsCode(phone, smsCode, SmsScene.PARTNER_STAFF_ADD);
|
||||
} catch (err) {
|
||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_verify_fail', parentAccountId, {
|
||||
phone: this.maskPhone(phone),
|
||||
reason: err instanceof BadRequestException ? err.message : '验证码错误',
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||
|
||||
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL;
|
||||
const permissions =
|
||||
dto.permissions && dto.permissions.length > 0
|
||||
? dto.permissions
|
||||
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS];
|
||||
|
||||
const account = await this.prisma.partnerAccount.create({
|
||||
data: {
|
||||
phone,
|
||||
name,
|
||||
staffRole,
|
||||
permissions,
|
||||
isPrimary: 0,
|
||||
parentAccountId: parent.id,
|
||||
status: 'DISABLED',
|
||||
},
|
||||
});
|
||||
|
||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
|
||||
name,
|
||||
phone: this.maskPhone(phone),
|
||||
staffRole,
|
||||
permissions,
|
||||
status: account.status,
|
||||
phoneVerified: true,
|
||||
});
|
||||
|
||||
return this.toStaffItem(account);
|
||||
}
|
||||
|
||||
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdatePartnerStaffDto) {
|
||||
const parentAccountId = actor.actorId;
|
||||
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
||||
const before = {
|
||||
name: staff.name,
|
||||
staffRole: staff.staffRole,
|
||||
status: staff.status,
|
||||
};
|
||||
const data: Record<string, unknown> = {};
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||
data.name = name;
|
||||
}
|
||||
if (dto.staffRole !== undefined) {
|
||||
data.staffRole = dto.staffRole as PartnerStaffRole;
|
||||
}
|
||||
if (dto.permissions !== undefined) {
|
||||
data.permissions = dto.permissions;
|
||||
}
|
||||
if (dto.status !== undefined) {
|
||||
data.status = dto.status;
|
||||
}
|
||||
const updated = await this.prisma.partnerAccount.update({
|
||||
where: { id: staff.id },
|
||||
data,
|
||||
});
|
||||
|
||||
const onlyRoleChange =
|
||||
(dto.staffRole !== undefined || dto.permissions !== undefined) &&
|
||||
dto.name === undefined &&
|
||||
dto.status === undefined;
|
||||
const eventName = onlyRoleChange ? 'partner_staff_permission_update' : 'partner_staff_update';
|
||||
|
||||
const primaryId = parentAccountId;
|
||||
this.trackStaffEvent(actor, primaryId, eventName, staff.id, {
|
||||
before,
|
||||
after: {
|
||||
name: updated.name,
|
||||
staffRole: updated.staffRole,
|
||||
status: updated.status,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toStaffItem(updated);
|
||||
}
|
||||
|
||||
async deleteStaff(actor: AuthUser, staffId: bigint) {
|
||||
const parentAccountId = actor.actorId;
|
||||
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
||||
|
||||
this.trackStaffEvent(actor, parentAccountId, 'partner_staff_delete', staff.id, {
|
||||
name: staff.name,
|
||||
phone: this.maskPhone(staff.phone),
|
||||
staffRole: staff.staffRole,
|
||||
status: staff.status,
|
||||
});
|
||||
|
||||
await this.prisma.partnerAccount.delete({ where: { id: staff.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private trackStaffEvent(
|
||||
actor: AuthUser,
|
||||
primaryAccountId: bigint,
|
||||
eventName: string,
|
||||
refId: bigint,
|
||||
extraJson?: Record<string, unknown>,
|
||||
) {
|
||||
this.analytics.trackPartnerOneSafe(actor.actorId, actor.clientApp, {
|
||||
partnerAccountId: primaryAccountId,
|
||||
eventName,
|
||||
refType: 'PARTNER_ACCOUNT',
|
||||
refId,
|
||||
extraJson,
|
||||
});
|
||||
}
|
||||
|
||||
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
|
||||
const staff = await this.prisma.partnerAccount.findFirst({
|
||||
where: { id: staffId, parentAccountId },
|
||||
});
|
||||
if (!staff) throw new NotFoundException('子账号不存在');
|
||||
return staff;
|
||||
}
|
||||
|
||||
private toStaffItem(row: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
phone: string;
|
||||
staffRole: string | null;
|
||||
permissions?: unknown;
|
||||
status: string;
|
||||
lastLoginAt: Date | null;
|
||||
}) {
|
||||
return serializeBigInt({
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
phone: this.maskPhone(row.phone),
|
||||
staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL,
|
||||
permissions: Array.isArray(row.permissions) ? row.permissions : undefined,
|
||||
status: row.status,
|
||||
lastLoginAt: row.lastLoginAt?.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private maskPhone(phone: string): string {
|
||||
if (phone.length !== 11) return phone;
|
||||
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export class AdminOrdersService {
|
||||
if (query.orderNo) where.orderNo = { contains: query.orderNo };
|
||||
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
|
||||
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.createdFrom || query.createdTo) {
|
||||
where.createdAt = {};
|
||||
@@ -46,6 +47,8 @@ export class AdminOrdersService {
|
||||
include: {
|
||||
user: { select: { id: true, userNo: true, phone: true, nickname: 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 }),
|
||||
@@ -75,6 +78,18 @@ export class AdminOrdersService {
|
||||
city: { select: { id: true, name: true, code: true } },
|
||||
product: { select: { id: true, name: true, skuCode: true, barcode69: 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('订单不存在');
|
||||
@@ -101,7 +116,7 @@ export class AdminOrdersService {
|
||||
throw new BadRequestException('暂仅支持小飞侠配送');
|
||||
}
|
||||
|
||||
const order = await this.prisma.order.findUnique({
|
||||
let order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
delivery: true,
|
||||
@@ -117,7 +132,30 @@ export class AdminOrdersService {
|
||||
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;
|
||||
if (!warehouse) {
|
||||
throw new BadRequestException('请先选择履约仓库');
|
||||
}
|
||||
const providerId =
|
||||
order.delivery?.fulfillmentProviderId ??
|
||||
warehouse?.fulfillmentProviderId ??
|
||||
|
||||
@@ -711,6 +711,11 @@ export class AdminShipOrderDto {
|
||||
@IsIn(['XFX'])
|
||||
provider: string;
|
||||
|
||||
/** 可选:指定/改派履约仓后再推仓配 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
warehouseId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fromName?: string;
|
||||
|
||||
@@ -52,6 +52,10 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cityId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
receiverPhone?: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
|
||||
import { SettlementService } from './settlement.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||
@@ -269,7 +270,7 @@ export class PartnerMeController {
|
||||
}
|
||||
|
||||
private async buildPartnerMe(actorId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
let account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: actorId },
|
||||
});
|
||||
let primary = account;
|
||||
@@ -278,6 +279,23 @@ export class PartnerMeController {
|
||||
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 {
|
||||
id: account.id.toString(),
|
||||
|
||||
@@ -75,19 +75,29 @@ export class PartnerStoreController {
|
||||
) {
|
||||
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')
|
||||
@UseGuards(JwtAuthGuard, PartnerPermissionGuard)
|
||||
@RequirePartnerPermissions('store:create', 'store:manage', 'order:view', 'warehouse:manage')
|
||||
export class PartnerDashboardController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePartnerPermissions('store:create', 'store:manage', 'order:view', 'warehouse:manage')
|
||||
dashboard(@CurrentUser() user: AuthUser) {
|
||||
return this.storeService.partnerDashboard(user.actorId);
|
||||
}
|
||||
|
||||
/** 任意合伙人子账号可看同主账号排行(激励),不校验业务权限点 */
|
||||
@Get('leaderboard')
|
||||
leaderboard(
|
||||
@CurrentUser() user: AuthUser,
|
||||
|
||||
@@ -85,15 +85,7 @@ export class StoreService {
|
||||
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
|
||||
}
|
||||
|
||||
const media = await this.prisma.commonResource.findMany({
|
||||
where: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: storeId,
|
||||
status: 'ACTIVE',
|
||||
bizType: { in: ['ENV', 'CONTRACT'] },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
const media = await this.loadPartnerStoreMedia(storeId);
|
||||
return serializeBigInt(mapStoreCompat({ ...store, media }));
|
||||
}
|
||||
|
||||
@@ -169,9 +161,7 @@ export class StoreService {
|
||||
|
||||
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
||||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||||
const envPhotoUrls = Array.isArray(body.envPhotoUrls)
|
||||
? body.envPhotoUrls.map((u) => String(u).trim()).filter(Boolean)
|
||||
: [];
|
||||
const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls);
|
||||
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
|
||||
|
||||
if (!coverUrl) throw new BadRequestException('请上传门头照');
|
||||
@@ -317,7 +307,7 @@ export class StoreService {
|
||||
status: 'OPEN' | 'PAUSED' | 'CLOSED',
|
||||
) {
|
||||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||
this.assertPrimaryAccount(account);
|
||||
await this.assertCanMutateStore(account, partnerAccountId, storeId);
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id: storeId, partnerAccountId: primaryId },
|
||||
});
|
||||
@@ -365,7 +355,7 @@ export class StoreService {
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||
this.assertPrimaryAccount(account);
|
||||
await this.assertCanMutateStore(account, partnerAccountId, storeId);
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id: storeId, partnerAccountId: primaryId },
|
||||
});
|
||||
@@ -429,6 +419,115 @@ export class StoreService {
|
||||
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) {
|
||||
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||
@@ -889,6 +988,88 @@ export class StoreService {
|
||||
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 }) {
|
||||
if (this.isSubAccount(account)) {
|
||||
throw new ForbiddenException('子账号无权执行此操作');
|
||||
|
||||
@@ -75,7 +75,6 @@ export class TradeService {
|
||||
city.localMinQty,
|
||||
city.crossMinQty,
|
||||
);
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
|
||||
const unitPrice = Number(product.price);
|
||||
const productAmount = unitPrice * body.quantity;
|
||||
@@ -96,6 +95,10 @@ export class TradeService {
|
||||
payAmount: productAmount,
|
||||
benefitAmount: benefitPerUnit * body.quantity,
|
||||
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,
|
||||
) {
|
||||
const preview = await this.preview(userId, body);
|
||||
if (preview.quantityOk === false) {
|
||||
throw new BadRequestException(preview.quantityMessage || '购买数量不满足起购要求');
|
||||
}
|
||||
const address = await this.prisma.userAddress.findFirst({
|
||||
where: { id: BigInt(body.addressId), userId },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user