diff --git a/.umirc.ts b/.umirc.ts index 723972c..0e17571 100644 --- a/.umirc.ts +++ b/.umirc.ts @@ -4,7 +4,7 @@ const isDev = process.env.NODE_ENV === 'development'; const UMI_APP_API_URL = isDev ? 'http://localhost:7001' : 'https://api.yoone.ca'; - import { codeInspectorPlugin } from 'code-inspector-plugin'; +import { codeInspectorPlugin } from 'code-inspector-plugin'; export default defineConfig({ hash: true, @@ -43,6 +43,18 @@ export default defineConfig({ }, ], }, + { + name: '站点管理', + path: '/site', + access: 'canSeeSite', + routes: [ + { + name: '站点列表', + path: '/site/list', + component: './Site/List', + }, + ], + }, { name: '商品管理', path: '/product', @@ -124,6 +136,24 @@ export default defineConfig({ }, ], }, + // 新增:订阅管理路由分组(权限复用 canSeeOrder) + { + name: '订阅管理', + path: '/subscription', + access: 'canSeeOrder', + routes: [ + { + name: '订阅列表', + path: '/subscription/list', + component: './Subscription/List', + }, + { + name: '订单管理', + path: '/subscription/orders', + component: './Subscription/Orders', + }, + ], + }, { name: '客户管理', path: '/customer', diff --git a/package.json b/package.json index 07c5682..d77a71f 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "code-inspector-plugin": "^1.2.10", "husky": "^9", "lint-staged": "^13.2.0", + "openapi2ts": "^1.1.14", "prettier": "^2.8.7", "prettier-plugin-organize-imports": "^3.2.2", "prettier-plugin-packagejson": "^2.4.3", diff --git a/src/access.ts b/src/access.ts index 35a8b86..c6d252a 100644 --- a/src/access.ts +++ b/src/access.ts @@ -9,6 +9,7 @@ export default (initialState: any) => { const canSeeCustomer = (isSuper || isAdmin) || (initialState?.user?.permissions?.includes('customer') ?? false); const canSeeLogistics = (isSuper || isAdmin) || (initialState?.user?.permissions?.includes('logistics') ?? false); const canSeeStatistics = (isSuper || isAdmin) || (initialState?.user?.permissions?.includes('statistics') ?? false); + const canSeeSite = (isSuper || isAdmin) || (initialState?.user?.permissions?.includes('site') ?? false); return { canSeeOrganiza, @@ -18,5 +19,6 @@ export default (initialState: any) => { canSeeCustomer, canSeeLogistics, canSeeStatistics, + canSeeSite, }; }; diff --git a/src/app.tsx b/src/app.tsx index ad17b86..50ea763 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -16,7 +16,7 @@ import { usercontrollerGetuser } from './servers/api/user'; dayjs.locale('zh-cn'); // 全局初始化数据配置,用于 Layout 用户信息和权限初始化 -// 更多信息见文档:https://umijs.org/docs/api/runtime-config#getinitialstate +// 更多信息见文档:https://umijs.org/docs/api/runtime-config#getinitialstate export async function getInitialState(): Promise<{ user?: Record; categoryList?: ProSchemaValueEnumObj; diff --git a/src/hooks/useDeviceFingerprint.ts b/src/hooks/useDeviceFingerprint.ts index f51f6df..b31dee2 100644 --- a/src/hooks/useDeviceFingerprint.ts +++ b/src/hooks/useDeviceFingerprint.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import FingerprintJS from '@fingerprintjs/fingerprintjs'; /** - * Hook: 获取设备指纹(visitorId) + * Hook: 获取设备指纹(visitorId) */ export function useDeviceFingerprint() { const [fingerprint, setFingerprint] = useState(null); diff --git a/src/pages/Order/Items/index.tsx b/src/pages/Order/Items/index.tsx new file mode 100644 index 0000000..945ddbf --- /dev/null +++ b/src/pages/Order/Items/index.tsx @@ -0,0 +1,149 @@ +import React, { useRef } from 'react'; +import { PageContainer } from '@ant-design/pro-layout'; +import type { ProColumns, ActionType, ProTableProps } from '@ant-design/pro-components'; +import { ProTable } from '@ant-design/pro-components'; +import { App } from 'antd'; +import dayjs from 'dayjs'; +import { ordercontrollerGetordersales } from '@/servers/api/order'; +import { sitecontrollerAll } from '@/servers/api/site'; + +// 列表行数据结构(订单商品聚合) +interface OrderItemAggRow { + externalProductId: number; // 商品ID(来自 WooCommerce 产品ID) + externalVariationId: number; // 变体ID(来自 WooCommerce 变体ID) + name: string; // 商品名称 + totalQuantity: number; // 总售出数量(时间范围内) + totalOrders: number; // 涉及订单数(去重) + firstOrderCount: number; // 客户首单次数(该商品) + secondOrderCount: number; // 客户第二次购买次数(该商品) + thirdOrderCount: number; // 客户第三次购买次数(该商品) + moreThirdOrderCount: number; // 客户超过三次购买次数(该商品) +} + +const OrderItemsPage: React.FC = () => { + const actionRef = useRef(); + const { message } = App.useApp(); + + // 列配置(中文标题,符合当前项目风格;显示英文默认语言可后续走国际化) + const columns: ProColumns[] = [ + { + title: '商品名称', + dataIndex: 'name', + width: 220, + }, + { + title: '商品ID', + dataIndex: 'externalProductId', + width: 120, + hideInSearch: true, + }, + { + title: '变体ID', + dataIndex: 'externalVariationId', + width: 120, + hideInSearch: true, + }, + { + title: '总售出数量', + dataIndex: 'totalQuantity', + width: 130, + hideInSearch: true, + }, + { + title: '订单数', + dataIndex: 'totalOrders', + width: 110, + hideInSearch: true, + }, + { + title: '首单次数', + dataIndex: 'firstOrderCount', + width: 120, + hideInSearch: true, + }, + { + title: '第二次购买', + dataIndex: 'secondOrderCount', + width: 120, + hideInSearch: true, + }, + { + title: '第三次购买', + dataIndex: 'thirdOrderCount', + width: 120, + hideInSearch: true, + }, + { + title: '超过三次购买', + dataIndex: 'moreThirdOrderCount', + width: 140, + hideInSearch: true, + }, + // 搜索区域字段 + { + title: '站点', + dataIndex: 'siteId', + valueType: 'select', + request: async () => { + // 拉取站点列表(后台 /site/all) + const { data = [] } = await sitecontrollerAll(); + return (data || []).map((item: any) => ({ label: item.siteName, value: item.id })); + }, + }, + { + title: '时间范围', + dataIndex: 'dateRange', + valueType: 'dateRange', + hideInTable: true, + }, + { + title: '商品关键字', + dataIndex: 'name', + hideInTable: true, + }, + ]; + + // 表格请求方法:调用 /order/getOrderSales 接口并设置 isSource=true 获取订单项聚合 + const request: ProTableProps['request'] = async (params:any) => { + try { + const { current = 1, pageSize = 10, siteId, name } = params as any; + const [startDate, endDate] = (params as any).dateRange || []; + // 调用后端接口(isSource=true 表示按订单项聚合) + const resp = await ordercontrollerGetordersales({ + current, + pageSize, + siteId, + name, + isSource: true as any, + startDate: startDate ? (dayjs(startDate).toISOString() as any) : undefined, + endDate: endDate ? (dayjs(endDate).toISOString() as any) : undefined, + } as any); + const { success, data, message: errMsg } = resp as any; + if (!success) throw new Error(errMsg || '获取失败'); + return { + data: (data?.items ?? []) as OrderItemAggRow[], + total: data?.total ?? 0, + success: true, + }; + } catch (e: any) { + message.error(e?.message || '获取失败'); + return { data: [], total: 0, success: false }; + } + }; + + return ( + + + actionRef={actionRef} + rowKey={(r) => `${r.externalProductId}-${r.externalVariationId}-${r.name}`} + columns={columns} + request={request} + pagination={{ showSizeChanger: true }} + search={{ labelWidth: 90, span: 6 }} + toolBarRender={false} + /> + + ); +}; + +export default OrderItemsPage; \ No newline at end of file diff --git a/src/pages/Order/List/index.tsx b/src/pages/Order/List/index.tsx index 35e2d6e..c45abaf 100644 --- a/src/pages/Order/List/index.tsx +++ b/src/pages/Order/List/index.tsx @@ -77,9 +77,10 @@ import { Space, Tabs, TabsProps, + Tag, } from 'antd'; import Item from 'antd/es/list/Item'; -import dayjs from 'dayjs'; +import RelatedOrders from '../../Subscription/Orders/RelatedOrders'; import React, { useMemo, useRef, useState } from 'react'; import { printPDF } from '@/utils/util'; @@ -172,9 +173,16 @@ const ListPage: React.FC = () => { hideInTable: true, valueType: 'dateRange', }, + { - title: '订单号', - dataIndex: 'externalOrderId', + title: '订阅', + dataIndex: 'isSubscription', + hideInSearch: true, + render: (_, record) => { + const related = Array.isArray((record as any)?.related) ? (record as any).related : []; + const isSub = related.some((it) => it?.externalSubscriptionId || it?.billing_period || it?.line_items); + return {isSub ? '是' : '否'}; + }, }, { title: '站点', @@ -816,7 +824,7 @@ const Detail: React.FC<{ return (
- company: + company: {record?.shipping?.company || record?.billing?.company || @@ -824,7 +832,7 @@ const Detail: React.FC<{
- first_name: + first_name: {record?.shipping?.first_name || record?.billing?.first_name || @@ -832,7 +840,7 @@ const Detail: React.FC<{
- last_name: + last_name: {record?.shipping?.last_name || record?.billing?.last_name || @@ -840,7 +848,7 @@ const Detail: React.FC<{
- country: + country: {record?.shipping?.country || record?.billing?.country || @@ -848,19 +856,19 @@ const Detail: React.FC<{
- state: + state: {record?.shipping?.state || record?.billing?.state || '-'}
- city: + city: {record?.shipping?.city || record?.billing?.city || '-'}
- postcode: + postcode: {record?.shipping?.postcode || record?.billing?.postcode || @@ -868,13 +876,13 @@ const Detail: React.FC<{
- phone: + phone: {record?.shipping?.phone || record?.billing?.phone || '-'}
- address_1: + address_1: {record?.shipping?.address_1 || record?.billing?.address_1 || @@ -885,6 +893,7 @@ const Detail: React.FC<{ ); }} /> + {/* 原始订单 */} {record?.items?.map((item: any) => (
  • - {item.name}:{item.quantity} + {item.name}:{item.quantity}
  • ))} ); }} /> + {/* 显示 related order */} + { + return ; + }} +/> + {/* 订单内容 */} {record?.sales?.map((item: any) => (
  • - {item.name}:{item.quantity} + {item.name}:{item.quantity}
  • ))} @@ -1021,7 +1039,7 @@ const Detail: React.FC<{ : [] } > -
    订单号: {v?.orderIds?.join(',')}
    +
    订单号: {v?.orderIds?.join(',')}
    {v?.items?.map((item) => (
    {item.name}: {item.quantity} diff --git a/src/pages/Site/List/index.tsx b/src/pages/Site/List/index.tsx new file mode 100644 index 0000000..40ad842 --- /dev/null +++ b/src/pages/Site/List/index.tsx @@ -0,0 +1,254 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { ActionType, ProColumns, ProTable, ProFormInstance } from '@ant-design/pro-components'; +import { DrawerForm, ProFormText, ProFormSelect, ProFormSwitch } from '@ant-design/pro-components'; +import { Button, message, Popconfirm, Space, Tag } from 'antd'; +import { request } from '@umijs/max'; + +// 站点数据项类型(前端不包含密钥字段,后端列表不返回密钥) +interface SiteItem { + id: number; + siteName: string; + apiUrl?: string; + type?: 'woocommerce' | 'shopyy'; + skuPrefix?: string; + isDisabled: number; +} + +// 创建/更新表单的值类型,包含可选的密钥字段 +interface SiteFormValues { + siteName: string; + apiUrl?: string; + type?: 'woocommerce' | 'shopyy'; + isDisabled?: boolean; + consumerKey?: string; // WooCommerce REST API 的 consumer key + consumerSecret?: string; // WooCommerce REST API 的 consumer secret + skuPrefix?: string; +} + +const SiteList: React.FC = () => { + const actionRef = useRef(); + const formRef = useRef(); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + + useEffect(() => { + if (!open) return; + if (editing) { + formRef.current?.setFieldsValue({ + siteName: editing.siteName, + apiUrl: editing.apiUrl, + type: editing.type, + skuPrefix: editing.skuPrefix, + isDisabled: !!editing.isDisabled, + consumerKey: undefined, + consumerSecret: undefined, + }); + } else { + formRef.current?.setFieldsValue({ + siteName: undefined, + apiUrl: undefined, + type: 'woocommerce', + skuPrefix: undefined, + isDisabled: false, + consumerKey: undefined, + consumerSecret: undefined, + }); + } + }, [open, editing]); + + // 表格列定义 + const columns: ProColumns[] = [ + { title: 'ID', dataIndex: 'id', width: 80, sorter: true, hideInSearch: true }, + { title: '站点名称', dataIndex: 'siteName', width: 220 }, + { title: 'API 地址', dataIndex: 'apiUrl', width: 280, hideInSearch: true }, + { title: 'SKU 前缀', dataIndex: 'skuPrefix', width: 160, hideInSearch: true }, + { + title: '平台', + dataIndex: 'type', + width: 140, + valueType: 'select', + request: async () => [ + { label: 'WooCommerce', value: 'woocommerce' }, + { label: 'Shopyy', value: 'shopyy' }, + ], + }, + { + title: '状态', + dataIndex: 'isDisabled', + width: 120, + hideInSearch: true, + render: (_, row) => ( + + {row.isDisabled ? '已禁用' : '启用中'} + + ), + }, + { + title: '操作', + dataIndex: 'actions', + width: 240, + hideInSearch: true, + render: (_, row) => ( + + + { + try { + await request(`/site/disable/${row.id}`, { + method: 'PUT', + data: { disabled: !row.isDisabled }, + }); + message.success('更新成功'); + actionRef.current?.reload(); + } catch (e: any) { + message.error(e?.message || '更新失败'); + } + }} + > + + + + ), + }, + ]; + + // 表格数据请求 + const tableRequest = async (params: Record) => { + try { + const { current = 1, pageSize = 10, siteName, type } = params; + const resp = await request('/site/list', { + method: 'GET', + params: { + current, + pageSize, + keyword: siteName || undefined, + type: type || undefined, + }, + }); + const { success, data, message: errMsg } = resp as any; + if (!success) throw new Error(errMsg || '获取失败'); + return { + data: (data?.items ?? []) as SiteItem[], + total: data?.total ?? 0, + success: true, + }; + } catch (e: any) { + message.error(e?.message || '获取失败'); + return { data: [], total: 0, success: false }; + } + }; + + // 提交创建/更新逻辑;编辑时未填写密钥则不提交(保持原值) + const handleSubmit = async (values: SiteFormValues) => { + try { + if (editing) { + const payload: Record = { + // 仅提交存在的字段,避免覆盖为 null/空 + ...(values.siteName ? { siteName: values.siteName } : {}), + ...(values.apiUrl ? { apiUrl: values.apiUrl } : {}), + ...(values.type ? { type: values.type } : {}), + ...(typeof values.isDisabled === 'boolean' ? { isDisabled: values.isDisabled } : {}), + ...(values.skuPrefix ? { skuPrefix: values.skuPrefix } : {}), + }; + // 仅当输入了新密钥时才提交,未输入则保持原本值 + if (values.consumerKey && values.consumerKey.trim()) { + payload.consumerKey = values.consumerKey.trim(); + } + if (values.consumerSecret && values.consumerSecret.trim()) { + payload.consumerSecret = values.consumerSecret.trim(); + } + await request(`/site/update/${editing.id}`, { method: 'PUT', data: payload }); + } else { + // 新增站点时要求填写 consumerKey 和 consumerSecret + if (!values.consumerKey || !values.consumerSecret) { + throw new Error('Consumer Key and Secret are required'); + } + await request('/site/create', { + method: 'POST', + data: { + siteName: values.siteName, + apiUrl: values.apiUrl, + type: values.type || 'woocommerce', + consumerKey: values.consumerKey, + consumerSecret: values.consumerSecret, + skuPrefix: values.skuPrefix, + }, + }); + } + message.success('提交成功'); + setOpen(false); + setEditing(null); + actionRef.current?.reload(); + return true; + } catch (e: any) { + message.error(e?.message || '提交失败'); + return false; + } + }; + + return ( + <> + + actionRef={actionRef} + rowKey="id" + columns={columns} + request={tableRequest} + toolBarRender={() => [ + , + ]} + /> + + + title={editing ? '编辑站点' : '新增站点'} + open={open} + onOpenChange={setOpen} + formRef={formRef} + onFinish={handleSubmit} + > + {/* 站点名称,必填 */} + + {/* API 地址,可选 */} + + {/* 平台类型选择 */} + + {/* 是否禁用 */} + + + {/* WooCommerce REST consumer key;新增必填,编辑不填则保持原值 */} + + {/* WooCommerce REST consumer secret;新增必填,编辑不填则保持原值 */} + + + + ); +}; + +export default SiteList; \ No newline at end of file diff --git a/src/pages/Statistics/Customer/index.tsx b/src/pages/Statistics/Customer/index.tsx index c47680b..f08e024 100644 --- a/src/pages/Statistics/Customer/index.tsx +++ b/src/pages/Statistics/Customer/index.tsx @@ -192,7 +192,7 @@ const ListPage: React.FC = () => {
    {record?.first_hot_purchase?.map((v) => (
    - 产品名称:{v.name} 用户数:{v.user_count} + 产品名称:{v.name} 用户数:{v.user_count}
    ))}
    @@ -206,7 +206,7 @@ const ListPage: React.FC = () => {
    {record?.second_hot_purchase?.map((v) => (
    - 产品名称:{v.name} 用户数:{v.user_count} + 产品名称:{v.name} 用户数:{v.user_count}
    ))}
    @@ -220,7 +220,7 @@ const ListPage: React.FC = () => {
    {record?.third_hot_purchase?.map((v) => (
    - 产品名称:{v.name} 用户数:{v.user_count} + 产品名称:{v.name} 用户数:{v.user_count}
    ))}
    diff --git a/src/pages/Statistics/Sales/index.tsx b/src/pages/Statistics/Sales/index.tsx index 5ca0853..e39a415 100644 --- a/src/pages/Statistics/Sales/index.tsx +++ b/src/pages/Statistics/Sales/index.tsx @@ -157,7 +157,7 @@ const ListPage: React.FC = () => { }} columns={columns} dateFormatter="number" - footer={() => `总计: ${total}`} + footer={() => `总计: ${total}`} toolBarRender={() => [ } /> {({ items }) => { - return '数量:' + (items?.reduce?.((acc, cur) => acc + cur.quantity, 0)||0); + return '数量:' + (items?.reduce?.((acc, cur) => acc + cur.quantity, 0)||0); }} {({ items }) => { - return '数量:' + items?.reduce?.((acc, cur) => acc + cur.quantity, 0); + return '数量:' + items?.reduce?.((acc, cur) => acc + cur.quantity, 0); }} = { + active: { text: '激活' }, + cancelled: { text: '已取消' }, + expired: { text: '已过期' }, + pending: { text: '待处理' }, + 'on-hold': { text: '暂停' }, +}; + +/** + * 订阅列表页:展示、筛选、触发订阅同步 + */ +const ListPage: React.FC = () => { + // 表格操作引用:用于在同步后触发表格刷新 + const actionRef = useRef(); + const { message } = App.useApp(); + + // 关联订单抽屉状态 + const [drawerOpen, setDrawerOpen] = useState(false); + const [drawerTitle, setDrawerTitle] = useState('详情'); + const [relatedOrders, setRelatedOrders] = useState([]); + + // 表格列定义(尽量与项目风格保持一致) + const columns: ProColumns[] = [ + { + title: '站点', + dataIndex: 'siteId', + valueType: 'select', + // 动态加载站点选项 + request: async () => { + const { data = [] } = await sitecontrollerAll(); + return data.map((item) => ({ + label: item.siteName, + value: item.id, + })); + }, + render: (_, row) => row?.siteId ?? '-', + }, + { + title: '订阅ID', + dataIndex: 'externalSubscriptionId', + hideInSearch: true, + width: 120, + }, + { + title: '父订单号', + dataIndex: 'parent_id', + hideInSearch: true, + width: 120, + render: (_, row) => (row?.parent_id ? {row.parent_id} : '-'), + }, + { + title: '状态', + dataIndex: 'status', + valueType: 'select', + valueEnum: SUBSCRIPTION_STATUS_ENUM, + // 以 Tag 形式展示,更易辨识 + render: (_, row) => + row?.status ? {SUBSCRIPTION_STATUS_ENUM[row.status]?.text || row.status} : '-', + width: 120, + }, + { + title: '客户邮箱', + dataIndex: 'customer_email', + width: 180, + }, + { + title: '金额', + dataIndex: 'total', + hideInSearch: true, + width: 100, + }, + { + title: '币种', + dataIndex: 'currency', + hideInSearch: true, + width: 80, + }, + { + title: '开始时间', + dataIndex: 'start_date', + hideInSearch: true, + width: 160, + }, + { + title: '下次支付', + dataIndex: 'next_payment_date', + hideInSearch: true, + width: 160, + }, + { + title: '结束时间', + dataIndex: 'end_date', + hideInSearch: true, + width: 160, + }, + { + title: '创建时间', + dataIndex: 'createdAt', + valueType: 'dateTime', + hideInSearch: true, + width: 160, + }, + { + title: '更新时间', + dataIndex: 'updatedAt', + valueType: 'dateTime', + hideInSearch: true, + width: 160, + }, + { + title: '操作', + dataIndex: 'actions', + hideInSearch: true, + width: 120, + render: (_, row) => ( + + ), + }, + ]; + + return ( + + + headerTitle="查询表格" + rowKey="id" + actionRef={actionRef} + /** + * 列表数据请求;保持与后端分页参数一致 + * 兼容后端 data.items 或 data.list 返回字段 + */ + request={async (params) => { + const { data, success } = await subscriptioncontrollerList(params); + return { + total: data?.total || 0, + data: data?.items || data?.list || [], + success, + }; + }} + columns={columns} + // 工具栏:订阅同步入口 + toolBarRender={() => []} + /> + {/* 关联订单抽屉:展示订单号、关系、时间、状态与金额 */} + setDrawerOpen(false)} + > + 关联订单
    } + dataSource={relatedOrders} + renderItem={(item: any) => ( + + +
    + {item?.date_created ? dayjs(item.date_created).format('YYYY-MM-DD HH:mm') : '-'} + {item?.status || '-'} + + {item?.currency_symbol || ''} + {typeof item?.total === 'number' ? item.total.toFixed(2) : item?.total ?? '-'} + +
    +
    + )} + /> + + + ); +}; + +/** + * 同步订阅抽屉表单:选择站点后触发同步 + */ +const SyncForm: React.FC<{ + tableRef: React.MutableRefObject; +}> = ({ tableRef }) => { + const { message } = App.useApp(); + + return ( + + title="同步订阅" + trigger={ + + } + autoFocusFirstInput + drawerProps={{ destroyOnHidden: true }} + /** + * 提交逻辑: + * 1. 必填校验由 ProForm + rules 保证 + * 2. 调用同步接口,失败时友好提示 + * 3. 成功后刷新列表 + */ + onFinish={async (values) => { + try { + const { success, message: errMsg } = await subscriptioncontrollerSync(values); + if (!success) { + throw new Error(errMsg); + } + message.success('同步成功'); + tableRef.current?.reload(); + return true; + } catch (error: any) { + message.error(error.message || '同步失败'); + } + }} + > + { + const { data = [] } = await sitecontrollerAll(); + return data.map((item) => ({ + label: item.siteName, + value: item.id, + })); + }} + rules={[{ required: true, message: '请选择站点' }]} + /> + + ); +}; + +export default ListPage; diff --git a/src/pages/Subscription/Orders/OrderDetailDrawer.tsx b/src/pages/Subscription/Orders/OrderDetailDrawer.tsx new file mode 100644 index 0000000..eabbbb3 --- /dev/null +++ b/src/pages/Subscription/Orders/OrderDetailDrawer.tsx @@ -0,0 +1,327 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + App, + Button, + Card, + Divider, + Drawer, + Empty, + Popconfirm, + Space, + Tag, +} from 'antd'; +import { ActionType, ProDescriptions } from '@ant-design/pro-components'; +import { CopyOutlined, DeleteFilled } from '@ant-design/icons'; + +// 服务器 API 引用(保持与原 index.tsx 一致) +import { + ordercontrollerChangestatus, + ordercontrollerGetorderdetail, + ordercontrollerSyncorderbyid, +} from '@/servers/api/order'; +import { logisticscontrollerDelshipment } from '@/servers/api/logistics'; +import { sitecontrollerAll } from '@/servers/api/site'; + +// 工具与子组件 +import { formatShipmentState, formatSource } from '@/utils/format'; +import RelatedOrders from './RelatedOrders'; +import { ORDER_STATUS_ENUM } from '@/constants'; + +// 为保持原文件结构简单,此处从 index.tsx 引入的子组件仍由原文件导出或保持原状 +// 若后续需要彻底解耦,可将 OrderNote / Shipping / SalesChange 也独立到文件 +// 当前按你的要求仅抽离详情 Drawer + +type OrderRecord = API.Order; + +interface OrderDetailDrawerProps { + tableRef: React.MutableRefObject; // 列表刷新引用 + orderId: number; // 订单主键 ID + record: OrderRecord; // 订单行记录 + open: boolean; // 是否打开抽屉 + onClose: () => void; // 关闭抽屉回调 + setActiveLine: (id: number) => void; // 高亮当前行 + OrderNoteComponent: React.ComponentType; // 备注组件(从外部注入) + SalesChangeComponent: React.ComponentType; // 换货组件(从外部注入) +} + +const OrderDetailDrawer: React.FC = ({ + tableRef, + orderId, + record, + open, + onClose, + setActiveLine, + OrderNoteComponent, + SalesChangeComponent, +}) => { + const { message } = App.useApp(); + const ref = useRef(); + + // 加载详情数据(与 index.tsx 中完全保持一致) + const initRequest = async () => { + const { data, success }: API.OrderDetailRes = await ordercontrollerGetorderdetail({ orderId }); + if (!success || !data) return { data: {} } as any; + data.sales = data.sales?.reduce((acc: API.OrderSale[], cur: API.OrderSale) => { + const idx = acc.findIndex((v: any) => v.productId === cur.productId); + if (idx === -1) acc.push(cur); + else acc[idx].quantity += cur.quantity; + return acc; + }, []); + return { data } as any; + }; + + useEffect(() => { + if (open && record?.id) { + setActiveLine(record.id as number); + } + }, [open, record?.id]); + + return ( + , + ...(['after_sale_pending', 'pending_reshipment'].includes( + record.orderStatus, + ) + ? [] + : [ + , + , + ]), + ...([ + 'processing', + 'pending_reshipment', + 'completed', + 'pending_refund', + ].includes(record.orderStatus) + ? [ + , + { + try { + const { success, message: errMsg } = + await ordercontrollerChangestatus( + { id: record.id }, + { status: 'after_sale_pending' }, + ); + if (!success) throw new Error(errMsg); + tableRef.current?.reload(); + } catch (error: any) { + message.error(error.message); + } + }} + > + + , + ] + : []), + ...(record.orderStatus === 'after_sale_pending' + ? [ + , + { + try { + const { success, message: errMsg } = + await ordercontrollerChangestatus( + { id: record.id }, + { status: 'cancelled' }, + ); + if (!success) throw new Error(errMsg); + tableRef.current?.reload(); + } catch (error: any) { + message.error(error.message); + } + }} + > + + , + , + { + try { + const { success, message: errMsg } = + await ordercontrollerChangestatus( + { id: record.id }, + { status: 'refund_requested' }, + ); + if (!success) throw new Error(errMsg); + tableRef.current?.reload(); + } catch (error: any) { + message.error(error.message); + } + }} + > + + , + , + { + try { + const { success, message: errMsg } = + await ordercontrollerChangestatus( + { id: record.id }, + { status: 'completed' }, + ); + if (!success) throw new Error(errMsg); + tableRef.current?.reload(); + } catch (error: any) { + message.error(error.message); + } + }} + > + + , + ] + : []), + ]} + > + + { + const { data = [] } = await sitecontrollerAll(); + return data.map((item) => ({ label: item.siteName, value: item.id })); + }} /> + + + + + ( +
    {r?.shipping?.phone || r?.billing?.phone || '-'}
    + )} /> + + + + formatSource(r.source_type, r.utm_source)} /> + + + + ( +
    +
    company:{r?.shipping?.company || r?.billing?.company || '-'}
    +
    first_name:{r?.shipping?.first_name || r?.billing?.first_name || '-'}
    +
    last_name:{r?.shipping?.last_name || r?.billing?.last_name || '-'}
    +
    country:{r?.shipping?.country || r?.billing?.country || '-'}
    +
    state:{r?.shipping?.state || r?.billing?.state || '-'}
    +
    city:{r?.shipping?.city || r?.billing?.city || '-'}
    +
    postcode:{r?.shipping?.postcode || r?.billing?.postcode || '-'}
    +
    phone:{r?.shipping?.phone || r?.billing?.phone || '-'}
    +
    address_1:{r?.shipping?.address_1 || r?.billing?.address_1 || '-'}
    +
    + )} /> + ( +
      + {(r?.items || []).map((item: any) => ( +
    • {item.name}:{item.quantity}
    • + ))} +
    + )} /> + ( + + )} /> + ( +
      + {(r?.sales || []).map((item: any) => ( +
    • {item.name}:{item.quantity}
    • + ))} +
    + )} /> + ( + + )} /> + { + if (!r.notes || r.notes.length === 0) return (); + return ( +
    + {r.notes.map((note: any) => ( +
    +
    + {note.username} + {note.createdAt} +
    +
    {note.content}
    +
    + ))} +
    + ); + }} /> + { + if (!r.shipment || r.shipment.length === 0) return (); + return ( +
    + {r.shipment.map((v: any) => ( + + {v.tracking_provider} + {v.primary_tracking_number} + { + try { await navigator.clipboard.writeText(v.tracking_url); message.success('复制成功!'); } + catch { message.error('复制失败!'); } + }} /> + } + actions={ (v.state === 'waiting-for-scheduling' || v.state === 'waiting-for-transit') ? [ + { + try { const { success, message: errMsg } = await logisticscontrollerDelshipment({ id: v.id }); if (!success) throw new Error(errMsg); tableRef.current?.reload(); ref.current?.reload?.(); } + catch (error: any) { message.error(error.message); } + }}> + 取消运单 + + ] : [] } + > +
    订单号: {Array.isArray(v?.orderIds) ? v.orderIds.join(',') : '-'}
    + {Array.isArray(v?.items) && v.items.map((item: any) => ( +
    {item.name}: {item.quantity}
    + ))} +
    + ))} +
    + ); + }} /> +
    +
    + ); +}; + +export default OrderDetailDrawer; \ No newline at end of file diff --git a/src/pages/Subscription/Orders/RelatedOrders.tsx b/src/pages/Subscription/Orders/RelatedOrders.tsx new file mode 100644 index 0000000..5b06530 --- /dev/null +++ b/src/pages/Subscription/Orders/RelatedOrders.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Empty, Tag } from 'antd'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +dayjs.extend(relativeTime); + +/** + * RelatedOrders 表格组件 + * 用于展示订单详情中的关联数据(订阅/订单),按统一表格样式渲染 + * 本组件将订阅与订单统一归一化为五列展示,便于快速浏览 + */ +const RelatedOrders: React.FC<{ data?: any[] }> = ({ data = [] }) => { + const rows = (Array.isArray(data) ? data : []).map((it: any) => { + const isSubscription = !!it?.externalSubscriptionId || !!it?.billing_period || !!it?.line_items; + const number = isSubscription ? `#${it?.externalSubscriptionId || it?.id}` : `#${it?.externalOrderId || it?.id}`; + const relationship = isSubscription ? 'Subscription' : 'Order'; + const dateRaw = it?.start_date || it?.date_created || it?.createdAt || it?.updatedAt; + const dateText = dateRaw ? dayjs(dateRaw).fromNow() : '-'; + const status = (isSubscription ? it?.status : it?.orderStatus) || '-'; + const statusLower = String(status).toLowerCase(); + const color = statusLower === 'active' ? 'green' : statusLower === 'cancelled' ? 'red' : 'default'; + const totalNum = Number(it?.total || 0); + const totalText = isSubscription ? `$${totalNum.toFixed(2)} / ${it?.billing_period || 'period'}` : `$${totalNum.toFixed(2)}`; + return { key: `${isSubscription ? 'sub' : 'order'}-${it?.id}`, number, relationship, dateText, status, color, totalText }; + }); + + if (rows.length === 0) return ; + + return ( +
    + {/* 表头(英文文案,符合国际化默认英文的要求) */} +
    +
    订单编号
    +
    关系
    +
    日期
    +
    状态
    +
    金额
    +
    +
    + {rows.map((r) => ( +
    + +
    {r.relationship}
    +
    {r.dateText}
    +
    {r.status}
    +
    {r.totalText}
    +
    + ))} +
    +
    + ); +}; + +export default RelatedOrders; diff --git a/src/pages/Subscription/Orders/index.tsx b/src/pages/Subscription/Orders/index.tsx new file mode 100644 index 0000000..bc4a9bb --- /dev/null +++ b/src/pages/Subscription/Orders/index.tsx @@ -0,0 +1,169 @@ +import React, { useRef, useState } from 'react'; +import { PageContainer } from '@ant-design/pro-layout'; +import type { ProColumns, ActionType, ProTableProps } from '@ant-design/pro-components'; +import { ProTable } from '@ant-design/pro-components'; +import { App, Tag, Button } from 'antd'; +import dayjs from 'dayjs'; +import { ordercontrollerGetorders } from '@/servers/api/order'; +import OrderDetailDrawer from './OrderDetailDrawer'; +import { sitecontrollerAll } from '@/servers/api/site'; + +interface OrderItemRow { + id: number; + externalOrderId: string; + siteId: string; + date_created: string; + customer_email: string; + payment_method: string; + total: number; + orderStatus: string; +} + +const OrdersPage: React.FC = () => { + const actionRef = useRef(); + const { message } = App.useApp(); + // 抽屉状态:改为复用订单详情抽屉组件 + const [detailOpen, setDetailOpen] = useState(false); + const [detailRecord, setDetailRecord] = useState(null); + const [detailOrderId, setDetailOrderId] = useState(null); + const Noop: React.FC = () => null; + + const columns: ProColumns[] = [ + { + title: '订单ID', + dataIndex: 'externalOrderId', + width: 120, + ellipsis: true, + hideInSearch: true, + }, + { + title: '站点', + dataIndex: 'siteId', + width: 120, + valueType: 'select', + request: async () => { + const { data = [] } = await sitecontrollerAll(); + return (data || []).map((item: any) => ({ label: item.siteName, value: item.id })); + }, + }, + { + title: '下单时间', + dataIndex: 'date_created', + width: 180, + hideInSearch: true, + render: (_, row) => (row?.date_created ? dayjs(row.date_created).format('YYYY-MM-DD HH:mm') : '-'), + }, + { + title: '邮箱', + dataIndex: 'customer_email', + width: 200, + }, + { + title: '支付方式', + dataIndex: 'payment_method', + width: 140, + }, + { + title: '金额', + dataIndex: 'total', + width: 100, + hideInSearch: true, + }, + { + title: 'ERP状态', + dataIndex: 'orderStatus', + width: 120, + hideInSearch: true, + render: (_, row) => {row.orderStatus}, + }, + { + title: '订阅关联', + dataIndex: 'subscription_related', + width: 120, + hideInSearch: true, + render: (_, row) => ( + + ), + }, + { + title: '时间范围', + dataIndex: 'dateRange', + valueType: 'dateRange', + hideInTable: true, + }, + { + title: '商品关键字', + dataIndex: 'keyword', + hideInTable: true, + }, + ]; + + const request: ProTableProps['request'] = async (params) => { + try { + const { current = 1, pageSize = 10, siteId, keyword, customer_email, payment_method } = params as any; + const [startDate, endDate] = (params as any).dateRange || []; + const resp = await ordercontrollerGetorders({ + current, + pageSize, + siteId, + keyword, + customer_email, + payment_method, + isSubscriptionOnly: true as any, + startDate: startDate ? (dayjs(startDate).toISOString() as any) : undefined, + endDate: endDate ? (dayjs(endDate).toISOString() as any) : undefined, + } as any); + const { success, data, message: errMsg } = resp as any; + if (!success) throw new Error(errMsg || '获取失败'); + return { + data: (data?.items ?? []) as OrderItemRow[], + total: data?.total ?? 0, + success: true, + }; + } catch (e: any) { + message.error(e?.message || '获取失败'); + return { data: [], total: 0, success: false }; + } + }; + + return ( + + + actionRef={actionRef} + rowKey='id' + columns={columns} + request={request} + pagination={{ showSizeChanger: true }} + search={{ + labelWidth: 90, + span: 6, + }} + toolBarRender={false} + /> + {/* 订阅关联:直接使用订单详情抽屉组件 */} + {detailRecord && detailOrderId !== null && ( + setDetailOpen(false)} + tableRef={actionRef} + orderId={detailOrderId as number} + record={detailRecord as any} + setActiveLine={() => {}} + OrderNoteComponent={Noop} + SalesChangeComponent={Noop} + /> + )} + + ); +}; + +export default OrdersPage; diff --git a/src/servers/api/index.ts b/src/servers/api/index.ts index 4737f7c..7e0131b 100644 --- a/src/servers/api/index.ts +++ b/src/servers/api/index.ts @@ -1,7 +1,7 @@ // @ts-ignore /* eslint-disable */ -// API 更新时间: -// API 唯一标识: +// API 更新时间: +// API 唯一标识: import * as customer from './customer'; import * as logistics from './logistics'; import * as order from './order'; @@ -9,6 +9,7 @@ import * as product from './product'; import * as site from './site'; import * as statistics from './statistics'; import * as stock from './stock'; +import * as subscription from './subscription'; import * as user from './user'; import * as webhook from './webhook'; import * as wpProduct from './wpProduct'; @@ -20,6 +21,7 @@ export default { site, statistics, stock, + subscription, user, webhook, wpProduct, diff --git a/src/servers/api/subscription.ts b/src/servers/api/subscription.ts new file mode 100644 index 0000000..9f49d74 --- /dev/null +++ b/src/servers/api/subscription.ts @@ -0,0 +1,32 @@ +// @ts-ignore +/* eslint-disable */ +import { request } from 'umi'; + +/** 此处后端没有提供注释 GET /subscription/list */ +export async function subscriptioncontrollerList( + // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) + params: API.subscriptioncontrollerListParams, + options?: { [key: string]: any }, +) { + return request('/subscription/list', { + method: 'GET', + params: { + ...params, + }, + ...(options || {}), + }); +} + +/** 此处后端没有提供注释 POST /subscription/sync/${param0} */ +export async function subscriptioncontrollerSync( + // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) + params: API.subscriptioncontrollerSyncParams, + options?: { [key: string]: any }, +) { + const { siteId: param0, ...queryParams } = params; + return request(`/subscription/sync/${param0}`, { + method: 'POST', + params: { ...queryParams }, + ...(options || {}), + }); +} diff --git a/src/servers/api/typings.d.ts b/src/servers/api/typings.d.ts index f906d72..2a3357d 100644 --- a/src/servers/api/typings.d.ts +++ b/src/servers/api/typings.d.ts @@ -235,7 +235,6 @@ declare namespace API { total_tax?: number; customer_id?: number; customer_email?: string; - billing_phone?: string; order_key?: string; billing?: OrderAddress; shipping?: OrderAddress; @@ -258,6 +257,8 @@ declare namespace API { device_type?: string; source_type?: string; utm_source?: string; + is_exchange?: boolean; + exchange_frequency?: number; /** 创建时间 */ createdAt: string; /** 更新时间 */ @@ -333,9 +334,9 @@ declare namespace API { | 'after_sale_pending' | 'pending_reshipment' | 'pending_refund' - | 'refund_requested' - | 'refund_approved' - | 'refund_cancelled'; + | 'return-requested' + | 'return-approved' + | 'return-cancelled'; payment_method?: string; }; @@ -377,7 +378,6 @@ declare namespace API { total_tax?: number; customer_id?: number; customer_email?: string; - billing_phone?: string; order_key?: string; billing?: OrderAddress; shipping?: OrderAddress; @@ -400,6 +400,8 @@ declare namespace API { device_type?: string; source_type?: string; utm_source?: string; + is_exchange?: boolean; + exchange_frequency?: number; /** 创建时间 */ createdAt: string; /** 更新时间 */ @@ -861,9 +863,9 @@ declare namespace API { | 'after_sale_pending' | 'pending_reshipment' | 'pending_refund' - | 'refund_requested' - | 'refund_approved' - | 'refund_cancelled'; + | 'return-requested' + | 'return-approved' + | 'return-cancelled'; payment_method?: string; }; @@ -946,6 +948,27 @@ declare namespace API { name?: string; }; + type QuerySubscriptionDTO = { + /** 页码 */ + current?: number; + /** 每页大小 */ + pageSize?: number; + /** 站点ID */ + siteId?: string; + /** 订阅状态 */ + status?: + | 'active' + | 'pending' + | 'on-hold' + | 'cancelled' + | 'expired' + | 'pending-cancel'; + /** 客户邮箱 */ + customer_email?: string; + /** 关键字(订阅ID、邮箱等) */ + keyword?: string; + }; + type QueryWpProductDTO = { /** 页码 */ current?: number; @@ -1307,6 +1330,82 @@ declare namespace API { items?: StockRecordDTO[]; }; + type Subscription = { + id?: number; + /** 来源站点唯一标识 */ + siteId?: string; + /** WooCommerce 订阅 ID */ + externalSubscriptionId?: string; + status?: any; + currency?: string; + total?: number; + /** 计费周期 e.g. day/week/month/year */ + billing_period?: string; + /** 计费周期间隔 e.g. 1/3/12 */ + billing_interval?: number; + customer_id?: number; + customer_email?: string; + /** 父订单/父订阅ID(如有) */ + parent_id?: number; + start_date?: string; + trial_end?: string; + next_payment_date?: string; + end_date?: string; + line_items?: any; + meta_data?: any; + /** 创建时间 */ + createdAt: string; + /** 更新时间 */ + updatedAt: string; + }; + + type subscriptioncontrollerListParams = { + /** 页码 */ + current?: number; + /** 每页大小 */ + pageSize?: number; + /** 站点ID */ + siteId?: string; + /** 订阅状态 */ + status?: + | 'active' + | 'pending' + | 'on-hold' + | 'cancelled' + | 'expired' + | 'pending-cancel'; + /** 客户邮箱 */ + customer_email?: string; + /** 关键字(订阅ID、邮箱等) */ + keyword?: string; + }; + + type subscriptioncontrollerSyncParams = { + siteId: string; + }; + + type SubscriptionListRes = { + /** 状态码 */ + code?: number; + /** 是否成功 */ + success?: boolean; + /** 消息内容 */ + message?: string; + /** 响应数据 */ + data?: SubscriptionPaginatedResponse; + }; + + type SubscriptionPaginatedResponse = { + /** 当前页码 */ + page?: number; + /** 每页大小 */ + pageSize?: number; + /** 总记录数 */ + total?: number; + /** 数据列表 */ + items?: Subscription[]; + }; + type Surcharges = { type?: string; amount?: Money; @@ -1415,6 +1514,8 @@ declare namespace API { sale_price?: number; /** 是否促销中 */ on_sale?: boolean; + /** 是否删除 */ + on_delete?: boolean; /** 创建时间 */ createdAt: string; /** 更新时间 */ @@ -1448,6 +1549,10 @@ declare namespace API { | 'inherit'; }; + type wpproductcontrollerSearchproductsParams = { + name?: string; + }; + type wpproductcontrollerSetconstitutionParams = { id: number; }; @@ -1500,6 +1605,8 @@ declare namespace API { sale_price?: number; /** 是否促销中 */ on_sale?: boolean; + /** 是否删除 */ + on_delete?: boolean; /** 产品类型 */ type?: 'simple' | 'variable' | 'woosb'; /** 创建时间 */ diff --git a/src/servers/api/wpProduct.ts b/src/servers/api/wpProduct.ts index bb2e9c1..dd072dd 100644 --- a/src/servers/api/wpProduct.ts +++ b/src/servers/api/wpProduct.ts @@ -36,6 +36,21 @@ export async function wpproductcontrollerGetwpproducts( }); } +/** 此处后端没有提供注释 GET /wp_product/search */ +export async function wpproductcontrollerSearchproducts( + // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) + params: API.wpproductcontrollerSearchproductsParams, + options?: { [key: string]: any }, +) { + return request('/wp_product/search', { + method: 'GET', + params: { + ...params, + }, + ...(options || {}), + }); +} + /** 此处后端没有提供注释 PUT /wp_product/siteId/${param1}/products/${param0} */ export async function wpproductcontrollerUpdateproduct( // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) diff --git a/typings.d.ts b/typings.d.ts index 82c7dc7..651fbe8 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -5,8 +5,8 @@ declare namespace BaseType { type EnumTransformOptions { value: string; // 用于作为 value 的字段名 label: string; // 用于作为 text 的字段名 - status?: string | undefined; // 可选:用于设置状态的字段名 - color?: string | undefined; // 可选:用于设置颜色的字段名 + status?: string | undefined; // 可选:用于设置状态的字段名 + color?: string | undefined; // 可选:用于设置颜色的字段名 } }