diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index ce0e02b..e44413a 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -281,6 +281,26 @@ const AdminBackupsPage = lazy(() => default: module.AdminBackupsPage, })) ) +const AdminCrashCategoriesPage = lazy(() => + import('@/features/admin/AdminCrashCategoriesPage').then(module => ({ + default: module.AdminCrashCategoriesPage, + })) +) +const AdminCrashProductsPage = lazy(() => + import('@/features/admin/AdminCrashProductsPage').then(module => ({ + default: module.AdminCrashProductsPage, + })) +) +const AdminCrashOrdersPage = lazy(() => + import('@/features/admin/AdminCrashOrdersPage').then(module => ({ + default: module.AdminCrashOrdersPage, + })) +) +const AdminCrashConfigPage = lazy(() => + import('@/features/admin/AdminCrashConfigPage').then(module => ({ + default: module.AdminCrashConfigPage, + })) +) const AdminManualEntriesPage = lazy(() => import('@/features/admin/AdminManualEntriesPage').then(module => ({ default: module.AdminManualEntriesPage, @@ -495,6 +515,10 @@ export function AppRouter() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/admin/AdminCrashCategoriesPage.tsx b/frontend/src/features/admin/AdminCrashCategoriesPage.tsx new file mode 100644 index 0000000..d6c7fda --- /dev/null +++ b/frontend/src/features/admin/AdminCrashCategoriesPage.tsx @@ -0,0 +1,252 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Alert, App, Button, Form, Input, InputNumber, Modal, Select, Space, Tag, type TableColumnsType } from 'antd' +import { PlusOutlined, ReloadOutlined } from '@ant-design/icons' + +import { + createCrashCategory, + deleteCrashCategory, + fetchCrashCategories, + updateCrashCategory, +} from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import type { AdminCrashCategory } from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatText } from '@/shared/utils/format' + +interface FormValues { + name: string + code?: string + sort_order?: number + status: string +} + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + return response?.data?.message || (error instanceof Error ? error.message : '请求失败') +} + +export function AdminCrashCategoriesPage() { + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState() + const [submitting, setSubmitting] = useState(false) + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean(permissions?.includes('*') || permissions?.includes('mohong:product_view')) + const canManage = Boolean(permissions?.includes('*') || permissions?.includes('mohong:category')) + + const categoriesQuery = useQuery({ + queryKey: ['admin-crash-categories'], + queryFn: fetchCrashCategories, + enabled: canView, + }) + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ['admin-crash-categories'] }) + + const save = async () => { + const values = await form.validateFields() + setSubmitting(true) + try { + if (editing) { + await updateCrashCategory(editing.id, { + name: values.name.trim(), + sort_order: values.sort_order ?? 0, + status: values.status, + }) + message.success('分类已更新') + } else { + await createCrashCategory({ + name: values.name.trim(), + code: values.code?.trim(), + sort_order: values.sort_order ?? 0, + status: values.status, + }) + message.success('分类已创建') + } + setOpen(false) + await invalidate() + } catch (error) { + message.error(errorText(error)) + } finally { + setSubmitting(false) + } + } + + const columns: TableColumnsType = [ + { + title: '分类', + key: 'name', + render: (_, row) => ( +
+ {row.name} + {formatText(row.code)} +
+ ), + }, + { + title: '商品数', + dataIndex: 'product_count', + width: 110, + align: 'right', + }, + { + title: '排序', + dataIndex: 'sort_order', + width: 90, + align: 'right', + }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: value => ( + + {value === 'active' ? '启用' : '停用'} + + ), + }, + { + title: '更新时间', + dataIndex: 'updated_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '操作', + key: 'actions', + width: 150, + render: (_, row) => ( + + + + + ), + }, + ] + + if (!canView) { + return ( +
+ + +
+ ) + } + + return ( +
+ 共 {categoriesQuery.data?.length ?? 0} 个分类} + extra={ + + + + + } + /> + + + columns={columns} + dataSource={categoriesQuery.data ?? []} + rowKey="id" + loading={categoriesQuery.isFetching} + emptyDescription="还没有分类" + /> + + void save()} + onCancel={() => (submitting ? undefined : setOpen(false))} + > +
+ + + + + + + + + + + + + + + + + + + 模板留空将回退到后端默认模板 + + +
+ +
+ ) +} diff --git a/frontend/src/features/admin/AdminCrashOrdersPage.tsx b/frontend/src/features/admin/AdminCrashOrdersPage.tsx new file mode 100644 index 0000000..a214cb4 --- /dev/null +++ b/frontend/src/features/admin/AdminCrashOrdersPage.tsx @@ -0,0 +1,293 @@ +import { useState } from 'react' +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' +import { Alert, App, Button, Input, Select, Space, Tag, Typography, type TableColumnsType } from 'antd' +import { ReloadOutlined } from '@ant-design/icons' + +import { + cancelCrashOrder, + completeCrashOrder, + fetchCrashOrders, + receiveCrashOrder, + refundCrashOrder, +} from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import { FilterBar, FilterField } from '@/features/admin/components/FilterBar' +import type { AdminCrashOrder } from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +const statusMeta: Record = { + pending_payment: { label: '待支付', color: 'gold' }, + paid: { label: '已支付', color: 'blue' }, + received: { label: '已接单', color: 'cyan' }, + completed: { label: '已完成', color: 'green' }, + cancelled: { label: '已取消', color: 'default' }, + refunded: { label: '已退款', color: 'red' }, +} + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + return response?.data?.message || (error instanceof Error ? error.message : '请求失败') +} + +export function AdminCrashOrdersPage() { + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const [filters, setFilters] = useState<{ keyword: string; status?: string }>({ keyword: '' }) + const [applied, setApplied] = useState(filters) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean(permissions?.includes('*') || permissions?.includes('mohong:order_view')) + const canManage = Boolean(permissions?.includes('*') || permissions?.includes('mohong:order_manage')) + + const query = { + keyword: applied.keyword.trim() || undefined, + status: applied.status, + page, + page_size: pageSize, + } + + const ordersQuery = useQuery({ + queryKey: ['admin-crash-orders', query], + queryFn: () => fetchCrashOrders(query), + enabled: canView, + placeholderData: keepPreviousData, + }) + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin-crash-orders'] }) + + const act = async (action: () => Promise, success: string) => { + try { + await action() + message.success(success) + await invalidate() + } catch (error) { + message.error(errorText(error)) + } + } + + const columns: TableColumnsType = [ + { + title: '订单', + key: 'order', + width: 240, + render: (_, row) => ( +
+ {row.order_no} + {row.product_title} +
+ ), + }, + { + title: '买家', + key: 'buyer', + width: 170, + render: (_, row) => ( +
+ {formatText(row.buyer_nickname)} + {formatText(row.buyer_phone)} +
+ ), + }, + { + title: '数量 / 金额', + key: 'amount', + width: 150, + align: 'right', + render: (_, row) => ( +
+ {formatMoney(row.amount_cent)} + × {row.quantity} +
+ ), + }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: value => { + const meta = statusMeta[value] ?? { label: value, color: 'default' } + return ( + + {meta.label} + + ) + }, + }, + { + title: '备注', + key: 'remark', + render: (_, row) => formatText(row.admin_remark || row.cancel_reason), + }, + { + title: '下单时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '操作', + key: 'actions', + width: 260, + fixed: 'right', + render: (_, row) => ( + + {row.status === 'paid' ? ( + + ) : null} + {row.status === 'received' ? ( + + ) : null} + {row.status === 'paid' || row.status === 'received' ? ( + + ) : null} + {row.status === 'paid' || row.status === 'received' || row.status === 'completed' ? ( + + ) : null} + + ), + }, + ] + + if (!canView) { + return ( +
+ + +
+ ) + } + + return ( +
+ 共 {ordersQuery.data?.total ?? 0} 单} + extra={ + + } + /> + + { + setPage(1) + setApplied(filters) + }} + onReset={() => { + const empty = { keyword: '' } + setFilters(empty) + setApplied(empty) + setPage(1) + }} + > + + setFilters(current => ({ ...current, keyword: event.target.value }))} + /> + + + setFilters(current => ({ ...current, keyword: event.target.value }))} + /> + + + setFilters(current => ({ ...current, status: value }))} + /> + + + + + columns={columns} + dataSource={productsQuery.data?.items ?? []} + rowKey="id" + loading={productsQuery.isFetching} + total={productsQuery.data?.total ?? 0} + page={productsQuery.data?.page ?? page} + pageSize={productsQuery.data?.page_size ?? pageSize} + onPageChange={(nextPage, nextSize) => { + setPage(nextPage) + setPageSize(nextSize) + }} + emptyDescription="还没有商品" + /> + + void save()} + onCancel={() => (submitting ? undefined : setOpen(false))} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + setUploading(true) + void uploadAdminFile(file as File, 'listing') + .then(uploaded => { + form.setFieldValue('cover_url', uploaded.url) + message.success('封面已上传') + }) + .catch(error => message.error(errorText(error))) + .finally(() => setUploading(false)) + return false + }} + > + + + 上传后自动填入封面 URL + + + + + + + +
+
+
+ ) +} diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts index 0609829..0be4b0a 100644 --- a/frontend/src/features/admin/api.ts +++ b/frontend/src/features/admin/api.ts @@ -129,6 +129,11 @@ import type { AdminInviteRebateSummary, AdminBackupStatus, AdminBackupSchedule, + AdminCrashCategory, + AdminCrashProduct, + AdminCrashOrder, + AdminCrashConfig, + AdminPaged, ManualDisbursementPayload, OperatingExpensePage, OperatingExpensePayload, @@ -1900,3 +1905,124 @@ export async function updateAdminBackupSchedule(payload: AdminBackupSchedule) { ) return data.data } + +export async function fetchCrashCategories() { + const { data } = await adminClient.get>( + '/admin/crash/categories' + ) + return data.data ?? [] +} + +export async function createCrashCategory(payload: { + name: string + code?: string + sort_order?: number + status?: string +}) { + const { data } = await adminClient.post>( + '/admin/crash/categories', + payload + ) + return data.data +} + +export async function updateCrashCategory( + id: number, + payload: { name?: string; sort_order?: number; status?: string } +) { + const { data } = await adminClient.put>( + `/admin/crash/categories/${id}`, + payload + ) + return data.data +} + +export async function deleteCrashCategory(id: number) { + await adminClient.delete(`/admin/crash/categories/${id}`) +} + +export async function fetchCrashProducts(query: { + keyword?: string + status?: string + category_id?: number + page?: number + page_size?: number +}) { + const { data } = await adminClient.get>>>( + '/admin/crash/products', + { params: compactParams(query) } + ) + return { + items: data.data.items ?? [], + total: data.data.total ?? 0, + page: data.data.page ?? query.page ?? 1, + page_size: data.data.page_size ?? query.page_size ?? 20, + } +} + +export async function createCrashProduct(payload: Record) { + const { data } = await adminClient.post>( + '/admin/crash/products', + payload + ) + return data.data +} + +export async function updateCrashProduct(id: number, payload: Record) { + const { data } = await adminClient.put>( + `/admin/crash/products/${id}`, + payload + ) + return data.data +} + +export async function deleteCrashProduct(id: number) { + await adminClient.delete(`/admin/crash/products/${id}`) +} + +export async function fetchCrashOrders(query: { + keyword?: string + status?: string + page?: number + page_size?: number +}) { + const { data } = await adminClient.get>>>( + '/admin/crash/orders', + { params: compactParams(query) } + ) + return { + items: data.data.items ?? [], + total: data.data.total ?? 0, + page: data.data.page ?? query.page ?? 1, + page_size: data.data.page_size ?? query.page_size ?? 20, + } +} + +export async function receiveCrashOrder(id: number, remark = '') { + await adminClient.post(`/admin/crash/orders/${id}/receive`, { remark }) +} + +export async function completeCrashOrder(id: number, remark = '') { + await adminClient.post(`/admin/crash/orders/${id}/complete`, { remark }) +} + +export async function cancelCrashOrder(id: number, reason: string) { + await adminClient.post(`/admin/crash/orders/${id}/cancel`, { reason }) +} + +export async function refundCrashOrder(id: number, payload: { reason?: string; amount_cent?: number }) { + await adminClient.post(`/admin/crash/orders/${id}/refund`, payload) +} + +export async function fetchCrashConfig() { + const { data } = await adminClient.get>('/admin/crash/config') + return data.data +} + +export async function updateCrashConfig(payload: Partial) { + const { data } = await adminClient.put>( + '/admin/crash/config', + payload + ) + return data.data +} diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts index 3075364..4cc71fc 100644 --- a/frontend/src/features/admin/types.ts +++ b/frontend/src/features/admin/types.ts @@ -2060,3 +2060,67 @@ export interface AdminBackupSchedule { full_minute: number binlog_interval_minutes: number } + +export interface AdminCrashCategory { + id: number + name: string + code: string + sort_order: number + status: string + product_count: number + created_at: string + updated_at: string +} + +export interface AdminCrashProduct { + id: number + category_id?: number | null + category_name?: string + title: string + cover_url: string + image_urls: string[] + description: string + price_cent: number + original_price_cent: number + unit: string + stock: number + sort_order: number + status: string + qrcode_image_url?: string + created_at: string + updated_at: string +} + +export interface AdminCrashOrder { + id: number + order_no: string + user_id: number + product_id: number + quantity: number + unit_price_cent: number + amount_cent: number + status: string + product_title: string + product_cover_url: string + buyer_nickname?: string + buyer_phone?: string + admin_remark?: string + cancel_reason?: string + copy_text?: string + paid_at?: string | null + completed_at?: string | null + cancelled_at?: string | null + created_at: string +} + +export interface AdminCrashConfig { + default_qrcode_url: string + order_copy_template: string +} + +export interface AdminPaged { + items: T[] + total: number + page: number + page_size: number +} diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx index d1b3a32..3d33c79 100644 --- a/frontend/src/layouts/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout.tsx @@ -17,6 +17,7 @@ import { CustomerServiceOutlined, DashboardOutlined, FileDoneOutlined, + GiftOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, @@ -130,6 +131,17 @@ const allNavGroups: NavGroup[] = [ { key: '/distribution/invitation', label: '邀请返利', permission: 'distribution:site_view' }, ], }, + { + key: 'crash', + label: '摸红', + icon: , + children: [ + { key: '/crash/categories', label: '分类管理', permission: 'mohong:product_view' }, + { key: '/crash/products', label: '商品管理', permission: 'mohong:product_view' }, + { key: '/crash/orders', label: '订单管理', permission: 'mohong:order_view' }, + { key: '/crash/config', label: '摸红配置', permission: 'mohong:config' }, + ], + }, { key: 'operations', label: '运营', @@ -212,6 +224,10 @@ const sectionMeta: Record = { title: '推广追踪', description: '管理推广短链、渠道成本与注册下单转化。', }, + '/crash/categories': { title: '摸红分类', description: '维护撞车商城商品分类与排序。' }, + '/crash/products': { title: '摸红商品', description: '维护撞车商城商品、价格与固定二维码。' }, + '/crash/orders': { title: '摸红订单', description: '处理撞车订单的接单、完成、取消与退款。' }, + '/crash/config': { title: '摸红配置', description: '维护默认二维码与订单一键复制模板。' }, '/notifications': { title: '通知中心', description: '查看后台系统通知并批量标记已读。' }, '/push': { title: '推送通知', description: '维护推送渠道与通知规则。' }, '/invitations': {