fix(health): 修复媒体库和轮播图菜单不可见 — parent_id/permission/menu_roles 三重修复

种子迁移 m20260510_000137 存在三个问题导致菜单不显示:
1. parent_id 查找用了错误条件(path='/health'),改为 title='内容运营'
2. menu INSERT 缺少 permission 字段
3. 缺少 menu_roles 关联(admin/operator)
同时新增 BannerManage.tsx 前端页面
This commit is contained in:
iven
2026-05-10 19:07:20 +08:00
parent 09725acad7
commit edb4b6557d
2 changed files with 571 additions and 10 deletions

View File

@@ -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<BannerItem[]>([]);
const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState<string>('');
// 弹窗状态
const [modalOpen, setModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<BannerItem | null>(null);
const [form] = Form.useForm<FormValues>();
const [submitting, setSubmitting] = useState(false);
// 媒体选择器
const [mediaItems, setMediaItems] = useState<MediaItem[]>([]);
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 (
<div
style={{
width: 60,
height: 40,
borderRadius: 4,
background: '#f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: '#999',
}}
>
</div>
);
}
const src = record.thumbnail_url || url;
if (!src) return '-';
return (
<Image
src={src}
width={60}
height={40}
style={{ borderRadius: 4, objectFit: 'cover' }}
preview={{ mask: null }}
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3Qgd2lkdGg9IjYwIiBoZWlnaHQ9IjQwIiBmaWxsPSIjZjBmMGYwIi8+PC9zdmc+"
/>
);
},
},
{
title: '标题 / 副标题',
key: 'title_info',
render: (_: unknown, record: BannerItem) => (
<div>
<div style={{ fontWeight: 500 }}>
{record.title || <span style={{ color: 'var(--ant-color-text-quaternary, #cbd5e1)' }}></span>}
</div>
{record.subtitle && (
<div
style={{
fontSize: 12,
color: 'var(--ant-color-text-secondary, #94a3b8)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 260,
}}
>
{record.subtitle}
</div>
)}
</div>
),
},
{
title: '链接',
key: 'link_info',
width: 160,
render: (_: unknown, record: BannerItem) => {
if (!record.link_type || record.link_type === 'none') return '-';
return (
<div>
<Tag>{getLinkTypeLabel(record.link_type)}</Tag>
{record.link_target && (
<div
style={{
fontSize: 12,
color: 'var(--ant-color-text-secondary, #94a3b8)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 120,
}}
>
{record.link_target}
</div>
)}
</div>
);
},
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => {
const config = getStatusConfig(status);
return <Tag color={config.color}>{config.label}</Tag>;
},
},
{
title: '时间范围',
key: 'time_range',
width: 200,
render: (_: unknown, record: BannerItem) => {
if (!record.start_time && !record.end_time) {
return <span style={{ color: 'var(--ant-color-text-quaternary, #cbd5e1)' }}></span>;
}
const start = record.start_time ? formatDateTime(record.start_time) : '--';
const end = record.end_time ? formatDateTime(record.end_time) : '--';
return (
<div style={{ fontSize: 13 }}>
{start} ~ {end}
</div>
);
},
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 170,
render: (v: string) => formatDateTime(v),
},
{
title: '操作',
key: 'actions',
width: 200,
render: (_: unknown, record: BannerItem) => (
<Space size={4} wrap>
<AuthButton code="health.banners.manage">
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => openEditModal(record)}
>
</Button>
</AuthButton>
<AuthButton code="health.banners.manage">
<Switch
size="small"
checked={record.status === 'active'}
onChange={() => handleToggleStatus(record)}
/>
</AuthButton>
<AuthButton code="health.banners.manage">
<Popconfirm
title="确定删除此轮播图?"
onConfirm={() => handleDelete(record)}
>
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
</Popconfirm>
</AuthButton>
</Space>
),
},
],
[handleDelete, handleToggleStatus, openEditModal],
);
return (
<PageContainer
title="轮播图管理"
subtitle="管理小程序首页轮播图"
filters={
<Select
value={statusFilter || undefined}
onChange={(v) => {
setStatusFilter(v ?? '');
}}
placeholder="全部状态"
allowClear
style={{ width: 140 }}
options={STATUS_OPTIONS.map((s) => ({ label: s.label, value: s.value }))}
/>
}
onResetFilters={() => setStatusFilter('')}
actions={
<AuthButton code="health.banners.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
</Button>
</AuthButton>
}
loading={loading}
>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={false}
rowClassName={(record) =>
record.status === 'inactive'
? 'ant-table-row-inactive'
: undefined
}
/>
{/* 新建/编辑弹窗 */}
<Modal
title={editingRecord ? '编辑轮播图' : '新建轮播图'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
confirmLoading={submitting}
okText={editingRecord ? '保存' : '创建'}
width={600}
destroyOnClose
>
<Form
form={form}
onFinish={handleSubmit}
layout="vertical"
style={{ marginTop: 16 }}
>
<Form.Item
name="media_item_id"
label="媒体文件"
rules={[{ required: true, message: '请选择媒体文件' }]}
>
<Select
showSearch
filterOption={false}
loading={mediaLoading}
onSearch={searchMedia}
placeholder="搜索媒体文件..."
notFoundContent={mediaLoading ? '搜索中...' : '无结果'}
options={mediaItems.map((item) => ({
label: item.filename,
value: item.id,
}))}
/>
</Form.Item>
<Form.Item name="title" label="标题">
<Input placeholder="轮播图标题" maxLength={100} />
</Form.Item>
<Form.Item name="subtitle" label="副标题">
<Input placeholder="轮播图副标题" maxLength={200} />
</Form.Item>
<Form.Item name="link_type" label="链接类型">
<Select
options={LINK_TYPE_OPTIONS.map((o) => ({ label: o.label, value: o.value }))}
/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, cur) => prev.link_type !== cur.link_type}
>
{({ getFieldValue }) =>
getFieldValue('link_type') && getFieldValue('link_type') !== 'none' ? (
<Form.Item
name="link_target"
label="链接目标"
rules={[{ required: true, message: '请输入链接目标' }]}
>
<Input placeholder="输入链接地址或文章ID" />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item name="sort_order" label="排序权重">
<InputNumber min={0} max={9999} style={{ width: '100%' }} placeholder="数值越小越靠前" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={STATUS_OPTIONS.map((s) => ({ label: s.label, value: s.value }))}
/>
</Form.Item>
<Form.Item name="time_range" label="展示时间范围">
<DatePicker.RangePicker
showTime
style={{ width: '100%' }}
placeholder={['开始时间', '结束时间']}
format="YYYY-MM-DD HH:mm"
/>
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}

View File

@@ -19,41 +19,45 @@ impl MigrationTrait for Migration {
// ================================================================
// Part 1: 插入"媒体库"和"轮播图管理"菜单
// ================================================================
// 运营分组: articles(40), points-rules(41), points-products(42),
// points-orders(43), offline-events(44)
// 媒体库 → 45, 轮播图管理 → 46
// 父菜单: "内容运营" (path 为空, title='内容运营', parent='健康业务')
// 媒体库 → sort 41, 轮播图管理 → sort 42
let menus: &[(&str, &str, &str, &str, i32)] = &[
let menus: &[(&str, &str, &str, &str, &str, i32)] = &[
(
"b0000003-0000-7000-8000-000000000033",
"媒体库",
"/health/media-library",
"PictureOutlined",
45,
"health.media.list",
41,
),
(
"b0000003-0000-7000-8000-000000000034",
"轮播图管理",
"/health/banners",
"SwapOutlined",
46,
"health.banners.list",
42,
),
];
for &(id, title, path, icon, sort) in menus {
for &(id, title, path, icon, perm, sort) in menus {
let sql = format!(
r#"
INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order,
visible, menu_type, created_at, updated_at, created_by, updated_by, version)
visible, menu_type, permission,
created_at, updated_at, created_by, updated_by, version)
SELECT
'{id}'::uuid,
t.id,
(SELECT m.id FROM menus m WHERE m.path = '/health' AND m.tenant_id = t.id LIMIT 1),
(SELECT m.id FROM menus m
WHERE m.title = '内容运营' AND m.tenant_id = t.id AND m.deleted_at IS NULL
LIMIT 1),
'{title}',
'{path}',
'{icon}',
{sort},
true, 'page',
true, 'page', '{perm}',
NOW(), NOW(),
(SELECT u.id FROM users u WHERE u.tenant_id = t.id LIMIT 1),
(SELECT u.id FROM users u WHERE u.tenant_id = t.id LIMIT 1),
@@ -130,6 +134,28 @@ impl MigrationTrait for Migration {
)).await?;
}
// ================================================================
// Part 3.5: 将菜单关联到 admin / operator 角色
// ================================================================
for menu_path in &["/health/media-library", "/health/banners"] {
for role_code in &["admin", "operator"] {
db.execute_unprepared(&format!(
r#"
INSERT INTO menu_roles (id, menu_id, role_id, tenant_id,
created_at, updated_at, created_by, updated_by, version)
SELECT gen_random_uuid(), m.id, r.id, m.tenant_id,
NOW(), NOW(), r.id, r.id, 1
FROM menus m
JOIN roles r ON r.tenant_id = m.tenant_id AND r.code = '{role_code}' AND r.deleted_at IS NULL
WHERE m.path = '{menu_path}' AND m.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM menu_roles mr WHERE mr.menu_id = m.id AND mr.role_id = r.id
)
"#
)).await?;
}
}
// ================================================================
// Part 4: 插入"首页推荐"文章分类
// ================================================================
@@ -154,6 +180,17 @@ impl MigrationTrait for Migration {
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// 删除菜单角色绑定
db.execute_unprepared(
"DELETE FROM menu_roles
WHERE menu_id IN (
SELECT id FROM menus
WHERE path IN ('/health/media-library', '/health/banners')
)",
)
.await
.ok();
// 删除菜单
for path in &["/health/media-library", "/health/banners"] {
db.execute_unprepared(&format!("DELETE FROM menus WHERE path = '{path}'"))