This commit is contained in:
2026-07-01 08:27:26 +08:00
parent 9f4577d3d8
commit 25f0d8e97b
56 changed files with 5298 additions and 2 deletions
+12
View File
@@ -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>
+27
View File
@@ -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"
}
}
+54
View File
@@ -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>
);
}
+7
View File
@@ -0,0 +1,7 @@
.admin-table-nowrap .ant-table-cell {
white-space: nowrap;
}
.admin-table-nowrap .ant-table-cell-ellipsis {
white-space: nowrap;
}
+117
View File
@@ -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>
);
}
+90
View File
@@ -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 };
};
+56
View File
@@ -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') : '—';
}
+28
View File
@@ -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 };
}
+17
View File
@@ -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>
);
}
+139
View File
@@ -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>
);
}
+119
View File
@@ -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>
);
}
+90
View File
@@ -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>
);
}
+229
View File
@@ -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>
);
}
+116
View File
@@ -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>
);
}
+125
View File
@@ -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>
);
}
+175
View File
@@ -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>
);
}
+206
View File
@@ -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>
);
}
+17
View File
@@ -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"]
}
+10
View File
@@ -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' },
},
});
+1
View File
@@ -7,6 +7,7 @@
"dev:user": "pnpm --filter @dukang/h5-user dev",
"dev:shop": "pnpm --filter @dukang/h5-shop dev",
"dev:partner": "pnpm --filter @dukang/h5-partner dev",
"dev:admin": "pnpm --filter @dukang/admin-web dev",
"build": "pnpm -r build",
"lint": "pnpm -r lint",
"test": "pnpm -r test",
+2
View File
@@ -4,6 +4,7 @@ export enum ClientApp {
PARTNER_MINI = 'PARTNER_MINI',
PARTNER_H5 = 'PARTNER_H5',
HQ_MINI = 'HQ_MINI',
HQ_WEB = 'HQ_WEB',
SHOP_H5 = 'SHOP_H5',
}
@@ -72,6 +73,7 @@ export const CLIENT_APP_ACTOR_MAP: Record<ClientApp, ActorType> = {
[ClientApp.PARTNER_MINI]: ActorType.PARTNER,
[ClientApp.PARTNER_H5]: ActorType.PARTNER,
[ClientApp.HQ_MINI]: ActorType.HQ,
[ClientApp.HQ_WEB]: ActorType.HQ,
[ClientApp.SHOP_H5]: ActorType.STORE,
};
+935
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -14,6 +14,7 @@ import { RedeemModule } from './modules/redeem/redeem.module';
import { SettlementModule } from './modules/settlement/settlement.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { JobsModule } from './jobs/jobs.module';
import { OpsModule } from './modules/ops/ops.module';
@Module({
imports: [
@@ -36,6 +37,7 @@ import { JobsModule } from './jobs/jobs.module';
SettlementModule,
AnalyticsModule,
JobsModule,
OpsModule,
],
})
export class AppModule {}
@@ -0,0 +1,26 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ClientApp } from '@dukang/shared-types';
import { JwtAuthGuard } from './jwt-auth.guard';
@Injectable()
export class HqAuthGuard extends JwtAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const ok = super.canActivate(context);
if (!ok) return false;
const req = context.switchToHttp().getRequest();
const clientApp = req.headers['x-client-app'] as ClientApp;
if (clientApp !== ClientApp.HQ_WEB) {
throw new UnauthorizedException('Invalid client app for admin');
}
if (req.user?.actorType !== 'HQ') {
throw new UnauthorizedException('HQ access required');
}
return true;
}
}
@@ -0,0 +1,29 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import type { AuthUser } from './jwt-auth.guard';
@Injectable()
export class SuperAdminGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser | undefined;
if (!user || user.actorType !== 'HQ') {
throw new ForbiddenException('需要 HQ 权限');
}
const account = await this.prisma.hqAccount.findUnique({
where: { id: user.actorId },
select: { adminRole: true, status: true },
});
if (!account || account.status !== 'ACTIVE' || account.adminRole !== 'SUPER_ADMIN') {
throw new ForbiddenException('需要超级管理员权限');
}
return true;
}
}
@@ -0,0 +1,28 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginSmsDto, SendSmsDto } from './dto/auth.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { AuthUser } from '../../common/guards/jwt-auth.guard';
import { ClientApp } from '@dukang/shared-types';
@Controller('admin/auth')
export class AdminAuthController {
constructor(private readonly authService: AuthService) {}
@Post('sms/send')
sendSms(@Body() dto: SendSmsDto) {
return this.authService.sendSms(dto.phone, dto.scene);
}
@Post('login/sms')
login(@Body() dto: LoginSmsDto) {
return this.authService.loginHq(dto.phone, dto.code, ClientApp.HQ_WEB);
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) {
return this.authService.getMe(user.actorType, user.actorId);
}
}
@@ -227,6 +227,24 @@ export class AuthService {
});
}
async loginHq(phone: string, code: string, clientApp: ClientApp) {
await this.smsProvider.verify(phone, code, SmsScene.HQ_LOGIN);
const account = await this.prisma.hqAccount.findUnique({ where: { phone } });
if (!account) throw new BadRequestException('HQ账号不存在');
if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用');
await this.prisma.hqAccount.update({
where: { id: account.id },
data: { lastLoginAt: new Date() },
});
return this.issueToken('HQ', account.id, clientApp, false, undefined, undefined, undefined, undefined, {
id: account.id.toString(),
phone: account.phone,
name: account.name,
adminRole: account.adminRole,
status: account.status,
});
}
async getMe(actorType: string, actorId: bigint) {
if (actorType === 'USER') {
const user = await this.assertActiveUser(actorId);
@@ -246,6 +264,10 @@ export class AuthService {
});
return serializeBigInt(account);
}
if (actorType === 'HQ') {
const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } });
return serializeBigInt(account);
}
return null;
}
@@ -358,6 +380,7 @@ export class AuthService {
store?: Record<string, unknown>,
partner?: Record<string, unknown>,
deviceKey?: string | null,
hq?: Record<string, unknown>,
) {
const payload = {
sub: actorId.toString(),
@@ -378,6 +401,7 @@ export class AuthService {
user,
store,
partner,
hq,
};
}
}
@@ -10,9 +10,11 @@ import {
} from './auth.controller';
import { UserAddressController } from './user-address.controller';
import { UserAddressService } from './user-address.service';
import { AdminAuthController } from './admin-auth.controller';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
@Module({
imports: [
@@ -28,8 +30,9 @@ import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guar
PartnerAuthController,
UserProfileController,
UserAddressController,
AdminAuthController,
],
providers: [AuthService, UserAddressService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard],
exports: [AuthService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard],
providers: [AuthService, UserAddressService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard],
exports: [AuthService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard],
})
export class IamModule {}
@@ -0,0 +1,36 @@
import { Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminBenefitService } from './admin-benefit.service';
import { AdminBenefitCouponsQueryDto, AdminBenefitLedgersQueryDto } from './dto/admin-query.dto';
@Controller('admin/benefit/coupons')
@UseGuards(HqAuthGuard)
export class AdminBenefitCouponsController {
constructor(private readonly service: AdminBenefitService) {}
@Get()
list(@Query() query: AdminBenefitCouponsQueryDto) {
return this.service.listCoupons(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detailCoupon(BigInt(id));
}
@Post(':id/void')
voidCoupon(@Param('id') id: string) {
return this.service.voidCoupon(BigInt(id));
}
}
@Controller('admin/benefit/ledgers')
@UseGuards(HqAuthGuard)
export class AdminBenefitLedgersController {
constructor(private readonly service: AdminBenefitService) {}
@Get()
list(@Query() query: AdminBenefitLedgersQueryDto) {
return this.service.listLedgers(query);
}
}
@@ -0,0 +1,100 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminBenefitCouponsQueryDto, AdminBenefitLedgersQueryDto } from './dto/admin-query.dto';
@Injectable()
export class AdminBenefitService {
constructor(private readonly prisma: PrismaService) {}
async listCoupons(query: AdminBenefitCouponsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.BenefitCouponWhereInput = {};
if (query.couponNo) where.couponNo = { contains: query.couponNo };
if (query.userId) where.userId = BigInt(query.userId);
if (query.status) where.status = query.status as Prisma.EnumBenefitCouponStatusFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.benefitCoupon.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
order: { select: { id: true, orderNo: true, status: true } },
},
}),
this.prisma.benefitCoupon.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detailCoupon(id: bigint) {
const coupon = await this.prisma.benefitCoupon.findUnique({
where: { id },
include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
order: { select: { id: true, orderNo: true, status: true, payAmount: true } },
ledgers: { orderBy: { createdAt: 'desc' }, take: 20 },
redeemRecords: { orderBy: { createdAt: 'desc' }, take: 10, include: { store: { select: { id: true, name: true } } } },
},
});
if (!coupon) throw new NotFoundException('权益券不存在');
return serializeBigInt(coupon);
}
async voidCoupon(id: bigint) {
const coupon = await this.prisma.benefitCoupon.findUnique({ where: { id } });
if (!coupon) throw new NotFoundException('权益券不存在');
if (coupon.status === 'VOID') throw new BadRequestException('权益券已作废');
const updated = await this.prisma.$transaction(async (tx) => {
const row = await tx.benefitCoupon.update({
where: { id },
data: { status: 'VOID', balance: 0 },
});
if (Number(coupon.balance) > 0) {
await tx.benefitLedger.create({
data: {
userId: coupon.userId,
couponId: coupon.id,
type: 'ADJUST',
amount: -Number(coupon.balance),
balanceAfter: 0,
refType: 'ADMIN_VOID',
remark: 'HQ 手动作废',
},
});
}
return row;
});
return serializeBigInt(updated);
}
async listLedgers(query: AdminBenefitLedgersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.BenefitLedgerWhereInput = {};
if (query.userId) where.userId = BigInt(query.userId);
if (query.couponId) where.couponId = BigInt(query.couponId);
if (query.type) where.type = query.type as Prisma.EnumBenefitLedgerTypeFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.benefitLedger.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
user: { select: { id: true, userNo: true } },
coupon: { select: { id: true, couponNo: true } },
},
}),
this.prisma.benefitLedger.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
}
@@ -0,0 +1,32 @@
import { Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminCitiesService } from './admin-cities.service';
import { AdminCitiesQueryDto } from './dto/admin-query.dto';
import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
import { Body } from '@nestjs/common';
@Controller('admin/cities')
@UseGuards(HqAuthGuard)
export class AdminCitiesController {
constructor(private readonly service: AdminCitiesService) {}
@Get()
list(@Query() query: AdminCitiesQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post()
create(@Body() dto: CreateCityDto) {
return this.service.create(dto);
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdateCityDto) {
return this.service.update(BigInt(id), dto);
}
}
@@ -0,0 +1,97 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminCitiesQueryDto } from './dto/admin-query.dto';
import type { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
@Injectable()
export class AdminCitiesService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminCitiesQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CityWhereInput = {};
if (query.name) where.name = { contains: query.name };
if (query.code) where.code = { contains: query.code };
if (query.status) where.status = query.status as Prisma.EnumCityStatusFilter['equals'];
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
const [items, total] = await Promise.all([
this.prisma.city.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
partner: { select: { id: true, companyName: true } },
_count: { select: { stores: true, orders: true } },
},
}),
this.prisma.city.count({ where }),
]);
return serializeBigInt({
items: items.map((c) => ({
...c,
storeCount: c._count.stores,
orderCount: c._count.orders,
_count: undefined,
})),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const city = await this.prisma.city.findUnique({
where: { id },
include: {
partner: true,
commissionRule: true,
_count: { select: { stores: true, orders: true } },
},
});
if (!city) throw new NotFoundException('开城城市不存在');
return serializeBigInt(city);
}
async create(dto: CreateCityDto) {
const exists = await this.prisma.city.findUnique({ where: { code: dto.code } });
if (exists) throw new BadRequestException('城市编码已存在');
const city = await this.prisma.city.create({
data: {
code: dto.code,
name: dto.name,
province: dto.province,
partnerId: dto.partnerId ? BigInt(dto.partnerId) : null,
status: (dto.status ?? 'PENDING') as 'PENDING' | 'ACTIVE' | 'PAUSED',
commissionRule: {
create: {
orderCommissionRate: 0.05,
redeemCommissionRate: 0.03,
},
},
},
});
return serializeBigInt(city);
}
async update(id: bigint, dto: UpdateCityDto) {
const city = await this.prisma.city.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.province !== undefined ? { province: dto.province } : {}),
...(dto.partnerId !== undefined
? { partnerId: dto.partnerId ? BigInt(dto.partnerId) : null }
: {}),
...(dto.status !== undefined ? { status: dto.status as 'PENDING' | 'ACTIVE' | 'PAUSED' } : {}),
...(dto.localMinQty !== undefined ? { localMinQty: dto.localMinQty } : {}),
...(dto.crossMinQty !== undefined ? { crossMinQty: dto.crossMinQty } : {}),
},
});
return serializeBigInt(city);
}
}
@@ -0,0 +1,14 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminDashboardService } from './admin-dashboard.service';
@Controller('admin/dashboard')
@UseGuards(HqAuthGuard)
export class AdminDashboardController {
constructor(private readonly dashboardService: AdminDashboardService) {}
@Get('stats')
stats() {
return this.dashboardService.getStats();
}
}
@@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
@Injectable()
export class AdminDashboardService {
constructor(private readonly prisma: PrismaService) {}
async getStats() {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const [
usersTotal,
guestUsers,
verifiedUsers,
mergedUsers,
ordersToday,
ordersByStatus,
storesTotal,
partnersTotal,
redeemToday,
deliveriesTotal,
] = await Promise.all([
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
this.prisma.user.count({
where: { status: 1, mergedIntoUserId: null, phoneVerifiedAt: null },
}),
this.prisma.user.count({
where: { status: 1, mergedIntoUserId: null, phoneVerifiedAt: { not: null } },
}),
this.prisma.user.count({ where: { mergedIntoUserId: { not: null } } }),
this.prisma.order.count({ where: { createdAt: { gte: todayStart } } }),
this.prisma.order.groupBy({
by: ['status'],
_count: { status: true },
}),
this.prisma.store.count(),
this.prisma.partner.count(),
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
this.prisma.orderDelivery.count(),
]);
return {
usersTotal,
guestUsers,
verifiedUsers,
mergedUsers,
ordersToday,
storesTotal,
partnersTotal,
redeemToday,
deliveriesTotal,
ordersByStatus: ordersByStatus.map((row) => ({
status: row.status,
count: row._count.status,
})),
};
}
}
@@ -0,0 +1,34 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { AdminHqAccountsService } from './admin-hq-accounts.service';
import { AdminHqAccountsQueryDto } from './dto/admin-query.dto';
import { CreateHqAccountDto, UpdateHqAccountDto } from './dto/admin-mutate.dto';
@Controller('admin/hq-accounts')
@UseGuards(HqAuthGuard)
export class AdminHqAccountsController {
constructor(private readonly service: AdminHqAccountsService) {}
@Get()
list(@Query() query: AdminHqAccountsQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post()
@UseGuards(SuperAdminGuard)
create(@Body() dto: CreateHqAccountDto) {
return this.service.create(dto);
}
@Put(':id')
@UseGuards(SuperAdminGuard)
update(@Param('id') id: string, @Body() dto: UpdateHqAccountDto) {
return this.service.update(BigInt(id), dto);
}
}
@@ -0,0 +1,64 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminHqAccountsQueryDto } from './dto/admin-query.dto';
import type { CreateHqAccountDto, UpdateHqAccountDto } from './dto/admin-mutate.dto';
@Injectable()
export class AdminHqAccountsService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminHqAccountsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.HqAccountWhereInput = {};
if (query.phone) where.phone = { contains: query.phone };
if (query.adminRole) where.adminRole = query.adminRole as Prisma.EnumHqAdminRoleFilter['equals'];
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.hqAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.hqAccount.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: bigint) {
const account = await this.prisma.hqAccount.findUnique({ where: { id } });
if (!account) throw new NotFoundException('HQ 账号不存在');
return serializeBigInt(account);
}
async create(dto: CreateHqAccountDto) {
const exists = await this.prisma.hqAccount.findUnique({ where: { phone: dto.phone } });
if (exists) throw new BadRequestException('手机号已存在');
const account = await this.prisma.hqAccount.create({
data: {
phone: dto.phone,
name: dto.name,
adminRole: (dto.adminRole ?? 'OPS') as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE',
},
});
return serializeBigInt(account);
}
async update(id: bigint, dto: UpdateHqAccountDto) {
const account = await this.prisma.hqAccount.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.adminRole !== undefined
? { adminRole: dto.adminRole as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' }
: {}),
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
},
});
return serializeBigInt(account);
}
}
@@ -0,0 +1,20 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminOrdersService } from './admin-orders.service';
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
@Controller('admin/orders')
@UseGuards(HqAuthGuard)
export class AdminOrdersController {
constructor(private readonly ordersService: AdminOrdersService) {}
@Get()
list(@Query() query: AdminOrdersQueryDto) {
return this.ordersService.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.ordersService.detail(BigInt(id));
}
}
@@ -0,0 +1,70 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
@Injectable()
export class AdminOrdersService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminOrdersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.OrderWhereInput = {};
if (query.orderNo) where.orderNo = { contains: query.orderNo };
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
if (query.userId) where.userId = BigInt(query.userId);
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
if (query.createdFrom || query.createdTo) {
where.createdAt = {};
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
if (query.createdTo) where.createdAt.lte = new Date(query.createdTo);
}
const [items, total] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } },
},
}),
this.prisma.order.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: bigint) {
const order = await this.prisma.order.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
userNo: true,
phone: true,
nickname: true,
deviceKey: true,
phoneVerifiedAt: true,
},
},
items: true,
delivery: true,
payment: true,
statusLogs: { orderBy: { createdAt: 'asc' } },
benefitCoupons: {
select: { id: true, couponNo: true, balance: true, status: true },
},
city: { select: { id: true, name: true, code: true } },
},
});
if (!order) throw new NotFoundException('订单不存在');
return serializeBigInt(order);
}
}
@@ -0,0 +1,62 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminPartnersService } from './admin-partners.service';
import { AdminPartnerAccountsQueryDto, AdminPartnersQueryDto } from './dto/admin-query.dto';
import {
CreatePartnerAccountDto,
CreatePartnerDto,
UpdatePartnerAccountDto,
UpdatePartnerDto,
} from './dto/admin-mutate.dto';
@Controller('admin/partners')
@UseGuards(HqAuthGuard)
export class AdminPartnersController {
constructor(private readonly service: AdminPartnersService) {}
@Get()
list(@Query() query: AdminPartnersQueryDto) {
return this.service.listPartners(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detailPartner(BigInt(id));
}
@Post()
create(@Body() dto: CreatePartnerDto) {
return this.service.createPartner(dto);
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdatePartnerDto) {
return this.service.updatePartner(BigInt(id), dto);
}
}
@Controller('admin/partner-accounts')
@UseGuards(HqAuthGuard)
export class AdminPartnerAccountsController {
constructor(private readonly service: AdminPartnersService) {}
@Get()
list(@Query() query: AdminPartnerAccountsQueryDto) {
return this.service.listPartnerAccounts(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detailPartnerAccount(BigInt(id));
}
@Post()
create(@Body() dto: CreatePartnerAccountDto) {
return this.service.createPartnerAccount(dto);
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdatePartnerAccountDto) {
return this.service.updatePartnerAccount(BigInt(id), dto);
}
}
@@ -0,0 +1,153 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminPartnerAccountsQueryDto, AdminPartnersQueryDto } from './dto/admin-query.dto';
import type {
CreatePartnerAccountDto,
CreatePartnerDto,
UpdatePartnerAccountDto,
UpdatePartnerDto,
} from './dto/admin-mutate.dto';
@Injectable()
export class AdminPartnersService {
constructor(private readonly prisma: PrismaService) {}
async listPartners(query: AdminPartnersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerWhereInput = {};
if (query.companyName) where.companyName = { contains: query.companyName };
if (query.contactPhone) where.contactPhone = { contains: query.contactPhone };
const [items, total] = await Promise.all([
this.prisma.partner.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
_count: { select: { stores: true, accounts: true, cities: true } },
},
}),
this.prisma.partner.count({ where }),
]);
return serializeBigInt({
items: items.map((p) => ({
...p,
storeCount: p._count.stores,
accountCount: p._count.accounts,
cityCount: p._count.cities,
_count: undefined,
})),
total,
page,
pageSize,
});
}
async detailPartner(id: bigint) {
const partner = await this.prisma.partner.findUnique({
where: { id },
include: {
cities: { select: { id: true, code: true, name: true, status: true } },
accounts: { select: { id: true, phone: true, name: true, isPrimary: true, status: true } },
stores: { select: { id: true, name: true, status: true }, take: 10, orderBy: { createdAt: 'desc' } },
_count: { select: { stores: true, accounts: true } },
},
});
if (!partner) throw new NotFoundException('开城合伙人不存在');
return serializeBigInt(partner);
}
async createPartner(dto: CreatePartnerDto) {
const partner = await this.prisma.partner.create({ data: dto });
return serializeBigInt(partner);
}
async updatePartner(id: bigint, dto: UpdatePartnerDto) {
const partner = await this.prisma.partner.update({ where: { id }, data: dto });
return serializeBigInt(partner);
}
async listPartnerAccounts(query: AdminPartnerAccountsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerAccountWhereInput = {};
if (query.phone) where.phone = { contains: query.phone };
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
partner: { select: { id: true, companyName: true } },
},
}),
this.prisma.partnerAccount.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detailPartnerAccount(id: bigint) {
const account = await this.prisma.partnerAccount.findUnique({
where: { id },
include: { partner: true },
});
if (!account) throw new NotFoundException('开城合伙人账号不存在');
const [bills, orders] = await Promise.all([
this.prisma.partnerBill.findMany({
where: { partnerId: account.partnerId },
orderBy: { createdAt: 'desc' },
take: 50,
}),
this.prisma.order.findMany({
where: { city: { partnerId: account.partnerId } },
orderBy: { createdAt: 'desc' },
take: 50,
select: {
id: true,
orderNo: true,
status: true,
payAmount: true,
createdAt: true,
user: { select: { userNo: true, phone: true } },
},
}),
]);
return serializeBigInt({ ...account, bills, orders });
}
async createPartnerAccount(dto: CreatePartnerAccountDto) {
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId) } });
if (!partner) throw new BadRequestException('开城合伙人不存在');
const account = await this.prisma.partnerAccount.create({
data: {
partnerId: partner.id,
phone: dto.phone,
name: dto.name,
staffRole: dto.staffRole ? (dto.staffRole as 'PARTNER' | 'INTERNAL' | 'PROMOTER') : undefined,
isPrimary: 0,
},
});
return serializeBigInt(account);
}
async updatePartnerAccount(id: bigint, dto: UpdatePartnerAccountDto) {
const account = await this.prisma.partnerAccount.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
},
});
return serializeBigInt(account);
}
}
@@ -0,0 +1,42 @@
import { Body, Controller, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminRedeemService, AdminDeliveriesService } from './admin-redeem.service';
import { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto';
import { UpdateDeliveryDto } from './dto/admin-mutate.dto';
@Controller('admin/redeem-records')
@UseGuards(HqAuthGuard)
export class AdminRedeemRecordsController {
constructor(private readonly service: AdminRedeemService) {}
@Get()
list(@Query() query: AdminRedeemRecordsQueryDto) {
return this.service.listRecords(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detailRecord(BigInt(id));
}
}
@Controller('admin/deliveries')
@UseGuards(HqAuthGuard)
export class AdminDeliveriesController {
constructor(private readonly service: AdminDeliveriesService) {}
@Get()
list(@Query() query: AdminDeliveriesQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdateDeliveryDto) {
return this.service.update(BigInt(id), dto);
}
}
@@ -0,0 +1,119 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto';
import type { UpdateDeliveryDto } from './dto/admin-mutate.dto';
@Injectable()
export class AdminRedeemService {
constructor(private readonly prisma: PrismaService) {}
async listRecords(query: AdminRedeemRecordsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.RedeemRecordWhereInput = {};
if (query.redeemNo) where.redeemNo = { contains: query.redeemNo };
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.userId) where.userId = BigInt(query.userId);
const [items, total] = await Promise.all([
this.prisma.redeemRecord.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
store: { select: { id: true, name: true, cityName: true } },
coupon: { select: { id: true, couponNo: true, balance: true } },
},
}),
this.prisma.redeemRecord.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detailRecord(id: bigint) {
const record = await this.prisma.redeemRecord.findUnique({
where: { id },
include: {
user: true,
store: { include: { partner: { select: { id: true, companyName: true } } } },
coupon: true,
payout: true,
commissions: true,
},
});
if (!record) throw new NotFoundException('核销记录不存在');
return serializeBigInt(record);
}
}
@Injectable()
export class AdminDeliveriesService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminDeliveriesQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.OrderDeliveryWhereInput = {};
if (query.provider) where.provider = query.provider;
if (query.trackingNo) where.trackingNo = { contains: query.trackingNo };
if (query.orderNo) {
where.order = { orderNo: { contains: query.orderNo } };
}
const [items, total] = await Promise.all([
this.prisma.orderDelivery.findMany({
where,
orderBy: { updatedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
order: {
select: {
id: true,
orderNo: true,
status: true,
receiverName: true,
receiverPhone: true,
deliveryType: true,
},
},
},
}),
this.prisma.orderDelivery.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: bigint) {
const delivery = await this.prisma.orderDelivery.findUnique({
where: { id },
include: {
order: {
include: {
user: { select: { id: true, userNo: true, phone: true } },
items: true,
},
},
},
});
if (!delivery) throw new NotFoundException('配送单不存在');
return serializeBigInt(delivery);
}
async update(id: bigint, dto: UpdateDeliveryDto) {
const delivery = await this.prisma.orderDelivery.update({
where: { id },
data: {
...(dto.provider !== undefined ? { provider: dto.provider } : {}),
...(dto.providerOrderNo !== undefined ? { providerOrderNo: dto.providerOrderNo } : {}),
...(dto.trackingNo !== undefined ? { trackingNo: dto.trackingNo } : {}),
},
include: { order: { select: { orderNo: true, status: true } } },
});
return serializeBigInt(delivery);
}
}
@@ -0,0 +1,100 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminStoresService } from './admin-stores.service';
import {
AdminStoreAccountsQueryDto,
AdminStoreMediaQueryDto,
AdminStoresQueryDto,
} from './dto/admin-query.dto';
import {
CreateStoreAccountDto,
CreateStoreDto,
CreateStoreMediaDto,
UpdateStoreAccountDto,
UpdateStoreDto,
UpdateStoreMediaDto,
UpdateStoreStatusDto,
} from './dto/admin-mutate.dto';
@Controller('admin/stores')
@UseGuards(HqAuthGuard)
export class AdminStoresController {
constructor(private readonly service: AdminStoresService) {}
@Get()
list(@Query() query: AdminStoresQueryDto) {
return this.service.listStores(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detailStore(BigInt(id));
}
@Post()
create(@Body() dto: CreateStoreDto) {
return this.service.createStore(dto);
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdateStoreDto) {
return this.service.updateStore(BigInt(id), dto);
}
@Put(':id/status')
updateStatus(@Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
return this.service.updateStoreStatus(BigInt(id), dto);
}
}
@Controller('admin/store-accounts')
@UseGuards(HqAuthGuard)
export class AdminStoreAccountsController {
constructor(private readonly service: AdminStoresService) {}
@Get()
list(@Query() query: AdminStoreAccountsQueryDto) {
return this.service.listStoreAccounts(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detailStoreAccount(BigInt(id));
}
@Post()
create(@Body() dto: CreateStoreAccountDto) {
return this.service.createStoreAccount(dto);
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdateStoreAccountDto) {
return this.service.updateStoreAccount(BigInt(id), dto);
}
}
@Controller('admin/store-media')
@UseGuards(HqAuthGuard)
export class AdminStoreMediaController {
constructor(private readonly service: AdminStoresService) {}
@Get()
list(@Query() query: AdminStoreMediaQueryDto) {
return this.service.listStoreMedia(query);
}
@Post()
create(@Body() dto: CreateStoreMediaDto) {
return this.service.createStoreMedia(dto);
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdateStoreMediaDto) {
return this.service.updateStoreMedia(BigInt(id), dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.service.deleteStoreMedia(BigInt(id));
}
}
@@ -0,0 +1,233 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
import type {
CreateStoreAccountDto,
CreateStoreDto,
CreateStoreMediaDto,
UpdateStoreAccountDto,
UpdateStoreDto,
UpdateStoreMediaDto,
UpdateStoreStatusDto,
} from './dto/admin-mutate.dto';
@Injectable()
export class AdminStoresService {
constructor(private readonly prisma: PrismaService) {}
async listStores(query: AdminStoresQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.StoreWhereInput = {};
if (query.name) where.name = { contains: query.name };
if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals'];
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.phone) where.phone = { contains: query.phone };
const [items, total] = await Promise.all([
this.prisma.store.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
cityRef: { select: { id: true, name: true, code: true } },
partner: { select: { id: true, companyName: true } },
account: { select: { id: true, phone: true, name: true, status: true } },
},
}),
this.prisma.store.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detailStore(id: bigint) {
const store = await this.prisma.store.findUnique({
where: { id },
include: {
cityRef: true,
partner: true,
category: true,
account: true,
media: { orderBy: { sortOrder: 'asc' } },
audits: { orderBy: { submittedAt: 'desc' }, take: 5 },
_count: { select: { redeemRecords: true, ratings: true } },
},
});
if (!store) throw new NotFoundException('门店不存在');
return serializeBigInt({
...store,
redeemCount: store._count.redeemRecords,
ratingCount: store._count.ratings,
_count: undefined,
});
}
async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto) {
const store = await this.prisma.store.update({
where: { id },
data: { status: dto.status as 'OPEN' | 'PAUSED' | 'CLOSED' },
});
return serializeBigInt(store);
}
async updateStore(id: bigint, dto: UpdateStoreDto) {
const store = await this.prisma.store.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.phone !== undefined ? { phone: dto.phone } : {}),
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
...(dto.coverUrl !== undefined ? { coverUrl: dto.coverUrl } : {}),
...(dto.address !== undefined ? { address: dto.address } : {}),
...(dto.district !== undefined ? { district: dto.district } : {}),
},
});
return serializeBigInt(store);
}
async createStore(dto: CreateStoreDto) {
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId) } });
if (!partner) throw new BadRequestException('开城合伙人不存在');
const city = await this.prisma.city.findUnique({ where: { id: BigInt(dto.cityId) } });
if (!city) throw new BadRequestException('开城城市不存在');
const store = await this.prisma.store.create({
data: {
cityId: city.id,
partnerId: partner.id,
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
name: dto.name,
phone: dto.phone,
province: dto.province ?? city.province,
cityName: dto.city ?? city.name,
district: dto.district ?? '',
address: dto.address,
intro: dto.intro ?? null,
coverUrl: dto.coverUrl ?? null,
status: 'OPEN',
},
});
await this.prisma.storeAccount.create({
data: {
storeId: store.id,
phone: dto.accountPhone ?? dto.phone,
name: dto.accountName ?? dto.name,
},
});
return serializeBigInt(store);
}
async createStoreAccount(dto: CreateStoreAccountDto) {
const store = await this.prisma.store.findUnique({
where: { id: BigInt(dto.storeId) },
include: { account: true },
});
if (!store) throw new BadRequestException('门店不存在');
if (store.account) throw new BadRequestException('门店已有账户');
const account = await this.prisma.storeAccount.create({
data: { storeId: store.id, phone: dto.phone, name: dto.name },
});
return serializeBigInt(account);
}
async listStoreMedia(query: AdminStoreMediaQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.StoreMediaWhereInput = {};
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.mediaType) where.mediaType = query.mediaType;
const [items, total] = await Promise.all([
this.prisma.storeMedia.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
include: { store: { select: { id: true, name: true } } },
}),
this.prisma.storeMedia.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async createStoreMedia(dto: CreateStoreMediaDto) {
const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) } });
if (!store) throw new BadRequestException('门店不存在');
const media = await this.prisma.storeMedia.create({
data: {
storeId: store.id,
mediaType: dto.mediaType,
url: dto.url,
sortOrder: dto.sortOrder ?? 0,
},
});
return serializeBigInt(media);
}
async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto) {
const media = await this.prisma.storeMedia.update({
where: { id },
data: {
...(dto.url !== undefined ? { url: dto.url } : {}),
...(dto.mediaType !== undefined ? { mediaType: dto.mediaType } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
},
});
return serializeBigInt(media);
}
async deleteStoreMedia(id: bigint) {
await this.prisma.storeMedia.delete({ where: { id } });
return { ok: true };
}
async listStoreAccounts(query: AdminStoreAccountsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.StoreAccountWhereInput = {};
if (query.phone) where.phone = { contains: query.phone };
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.storeAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
store: { select: { id: true, name: true, status: true, cityName: true } },
},
}),
this.prisma.storeAccount.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detailStoreAccount(id: bigint) {
const account = await this.prisma.storeAccount.findUnique({
where: { id },
include: { store: { include: { cityRef: true, partner: true } } },
});
if (!account) throw new NotFoundException('门店账号不存在');
return serializeBigInt(account);
}
async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto) {
const account = await this.prisma.storeAccount.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.phone !== undefined ? { phone: dto.phone } : {}),
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
},
});
return serializeBigInt(account);
}
}
@@ -0,0 +1,20 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminUsersService } from './admin-users.service';
import { AdminUsersQueryDto } from './dto/admin-query.dto';
@Controller('admin/users')
@UseGuards(HqAuthGuard)
export class AdminUsersController {
constructor(private readonly usersService: AdminUsersService) {}
@Get()
list(@Query() query: AdminUsersQueryDto) {
return this.usersService.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.usersService.detail(BigInt(id));
}
}
@@ -0,0 +1,88 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminUsersQueryDto } from './dto/admin-query.dto';
@Injectable()
export class AdminUsersService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminUsersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.UserWhereInput = {};
if (query.phone) where.phone = { contains: query.phone };
if (query.userNo) where.userNo = { contains: query.userNo };
if (query.deviceKey) where.deviceKey = query.deviceKey;
if (query.status !== undefined) where.status = query.status;
if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null };
if (query.phoneVerified === '0') where.phoneVerifiedAt = null;
const [items, total] = await Promise.all([
this.prisma.user.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
userNo: true,
deviceKey: true,
phone: true,
phoneVerifiedAt: true,
mergedIntoUserId: true,
nickname: true,
status: true,
createdAt: true,
updatedAt: true,
_count: { select: { orders: true } },
},
}),
this.prisma.user.count({ where }),
]);
return serializeBigInt({
items: items.map((u) => ({
...u,
orderCount: u._count.orders,
_count: undefined,
})),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const user = await this.prisma.user.findUnique({
where: { id },
include: {
cityPref: true,
mergedInto: { select: { id: true, userNo: true, phone: true, nickname: true } },
orders: {
orderBy: { createdAt: 'desc' },
take: 5,
select: {
id: true,
orderNo: true,
status: true,
payAmount: true,
createdAt: true,
},
},
_count: { select: { mergedFrom: true, orders: true, addresses: true } },
},
});
if (!user) throw new NotFoundException('用户不存在');
return serializeBigInt({
...user,
mergedFromCount: user._count.mergedFrom,
orderCount: user._count.orders,
addressCount: user._count.addresses,
_count: undefined,
});
}
}
@@ -0,0 +1,313 @@
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class UpdateStoreStatusDto {
@IsString()
@IsIn(['OPEN', 'PAUSED', 'CLOSED'])
status: string;
}
export class CreateStoreDto {
@IsString()
@IsNotEmpty()
partnerId: string;
@IsString()
@IsNotEmpty()
cityId: string;
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsNotEmpty()
phone: string;
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional()
@IsString()
province?: string;
@IsOptional()
@IsString()
city?: string;
@IsOptional()
@IsString()
district?: string;
@IsString()
@IsNotEmpty()
address: string;
@IsOptional()
@IsString()
intro?: string;
@IsOptional()
@IsString()
coverUrl?: string;
@IsOptional()
@IsString()
accountPhone?: string;
@IsOptional()
@IsString()
accountName?: string;
}
export class UpdateStoreDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
intro?: string;
@IsOptional()
@IsString()
coverUrl?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
district?: string;
}
export class CreateStoreAccountDto {
@IsString()
@IsNotEmpty()
storeId: string;
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
name: string;
}
export class UpdateStoreAccountDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
}
export class CreatePartnerDto {
@IsString()
@IsNotEmpty()
companyName: string;
@IsString()
@IsNotEmpty()
address: string;
@IsString()
@IsNotEmpty()
contactPhone: string;
@IsOptional()
@IsString()
bankAccountName?: string;
@IsOptional()
@IsString()
bankAccountNo?: string;
@IsOptional()
@IsString()
bankBranch?: string;
}
export class UpdatePartnerDto {
@IsOptional()
@IsString()
companyName?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
contactPhone?: string;
@IsOptional()
@IsString()
bankAccountName?: string;
@IsOptional()
@IsString()
bankAccountNo?: string;
@IsOptional()
@IsString()
bankBranch?: string;
}
export class UpdatePartnerAccountDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
}
export class CreatePartnerAccountDto {
@IsString()
@IsNotEmpty()
partnerId: string;
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
name: string;
@IsOptional()
@IsIn(['PARTNER', 'INTERNAL', 'PROMOTER'])
staffRole?: string;
}
export class CreateCityDto {
@IsString()
@IsNotEmpty()
code: string;
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsNotEmpty()
province: string;
@IsOptional()
@IsString()
partnerId?: string;
@IsOptional()
@IsIn(['PENDING', 'ACTIVE', 'PAUSED'])
status?: string;
}
export class UpdateCityDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
province?: string;
@IsOptional()
@IsString()
partnerId?: string;
@IsOptional()
@IsIn(['PENDING', 'ACTIVE', 'PAUSED'])
status?: string;
@IsOptional()
localMinQty?: number;
@IsOptional()
crossMinQty?: number;
}
export class CreateStoreMediaDto {
@IsString()
@IsNotEmpty()
storeId: string;
@IsString()
@IsIn(['IMAGE', 'VIDEO'])
mediaType: string;
@IsString()
@IsNotEmpty()
url: string;
@IsOptional()
sortOrder?: number;
}
export class UpdateStoreMediaDto {
@IsOptional()
@IsString()
url?: string;
@IsOptional()
@IsIn(['IMAGE', 'VIDEO'])
mediaType?: string;
@IsOptional()
sortOrder?: number;
}
export class UpdateDeliveryDto {
@IsOptional()
@IsString()
provider?: string;
@IsOptional()
@IsString()
providerOrderNo?: string;
@IsOptional()
@IsString()
trackingNo?: string;
}
export class CreateHqAccountDto {
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
name: string;
@IsOptional()
@IsIn(['SUPER_ADMIN', 'OPS', 'FINANCE', 'CUSTOMER_SERVICE'])
adminRole?: string;
}
export class UpdateHqAccountDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsIn(['SUPER_ADMIN', 'OPS', 'FINANCE', 'CUSTOMER_SERVICE'])
adminRole?: string;
@IsOptional()
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
}
@@ -0,0 +1,224 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class PaginationQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number = 20;
}
export class AdminUsersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
userNo?: string;
@IsOptional()
@IsString()
deviceKey?: string;
@IsOptional()
@IsIn(['0', '1'])
phoneVerified?: string;
@IsOptional()
@Type(() => Number)
@IsIn([0, 1])
status?: number;
}
export class AdminOrdersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
orderNo?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
receiverPhone?: string;
@IsOptional()
@IsString()
createdFrom?: string;
@IsOptional()
@IsString()
createdTo?: string;
}
export class AdminStoresQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
cityId?: string;
@IsOptional()
@IsString()
partnerId?: string;
@IsOptional()
@IsString()
phone?: string;
}
export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
storeId?: string;
@IsOptional()
@IsString()
status?: string;
}
export class AdminPartnersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
companyName?: string;
@IsOptional()
@IsString()
contactPhone?: string;
}
export class AdminPartnerAccountsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
partnerId?: string;
@IsOptional()
@IsString()
status?: string;
}
export class AdminBenefitCouponsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
couponNo?: string;
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
status?: string;
}
export class AdminBenefitLedgersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
couponId?: string;
@IsOptional()
@IsString()
type?: string;
}
export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
redeemNo?: string;
@IsOptional()
@IsString()
storeId?: string;
@IsOptional()
@IsString()
userId?: string;
}
export class AdminDeliveriesQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
provider?: string;
@IsOptional()
@IsString()
trackingNo?: string;
@IsOptional()
@IsString()
orderNo?: string;
}
export class AdminHqAccountsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
adminRole?: string;
@IsOptional()
@IsString()
status?: string;
}
export class AdminCitiesQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
partnerId?: string;
}
export class AdminStoreMediaQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
storeId?: string;
@IsOptional()
@IsString()
mediaType?: string;
}
@@ -0,0 +1,55 @@
import { Module } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { AdminDashboardController } from './admin-dashboard.controller';
import { AdminDashboardService } from './admin-dashboard.service';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
import { AdminOrdersController } from './admin-orders.controller';
import { AdminOrdersService } from './admin-orders.service';
import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller';
import { AdminStoresService } from './admin-stores.service';
import { AdminPartnersController, AdminPartnerAccountsController } from './admin-partners.controller';
import { AdminPartnersService } from './admin-partners.service';
import { AdminCitiesController } from './admin-cities.controller';
import { AdminCitiesService } from './admin-cities.service';
import { AdminBenefitCouponsController, AdminBenefitLedgersController } from './admin-benefit.controller';
import { AdminBenefitService } from './admin-benefit.service';
import { AdminRedeemRecordsController, AdminDeliveriesController } from './admin-redeem.controller';
import { AdminRedeemService, AdminDeliveriesService } from './admin-redeem.service';
import { AdminHqAccountsController } from './admin-hq-accounts.controller';
import { AdminHqAccountsService } from './admin-hq-accounts.service';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
@Module({
imports: [IamModule],
controllers: [
AdminDashboardController,
AdminUsersController,
AdminOrdersController,
AdminStoresController,
AdminStoreAccountsController,
AdminStoreMediaController,
AdminPartnersController,
AdminPartnerAccountsController,
AdminCitiesController,
AdminBenefitCouponsController,
AdminBenefitLedgersController,
AdminRedeemRecordsController,
AdminDeliveriesController,
AdminHqAccountsController,
],
providers: [
AdminDashboardService,
AdminUsersService,
AdminOrdersService,
AdminStoresService,
AdminPartnersService,
AdminCitiesService,
AdminBenefitService,
AdminRedeemService,
AdminDeliveriesService,
AdminHqAccountsService,
SuperAdminGuard,
],
})
export class OpsModule {}