38 Commits

Author SHA1 Message Date
jacy 81a6e3674b Merge pull request 'Dev' (#9) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/9
2026-07-17 10:50:17 +08:00
jacy 76270b20bf Merge branches 'dev_jacy' and 'dev' of git.yqidian.com:jacy/dukang into dev
CI / verify (pull_request) Has been cancelled
2026-07-17 10:43:19 +08:00
jacy c2914c37e5 fix(partner): grant store staff open/close and media permissions by default
CI / verify (pull_request) Has been cancelled
Default new sub-accounts to store:create+store:manage, backfill empty permissions on /partner/me, and treat legacy store staff as allowed to mutate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:43:15 +08:00
jacy 9f9b7cb2bd merge(dev_jacy): partner store manage and env photo dedupe 2026-07-17 10:36:31 +08:00
jacy de396442a4 feat(partner): let staff manage stores and fix env photo dupes
CI / verify (pull_request) Has been cancelled
Allow store:create/manage sub-accounts to edit, open/close, and re-upload media; dedupe ENV photos on write/read and replace via media API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:36:29 +08:00
jacy 02efe6fa14 merge(dev_jacy): fix login agreement visibility 2026-07-17 10:20:36 +08:00
jacy 14b867a3a5 fix(auth): show user agreement before login actions
CI / verify (pull_request) Has been cancelled
Quick login required agreement without a checkbox; move the consent row above CTAs across partner/shop/user/mini login pages so it stays visible on short screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:20:32 +08:00
jacy f750fea667 merge(dev_jacy): skip unauthorized partner home APIs 2026-07-17 10:13:45 +08:00
jacy 55e91158b3 fix(partner): skip unauthorized home APIs for sub-accounts
CI / verify (pull_request) Has been cancelled
Gate dashboard/stores/orders by permission and move dashboard auth to method level so leaderboard stays open without toasting 403s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:13:43 +08:00
jacy 19cb13d26c merge(dev_jacy): partner sub-account team leaderboard 2026-07-17 10:02:44 +08:00
jacy 04253b9120 feat(partner): let sub-accounts view team leaderboard
CI / verify (pull_request) Has been cancelled
Expose same-primary ranking on home/me for all staff including warehouse roles, and open the leaderboard API without business permission gates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:02:42 +08:00
jacy aaf6ee81d5 merge(dev_jacy): fix mini-user production API origin 2026-07-17 09:30:12 +08:00
jacy 972bdde650 fix(mini-user): restore production API origin for H5 builds
CI / verify (pull_request) Has been cancelled
Production builds were baking localhost:3000 after the local-dev default change, causing failed to fetch on product list.

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