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
+ { + if (key.startsWith('/')) navigate(key); + }} + /> + + +
+ + 管理后台 + + + {profile?.name || '—'} + {profile?.adminRole} + + +
+ + + +
+ + ); +} 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 ( +
+ 好客权益券 +
{ setFilters(v); setPage(1); }}> + + + + + + + + + + + + ({ value, label }))} /> + + + +
+ + )} + + 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(); + }}> +
+ + + + + ({ value, label }))} /> + +
+
+
+ ); +} 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 ( +
+ 快递/配送单 +
{ setFilters(v); setPage(1); }}> + + + + + +
{ 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 && } + +
{ setFilters(v); setPage(1); }}> + + +
{ setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)} + extra={ + + }> + + + + ({ value, label }))} /> + + + + 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(); + }}> +
+ + + + + + + 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(1); void load(); }}> + + + + + + + + + + + + + + +
{ + 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 ( +
+ + 开城合伙人账户 + + +
{ setFilters(v); setPage(1); }}> + + +
{ setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)} + extra={detail && ( +
+ ), + }, + { + key: 'orders', + label: `名下订单 (${detail.orders?.length ?? 0})`, + children: ( +
+ ), + }, + ]} /> + )} + + 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 ( +
+ + 开城合伙人 + + +
{ setFilters(v); setPage(1); }}> + + + + +
{ 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 ( +
+ 核销记录 +
{ setFilters(v); setPage(1); }}> + + + +
{ 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 ( +
+ + 门店账户 + + +
{ setFilters(v); setPage(1); }}> + + +
{ setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)} + extra={detail && ( + ({ value: s.id, label: s.name }))} /> + + + + + + + ); +} 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 ( +
+ + 门店资源 + + +
{ setFilters(v); setPage(1); }}> + + +
{ 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(); + }}> + + + ({ value, label }))} /> + + + + + + 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 ( +
+ + 门店 + + +
{ setFilters(v); setPage(1); }}> + + + +
{ setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)} + extra={detail && ( + + + + + + + + + + )} + + 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(); + }}> +
+ + ({ value: c.id, label: `${c.name} (${c.code})` }))} /> + + + + + + + + + + +
+ + ); +} 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(1); void load(); }}> + + + + + + + + + + + + + + + + + + + + +
{ + 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 {}