hqweb端
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>杜康好客 · HQ 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@dukang/admin-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5175",
|
||||
"build": "vite build",
|
||||
"lint": "echo ok"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"antd": "^5.22.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { getToken } from './lib/api';
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import UsersPage from './pages/UsersPage';
|
||||
import OrdersPage from './pages/OrdersPage';
|
||||
import StoresPage from './pages/StoresPage';
|
||||
import StoreAccountsPage from './pages/StoreAccountsPage';
|
||||
import PartnersPage from './pages/PartnersPage';
|
||||
import PartnerAccountsPage from './pages/PartnerAccountsPage';
|
||||
import BenefitCouponsPage from './pages/BenefitCouponsPage';
|
||||
import BenefitLedgersPage from './pages/BenefitLedgersPage';
|
||||
import RedeemRecordsPage from './pages/RedeemRecordsPage';
|
||||
import DeliveriesPage from './pages/DeliveriesPage';
|
||||
import HqAccountsPage from './pages/HqAccountsPage';
|
||||
import CitiesPage from './pages/CitiesPage';
|
||||
import StoreMediaPage from './pages/StoreMediaPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AdminLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/orders" element={<OrdersPage />} />
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
||||
<Route path="/store-media" element={<StoreMediaPage />} />
|
||||
<Route path="/partners" element={<PartnersPage />} />
|
||||
<Route path="/cities" element={<CitiesPage />} />
|
||||
<Route path="/partner-accounts" element={<PartnerAccountsPage />} />
|
||||
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
|
||||
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
||||
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
|
||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.admin-table-nowrap .ant-table-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table-nowrap .ant-table-cell-ellipsis {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Layout, Menu, Typography, Button, Space } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
ShoppingOutlined,
|
||||
ShopOutlined,
|
||||
TeamOutlined,
|
||||
GiftOutlined,
|
||||
CarOutlined,
|
||||
SafetyOutlined,
|
||||
LogoutOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||
{
|
||||
key: 'stores-group',
|
||||
icon: <ShopOutlined />,
|
||||
label: '门店',
|
||||
children: [
|
||||
{ key: '/stores', label: '门店列表' },
|
||||
{ key: '/store-accounts', label: '门店账户' },
|
||||
{ key: '/store-media', label: '门店资源' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'partners-group',
|
||||
icon: <TeamOutlined />,
|
||||
label: '开城',
|
||||
children: [
|
||||
{ key: '/partners', label: '开城合伙人' },
|
||||
{ key: '/cities', label: '开城城市' },
|
||||
{ key: '/partner-accounts', label: '开城合伙人账户' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'benefit-group',
|
||||
icon: <GiftOutlined />,
|
||||
label: '好客权益',
|
||||
children: [
|
||||
{ key: '/benefit/coupons', label: '权益券' },
|
||||
{ key: '/benefit/ledgers', label: '流水' },
|
||||
{ key: '/redeem-records', label: '核销记录' },
|
||||
],
|
||||
},
|
||||
{ key: '/deliveries', icon: <CarOutlined />, label: '配送单' },
|
||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
function logout() {
|
||||
clearAuth();
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
const selectedKey = location.pathname;
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider breakpoint="lg" collapsedWidth={64} theme="dark" width={220}>
|
||||
<div style={{ padding: '16px', color: '#fff', fontWeight: 600 }}>杜康 HQ</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
defaultOpenKeys={['stores-group', 'partners-group', 'benefit-group']}
|
||||
items={MENU_ITEMS}
|
||||
onClick={({ key }) => {
|
||||
if (key.startsWith('/')) navigate(key);
|
||||
}}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: '0 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
管理后台
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
<span>{profile?.name || '—'}</span>
|
||||
<span style={{ color: '#999' }}>{profile?.adminRole}</span>
|
||||
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
|
||||
退出
|
||||
</Button>
|
||||
</Space>
|
||||
</Header>
|
||||
<Content style={{ margin: 24 }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
export const apiBase = '/api/v1';
|
||||
export const CLIENT_APP = 'HQ_WEB';
|
||||
|
||||
export type HqProfile = {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
adminRole: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem('accessToken');
|
||||
}
|
||||
|
||||
export function saveAuth(data: { accessToken: string; refreshToken?: string }) {
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
if (data.refreshToken) localStorage.setItem('refreshToken', data.refreshToken);
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': CLIENT_APP,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||
const json = await res.json();
|
||||
if (json.code === 401) {
|
||||
clearAuth();
|
||||
window.location.href = '/login';
|
||||
throw new Error('未登录');
|
||||
}
|
||||
if (json.code !== 0) throw new Error(json.message || '请求失败');
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
export type Paginated<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type DashboardStats = {
|
||||
usersTotal: number;
|
||||
guestUsers: number;
|
||||
verifiedUsers: number;
|
||||
mergedUsers: number;
|
||||
ordersToday: number;
|
||||
storesTotal: number;
|
||||
partnersTotal: number;
|
||||
redeemToday: number;
|
||||
deliveriesTotal: number;
|
||||
ordersByStatus: Array<{ status: string; count: number }>;
|
||||
};
|
||||
|
||||
export type AdminUserRow = {
|
||||
id: string;
|
||||
userNo: string;
|
||||
deviceKey: string | null;
|
||||
phone: string | null;
|
||||
phoneVerifiedAt: string | null;
|
||||
mergedIntoUserId: string | null;
|
||||
nickname: string | null;
|
||||
status: number;
|
||||
createdAt: string;
|
||||
orderCount: number;
|
||||
};
|
||||
|
||||
export type AdminOrderRow = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
deliveryType: string;
|
||||
payAmount: number;
|
||||
receiverName: string;
|
||||
receiverPhone: string;
|
||||
createdAt: string;
|
||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||
delivery?: { provider: string; trackingNo: string | null; providerOrderNo: string | null };
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
OUT_WAREHOUSE: '已出库',
|
||||
SHIPPING: '配送中',
|
||||
PENDING_RECEIVE: '待收货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
export const STORE_STATUS_LABELS: Record<string, string> = {
|
||||
OPEN: '营业中',
|
||||
PAUSED: '暂停',
|
||||
CLOSED: '已关闭',
|
||||
};
|
||||
|
||||
export const ACCOUNT_STATUS_LABELS: Record<string, string> = {
|
||||
ACTIVE: '正常',
|
||||
DISABLED: '停用',
|
||||
};
|
||||
|
||||
export const COUPON_STATUS_LABELS: Record<string, string> = {
|
||||
ACTIVE: '可用',
|
||||
USED_UP: '已用完',
|
||||
VOID: '已作废',
|
||||
};
|
||||
|
||||
export const CITY_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: '待开城',
|
||||
ACTIVE: '已开城',
|
||||
PAUSED: '已暂停',
|
||||
};
|
||||
|
||||
export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: '草稿',
|
||||
CONFIRMED: '已确认',
|
||||
PAID: '已结算',
|
||||
};
|
||||
|
||||
export const MEDIA_TYPE_LABELS: Record<string, string> = {
|
||||
IMAGE: '图片',
|
||||
VIDEO: '视频',
|
||||
};
|
||||
|
||||
export const LEDGER_TYPE_LABELS: Record<string, string> = {
|
||||
GRANT: '发放',
|
||||
REDEEM: '核销',
|
||||
REFUND_VOID: '退款作废',
|
||||
ADJUST: '调整',
|
||||
};
|
||||
|
||||
export function fmtTime(v?: string | null) {
|
||||
return v ? new Date(v).toLocaleString('zh-CN') : '—';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { request, type Paginated } from './api';
|
||||
|
||||
export function useAdminList<T>(path: string, buildQuery: () => URLSearchParams, deps: unknown[]) {
|
||||
const [data, setData] = useState<Paginated<T> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const qs = buildQuery();
|
||||
qs.set('page', String(page));
|
||||
qs.set('pageSize', String(pageSize));
|
||||
const res = await request<Paginated<T>>(`${path}?${qs}`);
|
||||
setData(res);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [path, page, pageSize, ...deps]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return { data, loading, page, pageSize, setPage, setPageSize, reload: load };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import App from './App';
|
||||
import './admin.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Popconfirm, Select, Table, Tag, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; couponNo: string; totalAmount: number; balance: number; usedAmount: number;
|
||||
status: string; sourceProduct: string; createdAt: string;
|
||||
user?: { userNo: string; phone: string | null };
|
||||
order?: { orderNo: string };
|
||||
};
|
||||
|
||||
export default function BenefitCouponsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/benefit/coupons',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.couponNo) qs.set('couponNo', filters.couponNo);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.userId) qs.set('userId', filters.userId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
|
||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
|
||||
{ title: '订单', dataIndex: ['order', 'orderNo'], width: 180, ellipsis: false },
|
||||
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` },
|
||||
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '来源', dataIndex: 'sourceProduct', ellipsis: true },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>好客权益券</Typography.Title>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="couponNo" label="券号"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 100 }} options={Object.entries(COUPON_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="权益券详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && detail.status !== 'VOID' && (
|
||||
<Popconfirm title="确认作废此券?" onConfirm={async () => {
|
||||
await request(`/admin/benefit/coupons/${detail.id}/void`, { method: 'POST' });
|
||||
message.success('已作废');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}}>
|
||||
<Button danger>作废</Button>
|
||||
</Popconfirm>
|
||||
)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="券号">{String(detail.couponNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="总额">¥{String(detail.totalAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="余额">¥{String(detail.balance)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="来源">{String(detail.sourceProduct)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react';
|
||||
import { Form, Input, Select, Button, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string;
|
||||
user?: { userNo: string }; coupon?: { couponNo: string };
|
||||
};
|
||||
|
||||
export default function BenefitLedgersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/benefit/ledgers',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.userId) qs.set('userId', filters.userId);
|
||||
if (filters.couponId) qs.set('couponId', filters.couponId);
|
||||
if (filters.type) qs.set('type', filters.type);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '类型', dataIndex: 'type', width: 90, render: (t) => <Tag>{LEDGER_TYPE_LABELS[t] || t}</Tag> },
|
||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
|
||||
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 200, ellipsis: false },
|
||||
{ title: '变动', dataIndex: 'amount', width: 90, render: (v) => `${v >= 0 ? '+' : ''}${v}` },
|
||||
{ title: '余额后', dataIndex: 'balanceAfter', width: 90 },
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>权益流水</Typography.Title>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="userId" label="用户ID"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="type" label="类型">
|
||||
<Select allowClear style={{ width: 100 }} options={Object.entries(LEDGER_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; code: string; name: string; province: string; status: string;
|
||||
storeCount: number; orderCount: number; createdAt: string;
|
||||
partner?: { id: string; companyName: string };
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
|
||||
export default function CitiesPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/cities',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.code) qs.set('code', filters.code);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
|
||||
async function loadPartners() {
|
||||
const res = await request<Paginated<PartnerOption>>('/admin/partners?pageSize=200');
|
||||
setPartners(res.items);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 90 },
|
||||
{ title: '城市', dataIndex: 'name', width: 100 },
|
||||
{ title: '省份', dataIndex: 'province', width: 90 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 140, ellipsis: true },
|
||||
{ title: '门店', dataIndex: 'storeCount', width: 70 },
|
||||
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/cities/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({
|
||||
...d,
|
||||
partnerId: (d.partner as { id?: string } | null)?.id ?? (d as { partnerId?: string }).partnerId,
|
||||
});
|
||||
void loadPartners();
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城城市</Typography.Title>
|
||||
<Button type="primary" onClick={() => { void loadPartners(); setCreateOpen(true); }}>新建城市</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="name" label="城市"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 110 }} options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="编辑开城城市" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/cities/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
)}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="编码">{String(detail.code)}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店数">{String(detail.storeCount ?? (detail as { _count?: { stores?: number } })._count?.stores ?? '—')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="partnerId" label="开城合伙人">
|
||||
<Select allowClear placeholder="选择合伙人" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="localMinQty" label="同城起订量"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="crossMinQty" label="跨城起订量"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建开城城市" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/cities', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="code" label="城市编码" rules={[{ required: true }]}><Input placeholder="如 ZZ" /></Form.Item>
|
||||
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="partnerId" label="开城合伙人">
|
||||
<Select allowClear placeholder="选择合伙人" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" initialValue="PENDING">
|
||||
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Col, Row, Statistic, Table, Typography } from 'antd';
|
||||
import { request, type DashboardStats } from '../lib/api';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
OUT_WAREHOUSE: '已出库',
|
||||
SHIPPING: '配送中',
|
||||
PENDING_RECEIVE: '待收货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<DashboardStats>('/admin/dashboard/stats')
|
||||
.then(setStats)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>数据概览</Typography.Title>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="门店" value={stats?.storesTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="已合并访客账号" loading={loading}>
|
||||
<Statistic value={stats?.mergedUsers ?? 0} suffix="个" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="订单状态分布" loading={loading}>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="status"
|
||||
dataSource={stats?.ordersByStatus ?? []}
|
||||
columns={[
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (s: string) => STATUS_LABELS[s] || s,
|
||||
},
|
||||
{ title: '数量', dataIndex: 'count' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string;
|
||||
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: string };
|
||||
};
|
||||
|
||||
export default function DeliveriesPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/deliveries',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.provider) qs.set('provider', filters.provider);
|
||||
if (filters.trackingNo) qs.set('trackingNo', filters.trackingNo);
|
||||
if (filters.orderNo) qs.set('orderNo', filters.orderNo);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
|
||||
{ title: 'provider', dataIndex: 'provider', width: 90 },
|
||||
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
||||
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
|
||||
{ title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s },
|
||||
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
||||
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>快递/配送单</Typography.Title>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></Form.Item>
|
||||
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||
<Button type="primary" onClick={async () => {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/deliveries/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
</Space>
|
||||
}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="provider" label="provider" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
||||
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
import { ACCOUNT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; phone: string; name: string; adminRole: string; status: string; lastLoginAt: string | null; createdAt: string;
|
||||
};
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
OPS: '运营',
|
||||
FINANCE: '财务',
|
||||
CUSTOMER_SERVICE: '客服',
|
||||
};
|
||||
|
||||
export default function HqAccountsPage() {
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/hq-accounts',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.adminRole) qs.set('adminRole', filters.adminRole);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
useEffect(() => {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 130 },
|
||||
{ title: '角色', dataIndex: 'adminRole', width: 110, render: (r) => ROLE_LABELS[r] || r },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" disabled={!isSuperAdmin} onClick={() => {
|
||||
setDetail(row);
|
||||
editForm.setFieldsValue(row);
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>HQ 账户</Typography.Title>
|
||||
{isSuperAdmin && <Button type="primary" onClick={() => setCreateOpen(true)}>新建账户</Button>}
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="adminRole" label="角色">
|
||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="编辑 HQ 账户" width={420} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
<Button type="primary" onClick={async () => {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
}>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="adminRole" label="角色">
|
||||
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
<Modal title="新建 HQ 账户" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="adminRole" label="角色" initialValue="OPS">
|
||||
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Card, Form, Input, message, Typography } from 'antd';
|
||||
import { saveAuth, request } from '../lib/api';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const phone = Form.useWatch('phone', form);
|
||||
|
||||
async function sendCode() {
|
||||
if (!phone) {
|
||||
message.warning('请先输入手机号');
|
||||
return;
|
||||
}
|
||||
await request('/admin/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'HQ_LOGIN' }),
|
||||
});
|
||||
message.success('验证码已发送(Mock: 123456)');
|
||||
setCodeCooldown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCodeCooldown((c) => {
|
||||
if (c <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function onFinish(values: { phone: string; code: string }) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request<{ accessToken: string; refreshToken: string }>(
|
||||
'/admin/auth/login/sms',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
},
|
||||
);
|
||||
saveAuth(data);
|
||||
message.success('登录成功');
|
||||
navigate('/');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<Card style={{ width: 400 }}>
|
||||
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
HQ 管理后台
|
||||
</Typography.Title>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish} initialValues={{ phone: '13600000001', code: '123456' }}>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||
<Input placeholder="13600000001" maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="验证码" rules={[{ required: true, message: '请输入验证码' }]}>
|
||||
<Input
|
||||
placeholder="123456"
|
||||
addonAfter={
|
||||
<Button type="link" size="small" disabled={codeCooldown > 0} onClick={() => void sendCode()}>
|
||||
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type AdminOrderRow, type Paginated } from '../lib/api';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
OUT_WAREHOUSE: '已出库',
|
||||
SHIPPING: '配送中',
|
||||
PENDING_RECEIVE: '待收货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
type OrderDetail = AdminOrderRow & {
|
||||
receiverAddress?: string;
|
||||
receiverProvince?: string;
|
||||
receiverCity?: string;
|
||||
receiverDistrict?: string;
|
||||
clientIp?: string | null;
|
||||
ipProvince?: string | null;
|
||||
ipCity?: string | null;
|
||||
ipDistrict?: string | null;
|
||||
gpsProvince?: string | null;
|
||||
gpsCity?: string | null;
|
||||
gpsDistrict?: string | null;
|
||||
gpsLatitude?: number | null;
|
||||
gpsLongitude?: number | null;
|
||||
gpsAddress?: string | null;
|
||||
productAmount?: number;
|
||||
freightAmount?: number;
|
||||
benefitAmount?: number;
|
||||
paidAt?: string | null;
|
||||
payExpireAt?: string | null;
|
||||
items?: Array<Record<string, unknown>>;
|
||||
payment?: Record<string, unknown> | null;
|
||||
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
|
||||
benefitCoupons?: Array<Record<string, unknown>>;
|
||||
city?: { name: string; code: string };
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const values = form.getFieldsValue();
|
||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
||||
if (values.status) qs.set('status', values.status);
|
||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||
setData(res);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const res = await request<OrderDetail>(`/admin/orders/${id}`);
|
||||
setDetail(res);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminOrderRow> = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s) => <Tag>{STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '配送',
|
||||
dataIndex: 'deliveryType',
|
||||
width: 90,
|
||||
render: (v) => (v === 'LOCAL' ? '同城' : '跨城'),
|
||||
},
|
||||
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
|
||||
{ title: '手机', dataIndex: 'receiverPhone', width: 120 },
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: ['user', 'userNo'],
|
||||
width: 110,
|
||||
render: (_, row) => row.user?.userNo || '—',
|
||||
},
|
||||
{
|
||||
title: '快递',
|
||||
width: 100,
|
||||
render: (_, row) => row.delivery?.trackingNo || row.delivery?.provider || '—',
|
||||
},
|
||||
{
|
||||
title: '下单时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (v) => new Date(v).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>订单监控</Typography.Title>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form.Item name="orderNo" label="订单号">
|
||||
<Input placeholder="DK..." allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="receiverPhone" label="收货手机">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer title="订单详情" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" title="基本信息">
|
||||
<Descriptions.Item label="订单号">{detail.orderNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
|
||||
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
|
||||
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
|
||||
<Descriptions.Item label="好客权益">¥{detail.benefitAmount}</Descriptions.Item>
|
||||
<Descriptions.Item label="下单时间">{new Date(detail.createdAt).toLocaleString('zh-CN')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Descriptions column={1} bordered size="small" title="收货信息" style={{ marginTop: 16 }}>
|
||||
<Descriptions.Item label="收货人">{detail.receiverName} {detail.receiverPhone}</Descriptions.Item>
|
||||
<Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Descriptions column={1} bordered size="small" title="位置快照(方案C)" style={{ marginTop: 16 }}>
|
||||
<Descriptions.Item label="clientIp">{detail.clientIp || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="IP解析">{[detail.ipProvince, detail.ipCity, detail.ipDistrict].filter(Boolean).join(' / ') || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="GPS">{[detail.gpsProvince, detail.gpsCity, detail.gpsDistrict].filter(Boolean).join(' / ') || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="坐标">
|
||||
{detail.gpsLatitude != null ? `${detail.gpsLatitude}, ${detail.gpsLongitude}` : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{detail.delivery && (
|
||||
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
|
||||
<Descriptions.Item label="provider">{detail.delivery.provider}</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
{detail.statusLogs && detail.statusLogs.length > 0 && (
|
||||
<>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="createdAt"
|
||||
pagination={false}
|
||||
dataSource={detail.statusLogs}
|
||||
columns={[
|
||||
{ title: '从', dataIndex: 'fromStatus', render: (v) => v || '—' },
|
||||
{ title: '到', dataIndex: 'toStatus', render: (v) => STATUS_LABELS[v] || v },
|
||||
{ title: '时间', dataIndex: 'createdAt', render: (v) => new Date(v).toLocaleString('zh-CN') },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Modal, Select, Space, Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ACCOUNT_STATUS_LABELS, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; phone: string; name: string; status: string; isPrimary: number; createdAt: string;
|
||||
partner?: { id: string; companyName: string };
|
||||
};
|
||||
|
||||
type BillRow = {
|
||||
id: string; billNo: string; totalAmount: number; orderCommission: number; redeemCommission: number;
|
||||
status: string; periodStart: string; periodEnd: string; createdAt: string;
|
||||
};
|
||||
|
||||
type OrderRow = {
|
||||
id: string; orderNo: string; status: string; payAmount: number; createdAt: string;
|
||||
user?: { userNo: string; phone: string | null };
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
|
||||
type Detail = Row & { bills?: BillRow[]; orders?: OrderRow[] };
|
||||
|
||||
export default function PartnerAccountsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partner-accounts',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
|
||||
async function loadPartners() {
|
||||
const res = await request<Paginated<PartnerOption>>('/admin/partners?pageSize=200');
|
||||
setPartners(res.items);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 140 },
|
||||
{ title: '主账号', dataIndex: 'isPrimary', width: 80, render: (v) => (v === 1 ? '是' : '否') },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request<Detail>(`/admin/partner-accounts/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const billColumns: ColumnsType<BillRow> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 140 },
|
||||
{ title: '总额', dataIndex: 'totalAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '订单佣金', dataIndex: 'orderCommission', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '核销佣金', dataIndex: 'redeemCommission', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{PARTNER_BILL_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart)} ~ ${fmtTime(r.periodEnd)}` },
|
||||
];
|
||||
|
||||
const orderColumns: ColumnsType<OrderRow> = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
|
||||
{ title: '金额', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '下单', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人账户</Typography.Title>
|
||||
<Button type="primary" onClick={() => { void loadPartners(); setCreateOpen(true); }}>新建账户</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 100 }} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="开城合伙人账户" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Select defaultValue={detail.status} style={{ width: 100 }}
|
||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={async (status) => {
|
||||
await request(`/admin/partner-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify({ status }) });
|
||||
message.success('已更新');
|
||||
void reload();
|
||||
}} />
|
||||
)}>
|
||||
{detail && (
|
||||
<Tabs items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="姓名">{detail.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="开城合伙人">{detail.partner?.companyName}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bills',
|
||||
label: `账单 (${detail.bills?.length ?? 0})`,
|
||||
children: (
|
||||
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={billColumns}
|
||||
dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 700 }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'orders',
|
||||
label: `名下订单 (${detail.orders?.length ?? 0})`,
|
||||
children: (
|
||||
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={orderColumns}
|
||||
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 650 }} />
|
||||
),
|
||||
},
|
||||
]} />
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建开城合伙人账户" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/partner-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Modal, Space, Table, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; companyName: string; contactPhone: string; address: string;
|
||||
storeCount: number; accountCount: number; cityCount: number; createdAt: string;
|
||||
};
|
||||
|
||||
export default function PartnersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.contactPhone) qs.set('contactPhone', filters.contactPhone);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '公司名', dataIndex: 'companyName' },
|
||||
{ title: '联系电话', dataIndex: 'contactPhone', width: 130 },
|
||||
{ title: '门店', dataIndex: 'storeCount', width: 70 },
|
||||
{ title: '账号', dataIndex: 'accountCount', width: 70 },
|
||||
{ title: '开城城市', dataIndex: 'cityCount', width: 90 },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 120,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/admin/partners/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => {
|
||||
editForm.setFieldsValue(row);
|
||||
setDetail(row as unknown as Record<string, unknown>);
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人</Typography.Title>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>新建开城合伙人</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="开城合伙人详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
)}>
|
||||
{detail && (
|
||||
<>
|
||||
{Array.isArray(detail.cities) && (
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }} title="关联开城城市">
|
||||
{(detail.cities as Array<{ name: string; code: string; status: string }>).map((c) => (
|
||||
<Descriptions.Item key={c.code} label={c.code}>{c.name} ({c.status})</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
)}
|
||||
<Form form={editForm} layout="vertical" initialValues={detail as Record<string, unknown>}>
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建开城合伙人" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/partners', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Table, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; redeemNo: string; amount: number; settleAmount: number; createdAt: string;
|
||||
user?: { userNo: string; phone: string | null };
|
||||
store?: { name: string; cityName: string };
|
||||
coupon?: { couponNo: string };
|
||||
};
|
||||
|
||||
export default function RedeemRecordsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/redeem-records',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
|
||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
||||
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/admin/redeem-records/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>核销记录</Typography.Title>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="redeemNo" label="核销号"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ACCOUNT_STATUS_LABELS, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; phone: string; name: string; status: string; createdAt: string;
|
||||
store?: { id: string; name: string; status: string; cityName: string };
|
||||
};
|
||||
|
||||
type StoreOption = { id: string; name: string };
|
||||
|
||||
export default function StoreAccountsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/store-accounts',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
|
||||
async function loadStores() {
|
||||
const res = await request<Paginated<StoreOption>>('/admin/stores?pageSize=200');
|
||||
setStores(res.items);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
|
||||
{ title: '门店状态', dataIndex: ['store', 'status'], width: 90, render: (s) => s ? <Tag>{STORE_STATUS_LABELS[s] || s}</Tag> : '—' },
|
||||
{ title: '账号状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/admin/store-accounts/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
|
||||
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新建账户</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 100 }} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="门店账户" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Select defaultValue={detail.status} style={{ width: 100 }}
|
||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={async (status) => {
|
||||
await request(`/admin/store-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify({ status }) });
|
||||
message.success('已更新');
|
||||
void reload();
|
||||
}} />
|
||||
)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="姓名">{detail.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建门店账户" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/store-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={stores.map((s) => ({ value: s.id, label: s.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Form, Image, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; mediaType: string; url: string; sortOrder: number; createdAt: string;
|
||||
store?: { id: string; name: string };
|
||||
};
|
||||
|
||||
type StoreOption = { id: string; name: string };
|
||||
|
||||
export default function StoreMediaPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/store-media',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.mediaType) qs.set('mediaType', filters.mediaType);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Row | null>(null);
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
|
||||
async function loadStores() {
|
||||
const res = await request<Paginated<StoreOption>>('/admin/stores?pageSize=200');
|
||||
setStores(res.items);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
|
||||
{ title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> },
|
||||
{
|
||||
title: '预览', dataIndex: 'url', width: 100,
|
||||
render: (url, row) => row.mediaType === 'IMAGE'
|
||||
? <Image src={url} width={60} height={40} style={{ objectFit: 'cover' }} />
|
||||
: <a href={url} target="_blank" rel="noreferrer">视频</a>,
|
||||
},
|
||||
{ title: 'URL', dataIndex: 'url', ellipsis: true },
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 120,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => {
|
||||
setEditing(row);
|
||||
editForm.setFieldsValue(row);
|
||||
setEditOpen(true);
|
||||
}}>编辑</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={async () => {
|
||||
await request(`/admin/store-media/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
void reload();
|
||||
}}>
|
||||
<Button type="link" size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店资源</Typography.Title>
|
||||
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新增资源</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="mediaType" label="类型">
|
||||
<Select allowClear style={{ width: 100 }} options={Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Modal title="新增门店资源" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/store-media', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={stores.map((s) => ({ value: s.id, label: s.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="mediaType" label="类型" rules={[{ required: true }]} initialValue="IMAGE">
|
||||
<Select options={Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="url" label="资源 URL" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序" initialValue={0}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Modal title="编辑门店资源" open={editOpen} onCancel={() => setEditOpen(false)} onOk={async () => {
|
||||
if (!editing) return;
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/store-media/${editing.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
setEditOpen(false);
|
||||
void reload();
|
||||
}}>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="mediaType" label="类型"><Select options={Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} /></Form.Item>
|
||||
<Form.Item name="url" label="资源 URL" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Image, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type StoreRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
status: string;
|
||||
cityName: string;
|
||||
district: string;
|
||||
address: string;
|
||||
intro: string | null;
|
||||
coverUrl: string | null;
|
||||
createdAt: string;
|
||||
cityRef?: { name: string; code: string };
|
||||
partner?: { companyName: string };
|
||||
account?: { phone: string; name: string; status: string };
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
type CityOption = { id: string; name: string; code: string };
|
||||
|
||||
export default function StoresPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
|
||||
'/admin/stores',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
|
||||
async function loadOptions() {
|
||||
const [p, c] = await Promise.all([
|
||||
request<Paginated<PartnerOption>>('/admin/partners?pageSize=200'),
|
||||
request<Paginated<CityOption>>('/admin/cities?pageSize=200'),
|
||||
]);
|
||||
setPartners(p.items);
|
||||
setCities(c.items);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<StoreRow> = [
|
||||
{
|
||||
title: '封面', dataIndex: 'coverUrl', width: 72,
|
||||
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
||||
},
|
||||
{ title: '门店名', dataIndex: 'name', width: 140 },
|
||||
{ title: '城市', dataIndex: 'cityName', width: 80 },
|
||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 90,
|
||||
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 120 },
|
||||
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/stores/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ name: d.name, phone: d.phone, intro: d.intro, coverUrl: d.coverUrl, address: d.address, district: d.district });
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店</Typography.Title>
|
||||
<Button type="primary" onClick={() => { void loadOptions(); setCreateOpen(true); }}>新建门店</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="phone" label="电话"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 100 }} options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="门店详情" width={600} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Space>
|
||||
<Select defaultValue={String(detail.status)} style={{ width: 120 }}
|
||||
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={async (status) => {
|
||||
await request(`/admin/stores/${detail.id}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
|
||||
message.success('状态已更新');
|
||||
setDetail({ ...detail, status });
|
||||
void reload();
|
||||
}} />
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/stores/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
setDetail({ ...detail, ...v });
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
</Space>
|
||||
)}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
||||
{detail.coverUrl && (
|
||||
<Descriptions.Item label="封面">
|
||||
<Image src={String(detail.coverUrl)} width={120} />
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面图 URL"><Input /></Form.Item>
|
||||
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item>
|
||||
<Form.Item name="district" label="区县"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="详细地址"><Input /></Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建门店" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/stores', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="门店名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="门店电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="详细地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="district" label="区县"><Input /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面图 URL"><Input /></Form.Item>
|
||||
<Form.Item name="intro" label="介绍"><Input.TextArea rows={3} /></Form.Item>
|
||||
<Form.Item name="accountPhone" label="店长手机"><Input placeholder="默认同门店电话" /></Form.Item>
|
||||
<Form.Item name="accountName" label="店长姓名"><Input placeholder="默认同门店名" /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type AdminUserRow, type Paginated } from '../lib/api';
|
||||
|
||||
type UserDetail = AdminUserRow & {
|
||||
cityPref?: Record<string, unknown> | null;
|
||||
mergedInto?: { id: string; userNo: string; phone: string | null; nickname: string | null } | null;
|
||||
orders?: Array<{ id: string; orderNo: string; status: string; payAmount: number; createdAt: string }>;
|
||||
mergedFromCount?: number;
|
||||
orderCount?: number;
|
||||
addressCount?: number;
|
||||
};
|
||||
|
||||
export default function UsersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [detail, setDetail] = useState<UserDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const values = form.getFieldsValue();
|
||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
if (values.phone) qs.set('phone', values.phone);
|
||||
if (values.userNo) qs.set('userNo', values.userNo);
|
||||
if (values.deviceKey) qs.set('deviceKey', values.deviceKey);
|
||||
if (values.phoneVerified !== undefined && values.phoneVerified !== '') {
|
||||
qs.set('phoneVerified', values.phoneVerified);
|
||||
}
|
||||
if (values.status !== undefined && values.status !== '') {
|
||||
qs.set('status', String(values.status));
|
||||
}
|
||||
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
|
||||
setData(res);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const res = await request<UserDetail>(`/admin/users/${id}`);
|
||||
setDetail(res);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminUserRow> = [
|
||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
||||
{
|
||||
title: '手机',
|
||||
dataIndex: 'phone',
|
||||
width: 120,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '验手机',
|
||||
dataIndex: 'phoneVerifiedAt',
|
||||
width: 90,
|
||||
render: (v) => (v ? <Tag color="green">已验证</Tag> : <Tag color="orange">访客</Tag>),
|
||||
},
|
||||
{
|
||||
title: 'deviceKey',
|
||||
dataIndex: 'deviceKey',
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '合并',
|
||||
dataIndex: 'mergedIntoUserId',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="blue">已合并</Tag> : '—'),
|
||||
},
|
||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (v) => new Date(v).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>用户监控</Typography.Title>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input placeholder="模糊搜索" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="userNo" label="用户编号">
|
||||
<Input placeholder="DK..." allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="deviceKey" label="deviceKey">
|
||||
<Input placeholder="UUID" allowClear style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phoneVerified" label="验手机">
|
||||
<Select allowClear style={{ width: 100 }} options={[
|
||||
{ value: '1', label: '已验证' },
|
||||
{ value: '0', label: '访客' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 90 }} options={[
|
||||
{ value: 1, label: '正常' },
|
||||
{ value: 0, label: '停用' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer title="用户详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">{detail.phone || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="验手机时间">
|
||||
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="合并至">
|
||||
{detail.mergedInto
|
||||
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})`
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="注册时间">
|
||||
{new Date(detail.createdAt).toLocaleString('zh-CN')}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{detail.orders && detail.orders.length > 0 && (
|
||||
<>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>最近订单</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={detail.orders}
|
||||
columns={[
|
||||
{ title: '订单号', dataIndex: 'orderNo' },
|
||||
{ title: '状态', dataIndex: 'status' },
|
||||
{ title: '金额', dataIndex: 'payAmount', render: (v) => `¥${v}` },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5175,
|
||||
proxy: { '/api': 'http://localhost:3000' },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user