diff --git a/apps/admin-web/index.html b/apps/admin-web/index.html
new file mode 100644
index 0000000..b65b325
--- /dev/null
+++ b/apps/admin-web/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ 杜康好客 · HQ 管理后台
+
+
+
+
+
+
diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json
new file mode 100644
index 0000000..dd1f8e9
--- /dev/null
+++ b/apps/admin-web/package.json
@@ -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"
+ }
+}
diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx
new file mode 100644
index 0000000..3e64608
--- /dev/null
+++ b/apps/admin-web/src/App.tsx
@@ -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 ;
+ return <>{children}>;
+}
+
+export default function App() {
+ return (
+
+ } />
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ } />
+
+ );
+}
diff --git a/apps/admin-web/src/admin.css b/apps/admin-web/src/admin.css
new file mode 100644
index 0000000..eeba271
--- /dev/null
+++ b/apps/admin-web/src/admin.css
@@ -0,0 +1,7 @@
+.admin-table-nowrap .ant-table-cell {
+ white-space: nowrap;
+}
+
+.admin-table-nowrap .ant-table-cell-ellipsis {
+ white-space: nowrap;
+}
diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx
new file mode 100644
index 0000000..f116527
--- /dev/null
+++ b/apps/admin-web/src/layouts/AdminLayout.tsx
@@ -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: , label: '概览' },
+ { key: '/users', icon: , label: '用户' },
+ { key: '/orders', icon: , label: '订单' },
+ {
+ key: 'stores-group',
+ icon: ,
+ label: '门店',
+ children: [
+ { key: '/stores', label: '门店列表' },
+ { key: '/store-accounts', label: '门店账户' },
+ { key: '/store-media', label: '门店资源' },
+ ],
+ },
+ {
+ key: 'partners-group',
+ icon: ,
+ label: '开城',
+ children: [
+ { key: '/partners', label: '开城合伙人' },
+ { key: '/cities', label: '开城城市' },
+ { key: '/partner-accounts', label: '开城合伙人账户' },
+ ],
+ },
+ {
+ key: 'benefit-group',
+ icon: ,
+ label: '好客权益',
+ children: [
+ { key: '/benefit/coupons', label: '权益券' },
+ { key: '/benefit/ledgers', label: '流水' },
+ { key: '/redeem-records', label: '核销记录' },
+ ],
+ },
+ { key: '/deliveries', icon: , label: '配送单' },
+ { key: '/hq-accounts', icon: , label: 'HQ账户' },
+];
+
+export default function AdminLayout() {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [profile, setProfile] = useState(null);
+
+ useEffect(() => {
+ request('/admin/auth/me').then(setProfile).catch(() => {});
+ }, []);
+
+ function logout() {
+ clearAuth();
+ navigate('/login');
+ }
+
+ const selectedKey = location.pathname;
+
+ return (
+
+
+ 杜康 HQ
+
+
+
+
+ 管理后台
+
+
+ {profile?.name || '—'}
+ {profile?.adminRole}
+ } onClick={logout}>
+ 退出
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts
new file mode 100644
index 0000000..517d0ba
--- /dev/null
+++ b/apps/admin-web/src/lib/api.ts
@@ -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(path: string, options: RequestInit = {}): Promise {
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ 'X-Client-App': CLIENT_APP,
+ ...(options.headers as Record),
+ };
+ 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 = {
+ 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 };
+};
diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts
new file mode 100644
index 0000000..2638f01
--- /dev/null
+++ b/apps/admin-web/src/lib/constants.ts
@@ -0,0 +1,56 @@
+export const ORDER_STATUS_LABELS: Record = {
+ PENDING_PAY: '待付款',
+ PENDING_SHIP: '待发货',
+ OUT_WAREHOUSE: '已出库',
+ SHIPPING: '配送中',
+ PENDING_RECEIVE: '待收货',
+ COMPLETED: '已完成',
+ CANCELLED: '已取消',
+ REFUNDING: '退款中',
+ REFUNDED: '已退款',
+};
+
+export const STORE_STATUS_LABELS: Record = {
+ OPEN: '营业中',
+ PAUSED: '暂停',
+ CLOSED: '已关闭',
+};
+
+export const ACCOUNT_STATUS_LABELS: Record = {
+ ACTIVE: '正常',
+ DISABLED: '停用',
+};
+
+export const COUPON_STATUS_LABELS: Record = {
+ ACTIVE: '可用',
+ USED_UP: '已用完',
+ VOID: '已作废',
+};
+
+export const CITY_STATUS_LABELS: Record = {
+ PENDING: '待开城',
+ ACTIVE: '已开城',
+ PAUSED: '已暂停',
+};
+
+export const PARTNER_BILL_STATUS_LABELS: Record = {
+ DRAFT: '草稿',
+ CONFIRMED: '已确认',
+ PAID: '已结算',
+};
+
+export const MEDIA_TYPE_LABELS: Record = {
+ IMAGE: '图片',
+ VIDEO: '视频',
+};
+
+export const LEDGER_TYPE_LABELS: Record = {
+ GRANT: '发放',
+ REDEEM: '核销',
+ REFUND_VOID: '退款作废',
+ ADJUST: '调整',
+};
+
+export function fmtTime(v?: string | null) {
+ return v ? new Date(v).toLocaleString('zh-CN') : '—';
+}
diff --git a/apps/admin-web/src/lib/useAdminList.ts b/apps/admin-web/src/lib/useAdminList.ts
new file mode 100644
index 0000000..e39bca1
--- /dev/null
+++ b/apps/admin-web/src/lib/useAdminList.ts
@@ -0,0 +1,28 @@
+import { useCallback, useEffect, useState } from 'react';
+import { request, type Paginated } from './api';
+
+export function useAdminList(path: string, buildQuery: () => URLSearchParams, deps: unknown[]) {
+ const [data, setData] = useState | 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>(`${path}?${qs}`);
+ setData(res);
+ } finally {
+ setLoading(false);
+ }
+ }, [path, page, pageSize, ...deps]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ return { data, loading, page, pageSize, setPage, setPageSize, reload: load };
+}
diff --git a/apps/admin-web/src/main.tsx b/apps/admin-web/src/main.tsx
new file mode 100644
index 0000000..079ba85
--- /dev/null
+++ b/apps/admin-web/src/main.tsx
@@ -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(
+
+
+
+
+
+
+ ,
+);
diff --git a/apps/admin-web/src/pages/BenefitCouponsPage.tsx b/apps/admin-web/src/pages/BenefitCouponsPage.tsx
new file mode 100644
index 0000000..87e0f88
--- /dev/null
+++ b/apps/admin-web/src/pages/BenefitCouponsPage.tsx
@@ -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>({});
+ const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList(
+ '/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 | null>(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+
+ const columns: ColumnsType = [
+ { 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) => {COUPON_STATUS_LABELS[s] || s} },
+ { title: '来源', dataIndex: 'sourceProduct', ellipsis: true },
+ { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
+ {
+ title: '操作', width: 80,
+ render: (_, row) => (
+
+ ),
+ },
+ ];
+
+ return (
+
+
好客权益券
+
+
+
+
+
+
{ setPage(p); setPageSize(ps); } }} />
+ setDrawerOpen(false)}
+ extra={detail && detail.status !== 'VOID' && (
+ {
+ await request(`/admin/benefit/coupons/${detail.id}/void`, { method: 'POST' });
+ message.success('已作废');
+ setDrawerOpen(false);
+ void reload();
+ }}>
+
+
+ )}>
+ {detail && (
+
+ {String(detail.couponNo)}
+ ¥{String(detail.totalAmount)}
+ ¥{String(detail.balance)}
+ {COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)}
+ {String(detail.sourceProduct)}
+
+ )}
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/BenefitLedgersPage.tsx b/apps/admin-web/src/pages/BenefitLedgersPage.tsx
new file mode 100644
index 0000000..689d963
--- /dev/null
+++ b/apps/admin-web/src/pages/BenefitLedgersPage.tsx
@@ -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>({});
+ const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList(
+ '/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 = [
+ { title: '类型', dataIndex: 'type', width: 90, render: (t) => {LEDGER_TYPE_LABELS[t] || t} },
+ { 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 (
+
+
权益流水
+
+
+
+
+
+
+
{ setPage(p); setPageSize(ps); } }} />
+
+ );
+}
diff --git a/apps/admin-web/src/pages/CitiesPage.tsx b/apps/admin-web/src/pages/CitiesPage.tsx
new file mode 100644
index 0000000..161d550
--- /dev/null
+++ b/apps/admin-web/src/pages/CitiesPage.tsx
@@ -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>({});
+ const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList(
+ '/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 | null>(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [partners, setPartners] = useState([]);
+
+ async function loadPartners() {
+ const res = await request>('/admin/partners?pageSize=200');
+ setPartners(res.items);
+ }
+
+ const columns: ColumnsType = [
+ { title: '编码', dataIndex: 'code', width: 90 },
+ { title: '城市', dataIndex: 'name', width: 100 },
+ { title: '省份', dataIndex: 'province', width: 90 },
+ { title: '状态', dataIndex: 'status', width: 90, render: (s) => {CITY_STATUS_LABELS[s] || s} },
+ { 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) => (
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ 开城城市
+
+
+
+
+
+
+
+
+
{ setPage(p); setPageSize(ps); } }} />
+ setDrawerOpen(false)}
+ extra={detail && (
+
+ )}>
+ {detail && (
+ <>
+
+ {String(detail.code)}
+ {String(detail.storeCount ?? (detail as { _count?: { stores?: number } })._count?.stores ?? '—')}
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ 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();
+ }}>
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/DashboardPage.tsx b/apps/admin-web/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..8270b5d
--- /dev/null
+++ b/apps/admin-web/src/pages/DashboardPage.tsx
@@ -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 = {
+ PENDING_PAY: '待付款',
+ PENDING_SHIP: '待发货',
+ OUT_WAREHOUSE: '已出库',
+ SHIPPING: '配送中',
+ PENDING_RECEIVE: '待收货',
+ COMPLETED: '已完成',
+ CANCELLED: '已取消',
+ REFUNDING: '退款中',
+ REFUNDED: '已退款',
+};
+
+export default function DashboardPage() {
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ request('/admin/dashboard/stats')
+ .then(setStats)
+ .finally(() => setLoading(false));
+ }, []);
+
+ return (
+
+
数据概览
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ STATUS_LABELS[s] || s,
+ },
+ { title: '数量', dataIndex: 'count' },
+ ]}
+ />
+
+
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/DeliveriesPage.tsx b/apps/admin-web/src/pages/DeliveriesPage.tsx
new file mode 100644
index 0000000..a9ce9dc
--- /dev/null
+++ b/apps/admin-web/src/pages/DeliveriesPage.tsx
@@ -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>({});
+ const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList(
+ '/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(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+
+ const columns: ColumnsType = [
+ { 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) => (
+
+ ),
+ },
+ ];
+
+ return (
+
+
快递/配送单
+
+
+
+
+
+
{ setPage(p); setPageSize(ps); } }} />
+ setDrawerOpen(false)}
+ extra={
+
+
+
+
+ }>
+ {detail && (
+ <>
+
+ {detail.order?.orderNo}
+ {detail.order?.receiverName} {detail.order?.receiverPhone}
+
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/HqAccountsPage.tsx b/apps/admin-web/src/pages/HqAccountsPage.tsx
new file mode 100644
index 0000000..c56cd20
--- /dev/null
+++ b/apps/admin-web/src/pages/HqAccountsPage.tsx
@@ -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 = {
+ SUPER_ADMIN: '超级管理员',
+ OPS: '运营',
+ FINANCE: '财务',
+ CUSTOMER_SERVICE: '客服',
+};
+
+export default function HqAccountsPage() {
+ const [profile, setProfile] = useState(null);
+ const [form] = Form.useForm();
+ const [editForm] = Form.useForm();
+ const [createForm] = Form.useForm();
+ const [filters, setFilters] = useState>({});
+ const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList(
+ '/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(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [createOpen, setCreateOpen] = useState(false);
+ const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
+
+ useEffect(() => {
+ request('/admin/auth/me').then(setProfile).catch(() => {});
+ }, []);
+
+ const columns: ColumnsType = [
+ { 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) => {ACCOUNT_STATUS_LABELS[s] || s} },
+ { title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime },
+ {
+ title: '操作', width: 80,
+ render: (_, row) => (
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ HQ 账户
+ {isSuperAdmin && }
+
+
+
+
+
+
+
{ setPage(p); setPageSize(ps); } }} />
+ setDrawerOpen(false)}
+ extra={
+
+ }>
+
+
+
+
+
+
+
+ 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();
+ }}>
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/LoginPage.tsx b/apps/admin-web/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..0255659
--- /dev/null
+++ b/apps/admin-web/src/pages/LoginPage.tsx
@@ -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 (
+
+
+
+ HQ 管理后台
+
+
+
+
+
+ 0} onClick={() => void sendCode()}>
+ {codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
+
+ }
+ />
+
+
+
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx
new file mode 100644
index 0000000..8a4d545
--- /dev/null
+++ b/apps/admin-web/src/pages/OrdersPage.tsx
@@ -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 = {
+ 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>;
+ payment?: Record | null;
+ statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
+ benefitCoupons?: Array>;
+ city?: { name: string; code: string };
+};
+
+export default function OrdersPage() {
+ const [form] = Form.useForm();
+ const [data, setData] = useState | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(20);
+ const [detail, setDetail] = useState(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>(`/admin/orders?${qs}`);
+ setData(res);
+ } finally {
+ setLoading(false);
+ }
+ }, [form, page, pageSize]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ async function openDetail(id: string) {
+ const res = await request(`/admin/orders/${id}`);
+ setDetail(res);
+ setDrawerOpen(true);
+ }
+
+ const columns: ColumnsType = [
+ { title: '订单号', dataIndex: 'orderNo', width: 180 },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 100,
+ render: (s) => {STATUS_LABELS[s] || s},
+ },
+ {
+ 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) => (
+
+ ),
+ },
+ ];
+
+ return (
+
+
订单监控
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ setPage(p);
+ setPageSize(ps);
+ },
+ }}
+ />
+
+ setDrawerOpen(false)}>
+ {detail && (
+ <>
+
+ {detail.orderNo}
+ {STATUS_LABELS[detail.status] || detail.status}
+ {detail.user?.userNo} / {detail.user?.nickname}
+ ¥{detail.productAmount}
+ ¥{detail.payAmount}
+ ¥{detail.benefitAmount}
+ {new Date(detail.createdAt).toLocaleString('zh-CN')}
+
+
+
+ {detail.receiverName} {detail.receiverPhone}
+ {detail.receiverAddress}
+
+
+
+ {detail.clientIp || '—'}
+ {[detail.ipProvince, detail.ipCity, detail.ipDistrict].filter(Boolean).join(' / ') || '—'}
+ {[detail.gpsProvince, detail.gpsCity, detail.gpsDistrict].filter(Boolean).join(' / ') || '—'}
+
+ {detail.gpsLatitude != null ? `${detail.gpsLatitude}, ${detail.gpsLongitude}` : '—'}
+
+
+
+ {detail.delivery && (
+
+ {detail.delivery.provider}
+ {detail.delivery.trackingNo || '—'}
+
+ )}
+
+ {detail.statusLogs && detail.statusLogs.length > 0 && (
+ <>
+ 状态流转
+ v || '—' },
+ { title: '到', dataIndex: 'toStatus', render: (v) => STATUS_LABELS[v] || v },
+ { title: '时间', dataIndex: 'createdAt', render: (v) => new Date(v).toLocaleString('zh-CN') },
+ ]}
+ />
+ >
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/PartnerAccountsPage.tsx b/apps/admin-web/src/pages/PartnerAccountsPage.tsx
new file mode 100644
index 0000000..a5aa5f7
--- /dev/null
+++ b/apps/admin-web/src/pages/PartnerAccountsPage.tsx
@@ -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>({});
+ const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList(
+ '/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(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [partners, setPartners] = useState([]);
+
+ async function loadPartners() {
+ const res = await request>('/admin/partners?pageSize=200');
+ setPartners(res.items);
+ }
+
+ const columns: ColumnsType = [
+ { 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) => {ACCOUNT_STATUS_LABELS[s] || s} },
+ { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
+ {
+ title: '操作', width: 80,
+ render: (_, row) => (
+
+ ),
+ },
+ ];
+
+ const billColumns: ColumnsType = [
+ { 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) => {PARTNER_BILL_STATUS_LABELS[s] || s} },
+ { title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart)} ~ ${fmtTime(r.periodEnd)}` },
+ ];
+
+ const orderColumns: ColumnsType = [
+ { 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) => {ORDER_STATUS_LABELS[s] || s} },
+ { title: '下单', dataIndex: 'createdAt', width: 160, render: fmtTime },
+ ];
+
+ return (
+
+
+ 开城合伙人账户
+
+
+
+
+
+
+
+
{ setPage(p); setPageSize(ps); } }} />
+ setDrawerOpen(false)}
+ extra={detail && (
+
+ 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();
+ }}>
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/PartnersPage.tsx b/apps/admin-web/src/pages/PartnersPage.tsx
new file mode 100644
index 0000000..5470a11
--- /dev/null
+++ b/apps/admin-web/src/pages/PartnersPage.tsx
@@ -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>({});
+ const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList(
+ '/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 | null>(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [createOpen, setCreateOpen] = useState(false);
+
+ const columns: ColumnsType = [
+ { 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) => (
+
+
+
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ 开城合伙人
+
+
+
+
+
+
+
{ setPage(p); setPageSize(ps); } }} />
+ setDrawerOpen(false)}
+ extra={detail && (
+
+ )}>
+ {detail && (
+ <>
+ {Array.isArray(detail.cities) && (
+
+ {(detail.cities as Array<{ name: string; code: string; status: string }>).map((c) => (
+ {c.name} ({c.status})
+ ))}
+
+ )}
+
+
+
+
+
+
+ >
+ )}
+
+ 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();
+ }}>
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/RedeemRecordsPage.tsx b/apps/admin-web/src/pages/RedeemRecordsPage.tsx
new file mode 100644
index 0000000..2f7557d
--- /dev/null
+++ b/apps/admin-web/src/pages/RedeemRecordsPage.tsx
@@ -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>({});
+ const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList(
+ '/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 | null>(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+
+ const columns: ColumnsType = [
+ { 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) => (
+
+ ),
+ },
+ ];
+
+ return (
+
+
核销记录
+
+
+
+
{ setPage(p); setPageSize(ps); } }} />
+ setDrawerOpen(false)}>
+ {detail && (
+
+ {String(detail.redeemNo)}
+ ¥{String(detail.amount)}
+ ¥{String(detail.settleAmount)}
+ {fmtTime(String(detail.createdAt))}
+
+ )}
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/StoreAccountsPage.tsx b/apps/admin-web/src/pages/StoreAccountsPage.tsx
new file mode 100644
index 0000000..bbbcf7b
--- /dev/null
+++ b/apps/admin-web/src/pages/StoreAccountsPage.tsx
@@ -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>({});
+ const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList(
+ '/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(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [stores, setStores] = useState([]);
+
+ async function loadStores() {
+ const res = await request>('/admin/stores?pageSize=200');
+ setStores(res.items);
+ }
+
+ const columns: ColumnsType = [
+ { 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 ? {STORE_STATUS_LABELS[s] || s} : '—' },
+ { title: '账号状态', dataIndex: 'status', width: 90, render: (s) => {ACCOUNT_STATUS_LABELS[s] || s} },
+ { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
+ {
+ title: '操作', width: 80,
+ render: (_, row) => (
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ 门店账户
+
+
+
+
+
+
+
+
{ setPage(p); setPageSize(ps); } }} />
+ setDrawerOpen(false)}
+ extra={detail && (
+
+ 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();
+ }}>
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/StoreMediaPage.tsx b/apps/admin-web/src/pages/StoreMediaPage.tsx
new file mode 100644
index 0000000..32b7e73
--- /dev/null
+++ b/apps/admin-web/src/pages/StoreMediaPage.tsx
@@ -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>({});
+ const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList(
+ '/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(null);
+ const [stores, setStores] = useState([]);
+
+ async function loadStores() {
+ const res = await request>('/admin/stores?pageSize=200');
+ setStores(res.items);
+ }
+
+ const columns: ColumnsType = [
+ { title: '门店', dataIndex: ['store', 'name'], width: 140 },
+ { title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => {MEDIA_TYPE_LABELS[t] || t} },
+ {
+ title: '预览', dataIndex: 'url', width: 100,
+ render: (url, row) => row.mediaType === 'IMAGE'
+ ?
+ : 视频,
+ },
+ { title: 'URL', dataIndex: 'url', ellipsis: true },
+ { title: '排序', dataIndex: 'sortOrder', width: 70 },
+ { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
+ {
+ title: '操作', width: 120,
+ render: (_, row) => (
+
+
+ {
+ await request(`/admin/store-media/${row.id}`, { method: 'DELETE' });
+ message.success('已删除');
+ void reload();
+ }}>
+
+
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ 门店资源
+
+
+
+
+
+
+
+
{ setPage(p); setPageSize(ps); } }} />
+ 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();
+ }}>
+
+
+
+
+
+
+
+
+ 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();
+ }}>
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx
new file mode 100644
index 0000000..31e69df
--- /dev/null
+++ b/apps/admin-web/src/pages/StoresPage.tsx
@@ -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>({});
+ const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList(
+ '/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 | null>(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [partners, setPartners] = useState([]);
+ const [cities, setCities] = useState([]);
+
+ async function loadOptions() {
+ const [p, c] = await Promise.all([
+ request>('/admin/partners?pageSize=200'),
+ request>('/admin/cities?pageSize=200'),
+ ]);
+ setPartners(p.items);
+ setCities(c.items);
+ }
+
+ const columns: ColumnsType = [
+ {
+ title: '封面', dataIndex: 'coverUrl', width: 72,
+ render: (url) => url ? : '—',
+ },
+ { title: '门店名', dataIndex: 'name', width: 140 },
+ { title: '城市', dataIndex: 'cityName', width: 80 },
+ { title: '电话', dataIndex: 'phone', width: 120 },
+ {
+ title: '状态', dataIndex: 'status', width: 90,
+ render: (s) => {STORE_STATUS_LABELS[s] || s},
+ },
+ { 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) => (
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ 门店
+
+
+
+
+
+
+
+
+
{ setPage(p); setPageSize(ps); } }} />
+ setDrawerOpen(false)}
+ extra={detail && (
+
+
+ )}>
+ {detail && (
+ <>
+
+ {String(detail.id)}
+ {String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}
+ {String(detail.redeemCount ?? 0)}
+ {detail.coverUrl && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ 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();
+ }}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/UsersPage.tsx b/apps/admin-web/src/pages/UsersPage.tsx
new file mode 100644
index 0000000..95a3bf2
--- /dev/null
+++ b/apps/admin-web/src/pages/UsersPage.tsx
@@ -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 | 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 | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(20);
+ const [detail, setDetail] = useState(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>(`/admin/users?${qs}`);
+ setData(res);
+ } finally {
+ setLoading(false);
+ }
+ }, [form, page, pageSize]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ async function openDetail(id: string) {
+ const res = await request(`/admin/users/${id}`);
+ setDetail(res);
+ setDrawerOpen(true);
+ }
+
+ const columns: ColumnsType = [
+ { 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 ? 已验证 : 访客),
+ },
+ {
+ title: 'deviceKey',
+ dataIndex: 'deviceKey',
+ ellipsis: true,
+ render: (v) => v || '—',
+ },
+ {
+ title: '合并',
+ dataIndex: 'mergedIntoUserId',
+ width: 80,
+ render: (v) => (v ? 已合并 : '—'),
+ },
+ { title: '订单数', dataIndex: 'orderCount', width: 80 },
+ {
+ title: '注册时间',
+ dataIndex: 'createdAt',
+ width: 170,
+ render: (v) => new Date(v).toLocaleString('zh-CN'),
+ },
+ {
+ title: '操作',
+ width: 80,
+ render: (_, row) => (
+
+ ),
+ },
+ ];
+
+ return (
+
+
用户监控
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ setPage(p);
+ setPageSize(ps);
+ },
+ }}
+ />
+
+ setDrawerOpen(false)}>
+ {detail && (
+ <>
+
+ {detail.id}
+ {detail.userNo}
+ {detail.nickname || '—'}
+ {detail.phone || '—'}
+
+ {detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
+
+ {detail.deviceKey || '—'}
+
+ {detail.mergedInto
+ ? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})`
+ : '—'}
+
+ {detail.mergedFromCount ?? 0}
+ {detail.orderCount} / {detail.addressCount}
+
+ {new Date(detail.createdAt).toLocaleString('zh-CN')}
+
+
+ {detail.orders && detail.orders.length > 0 && (
+ <>
+ 最近订单
+ `¥${v}` },
+ ]}
+ />
+ >
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/admin-web/tsconfig.json b/apps/admin-web/tsconfig.json
new file mode 100644
index 0000000..42e0521
--- /dev/null
+++ b/apps/admin-web/tsconfig.json
@@ -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"]
+}
diff --git a/apps/admin-web/vite.config.ts b/apps/admin-web/vite.config.ts
new file mode 100644
index 0000000..62266b2
--- /dev/null
+++ b/apps/admin-web/vite.config.ts
@@ -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' },
+ },
+});
diff --git a/package.json b/package.json
index a0f1732..23a82a8 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts
index 268b3aa..a62295c 100644
--- a/packages/shared-types/src/enums.ts
+++ b/packages/shared-types/src/enums.ts
@@ -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.PARTNER_MINI]: ActorType.PARTNER,
[ClientApp.PARTNER_H5]: ActorType.PARTNER,
[ClientApp.HQ_MINI]: ActorType.HQ,
+ [ClientApp.HQ_WEB]: ActorType.HQ,
[ClientApp.SHOP_H5]: ActorType.STORE,
};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d592d19..fa3363b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,46 @@ importers:
.: {}
+ apps/admin-web:
+ dependencies:
+ '@ant-design/icons':
+ specifier: ^5.5.1
+ version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@dukang/shared-types':
+ specifier: workspace:*
+ version: link:../../packages/shared-types
+ antd:
+ specifier: ^5.22.0
+ version: 5.29.3(luxon@3.7.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ dayjs:
+ specifier: ^1.11.13
+ version: 1.11.21
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ react-router-dom:
+ specifier: ^6.26.0
+ version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ devDependencies:
+ '@types/react':
+ specifier: ^18.3.3
+ version: 18.3.31
+ '@types/react-dom':
+ specifier: ^18.3.0
+ version: 18.3.7(@types/react@18.3.31)
+ '@vitejs/plugin-react':
+ specifier: ^4.3.1
+ version: 4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.48.0))
+ typescript:
+ specifier: ^5.4.5
+ version: 5.9.3
+ vite:
+ specifier: ^5.4.0
+ version: 5.4.21(@types/node@20.19.43)(terser@5.48.0)
+
apps/h5-partner:
dependencies:
'@dukang/shared-types':
@@ -233,6 +273,40 @@ packages:
resolution: {integrity: sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==}
engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+ '@ant-design/colors@7.2.1':
+ resolution: {integrity: sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==}
+
+ '@ant-design/cssinjs-utils@1.1.3':
+ resolution: {integrity: sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@ant-design/cssinjs@1.24.0':
+ resolution: {integrity: sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==}
+ peerDependencies:
+ react: '>=16.0.0'
+ react-dom: '>=16.0.0'
+
+ '@ant-design/fast-color@2.0.6':
+ resolution: {integrity: sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==}
+ engines: {node: '>=8.x'}
+
+ '@ant-design/icons-svg@4.5.0':
+ resolution: {integrity: sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==}
+
+ '@ant-design/icons@5.6.1':
+ resolution: {integrity: sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==}
+ engines: {node: '>=8'}
+ peerDependencies:
+ react: '>=16.0.0'
+ react-dom: '>=16.0.0'
+
+ '@ant-design/react-slick@1.1.2':
+ resolution: {integrity: sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==}
+ peerDependencies:
+ react: '>=16.9.0'
+
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -304,6 +378,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
@@ -327,6 +405,12 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
+ '@emotion/hash@0.8.0':
+ resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==}
+
+ '@emotion/unitless@0.7.5':
+ resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==}
+
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@@ -650,6 +734,61 @@ packages:
'@prisma/get-platform@5.22.0':
resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==}
+ '@rc-component/async-validator@5.1.2':
+ resolution: {integrity: sha512-WYbrZSjzznU1ekD0qFq2qRxt309VoS61MTG5npnFQlKYcoy9IzU8T+ZCIhq5bGAXRbXysABFWTspicMfmWFwow==}
+ engines: {node: '>=14.x'}
+
+ '@rc-component/color-picker@2.0.1':
+ resolution: {integrity: sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/context@1.4.0':
+ resolution: {integrity: sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/mini-decimal@1.1.4':
+ resolution: {integrity: sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==}
+ engines: {node: '>=8.x'}
+
+ '@rc-component/mutate-observer@1.1.0':
+ resolution: {integrity: sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/portal@1.1.2':
+ resolution: {integrity: sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/qrcode@1.1.3':
+ resolution: {integrity: sha512-aGv6alnn4HbDEsURzKP+jv13rbi1VxmAYfBNZr5GKF1iohMNWy5tAVoJ1E3cOvzMB1kbUPvCXchM6zSFlRGPhA==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/tour@1.15.1':
+ resolution: {integrity: sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/trigger@2.3.1':
+ resolution: {integrity: sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
'@remix-run/router@1.23.3':
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
engines: {node: '>=14.0.0'}
@@ -1034,6 +1173,12 @@ packages:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
+ antd@5.29.3:
+ resolution: {integrity: sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
@@ -1177,6 +1322,9 @@ packages:
class-validator@0.14.4:
resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==}
+ classnames@2.5.1:
+ resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
+
cli-cursor@3.1.0:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'}
@@ -1227,6 +1375,9 @@ packages:
resolution: {integrity: sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==}
engines: {node: '>= 6'}
+ compute-scroll-into-view@3.1.1:
+ resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==}
+
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -1258,6 +1409,9 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
+ copy-to-clipboard@3.3.3:
+ resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
+
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -1288,6 +1442,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ dayjs@1.11.21:
+ resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
+
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
@@ -1705,6 +1862,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+ json2mq@0.2.0:
+ resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==}
+
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
@@ -2067,6 +2227,234 @@ packages:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
engines: {node: '>= 0.8'}
+ rc-cascader@3.34.0:
+ resolution: {integrity: sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-checkbox@3.5.0:
+ resolution: {integrity: sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-collapse@3.9.0:
+ resolution: {integrity: sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-dialog@9.6.0:
+ resolution: {integrity: sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-drawer@7.3.0:
+ resolution: {integrity: sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-dropdown@4.2.1:
+ resolution: {integrity: sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==}
+ peerDependencies:
+ react: '>=16.11.0'
+ react-dom: '>=16.11.0'
+
+ rc-field-form@2.7.1:
+ resolution: {integrity: sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-image@7.12.0:
+ resolution: {integrity: sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-input-number@9.5.0:
+ resolution: {integrity: sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-input@1.8.0:
+ resolution: {integrity: sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==}
+ peerDependencies:
+ react: '>=16.0.0'
+ react-dom: '>=16.0.0'
+
+ rc-mentions@2.20.0:
+ resolution: {integrity: sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-menu@9.16.1:
+ resolution: {integrity: sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-motion@2.9.5:
+ resolution: {integrity: sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-notification@5.6.4:
+ resolution: {integrity: sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-overflow@1.5.0:
+ resolution: {integrity: sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-pagination@5.1.0:
+ resolution: {integrity: sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-picker@4.11.3:
+ resolution: {integrity: sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ date-fns: '>= 2.x'
+ dayjs: '>= 1.x'
+ luxon: '>= 3.x'
+ moment: '>= 2.x'
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+ peerDependenciesMeta:
+ date-fns:
+ optional: true
+ dayjs:
+ optional: true
+ luxon:
+ optional: true
+ moment:
+ optional: true
+
+ rc-progress@4.0.0:
+ resolution: {integrity: sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-rate@2.13.1:
+ resolution: {integrity: sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-resize-observer@1.4.3:
+ resolution: {integrity: sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-segmented@2.7.1:
+ resolution: {integrity: sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==}
+ peerDependencies:
+ react: '>=16.0.0'
+ react-dom: '>=16.0.0'
+
+ rc-select@14.16.8:
+ resolution: {integrity: sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '*'
+ react-dom: '*'
+
+ rc-slider@11.1.9:
+ resolution: {integrity: sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-steps@6.0.1:
+ resolution: {integrity: sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-switch@4.1.0:
+ resolution: {integrity: sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-table@7.54.0:
+ resolution: {integrity: sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-tabs@15.7.0:
+ resolution: {integrity: sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-textarea@1.10.2:
+ resolution: {integrity: sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-tooltip@6.4.0:
+ resolution: {integrity: sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-tree-select@5.27.0:
+ resolution: {integrity: sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==}
+ peerDependencies:
+ react: '*'
+ react-dom: '*'
+
+ rc-tree@5.13.1:
+ resolution: {integrity: sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==}
+ engines: {node: '>=10.x'}
+ peerDependencies:
+ react: '*'
+ react-dom: '*'
+
+ rc-upload@4.11.0:
+ resolution: {integrity: sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-util@5.44.4:
+ resolution: {integrity: sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ rc-virtual-list@3.19.2:
+ resolution: {integrity: sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@@ -2123,6 +2511,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ resize-observer-polyfill@1.5.1:
+ resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
+
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -2167,6 +2558,9 @@ packages:
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
engines: {node: '>= 10.13.0'}
+ scroll-into-view-if-needed@3.1.0:
+ resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==}
+
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -2257,6 +2651,9 @@ packages:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
+ string-convert@0.2.1:
+ resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==}
+
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -2291,6 +2688,9 @@ packages:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'}
+ stylis@4.4.0:
+ resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
+
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -2355,6 +2755,10 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ throttle-debounce@5.0.2:
+ resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
+ engines: {node: '>=12.22'}
+
through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
@@ -2377,6 +2781,9 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ toggle-selection@1.0.6:
+ resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==}
+
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
@@ -2658,6 +3065,55 @@ snapshots:
transitivePeerDependencies:
- chokidar
+ '@ant-design/colors@7.2.1':
+ dependencies:
+ '@ant-design/fast-color': 2.0.6
+
+ '@ant-design/cssinjs-utils@1.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@ant-design/cssinjs': 1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@babel/runtime': 7.29.7
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@ant-design/cssinjs@1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@emotion/hash': 0.8.0
+ '@emotion/unitless': 0.7.5
+ classnames: 2.5.1
+ csstype: 3.2.3
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ stylis: 4.4.0
+
+ '@ant-design/fast-color@2.0.6':
+ dependencies:
+ '@babel/runtime': 7.29.7
+
+ '@ant-design/icons-svg@4.5.0': {}
+
+ '@ant-design/icons@5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@ant-design/colors': 7.2.1
+ '@ant-design/icons-svg': 4.5.0
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@ant-design/react-slick@1.1.2(react@18.3.1)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ json2mq: 0.2.0
+ react: 18.3.1
+ resize-observer-polyfill: 1.5.1
+ throttle-debounce: 5.0.2
+
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -2747,6 +3203,8 @@ snapshots:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.29.7
+ '@babel/runtime@7.29.7': {}
+
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
@@ -2779,6 +3237,10 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
+ '@emotion/hash@0.8.0': {}
+
+ '@emotion/unitless@0.7.5': {}
+
'@esbuild/aix-ppc64@0.21.5':
optional: true
@@ -3081,6 +3543,73 @@ snapshots:
dependencies:
'@prisma/debug': 5.22.0
+ '@rc-component/async-validator@5.1.2':
+ dependencies:
+ '@babel/runtime': 7.29.7
+
+ '@rc-component/color-picker@2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@ant-design/fast-color': 2.0.6
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@rc-component/context@1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@rc-component/mini-decimal@1.1.4':
+ dependencies:
+ '@babel/runtime': 7.29.7
+
+ '@rc-component/mutate-observer@1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@rc-component/portal@1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@rc-component/qrcode@1.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@rc-component/tour@1.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@rc-component/trigger@2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ classnames: 2.5.1
+ rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
'@remix-run/router@1.23.3': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}
@@ -3470,6 +3999,64 @@ snapshots:
ansi-styles@6.2.3: {}
+ antd@5.29.3(luxon@3.7.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@ant-design/colors': 7.2.1
+ '@ant-design/cssinjs': 1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@ant-design/cssinjs-utils': 1.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@ant-design/fast-color': 2.0.6
+ '@ant-design/icons': 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@ant-design/react-slick': 1.1.2(react@18.3.1)
+ '@babel/runtime': 7.29.7
+ '@rc-component/color-picker': 2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@rc-component/mutate-observer': 1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@rc-component/qrcode': 1.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@rc-component/tour': 1.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ classnames: 2.5.1
+ copy-to-clipboard: 3.3.3
+ dayjs: 1.11.21
+ rc-cascader: 3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-checkbox: 3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-collapse: 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-dialog: 9.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-drawer: 7.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-dropdown: 4.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-field-form: 2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-image: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-input: 1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-input-number: 9.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-mentions: 2.20.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-menu: 9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-notification: 5.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-pagination: 5.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-picker: 4.11.3(dayjs@1.11.21)(luxon@3.7.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-progress: 4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-rate: 2.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-segmented: 2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-select: 14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-slider: 11.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-steps: 6.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-switch: 4.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-table: 7.54.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-tabs: 15.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-textarea: 1.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-tooltip: 6.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-tree: 5.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-tree-select: 5.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-upload: 4.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ scroll-into-view-if-needed: 3.1.0
+ throttle-debounce: 5.0.2
+ transitivePeerDependencies:
+ - date-fns
+ - luxon
+ - moment
+
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
@@ -3635,6 +4222,8 @@ snapshots:
libphonenumber-js: 1.13.7
validator: 13.15.35
+ classnames@2.5.1: {}
+
cli-cursor@3.1.0:
dependencies:
restore-cursor: 3.1.0
@@ -3675,6 +4264,8 @@ snapshots:
has-own-prop: 2.0.0
repeat-string: 1.6.1
+ compute-scroll-into-view@3.1.1: {}
+
concat-map@0.0.1: {}
concat-stream@2.0.0:
@@ -3700,6 +4291,10 @@ snapshots:
cookie@0.7.2: {}
+ copy-to-clipboard@3.3.3:
+ dependencies:
+ toggle-selection: 1.0.6
+
core-util-is@1.0.3: {}
cors@2.8.5:
@@ -3730,6 +4325,8 @@ snapshots:
csstype@3.2.3: {}
+ dayjs@1.11.21: {}
+
debug@2.6.9:
dependencies:
ms: 2.0.0
@@ -4205,6 +4802,10 @@ snapshots:
json-schema-traverse@1.0.0: {}
+ json2mq@0.2.0:
+ dependencies:
+ string-convert: 0.2.1
+
json5@2.2.3: {}
jsonc-parser@3.2.1: {}
@@ -4539,6 +5140,326 @@ snapshots:
iconv-lite: 0.4.24
unpipe: 1.0.0
+ rc-cascader@3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-select: 14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-tree: 5.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-checkbox@3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-collapse@3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-dialog@9.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ classnames: 2.5.1
+ rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-drawer@7.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ classnames: 2.5.1
+ rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-dropdown@4.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/async-validator': 5.1.2
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-image@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ classnames: 2.5.1
+ rc-dialog: 9.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-input-number@9.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/mini-decimal': 1.1.4
+ classnames: 2.5.1
+ rc-input: 1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-input@1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-mentions@2.20.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ classnames: 2.5.1
+ rc-input: 1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-menu: 9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-textarea: 1.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-menu@9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ classnames: 2.5.1
+ rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-overflow: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-motion@2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-notification@5.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-overflow@1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-pagination@5.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-picker@4.11.3(dayjs@1.11.21)(luxon@3.7.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ classnames: 2.5.1
+ rc-overflow: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ dayjs: 1.11.21
+ luxon: 3.7.2
+
+ rc-progress@4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-rate@2.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-resize-observer@1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ resize-observer-polyfill: 1.5.1
+
+ rc-segmented@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-select@14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ classnames: 2.5.1
+ rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-overflow: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-virtual-list: 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-slider@11.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-steps@6.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-switch@4.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-table@7.54.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/context': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ classnames: 2.5.1
+ rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-virtual-list: 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-tabs@15.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-dropdown: 4.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-menu: 9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-textarea@1.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-input: 1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-tooltip@6.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-tree-select@5.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-select: 14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-tree: 5.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-tree@5.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-virtual-list: 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-upload@4.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ rc-util@5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-is: 18.3.1
+
+ rc-virtual-list@3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ classnames: 2.5.1
+ rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
@@ -4587,6 +5508,8 @@ snapshots:
require-from-string@2.0.2: {}
+ resize-observer-polyfill@1.5.1: {}
+
resolve-from@4.0.0: {}
restore-cursor@3.1.0:
@@ -4658,6 +5581,10 @@ snapshots:
ajv-formats: 2.1.1(ajv@8.20.0)
ajv-keywords: 5.1.0(ajv@8.20.0)
+ scroll-into-view-if-needed@3.1.0:
+ dependencies:
+ compute-scroll-into-view: 3.1.1
+
semver@6.3.1: {}
semver@7.8.5: {}
@@ -4761,6 +5688,8 @@ snapshots:
streamsearch@1.1.0: {}
+ string-convert@0.2.1: {}
+
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -4797,6 +5726,8 @@ snapshots:
dependencies:
'@tokenizer/token': 0.3.0
+ stylis@4.4.0: {}
+
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -4824,6 +5755,8 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
+ throttle-debounce@5.0.2: {}
+
through@2.3.8: {}
tinybench@2.9.0: {}
@@ -4840,6 +5773,8 @@ snapshots:
dependencies:
is-number: 7.0.0
+ toggle-selection@1.0.6: {}
+
toidentifier@1.0.1: {}
token-types@6.1.2:
diff --git a/server/dukang-api/src/app.module.ts b/server/dukang-api/src/app.module.ts
index 983236d..f37bf46 100644
--- a/server/dukang-api/src/app.module.ts
+++ b/server/dukang-api/src/app.module.ts
@@ -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 {}
diff --git a/server/dukang-api/src/common/guards/hq-auth.guard.ts b/server/dukang-api/src/common/guards/hq-auth.guard.ts
new file mode 100644
index 0000000..26dee18
--- /dev/null
+++ b/server/dukang-api/src/common/guards/hq-auth.guard.ts
@@ -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;
+ }
+}
diff --git a/server/dukang-api/src/common/guards/super-admin.guard.ts b/server/dukang-api/src/common/guards/super-admin.guard.ts
new file mode 100644
index 0000000..87d1be7
--- /dev/null
+++ b/server/dukang-api/src/common/guards/super-admin.guard.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/server/dukang-api/src/modules/iam/admin-auth.controller.ts b/server/dukang-api/src/modules/iam/admin-auth.controller.ts
new file mode 100644
index 0000000..c275795
--- /dev/null
+++ b/server/dukang-api/src/modules/iam/admin-auth.controller.ts
@@ -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);
+ }
+}
diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts
index 1c72aec..dd63efa 100644
--- a/server/dukang-api/src/modules/iam/auth.service.ts
+++ b/server/dukang-api/src/modules/iam/auth.service.ts
@@ -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,
partner?: Record,
deviceKey?: string | null,
+ hq?: Record,
) {
const payload = {
sub: actorId.toString(),
@@ -378,6 +401,7 @@ export class AuthService {
user,
store,
partner,
+ hq,
};
}
}
diff --git a/server/dukang-api/src/modules/iam/iam.module.ts b/server/dukang-api/src/modules/iam/iam.module.ts
index 9c65733..b285c0d 100644
--- a/server/dukang-api/src/modules/iam/iam.module.ts
+++ b/server/dukang-api/src/modules/iam/iam.module.ts
@@ -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 {}
diff --git a/server/dukang-api/src/modules/ops/admin-benefit.controller.ts b/server/dukang-api/src/modules/ops/admin-benefit.controller.ts
new file mode 100644
index 0000000..72dcb7a
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-benefit.controller.ts
@@ -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);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-benefit.service.ts b/server/dukang-api/src/modules/ops/admin-benefit.service.ts
new file mode 100644
index 0000000..95a1a79
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-benefit.service.ts
@@ -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 });
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-cities.controller.ts b/server/dukang-api/src/modules/ops/admin-cities.controller.ts
new file mode 100644
index 0000000..80992ae
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-cities.controller.ts
@@ -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);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-cities.service.ts b/server/dukang-api/src/modules/ops/admin-cities.service.ts
new file mode 100644
index 0000000..92c3f4d
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-cities.service.ts
@@ -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);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-dashboard.controller.ts b/server/dukang-api/src/modules/ops/admin-dashboard.controller.ts
new file mode 100644
index 0000000..3ce0cd3
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-dashboard.controller.ts
@@ -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();
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts
new file mode 100644
index 0000000..55ba1c8
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts
@@ -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,
+ })),
+ };
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-hq-accounts.controller.ts b/server/dukang-api/src/modules/ops/admin-hq-accounts.controller.ts
new file mode 100644
index 0000000..eb2b0a0
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-hq-accounts.controller.ts
@@ -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);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts b/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts
new file mode 100644
index 0000000..a289c1e
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts
@@ -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);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-orders.controller.ts b/server/dukang-api/src/modules/ops/admin-orders.controller.ts
new file mode 100644
index 0000000..1435f59
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-orders.controller.ts
@@ -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));
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts
new file mode 100644
index 0000000..ad63bbd
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts
@@ -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);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-partners.controller.ts b/server/dukang-api/src/modules/ops/admin-partners.controller.ts
new file mode 100644
index 0000000..762f883
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-partners.controller.ts
@@ -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);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-partners.service.ts b/server/dukang-api/src/modules/ops/admin-partners.service.ts
new file mode 100644
index 0000000..54e57f3
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-partners.service.ts
@@ -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);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-redeem.controller.ts b/server/dukang-api/src/modules/ops/admin-redeem.controller.ts
new file mode 100644
index 0000000..702733c
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-redeem.controller.ts
@@ -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);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-redeem.service.ts b/server/dukang-api/src/modules/ops/admin-redeem.service.ts
new file mode 100644
index 0000000..b1ba17a
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-redeem.service.ts
@@ -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);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-stores.controller.ts b/server/dukang-api/src/modules/ops/admin-stores.controller.ts
new file mode 100644
index 0000000..e8ef1a5
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-stores.controller.ts
@@ -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));
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts
new file mode 100644
index 0000000..d44a039
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts
@@ -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);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-users.controller.ts b/server/dukang-api/src/modules/ops/admin-users.controller.ts
new file mode 100644
index 0000000..b34e4f9
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-users.controller.ts
@@ -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));
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-users.service.ts b/server/dukang-api/src/modules/ops/admin-users.service.ts
new file mode 100644
index 0000000..4858020
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-users.service.ts
@@ -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,
+ });
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
new file mode 100644
index 0000000..6b2ef85
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
@@ -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;
+}
diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts
new file mode 100644
index 0000000..2acfb23
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts
@@ -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;
+}
diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts
new file mode 100644
index 0000000..4ea8a99
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/ops.module.ts
@@ -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 {}