From edb4b6557db3935cf492f3dcd77ae8edff1851fe Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 10 May 2026 19:07:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(health):=20=E4=BF=AE=E5=A4=8D=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E5=BA=93=E5=92=8C=E8=BD=AE=E6=92=AD=E5=9B=BE=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E4=B8=8D=E5=8F=AF=E8=A7=81=20=E2=80=94=20parent=5Fid/?= =?UTF-8?q?permission/menu=5Froles=20=E4=B8=89=E9=87=8D=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 种子迁移 m20260510_000137 存在三个问题导致菜单不显示: 1. parent_id 查找用了错误条件(path='/health'),改为 title='内容运营' 2. menu INSERT 缺少 permission 字段 3. 缺少 menu_roles 关联(admin/operator) 同时新增 BannerManage.tsx 前端页面 --- apps/web/src/pages/health/BannerManage.tsx | 524 ++++++++++++++++++ ...20260510_000137_seed_media_banner_menus.rs | 57 +- 2 files changed, 571 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/pages/health/BannerManage.tsx diff --git a/apps/web/src/pages/health/BannerManage.tsx b/apps/web/src/pages/health/BannerManage.tsx new file mode 100644 index 0000000..62b44fc --- /dev/null +++ b/apps/web/src/pages/health/BannerManage.tsx @@ -0,0 +1,524 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { + Table, + Button, + Space, + Input, + Select, + Tag, + Popconfirm, + message, + Modal, + Form, + InputNumber, + DatePicker, + Image, + Switch, +} from 'antd'; +import { + PlusOutlined, + EditOutlined, + DeleteOutlined, +} from '@ant-design/icons'; +import { + bannerApi, + type BannerItem, + type CreateBannerReq, + type UpdateBannerReq, +} from '../../api/health/banners'; +import { mediaApi, type MediaItem } from '../../api/health/media'; +import { AuthButton } from '../../components/AuthButton'; +import { PageContainer } from '../../components/PageContainer'; +import { formatDateTime } from '../../utils/format'; +import { dayjs } from '../../utils/dayjs'; + +// --- 常量 --- + +const STATUS_OPTIONS = [ + { value: 'active', label: '启用', color: 'success' }, + { value: 'inactive', label: '停用', color: 'default' }, +] as const; + +const LINK_TYPE_OPTIONS = [ + { value: 'none', label: '无链接' }, + { value: 'article', label: '文章' }, + { value: 'external', label: '外部链接' }, +] as const; + +// --- 状态配置 --- + +function getStatusConfig(status: string) { + return STATUS_OPTIONS.find((s) => s.value === status) ?? { value: status, label: status, color: 'default' }; +} + +function getLinkTypeLabel(type?: string) { + if (!type || type === 'none') return '-'; + return LINK_TYPE_OPTIONS.find((o) => o.value === type)?.label ?? type; +} + +// --- 表单值类型 --- + +interface FormValues { + media_item_id: string; + title?: string; + subtitle?: string; + link_type?: string; + link_target?: string; + sort_order?: number; + status?: string; + time_range?: [dayjs.Dayjs, dayjs.Dayjs]; +} + +export default function BannerManage() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [statusFilter, setStatusFilter] = useState(''); + + // 弹窗状态 + const [modalOpen, setModalOpen] = useState(false); + const [editingRecord, setEditingRecord] = useState(null); + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + + // 媒体选择器 + const [mediaItems, setMediaItems] = useState([]); + const [mediaLoading, setMediaLoading] = useState(false); + + // ---- 加载数据 ---- + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const result = await bannerApi.list(statusFilter || undefined); + setData(result); + } catch { + message.error('加载轮播图列表失败'); + } finally { + setLoading(false); + } + }, [statusFilter]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // ---- 媒体文件搜索 ---- + + const searchMedia = useCallback(async (keyword: string) => { + setMediaLoading(true); + try { + const result = await mediaApi.list({ + page: 1, + page_size: 20, + keyword: keyword || undefined, + content_type: 'image/', + }); + setMediaItems(result.data); + } catch { + // 静默处理 + } finally { + setMediaLoading(false); + } + }, []); + + // 初次打开弹窗时加载媒体 + useEffect(() => { + if (modalOpen) { + searchMedia(''); + } + }, [modalOpen, searchMedia]); + + // ---- 操作 ---- + + const openCreateModal = () => { + setEditingRecord(null); + form.resetFields(); + form.setFieldsValue({ + link_type: 'none', + status: 'active', + sort_order: 0, + }); + setModalOpen(true); + }; + + const openEditModal = useCallback((record: BannerItem) => { + setEditingRecord(record); + form.resetFields(); + form.setFieldsValue({ + media_item_id: record.media_item_id, + title: record.title, + subtitle: record.subtitle, + link_type: record.link_type || 'none', + link_target: record.link_target, + sort_order: record.sort_order, + status: record.status, + time_range: + record.start_time && record.end_time + ? [dayjs(record.start_time), dayjs(record.end_time)] + : undefined, + }); + setModalOpen(true); + }, [form]); + + const handleSubmit = async (values: FormValues) => { + setSubmitting(true); + try { + const payload: CreateBannerReq | UpdateBannerReq = { + media_item_id: values.media_item_id, + title: values.title, + subtitle: values.subtitle, + link_type: values.link_type === 'none' ? undefined : values.link_type, + link_target: values.link_target, + sort_order: values.sort_order, + status: values.status, + start_time: values.time_range?.[0]?.toISOString(), + end_time: values.time_range?.[1]?.toISOString(), + }; + + if (editingRecord) { + await bannerApi.update(editingRecord.id, { + ...payload, + version: editingRecord.version, + } as UpdateBannerReq); + message.success('轮播图已更新'); + } else { + await bannerApi.create(payload as CreateBannerReq); + message.success('轮播图已创建'); + } + + setModalOpen(false); + fetchData(); + } catch { + message.error(editingRecord ? '更新失败' : '创建失败'); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = useCallback(async (record: BannerItem) => { + try { + await bannerApi.delete(record.id, record.version); + message.success('轮播图已删除'); + fetchData(); + } catch { + message.error('删除失败'); + } + }, [fetchData]); + + const handleToggleStatus = useCallback(async (record: BannerItem) => { + try { + const newStatus = record.status === 'active' ? 'inactive' : 'active'; + await bannerApi.update(record.id, { + status: newStatus, + version: record.version, + }); + message.success(newStatus === 'active' ? '已启用' : '已停用'); + fetchData(); + } catch { + message.error('状态切换失败'); + } + }, [fetchData]); + + // ---- 列定义 ---- + + const columns = useMemo( + () => [ + { + title: '排序', + dataIndex: 'sort_order', + key: 'sort_order', + width: 80, + sorter: (a: BannerItem, b: BannerItem) => a.sort_order - b.sort_order, + }, + { + title: '图片', + dataIndex: 'image_url', + key: 'image_url', + width: 100, + render: (url: string | undefined, record: BannerItem) => { + if (record.media_deleted) { + return ( +
+ 已删除 +
+ ); + } + const src = record.thumbnail_url || url; + if (!src) return '-'; + return ( + + ); + }, + }, + { + title: '标题 / 副标题', + key: 'title_info', + render: (_: unknown, record: BannerItem) => ( +
+
+ {record.title || 无标题} +
+ {record.subtitle && ( +
+ {record.subtitle} +
+ )} +
+ ), + }, + { + title: '链接', + key: 'link_info', + width: 160, + render: (_: unknown, record: BannerItem) => { + if (!record.link_type || record.link_type === 'none') return '-'; + return ( +
+ {getLinkTypeLabel(record.link_type)} + {record.link_target && ( +
+ {record.link_target} +
+ )} +
+ ); + }, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (status: string) => { + const config = getStatusConfig(status); + return {config.label}; + }, + }, + { + title: '时间范围', + key: 'time_range', + width: 200, + render: (_: unknown, record: BannerItem) => { + if (!record.start_time && !record.end_time) { + return 永久; + } + const start = record.start_time ? formatDateTime(record.start_time) : '--'; + const end = record.end_time ? formatDateTime(record.end_time) : '--'; + return ( +
+ {start} ~ {end} +
+ ); + }, + }, + { + title: '更新时间', + dataIndex: 'updated_at', + key: 'updated_at', + width: 170, + render: (v: string) => formatDateTime(v), + }, + { + title: '操作', + key: 'actions', + width: 200, + render: (_: unknown, record: BannerItem) => ( + + + + + + handleToggleStatus(record)} + /> + + + handleDelete(record)} + > + + + } + loading={loading} + > + + record.status === 'inactive' + ? 'ant-table-row-inactive' + : undefined + } + /> + + {/* 新建/编辑弹窗 */} + setModalOpen(false)} + onOk={() => form.submit()} + confirmLoading={submitting} + okText={editingRecord ? '保存' : '创建'} + width={600} + destroyOnClose + > +
+ + + + + + + + + + + + ) : null + } + + + + + + + +