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:
524
apps/web/src/pages/health/BannerManage.tsx
Normal file
524
apps/web/src/pages/health/BannerManage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}'"))
|
||||
|
||||
Reference in New Issue
Block a user