feat(mohong): 新增摸红商城后台四页

This commit is contained in:
yml2213
2026-10-30 17:28:27 +08:00
parent c9b122d4c8
commit 68b0a94b63
8 changed files with 1323 additions and 0 deletions
+24
View File
@@ -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() {
<Route path="roles" element={<AdminRolesPage />} />
<Route path="admin-users" element={<AdminStaffAccountsPage />} />
<Route path="backups" element={<AdminBackupsPage />} />
<Route path="crash/categories" element={<AdminCrashCategoriesPage />} />
<Route path="crash/products" element={<AdminCrashProductsPage />} />
<Route path="crash/orders" element={<AdminCrashOrdersPage />} />
<Route path="crash/config" element={<AdminCrashConfigPage />} />
<Route path="manual-entries" element={<AdminManualEntriesPage />} />
<Route path="payments" element={<AdminPaymentsPage />} />
<Route path="pickup" element={<AdminPickupsPage />} />
@@ -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<FormValues>()
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<AdminCrashCategory>()
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<AdminCrashCategory> = [
{
title: '分类',
key: 'name',
render: (_, row) => (
<div className="admin-stack">
<strong>{row.name}</strong>
<span className="admin-mono">{formatText(row.code)}</span>
</div>
),
},
{
title: '商品数',
dataIndex: 'product_count',
width: 110,
align: 'right',
},
{
title: '排序',
dataIndex: 'sort_order',
width: 90,
align: 'right',
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: value => (
<Tag bordered={false} color={value === 'active' ? 'green' : 'default'}>
{value === 'active' ? '启用' : '停用'}
</Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 150,
render: (_, row) => (
<Space size={4}>
<Button
type="link"
size="small"
disabled={!canManage}
onClick={() => {
setEditing(row)
form.setFieldsValue({
name: row.name,
code: row.code,
sort_order: row.sort_order,
status: row.status,
})
setOpen(true)
}}
>
编辑
</Button>
<Button
type="link"
size="small"
danger
disabled={!canManage}
onClick={() =>
modal.confirm({
title: `确认删除分类「${row.name}」?`,
content: '分类下若仍有商品,后端会拒绝删除。',
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await deleteCrashCategory(row.id)
message.success('分类已删除')
await invalidate()
} catch (error) {
message.error(errorText(error))
}
},
})
}
>
删除
</Button>
</Space>
),
},
]
if (!canView) {
return (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 摸红" title="摸红分类" />
<Alert type="warning" showIcon message="当前账号没有摸红商品权限" />
</div>
)
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 摸红"
title="摸红分类"
description="维护撞车商城的商品分类与排序。"
meta={<Tag color="geekblue">共 {categoriesQuery.data?.length ?? 0} 个分类</Tag>}
extra={
<Space>
<Button
icon={<ReloadOutlined />}
loading={categoriesQuery.isFetching}
onClick={() => void categoriesQuery.refetch()}
>
刷新
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
disabled={!canManage}
onClick={() => {
setEditing(undefined)
form.setFieldsValue({ name: '', code: '', sort_order: 0, status: 'active' })
setOpen(true)
}}
>
新建分类
</Button>
</Space>
}
/>
<DataTable<AdminCrashCategory>
columns={columns}
dataSource={categoriesQuery.data ?? []}
rowKey="id"
loading={categoriesQuery.isFetching}
emptyDescription="还没有分类"
/>
<Modal
open={open}
title={editing ? `编辑分类 · ${editing.name}` : '新建分类'}
okText="保存"
cancelText="取消"
confirmLoading={submitting}
onOk={() => void save()}
onCancel={() => (submitting ? undefined : setOpen(false))}
>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input maxLength={64} />
</Form.Item>
<Form.Item name="code" label="编码" extra="创建后不可修改">
<Input maxLength={64} disabled={Boolean(editing)} />
</Form.Item>
<Form.Item name="sort_order" label="排序">
<InputNumber min={0} style={{ width: 160 }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '停用' },
]}
/>
</Form.Item>
</Form>
</Modal>
</div>
)
}
@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, App, Button, Card, Form, Input, Space, Typography } from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import { fetchCrashConfig, updateCrashConfig } from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { useAdminSessionStore } from '@/stores/adminSession'
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
return response?.data?.message || (error instanceof Error ? error.message : '请求失败')
}
export function AdminCrashConfigPage() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<{ default_qrcode_url: string; order_copy_template: string }>()
const [saving, setSaving] = useState(false)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(permissions?.includes('*') || permissions?.includes('mohong:config'))
const canManage = Boolean(permissions?.includes('*') || permissions?.includes('mohong:config'))
const configQuery = useQuery({
queryKey: ['admin-crash-config'],
queryFn: fetchCrashConfig,
enabled: canView,
})
useEffect(() => {
if (!configQuery.data) return
form.setFieldsValue(configQuery.data)
}, [configQuery.data, form])
if (!canView) {
return (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 摸红" title="摸红配置" />
<Alert type="warning" showIcon message="当前账号没有摸红配置权限" />
</div>
)
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 摸红"
title="摸红配置"
description="维护撞车订单默认固定二维码与订单一键复制模板。"
extra={
<Button
icon={<ReloadOutlined />}
loading={configQuery.isFetching}
onClick={() => void configQuery.refetch()}
>
刷新
</Button>
}
/>
<Card>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item
name="default_qrcode_url"
label="默认固定二维码图片 URL"
extra="订单未指定商品二维码时使用该图片"
>
<Input placeholder="https://..." />
</Form.Item>
<Form.Item
name="order_copy_template"
label="订单一键复制模板"
extra="支持变量:{{order_no}} {{created_at}} {{items}} {{quantity}} {{amount}} {{buyer_name}} {{buyer_phone}}"
>
<Input.TextArea rows={8} />
</Form.Item>
<Space>
<Button
type="primary"
loading={saving}
disabled={!canManage}
onClick={async () => {
const values = await form.validateFields()
setSaving(true)
try {
await updateCrashConfig(values)
message.success('配置已保存')
await queryClient.invalidateQueries({ queryKey: ['admin-crash-config'] })
} catch (error) {
message.error(errorText(error))
} finally {
setSaving(false)
}
}}
>
保存配置
</Button>
<Typography.Text type="secondary">
模板留空将回退到后端默认模板
</Typography.Text>
</Space>
</Form>
</Card>
</div>
)
}
@@ -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<string, { label: string; color: string }> = {
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<unknown>, success: string) => {
try {
await action()
message.success(success)
await invalidate()
} catch (error) {
message.error(errorText(error))
}
}
const columns: TableColumnsType<AdminCrashOrder> = [
{
title: '订单',
key: 'order',
width: 240,
render: (_, row) => (
<div className="admin-stack">
<strong>{row.order_no}</strong>
<span className="admin-mono">{row.product_title}</span>
</div>
),
},
{
title: '买家',
key: 'buyer',
width: 170,
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.buyer_nickname)}</span>
<span className="admin-mono">{formatText(row.buyer_phone)}</span>
</div>
),
},
{
title: '数量 / 金额',
key: 'amount',
width: 150,
align: 'right',
render: (_, row) => (
<div className="admin-stack">
<strong>{formatMoney(row.amount_cent)}</strong>
<span className="admin-mono">× {row.quantity}</span>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: value => {
const meta = statusMeta[value] ?? { label: value, color: 'default' }
return (
<Tag bordered={false} color={meta.color}>
{meta.label}
</Tag>
)
},
},
{
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) => (
<Space size={4} wrap>
{row.status === 'paid' ? (
<Button
type="link"
size="small"
disabled={!canManage}
onClick={() => void act(() => receiveCrashOrder(row.id), '已接单')}
>
接单
</Button>
) : null}
{row.status === 'received' ? (
<Button
type="link"
size="small"
disabled={!canManage}
onClick={() => void act(() => completeCrashOrder(row.id), '订单已完成')}
>
完成
</Button>
) : null}
{row.status === 'paid' || row.status === 'received' ? (
<Button
type="link"
size="small"
danger
disabled={!canManage}
onClick={() =>
modal.confirm({
title: `确认取消订单 ${row.order_no}?`,
content: (
<Input
placeholder="填写取消原因"
id={`cancel-reason-${row.id}`}
defaultValue="后台取消"
/>
),
okText: '确认取消',
okButtonProps: { danger: true },
cancelText: '返回',
onOk: async () => {
const el = document.getElementById(`cancel-reason-${row.id}`) as HTMLInputElement | null
await act(() => cancelCrashOrder(row.id, el?.value || '后台取消'), '订单已取消')
},
})
}
>
取消
</Button>
) : null}
{row.status === 'paid' || row.status === 'received' || row.status === 'completed' ? (
<Button
type="link"
size="small"
disabled={!canManage}
onClick={() =>
modal.confirm({
title: `确认退款订单 ${row.order_no}?`,
content: '将调用支付渠道发起退款,操作不可撤销。',
okText: '确认退款',
okButtonProps: { danger: true },
cancelText: '返回',
onOk: async () => act(() => refundCrashOrder(row.id, { reason: '后台退款' }), '退款已提交'),
})
}
>
退款
</Button>
) : null}
</Space>
),
},
]
if (!canView) {
return (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 摸红" title="摸红订单" />
<Alert type="warning" showIcon message="当前账号没有摸红订单权限" />
</div>
)
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 摸红"
title="摸红订单"
description="处理撞车商城订单:接单、完成、取消与退款。"
meta={<Tag color="geekblue">共 {ordersQuery.data?.total ?? 0} 单</Tag>}
extra={
<Button
icon={<ReloadOutlined />}
loading={ordersQuery.isFetching}
onClick={() => void ordersQuery.refetch()}
>
刷新
</Button>
}
/>
<FilterBar
loading={ordersQuery.isFetching}
onSearch={() => {
setPage(1)
setApplied(filters)
}}
onReset={() => {
const empty = { keyword: '' }
setFilters(empty)
setApplied(empty)
setPage(1)
}}
>
<FilterField label="关键字">
<Input
allowClear
value={filters.keyword}
placeholder="订单号 / 商品 / 手机号"
onChange={event => setFilters(current => ({ ...current, keyword: event.target.value }))}
/>
</FilterField>
<FilterField label="状态">
<Select
allowClear
value={filters.status}
placeholder="全部状态"
options={Object.entries(statusMeta).map(([value, meta]) => ({ value, label: meta.label }))}
onChange={(value: string | undefined) => setFilters(current => ({ ...current, status: value }))}
/>
</FilterField>
</FilterBar>
{ordersQuery.isError ? (
<Alert type="error" showIcon className="admin-table-alert" message="订单加载失败" />
) : null}
<DataTable<AdminCrashOrder>
columns={columns}
dataSource={ordersQuery.data?.items ?? []}
rowKey="id"
loading={ordersQuery.isFetching}
total={ordersQuery.data?.total ?? 0}
page={ordersQuery.data?.page ?? page}
pageSize={ordersQuery.data?.page_size ?? pageSize}
onPageChange={(nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
}}
emptyDescription="还没有订单"
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 10 }}>
提示:退款会走支付渠道,若渠道未配置会由后端返回错误;开发环境请勿执行真实退款。
</Typography.Paragraph>
</div>
)
}
@@ -0,0 +1,442 @@
import { useState } from 'react'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Form,
Image,
Input,
InputNumber,
Modal,
Select,
Space,
Tag,
Typography,
Upload,
type TableColumnsType,
} from 'antd'
import { PlusOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons'
import {
createCrashProduct,
deleteCrashProduct,
fetchCrashCategories,
fetchCrashProducts,
updateCrashProduct,
} 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 { AdminCrashProduct } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { uploadAdminFile } from '@/shared/api/files'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
interface ProductFormValues {
title: string
category_id?: number
cover_url?: string
price_yuan: number
original_price_yuan?: number
unit?: string
stock?: number
sort_order?: number
status: string
qrcode_image_url?: string
description?: 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 AdminCrashProductsPage() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<ProductFormValues>()
const [filters, setFilters] = useState<{ keyword: string; status?: string; category_id?: number }>({
keyword: '',
})
const [applied, setApplied] = useState(filters)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<AdminCrashProduct>()
const [uploading, setUploading] = useState(false)
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:product_manage'))
const query = {
keyword: applied.keyword.trim() || undefined,
status: applied.status,
category_id: applied.category_id,
page,
page_size: pageSize,
}
const productsQuery = useQuery({
queryKey: ['admin-crash-products', query],
queryFn: () => fetchCrashProducts(query),
enabled: canView,
placeholderData: keepPreviousData,
})
const categoriesQuery = useQuery({
queryKey: ['admin-crash-categories'],
queryFn: fetchCrashCategories,
enabled: canView,
})
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ['admin-crash-products'] })
const save = async () => {
const values = await form.validateFields()
const payload: Record<string, unknown> = {
title: values.title.trim(),
category_id: values.category_id ?? null,
cover_url: values.cover_url ?? '',
price_cent: Math.round(Number(values.price_yuan ?? 0) * 100),
original_price_cent: Math.round(Number(values.original_price_yuan ?? 0) * 100),
unit: values.unit?.trim() || '',
stock: values.stock ?? 0,
sort_order: values.sort_order ?? 0,
status: values.status,
qrcode_image_url: values.qrcode_image_url?.trim() || '',
description: values.description?.trim() || '',
}
if (editing) payload.clear_category = values.category_id == null
setSubmitting(true)
try {
if (editing) {
await updateCrashProduct(editing.id, payload)
message.success('商品已更新')
} else {
await createCrashProduct(payload)
message.success('商品已创建')
}
setOpen(false)
await invalidate()
} catch (error) {
message.error(errorText(error))
} finally {
setSubmitting(false)
}
}
const columns: TableColumnsType<AdminCrashProduct> = [
{
title: '商品',
key: 'product',
width: 300,
render: (_, row) => (
<Space size={10} align="start">
{row.cover_url ? <Image src={row.cover_url} width={48} height={48} /> : null}
<div className="admin-stack">
<strong>{row.title}</strong>
<span className="admin-mono">{formatText(row.category_name)}</span>
</div>
</Space>
),
},
{
title: '价格',
key: 'price',
width: 150,
align: 'right',
render: (_, row) => (
<div className="admin-stack">
<strong>{formatMoney(row.price_cent)}</strong>
<span className="admin-mono">原价 {formatMoney(row.original_price_cent)}</span>
</div>
),
},
{
title: '库存',
dataIndex: 'stock',
width: 90,
align: 'right',
},
{
title: '排序',
dataIndex: 'sort_order',
width: 90,
align: 'right',
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: value => (
<Tag bordered={false} color={value === 'active' ? 'green' : 'default'}>
{value === 'active' ? '上架' : '下架'}
</Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 150,
render: (_, row) => (
<Space size={4}>
<Button
type="link"
size="small"
disabled={!canManage}
onClick={() => {
setEditing(row)
form.setFieldsValue({
title: row.title,
category_id: row.category_id ?? undefined,
cover_url: row.cover_url,
price_yuan: row.price_cent / 100,
original_price_yuan: row.original_price_cent / 100,
unit: row.unit,
stock: row.stock,
sort_order: row.sort_order,
status: row.status,
qrcode_image_url: row.qrcode_image_url,
description: row.description,
})
setOpen(true)
}}
>
编辑
</Button>
<Button
type="link"
size="small"
danger
disabled={!canManage}
onClick={() =>
modal.confirm({
title: `确认删除商品「${row.title}」?`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await deleteCrashProduct(row.id)
message.success('商品已删除')
await invalidate()
} catch (error) {
message.error(errorText(error))
}
},
})
}
>
删除
</Button>
</Space>
),
},
]
if (!canView) {
return (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 摸红" title="摸红商品" />
<Alert type="warning" showIcon message="当前账号没有摸红商品权限" />
</div>
)
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 摸红"
title="摸红商品"
description="维护撞车商城商品、价格、库存与固定二维码。"
meta={<Tag color="geekblue">共 {productsQuery.data?.total ?? 0} 个商品</Tag>}
extra={
<Space>
<Button
icon={<ReloadOutlined />}
loading={productsQuery.isFetching}
onClick={() => void productsQuery.refetch()}
>
刷新
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
disabled={!canManage}
onClick={() => {
setEditing(undefined)
form.setFieldsValue({
title: '',
price_yuan: 0,
original_price_yuan: 0,
unit: '份',
stock: 0,
sort_order: 0,
status: 'active',
})
setOpen(true)
}}
>
新建商品
</Button>
</Space>
}
/>
<FilterBar
loading={productsQuery.isFetching}
onSearch={() => {
setPage(1)
setApplied(filters)
}}
onReset={() => {
const empty = { keyword: '' }
setFilters(empty)
setApplied(empty)
setPage(1)
}}
>
<FilterField label="关键字">
<Input
allowClear
value={filters.keyword}
placeholder="商品标题"
onChange={event => setFilters(current => ({ ...current, keyword: event.target.value }))}
/>
</FilterField>
<FilterField label="分类">
<Select
allowClear
value={filters.category_id}
placeholder="全部分类"
options={(categoriesQuery.data ?? []).map(item => ({
value: item.id,
label: item.name,
}))}
onChange={value => setFilters(current => ({ ...current, category_id: value }))}
/>
</FilterField>
<FilterField label="状态">
<Select
allowClear
value={filters.status}
placeholder="全部状态"
options={[
{ value: 'active', label: '上架' },
{ value: 'disabled', label: '下架' },
]}
onChange={value => setFilters(current => ({ ...current, status: value }))}
/>
</FilterField>
</FilterBar>
<DataTable<AdminCrashProduct>
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="还没有商品"
/>
<Modal
open={open}
title={editing ? `编辑商品 · ${editing.title}` : '新建商品'}
okText="保存"
cancelText="取消"
width={720}
confirmLoading={submitting}
onOk={() => void save()}
onCancel={() => (submitting ? undefined : setOpen(false))}
>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
<Input maxLength={128} />
</Form.Item>
<Space size={12} align="start" style={{ width: '100%' }}>
<Form.Item name="category_id" label="分类" style={{ width: 220 }}>
<Select
allowClear
placeholder="选择分类"
options={(categoriesQuery.data ?? []).map(item => ({
value: item.id,
label: item.name,
}))}
/>
</Form.Item>
<Form.Item name="status" label="状态" style={{ width: 160 }}>
<Select
options={[
{ value: 'active', label: '上架' },
{ value: 'disabled', label: '下架' },
]}
/>
</Form.Item>
<Form.Item name="unit" label="单位" style={{ width: 120 }}>
<Input placeholder="份" />
</Form.Item>
</Space>
<Space size={12} align="start" style={{ width: '100%' }}>
<Form.Item name="price_yuan" label="售价(元)" rules={[{ required: true }]} style={{ width: 180 }}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="original_price_yuan" label="原价(元)" style={{ width: 180 }}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="stock" label="库存" style={{ width: 140 }}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="sort_order" label="排序" style={{ width: 140 }}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Space>
<Form.Item name="cover_url" label="封面图">
<Input placeholder="图片 URL" />
</Form.Item>
<Space size={12} align="start">
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={file => {
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
}}
>
<Button icon={<UploadOutlined />} loading={uploading}>
上传封面
</Button>
</Upload>
<Typography.Text type="secondary">上传后自动填入封面 URL</Typography.Text>
</Space>
<Form.Item name="qrcode_image_url" label="固定二维码图片 URL" extra="留空使用全局默认二维码">
<Input placeholder="https://..." />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} maxLength={2000} showCount />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+126
View File
@@ -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<ApiResponse<AdminCrashCategory[]>>(
'/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<ApiResponse<AdminCrashCategory>>(
'/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<ApiResponse<AdminCrashCategory>>(
`/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<ApiResponse<Partial<AdminPaged<AdminCrashProduct>>>>(
'/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<string, unknown>) {
const { data } = await adminClient.post<ApiResponse<AdminCrashProduct>>(
'/admin/crash/products',
payload
)
return data.data
}
export async function updateCrashProduct(id: number, payload: Record<string, unknown>) {
const { data } = await adminClient.put<ApiResponse<AdminCrashProduct>>(
`/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<ApiResponse<Partial<AdminPaged<AdminCrashOrder>>>>(
'/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<ApiResponse<AdminCrashConfig>>('/admin/crash/config')
return data.data
}
export async function updateCrashConfig(payload: Partial<AdminCrashConfig>) {
const { data } = await adminClient.put<ApiResponse<AdminCrashConfig>>(
'/admin/crash/config',
payload
)
return data.data
}
+64
View File
@@ -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<T> {
items: T[]
total: number
page: number
page_size: number
}
+16
View File
@@ -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: <GiftOutlined />,
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<string, { title: string; description: string }> = {
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': {