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: 插入"媒体库"和"轮播图管理"菜单
|
// Part 1: 插入"媒体库"和"轮播图管理"菜单
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// 运营分组: articles(40), points-rules(41), points-products(42),
|
// 父菜单: "内容运营" (path 为空, title='内容运营', parent='健康业务')
|
||||||
// points-orders(43), offline-events(44)
|
// 媒体库 → sort 41, 轮播图管理 → sort 42
|
||||||
// 媒体库 → 45, 轮播图管理 → 46
|
|
||||||
|
|
||||||
let menus: &[(&str, &str, &str, &str, i32)] = &[
|
let menus: &[(&str, &str, &str, &str, &str, i32)] = &[
|
||||||
(
|
(
|
||||||
"b0000003-0000-7000-8000-000000000033",
|
"b0000003-0000-7000-8000-000000000033",
|
||||||
"媒体库",
|
"媒体库",
|
||||||
"/health/media-library",
|
"/health/media-library",
|
||||||
"PictureOutlined",
|
"PictureOutlined",
|
||||||
45,
|
"health.media.list",
|
||||||
|
41,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"b0000003-0000-7000-8000-000000000034",
|
"b0000003-0000-7000-8000-000000000034",
|
||||||
"轮播图管理",
|
"轮播图管理",
|
||||||
"/health/banners",
|
"/health/banners",
|
||||||
"SwapOutlined",
|
"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!(
|
let sql = format!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order,
|
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
|
SELECT
|
||||||
'{id}'::uuid,
|
'{id}'::uuid,
|
||||||
t.id,
|
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}',
|
'{title}',
|
||||||
'{path}',
|
'{path}',
|
||||||
'{icon}',
|
'{icon}',
|
||||||
{sort},
|
{sort},
|
||||||
true, 'page',
|
true, 'page', '{perm}',
|
||||||
NOW(), NOW(),
|
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),
|
||||||
(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?;
|
)).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: 插入"首页推荐"文章分类
|
// Part 4: 插入"首页推荐"文章分类
|
||||||
// ================================================================
|
// ================================================================
|
||||||
@@ -154,6 +180,17 @@ impl MigrationTrait for Migration {
|
|||||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
let db = manager.get_connection();
|
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"] {
|
for path in &["/health/media-library", "/health/banners"] {
|
||||||
db.execute_unprepared(&format!("DELETE FROM menus WHERE path = '{path}'"))
|
db.execute_unprepared(&format!("DELETE FROM menus WHERE path = '{path}'"))
|
||||||
|
|||||||
Reference in New Issue
Block a user