Compare commits

12 Commits

Author SHA1 Message Date
iven
09725acad7 feat(miniprogram): 访客首页支持无登录态获取轮播图(编译时注入默认 tenant_id) 2026-05-10 17:20:43 +08:00
iven
7fcabd2e6b fix(health): 修复迁移外键表名引用 + 公开轮播图签名 URL 路径拼接 2026-05-10 17:13:02 +08:00
iven
d2b79e4a1c feat(web): 添加 MediaPicker 组件并集成到 ArticleEditor 封面图选择 2026-05-10 16:54:30 +08:00
iven
b2c6d9c8c8 feat(miniprogram): 访客首页轮播图接入公开 API + 文章列表替换核心功能区域 2026-05-10 16:23:17 +08:00
iven
6bf8cc53f8 feat(web): 新增媒体库管理页面
- 左侧面板:文件夹树形结构,支持创建/重命名/删除文件夹
- 右侧面板:媒体网格视图,支持上传/搜索/类型筛选/批量删除
- 上传弹窗:TreeSelect 选择目标文件夹 + 公开访问开关
- 编辑弹窗:修改文件名/替代文本/公开状态
- 移动弹窗:选择目标文件夹移动文件
- 权限码:health.media.list + health.media.manage
2026-05-10 16:18:47 +08:00
iven
2c7d4a3d63 feat(web): 新增媒体库和轮播图 API client
媒体库(media.ts):文件列表/上传/更新/删除/移动/批量删除/裁剪 + 文件夹树形管理
轮播图(banners.ts):列表/创建/更新/删除/排序,字段与后端 DTO 完全对齐
2026-05-10 15:42:24 +08:00
iven
85bff6f267 feat(server): 配置签名 URL 密钥 — StorageConfig.secret_key 2026-05-10 15:39:11 +08:00
iven
1a459de4ad feat(health): 注册媒体库和轮播图路由 + 权限码 + 公开端点 2026-05-10 15:35:47 +08:00
iven
3a672636c0 feat(health): 实现媒体库 handler (12 端点) + 轮播图 handler (6 端点)
媒体库 handler (media_handler.rs):
- 上传/列表/详情/更新/删除媒体文件 + 文件夹 CRUD + 移动 + 裁剪

轮播图 handler (banner_handler.rs):
- 管理端 5 端点(列表/创建/更新/删除/排序)
- 公开端点 1 个(小程序无需认证获取生效轮播图)
2026-05-10 15:32:09 +08:00
iven
a9bd850ce2 feat(health): 实现轮播图 service — CRUD + 排序 + 签名 URL
- list_banners: 列出轮播图,可选状态筛选,批量加载 media_item 避免 N+1
- create_banner: 创建轮播图,验证 media_item 存在且未删除
- update_banner: 更新轮播图,带乐观锁
- delete_banner: 软删除轮播图
- sort_banners: 批量更新排序
- list_public_banners: 公开端点,查询生效轮播图 + HMAC-SHA256 签名 URL
- generate_signed_url: 同步函数,生成签名 URL token

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 15:15:11 +08:00
iven
601d977438 feat(health): 实现媒体库 service — CRUD + 缩略图 + 裁剪 2026-05-10 15:08:26 +08:00
iven
603a986281 feat(health): 新增 media_folder/media_item/banner 实体 + image/hmac/sha2 依赖 2026-05-10 14:19:55 +08:00
31 changed files with 3632 additions and 38 deletions

View File

@@ -19,6 +19,7 @@ export default defineConfig(async (merge) => {
'process.env.TARO_APP_WX_TEMPLATE_REPORT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_REPORT || ''),
'process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT || ''),
'process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL || ''),
'process.env.TARO_APP_DEFAULT_TENANT_ID': JSON.stringify(process.env.TARO_APP_DEFAULT_TENANT_ID || ''),
},
copy: { patterns: [], options: {} },
framework: 'react',

View File

@@ -339,6 +339,19 @@
&--1 {
background: linear-gradient(135deg, $pri-d 0%, $pri 60%, $pri-l 100%);
}
&--2 {
background: linear-gradient(135deg, $acc 0%, #3D5A40 60%, $acc-l 100%);
}
&--3 {
background: linear-gradient(135deg, #8B6F4E 0%, $wrn 60%, $wrn-l 100%);
}
}
.guest-slide-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.guest-slide:nth-child(2) .guest-slide-bg {
@@ -397,14 +410,27 @@
.guest-article-card {
background: $card;
border-radius: $r;
padding: 16px 18px;
overflow: hidden;
box-shadow: $shadow-sm;
display: flex;
&:active {
opacity: 0.85;
}
}
.guest-article-cover {
width: 100px;
height: 80px;
flex-shrink: 0;
}
.guest-article-body {
padding: 12px 14px;
flex: 1;
min-width: 0;
}
.guest-article-title {
font-size: var(--tk-font-body-sm);
font-weight: 600;

View File

@@ -1,5 +1,5 @@
import { View, Text, Swiper, SwiperItem } from '@tarojs/components';
import { useState, useCallback } from 'react';
import { View, Text, Swiper, SwiperItem, Image } from '@tarojs/components';
import { useState } from 'react';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import { useAuthStore } from '../../stores/auth';
import { useUIStore } from '../../stores/ui';
@@ -12,6 +12,8 @@ import * as appointmentApi from '@/services/appointment';
import * as followupApi from '@/services/followup';
import { listPendingSuggestions, type AiSuggestionItem } from '@/services/ai-analysis';
import { notificationService } from '@/services/notification';
import { api } from '@/services/request';
import type { Article } from '@/services/article';
import './index.scss';
interface ReminderItem {
@@ -21,15 +23,65 @@ interface ReminderItem {
tag: string;
}
interface PublicBanner {
id: string;
title?: string;
subtitle?: string;
image_url?: string;
link_type?: string;
link_target?: string;
}
// ─── 访客首页 ───
const CAROUSEL_SLIDES = [
{ id: 'slide-1', title: '专业血透中心', desc: '三甲级医护团队全程守护' },
{ id: 'slide-2', title: '智慧健康管理', desc: 'AI 驱动个性化健康方案' },
{ id: 'slide-3', title: '温馨就医环境', desc: '舒适安全的治疗体验' },
const FALLBACK_SLIDES = [
{ id: 'slide-1', title: '专业血透中心', desc: '三甲级医护团队全程守护', image_url: '' },
{ id: 'slide-2', title: '智慧健康管理', desc: 'AI 驱动个性化健康方案', image_url: '' },
{ id: 'slide-3', title: '温馨就医环境', desc: '舒适安全的治疗体验', image_url: '' },
];
function GuestHome({ modeClass }: { modeClass: string }) {
const [banners, setBanners] = useState<PublicBanner[]>([]);
const [articles, setArticles] = useState<Article[]>([]);
useDidShow(() => {
loadPublicData();
});
const loadPublicData = async () => {
let tenantId = Taro.getStorageSync('tenant_id');
if (!tenantId) {
tenantId = process.env.TARO_APP_DEFAULT_TENANT_ID || '';
}
if (!tenantId) {
setBanners(FALLBACK_SLIDES);
return;
}
try {
const [bannerData, articleData] = await Promise.allSettled([
api.get<PublicBanner[]>('/public/banners', { tenant_id: tenantId }, 300_000),
api.get<{ data: Article[]; total: number }>('/health/articles', {
status: 'published',
page_size: 4,
}, 300_000),
]);
if (bannerData.status === 'fulfilled' && bannerData.value?.length > 0) {
setBanners(bannerData.value);
} else {
setBanners(FALLBACK_SLIDES);
}
if (articleData.status === 'fulfilled' && articleData.value?.data?.length > 0) {
setArticles(articleData.value.data);
}
} catch {
setBanners(FALLBACK_SLIDES);
}
};
const slides = banners.length > 0 ? banners : FALLBACK_SLIDES;
return (
<View className={`guest-page ${modeClass}`}>
{/* 轮播图 */}
@@ -43,36 +95,58 @@ function GuestHome({ modeClass }: { modeClass: string }) {
interval={4000}
duration={500}
>
{CAROUSEL_SLIDES.map((slide) => (
<SwiperItem key={slide.id}>
{slides.map((slide, idx) => (
<SwiperItem key={slide.id || idx}>
<View className='guest-slide'>
<View className='guest-slide-bg guest-slide-bg--1' />
{slide.image_url ? (
<Image className='guest-slide-image' src={slide.image_url} mode='aspectFill' />
) : (
<View className={`guest-slide-bg guest-slide-bg--${(idx % 3) + 1}`} />
)}
<View className='guest-slide-content'>
<Text className='guest-slide-title'>{slide.title}</Text>
<Text className='guest-slide-desc'>{slide.desc}</Text>
<Text className='guest-slide-desc'>{slide.subtitle || slide.desc}</Text>
</View>
</View>
</SwiperItem>
))}
</Swiper>
{/* 功能亮点 */}
{/* 推荐文章(替换原来的"核心功能"区域) */}
<View className='guest-section'>
<Text className='guest-section-title'></Text>
<View className='guest-articles'>
<View className='guest-article-card'>
<Text className='guest-article-title'></Text>
<Text className='guest-article-summary'></Text>
<Text className='guest-section-title'></Text>
{articles.length > 0 ? (
<View className='guest-articles'>
{articles.map((article) => (
<View className='guest-article-card' key={article.id}>
{article.cover_image && (
<Image className='guest-article-cover' src={article.cover_image} mode='aspectFill' />
)}
<View className='guest-article-body'>
<Text className='guest-article-title'>{article.title}</Text>
<Text className='guest-article-summary'>
{article.summary || '点击查看详情'}
</Text>
</View>
</View>
))}
</View>
<View className='guest-article-card'>
<Text className='guest-article-title'></Text>
<Text className='guest-article-summary'>线</Text>
) : (
<View className='guest-articles'>
<View className='guest-article-card'>
<Text className='guest-article-title'></Text>
<Text className='guest-article-summary'></Text>
</View>
<View className='guest-article-card'>
<Text className='guest-article-title'></Text>
<Text className='guest-article-summary'>线</Text>
</View>
<View className='guest-article-card'>
<Text className='guest-article-title'>AI </Text>
<Text className='guest-article-summary'></Text>
</View>
</View>
<View className='guest-article-card'>
<Text className='guest-article-title'>AI </Text>
<Text className='guest-article-summary'></Text>
</View>
</View>
)}
</View>
{/* 底部登录引导 */}

View File

@@ -0,0 +1,107 @@
import client from '../client';
// ---------------------------------------------------------------------------
// 轮播图类型
// ---------------------------------------------------------------------------
export interface BannerItem {
id: string;
tenant_id: string;
media_item_id: string;
title?: string;
subtitle?: string;
link_type?: string;
link_target?: string;
sort_order: number;
status: string;
start_time?: string;
end_time?: string;
image_url?: string;
thumbnail_url?: string;
media_deleted: boolean;
created_at: string;
updated_at: string;
created_by?: string;
updated_by?: string;
version: number;
}
export interface CreateBannerReq {
media_item_id: string;
title?: string;
subtitle?: string;
link_type?: string;
link_target?: string;
sort_order?: number;
status?: string;
start_time?: string;
end_time?: string;
}
export interface UpdateBannerReq {
media_item_id?: string;
title?: string;
subtitle?: string;
link_type?: string;
link_target?: string;
sort_order?: number;
status?: string;
start_time?: string;
end_time?: string;
version: number;
}
export interface SortBannerReq {
items: Array<{ id: string; sort_order: number }>;
}
// ---------------------------------------------------------------------------
// 轮播图 API
// ---------------------------------------------------------------------------
export const bannerApi = {
/** 获取轮播图列表(可按状态筛选) */
list: async (status?: string) => {
const { data } = await client.get<{
success: boolean;
data: BannerItem[];
}>('/health/banners', { params: status ? { status } : undefined });
return data.data;
},
/** 创建轮播图 */
create: async (req: CreateBannerReq) => {
const { data } = await client.post<{
success: boolean;
data: BannerItem;
}>('/health/banners', req);
return data.data;
},
/** 更新轮播图 */
update: async (id: string, req: UpdateBannerReq) => {
const { data } = await client.put<{
success: boolean;
data: BannerItem;
}>(`/health/banners/${id}`, req);
return data.data;
},
/** 删除轮播图 */
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/banners/${id}`, { data: { version } });
return data.data;
},
/** 轮播图排序 */
sort: async (req: SortBannerReq) => {
const { data } = await client.put<{
success: boolean;
data: null;
}>('/health/banners/sort', req);
return data.data;
},
};

View File

@@ -0,0 +1,208 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// ---------------------------------------------------------------------------
// 媒体文件类型
// ---------------------------------------------------------------------------
export interface MediaItem {
id: string;
tenant_id: string;
folder_id?: string;
filename: string;
storage_path: string;
thumbnail_path?: string;
content_type: string;
file_size: number;
width?: number;
height?: number;
alt_text?: string;
is_public: boolean;
created_at: string;
updated_at: string;
created_by?: string;
updated_by?: string;
version: number;
}
export interface MediaListParams {
page?: number;
page_size?: number;
folder_id?: string;
content_type?: string;
keyword?: string;
is_public?: boolean;
}
export interface UpdateMediaReq {
filename?: string;
alt_text?: string;
is_public?: boolean;
folder_id?: string;
version: number;
}
export interface MoveMediaReq {
folder_id?: string;
version: number;
}
export interface CropReq {
x: number;
y: number;
width: number;
height: number;
version: number;
}
// ---------------------------------------------------------------------------
// 文件夹类型
// ---------------------------------------------------------------------------
export interface FolderItem {
id: string;
tenant_id: string;
name: string;
parent_id?: string;
sort_order: number;
children: FolderItem[];
item_count: number;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateFolderReq {
name: string;
parent_id?: string;
sort_order?: number;
}
export interface UpdateFolderReq {
name?: string;
parent_id?: string;
sort_order?: number;
version: number;
}
// ---------------------------------------------------------------------------
// 媒体文件 API
// ---------------------------------------------------------------------------
export const mediaApi = {
/** 分页查询媒体文件列表 */
list: async (params: MediaListParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<MediaItem>;
}>('/health/media', { params });
return data.data;
},
/** 上传媒体文件multipart/form-data */
upload: async (formData: FormData) => {
const { data } = await client.post<{
success: boolean;
data: MediaItem;
}>('/health/media/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.data;
},
/** 获取单个媒体文件详情 */
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: MediaItem;
}>(`/health/media/${id}`);
return data.data;
},
/** 更新媒体文件信息 */
update: async (id: string, req: UpdateMediaReq) => {
const { data } = await client.put<{
success: boolean;
data: MediaItem;
}>(`/health/media/${id}`, req);
return data.data;
},
/** 删除媒体文件 */
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/media/${id}`, { data: { version } });
return data.data;
},
/** 移动媒体文件到指定文件夹 */
move: async (id: string, req: MoveMediaReq) => {
const { data } = await client.post<{
success: boolean;
data: MediaItem;
}>(`/health/media/${id}/move`, req);
return data.data;
},
/** 批量删除媒体文件 */
batchDelete: async (ids: string[]) => {
const { data } = await client.post<{
success: boolean;
data: null;
}>('/health/media/batch-delete', { ids });
return data.data;
},
/** 裁剪媒体文件 */
crop: async (id: string, req: CropReq) => {
const { data } = await client.post<{
success: boolean;
data: MediaItem;
}>(`/health/media/${id}/crop`, req);
return data.data;
},
};
// ---------------------------------------------------------------------------
// 文件夹 API
// ---------------------------------------------------------------------------
export const mediaFolderApi = {
/** 获取文件夹树形结构 */
tree: async () => {
const { data } = await client.get<{
success: boolean;
data: FolderItem[];
}>('/health/media-folders');
return data.data;
},
/** 创建文件夹 */
create: async (req: CreateFolderReq) => {
const { data } = await client.post<{
success: boolean;
data: FolderItem;
}>('/health/media-folders', req);
return data.data;
},
/** 更新文件夹 */
update: async (id: string, req: UpdateFolderReq) => {
const { data } = await client.put<{
success: boolean;
data: FolderItem;
}>(`/health/media-folders/${id}`, req);
return data.data;
},
/** 删除文件夹 */
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/media-folders/${id}`, { data: { version } });
return data.data;
},
};

View File

@@ -0,0 +1,192 @@
import { useState, useEffect, useCallback } from 'react';
import { Modal, Input, Upload, Image, Empty, Spin, message } from 'antd';
import { SearchOutlined, UploadOutlined } from '@ant-design/icons';
import { mediaApi, type MediaItem } from '../../api/health/media';
import { uploadFile } from '../../api/upload';
interface MediaPickerProps {
open: boolean;
onClose: () => void;
onSelect: (url: string, item?: MediaItem) => void;
accept?: string;
}
const PAGE_SIZE = 20;
export default function MediaPicker({ open, onClose, onSelect, accept = 'image/*' }: MediaPickerProps) {
const [items, setItems] = useState<MediaItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState('');
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
try {
const result = await mediaApi.list({
page,
page_size: PAGE_SIZE,
keyword: keyword || undefined,
content_type: accept === 'image/*' ? 'image' : undefined,
});
setItems(result.data);
setTotal(result.total);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, [page, keyword, accept]);
useEffect(() => {
if (open) loadData();
}, [open, loadData]);
const handleUpload = async (file: File) => {
setUploading(true);
try {
await uploadFile(file);
message.success('上传成功');
await loadData();
} catch {
message.error('上传失败');
} finally {
setUploading(false);
}
return false;
};
const handleSelect = (item: MediaItem) => {
const token = localStorage.getItem('access_token');
const url = token ? `${item.storage_path}?token=${token}` : item.storage_path;
onSelect(url, item);
onClose();
};
const totalPages = Math.ceil(total / PAGE_SIZE);
return (
<Modal
title="选择媒体文件"
open={open}
onCancel={onClose}
footer={null}
width={720}
destroyOnClose
>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Input
prefix={<SearchOutlined />}
placeholder="搜索文件名..."
value={keyword}
onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
allowClear
style={{ flex: 1 }}
/>
<Upload
accept={accept}
showUploadList={false}
beforeUpload={(file) => { handleUpload(file); return false; }}
>
<button
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '4px 15px', borderRadius: 6, border: '1px solid #d9d9d9',
background: '#fff', cursor: 'pointer', fontSize: 14,
}}
disabled={uploading}
>
{uploading ? <Spin size="small" /> : <UploadOutlined />}
</button>
</Upload>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
) : items.length === 0 ? (
<Empty description="暂无媒体文件" />
) : (
<>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 12,
maxHeight: 400,
overflowY: 'auto',
}}
>
{items.map((item) => (
<div
key={item.id}
onClick={() => handleSelect(item)}
style={{
cursor: 'pointer',
borderRadius: 8,
overflow: 'hidden',
border: '1px solid #f0f0f0',
background: '#fafafa',
transition: 'border-color 0.2s',
}}
onMouseEnter={(e) => { (e.currentTarget.style.borderColor = '#1677ff'); }}
onMouseLeave={(e) => { (e.currentTarget.style.borderColor = '#f0f0f0'); }}
>
<div style={{ width: '100%', height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{item.content_type.startsWith('image/') ? (
<Image
src={(() => {
const token = localStorage.getItem('access_token');
const base = item.thumbnail_path || item.storage_path;
return token ? `${base}?token=${token}` : base;
})()}
alt={item.alt_text || item.filename}
style={{ maxWidth: '100%', maxHeight: 100, objectFit: 'cover' }}
preview={false}
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiNlOGU4ZTgiLz48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZmlsbD0iIzk5OSIgZm9udC1zaXplPSIxMiI+PC90ZXh0Pjwvc3ZnPg=="
/>
) : (
<span style={{ color: '#999', fontSize: 12 }}>{item.content_type.split('/')[1]}</span>
)}
</div>
<div
style={{
padding: '4px 8px',
fontSize: 11,
color: '#666',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
title={item.filename}
>
{item.filename}
</div>
</div>
))}
</div>
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 12, alignItems: 'center' }}>
<button
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
style={{ padding: '4px 12px', borderRadius: 4, border: '1px solid #d9d9d9', cursor: page <= 1 ? 'default' : 'pointer' }}
>
</button>
<span style={{ fontSize: 13, color: '#666' }}>{page} / {totalPages}</span>
<button
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
style={{ padding: '4px 12px', borderRadius: 4, border: '1px solid #d9d9d9', cursor: page >= totalPages ? 'default' : 'pointer' }}
>
</button>
</div>
)}
</>
)}
</Modal>
);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button, Input, Select, Space, message, Spin, Upload } from 'antd';
import { ArrowLeftOutlined, SaveOutlined, SendOutlined, UploadOutlined } from '@ant-design/icons';
import { ArrowLeftOutlined, SaveOutlined, SendOutlined, UploadOutlined, PictureOutlined } from '@ant-design/icons';
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
import {
@@ -13,7 +13,8 @@ import {
} from '../../api/health/articles';
import { useThemeMode } from '../../hooks/useThemeMode';
import { AuthButton } from '../../components/AuthButton';
import client, { handleApiError } from '../../api/client';
import MediaPicker from '../../components/MediaPicker';
import { handleApiError } from '../../api/client';
import { uploadFile } from '../../api/upload';
import '@wangeditor/editor/dist/css/style.css';
@@ -42,6 +43,7 @@ export default function ArticleEditor() {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [editor, setEditor] = useState<IDomEditor | null>(null);
const [mediaPickerOpen, setMediaPickerOpen] = useState(false);
// 加载分类和标签
useEffect(() => {
@@ -458,6 +460,9 @@ export default function ArticleEditor() {
placeholder="请输入封面图片 URL 或上传文件"
style={{ flex: 1 }}
/>
<Button icon={<PictureOutlined />} onClick={() => setMediaPickerOpen(true)}>
</Button>
<Upload
accept="image/*"
showUploadList={false}
@@ -538,6 +543,15 @@ export default function ArticleEditor() {
</div>
</div>
</div>
<MediaPicker
open={mediaPickerOpen}
onClose={() => setMediaPickerOpen(false)}
onSelect={(url) => {
setCoverImage(url);
message.success('已选择封面图');
}}
/>
</div>
);
}

View File

@@ -0,0 +1,308 @@
import { useState, useEffect, useCallback } from 'react';
import {
Button, Space, Input, Select, TreeSelect, Tree, Upload, Modal, Form,
Switch, message, Popconfirm, Empty, Card, Checkbox, Typography, Dropdown, Spin,
} from 'antd';
import {
UploadOutlined, FolderAddOutlined, EditOutlined, DeleteOutlined,
EllipsisOutlined, InboxOutlined, ReloadOutlined,
} from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { mediaApi, mediaFolderApi, type MediaItem, type FolderItem } from '../../api/health/media';
import { AuthButton } from '../../components/AuthButton';
import { formatDateTime } from '../../utils/format';
// --- 工具函数 ---
const CT_OPTIONS = [
{ value: '', label: '全部类型' },
{ value: 'image/', label: '图片' },
{ value: 'video/', label: '视频' },
{ value: 'application/', label: '文档' },
];
const formatSize = (b: number) => b > 1048576 ? (b / 1048576).toFixed(1) + 'MB' : b > 1024 ? (b / 1024).toFixed(1) + 'KB' : b + 'B';
const isImage = (ct: string) => ct.startsWith('image/');
interface TreeNode { id: string; name: string; children: TreeNode[] }
function buildTree(folders: FolderItem[]): TreeNode[] {
return folders.map((f) => ({ id: f.id, name: `${f.name} (${f.item_count})`, children: buildTree(f.children) }));
}
interface EditFormValues { filename: string; alt_text?: string; is_public: boolean }
interface FolderFormValues { name: string }
// ===========================================================================
export default function MediaLibrary() {
const [items, setItems] = useState<MediaItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const pageSize = 24;
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [contentType, setContentType] = useState('');
const [folderId, setFolderId] = useState<string | undefined>();
const [folders, setFolders] = useState<FolderItem[]>([]);
const [foldersLoading, setFoldersLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [uploadOpen, setUploadOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [moveOpen, setMoveOpen] = useState(false);
const [folderOpen, setFolderOpen] = useState(false);
const [editingItem, setEditingItem] = useState<MediaItem | null>(null);
const [movingItem, setMovingItem] = useState<MediaItem | null>(null);
const [editingFolder, setEditingFolder] = useState<FolderItem | null>(null);
const [uploadForm] = Form.useForm();
const [editForm] = Form.useForm<EditFormValues>();
const [folderForm] = Form.useForm<FolderFormValues>();
const [submitting, setSubmitting] = useState(false);
const fetchMedia = useCallback(async () => {
setLoading(true);
try {
const r = await mediaApi.list({ page, page_size: pageSize, folder_id: folderId, content_type: contentType || undefined, keyword: keyword || undefined });
setItems(r.data);
setTotal(r.total);
} catch { message.error('加载媒体列表失败'); } finally { setLoading(false); }
}, [page, pageSize, folderId, contentType, keyword]);
const fetchFolders = useCallback(async () => {
setFoldersLoading(true);
try { setFolders(await mediaFolderApi.tree()); }
catch { message.error('加载文件夹失败'); } finally { setFoldersLoading(false); }
}, []);
useEffect(() => { fetchMedia(); }, [fetchMedia]);
useEffect(() => { fetchFolders(); }, [fetchFolders]);
const resetPage = () => { setPage(1); setSelectedIds(new Set()); };
const handleFolderSelect = (keys: React.Key[]) => { setFolderId(keys.length ? (keys[0] as string) : undefined); resetPage(); };
const handleUpload: UploadProps['customRequest'] = async (opts) => {
const { file, onSuccess, onError } = opts;
try {
const fid = uploadForm.getFieldValue('folder_id') as string | undefined;
const pub = uploadForm.getFieldValue('is_public') as boolean;
const fd = new FormData();
fd.append('file', file as File);
if (fid) fd.append('folder_id', fid);
fd.append('is_public', String(pub ?? false));
await mediaApi.upload(fd);
onSuccess?.(null);
message.success('上传成功');
fetchMedia(); fetchFolders();
} catch (e) { onError?.(e as Error); message.error('上传失败'); }
};
const openEdit = (item: MediaItem) => {
setEditingItem(item);
editForm.setFieldsValue({ filename: item.filename, alt_text: item.alt_text, is_public: item.is_public });
setEditOpen(true);
};
const handleEditSubmit = async (v: EditFormValues) => {
if (!editingItem) return;
setSubmitting(true);
try { await mediaApi.update(editingItem.id, { ...v, version: editingItem.version }); message.success('更新成功'); setEditOpen(false); fetchMedia(); }
catch { message.error('更新失败'); } finally { setSubmitting(false); }
};
const handleMove = async (targetId: string | undefined) => {
if (!movingItem) return;
try { await mediaApi.move(movingItem.id, { folder_id: targetId, version: movingItem.version }); message.success('移动成功'); setMoveOpen(false); fetchMedia(); fetchFolders(); }
catch { message.error('移动失败'); }
};
const handleDelete = async (item: MediaItem) => {
try { await mediaApi.delete(item.id, item.version); message.success('删除成功'); fetchMedia(); fetchFolders(); }
catch { message.error('删除失败'); }
};
const handleBatchDelete = async () => {
if (!selectedIds.size) return;
try { await mediaApi.batchDelete([...selectedIds]); message.success(`已删除 ${selectedIds.size} 个文件`); setSelectedIds(new Set()); fetchMedia(); fetchFolders(); }
catch { message.error('批量删除失败'); }
};
const openCreateFolder = () => { setEditingFolder(null); folderForm.resetFields(); setFolderOpen(true); };
const openRenameFolder = (f: FolderItem) => { setEditingFolder(f); folderForm.setFieldsValue({ name: f.name }); setFolderOpen(true); };
const handleFolderSubmit = async (v: FolderFormValues) => {
setSubmitting(true);
try {
if (editingFolder) { await mediaFolderApi.update(editingFolder.id, { name: v.name, version: editingFolder.version }); message.success('文件夹已重命名'); }
else { await mediaFolderApi.create({ name: v.name, parent_id: folderId }); message.success('文件夹已创建'); }
setFolderOpen(false); fetchFolders();
} catch { message.error(editingFolder ? '重命名失败' : '创建失败'); } finally { setSubmitting(false); }
};
const handleDeleteFolder = async (f: FolderItem) => {
try { await mediaFolderApi.delete(f.id, f.version); message.success('文件夹已删除'); if (folderId === f.id) setFolderId(undefined); fetchFolders(); fetchMedia(); }
catch { message.error('删除文件夹失败(可能不为空)'); }
};
const toggleSelect = (id: string) => setSelectedIds((prev) => { const n = new Set(prev); if (n.has(id)) { n.delete(id); } else { n.add(id); } return n; });
const renderCard = (item: MediaItem) => {
const sel = selectedIds.has(item.id);
const actions = [
{ key: 'edit', label: '编辑信息', onClick: () => openEdit(item) },
{ key: 'move', label: '移动到...', onClick: () => { setMovingItem(item); setMoveOpen(true); } },
{ key: 'delete', label: '删除', danger: true as const, onClick: () => handleDelete(item) },
];
return (
<Card key={item.id} hoverable size="small" style={{ borderColor: sel ? 'var(--ant-color-primary)' : undefined }}
styles={{ body: { padding: 0 } }}
cover={
<div onClick={() => toggleSelect(item.id)} style={{ height: 140, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--ant-color-fill-quaternary, #f5f5f5)', overflow: 'hidden', position: 'relative', cursor: 'pointer' }}>
{isImage(item.content_type) ? (
<img src={item.thumbnail_path || item.storage_path} alt={item.alt_text || item.filename} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<InboxOutlined style={{ fontSize: 36, color: 'var(--ant-color-text-quaternary)' }} />
)}
{sel && <div style={{ position: 'absolute', top: 4, left: 4 }}><Checkbox checked /></div>}
<div style={{ position: 'absolute', top: 4, right: 4, opacity: 0, transition: 'opacity 0.2s' }}>
<Dropdown menu={{ items: actions }} trigger={['click']}>
<Button type="text" size="small" icon={<EllipsisOutlined />} />
</Dropdown>
</div>
</div>
}>
<div style={{ padding: '6px 8px' }}>
<Typography.Text ellipsis style={{ fontSize: 12, display: 'block' }} title={item.filename}>{item.filename}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{formatSize(item.file_size)} · {formatDateTime(item.created_at)}</Typography.Text>
</div>
</Card>
);
};
const treeData = [{ id: '__all__', name: '全部文件', children: buildTree(folders) }];
const pageCount = Math.ceil(total / pageSize);
return (
<div style={{ display: 'flex', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
{/* 左侧 — 文件夹树 */}
<div style={{ width: 260, borderRight: '1px solid var(--ant-color-border-secondary, #f0f0f0)', display: 'flex', flexDirection: 'column', background: 'var(--ant-color-bg-container, #fff)', flexShrink: 0 }}>
<div style={{ padding: '12px 12px 8px', borderBottom: '1px solid var(--ant-color-border-secondary, #f0f0f0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text strong style={{ fontSize: 13 }}></Typography.Text>
<AuthButton code="health.media.manage">
<Button type="text" size="small" icon={<FolderAddOutlined />} onClick={openCreateFolder} title="新建文件夹" />
</AuthButton>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '8px 4px' }}>
{foldersLoading ? <div style={{ textAlign: 'center', padding: 20 }}><Spin size="small" /></div> : (
<Tree defaultExpandAll selectedKeys={folderId ? [folderId] : ['__all__']} treeData={treeData}
fieldNames={{ title: 'name', key: 'id', children: 'children' }} onSelect={handleFolderSelect}
titleRender={(node: TreeNode) => {
if (node.id === '__all__') return <span>{node.name}</span>;
const matched = folders.find((f) => f.id === node.id);
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{node.name}</span>
{matched && <span style={{ marginLeft: 4 }}>
<AuthButton code="health.media.manage">
<Space size={4}>
<Button type="text" size="small" icon={<EditOutlined />} onClick={(e) => { e.stopPropagation(); openRenameFolder(matched); }} />
<Popconfirm title="确定删除此文件夹?" description="仅空文件夹可删除" onConfirm={() => handleDeleteFolder(matched)}>
<Button type="text" size="small" icon={<DeleteOutlined />} danger onClick={(e) => e.stopPropagation()} />
</Popconfirm>
</Space>
</AuthButton>
</span>}
</div>
);
}}
/>
)}
</div>
</div>
{/* 右侧 — 媒体列表 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* 工具栏 */}
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--ant-color-border-secondary, #f0f0f0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap', background: 'var(--ant-color-bg-container, #fff)' }}>
<Space>
<AuthButton code="health.media.manage">
<Button type="primary" icon={<UploadOutlined />} onClick={() => { uploadForm.resetFields(); uploadForm.setFieldsValue({ is_public: false }); setUploadOpen(true); }}></Button>
</AuthButton>
{selectedIds.size > 0 && <>
<AuthButton code="health.media.manage">
<Popconfirm title={`确定删除选中的 ${selectedIds.size} 个文件?`} onConfirm={handleBatchDelete}>
<Button danger icon={<DeleteOutlined />}> ({selectedIds.size})</Button>
</Popconfirm>
</AuthButton>
<Button size="small" onClick={() => setSelectedIds(new Set())}></Button>
</>}
</Space>
<Space>
<Input.Search placeholder="搜索文件名" allowClear style={{ width: 200 }} onSearch={(v) => { setKeyword(v); resetPage(); }} />
<Select value={contentType} onChange={(v) => { setContentType(v); resetPage(); }} style={{ width: 120 }} options={CT_OPTIONS} />
<Button icon={<ReloadOutlined />} onClick={() => { fetchMedia(); fetchFolders(); }} />
</Space>
</div>
{/* 媒体网格 */}
<div style={{ flex: 1, overflow: 'auto', padding: 16 }}>
{loading ? <div style={{ textAlign: 'center', padding: 60 }}><Spin size="large" /></div>
: items.length === 0 ? <Empty description="暂无文件" />
: <>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 12 }}>
{items.map(renderCard)}
</div>
{pageCount > 1 && (
<div style={{ textAlign: 'center', padding: '16px 0', display: 'flex', justifyContent: 'center', gap: 8, alignItems: 'center' }}>
<Button size="small" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}></Button>
<Typography.Text>{page} / {pageCount} ( {total} )</Typography.Text>
<Button size="small" disabled={page >= pageCount} onClick={() => setPage((p) => p + 1)}></Button>
</div>
)}
</>}
</div>
</div>
{/* 上传弹窗 */}
<Modal title="上传文件" open={uploadOpen} onCancel={() => setUploadOpen(false)} footer={null} destroyOnClose>
<Form form={uploadForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="folder_id" label="目标文件夹">
<TreeSelect allowClear placeholder="根目录" treeDefaultExpandAll fieldNames={{ label: 'name', value: 'id', children: 'children' }} treeData={buildTree(folders)} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="is_public" label="公开访问" valuePropName="checked"><Switch /></Form.Item>
<Upload.Dragger multiple customRequest={handleUpload} showUploadList={false} accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx">
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"></p>
</Upload.Dragger>
</Form>
</Modal>
{/* 编辑弹窗 */}
<Modal title="编辑文件信息" open={editOpen} onCancel={() => setEditOpen(false)} onOk={() => editForm.submit()} confirmLoading={submitting} destroyOnClose>
<Form form={editForm} onFinish={handleEditSubmit} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="filename" label="文件名" rules={[{ required: true, message: '请输入文件名' }]}><Input placeholder="文件名" /></Form.Item>
<Form.Item name="alt_text" label="替代文本"><Input.TextArea rows={2} placeholder="描述图片内容(用于无障碍访问)" /></Form.Item>
<Form.Item name="is_public" label="公开访问" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Modal>
{/* 移动弹窗 */}
<Modal title="移动到文件夹" open={moveOpen} onCancel={() => setMoveOpen(false)} footer={null} destroyOnClose>
<div style={{ marginTop: 16 }}>
<Typography.Paragraph type="secondary"></Typography.Paragraph>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Button onClick={() => handleMove(undefined)} block type={movingItem?.folder_id ? 'default' : 'primary'}></Button>
{folders.map((f) => <Button key={f.id} onClick={() => handleMove(f.id)} block type={movingItem?.folder_id === f.id ? 'primary' : 'default'}>{f.name}</Button>)}
</div>
</div>
</Modal>
{/* 文件夹创建/重命名弹窗 */}
<Modal title={editingFolder ? '重命名文件夹' : '新建文件夹'} open={folderOpen} onCancel={() => setFolderOpen(false)} onOk={() => folderForm.submit()} confirmLoading={submitting} destroyOnClose>
<Form form={folderForm} onFinish={handleFolderSubmit} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="文件夹名称" rules={[{ required: true, message: '请输入文件夹名称' }]}><Input placeholder="输入名称" maxLength={50} /></Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -27,3 +27,4 @@ zeroize = { version = "1", features = ["derive"] }
argon2.workspace = true
jsonwebtoken.workspace = true
rand_core = "0.6"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }

View File

@@ -0,0 +1,113 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use erp_core::sanitize::{sanitize_option, sanitize_string};
// ---------------------------------------------------------------------------
// 轮播图 DTOs
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BannerResp {
pub id: Uuid,
pub tenant_id: Uuid,
pub media_item_id: Uuid,
pub title: Option<String>,
pub subtitle: Option<String>,
pub link_type: Option<String>,
pub link_target: Option<String>,
pub sort_order: i32,
pub status: String,
pub start_time: Option<chrono::DateTime<chrono::Utc>>,
pub end_time: Option<chrono::DateTime<chrono::Utc>>,
/// 媒体文件访问 URL
pub image_url: Option<String>,
/// 缩略图 URL
pub thumbnail_url: Option<String>,
/// 关联的媒体文件是否已被删除
pub media_deleted: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub created_by: Option<Uuid>,
pub updated_by: Option<Uuid>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateBannerReq {
pub media_item_id: Uuid,
pub title: Option<String>,
pub subtitle: Option<String>,
pub link_type: Option<String>,
pub link_target: Option<String>,
#[serde(default)]
pub sort_order: i32,
#[serde(default = "default_active")]
pub status: String,
pub start_time: Option<chrono::DateTime<chrono::Utc>>,
pub end_time: Option<chrono::DateTime<chrono::Utc>>,
}
fn default_active() -> String {
"active".to_string()
}
impl CreateBannerReq {
pub fn sanitize(&mut self) {
self.title = sanitize_option(self.title.take());
self.subtitle = sanitize_option(self.subtitle.take());
if let Some(ref mut v) = self.link_type {
*v = sanitize_string(v);
}
self.link_target = sanitize_option(self.link_target.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateBannerReq {
pub media_item_id: Option<Uuid>,
pub title: Option<String>,
pub subtitle: Option<String>,
pub link_type: Option<String>,
pub link_target: Option<String>,
pub sort_order: Option<i32>,
pub status: Option<String>,
pub start_time: Option<chrono::DateTime<chrono::Utc>>,
pub end_time: Option<chrono::DateTime<chrono::Utc>>,
pub version: i32,
}
impl UpdateBannerReq {
pub fn sanitize(&mut self) {
self.title = sanitize_option(self.title.take());
self.subtitle = sanitize_option(self.subtitle.take());
if let Some(ref mut v) = self.link_type {
*v = sanitize_string(v);
}
self.link_target = sanitize_option(self.link_target.take());
}
}
/// 轮播图排序请求
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SortBannerReq {
pub items: Vec<SortItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SortItem {
pub id: Uuid,
pub sort_order: i32,
}
/// 小程序端公开轮播图(精简字段)
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PublicBannerResp {
pub id: Uuid,
pub title: Option<String>,
pub subtitle: Option<String>,
pub image_url: Option<String>,
pub link_type: Option<String>,
pub link_target: Option<String>,
}

View File

@@ -0,0 +1,132 @@
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use erp_core::sanitize::{sanitize_option, sanitize_string};
// ---------------------------------------------------------------------------
// 媒体文件 DTOs
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MediaItemResp {
pub id: Uuid,
pub tenant_id: Uuid,
pub folder_id: Option<Uuid>,
pub filename: String,
pub storage_path: String,
pub thumbnail_path: Option<String>,
pub content_type: String,
pub file_size: i64,
pub width: Option<i32>,
pub height: Option<i32>,
pub alt_text: Option<String>,
pub is_public: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub created_by: Option<Uuid>,
pub updated_by: Option<Uuid>,
pub version: i32,
}
#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct MediaListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub folder_id: Option<Uuid>,
/// 按内容类型筛选(如 image/png
pub content_type: Option<String>,
/// 仅公开资源
pub is_public: Option<bool>,
/// 关键词搜索(文件名模糊匹配)
pub keyword: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateMediaItemReq {
pub filename: Option<String>,
pub alt_text: Option<String>,
pub is_public: Option<bool>,
pub folder_id: Option<Uuid>,
pub version: i32,
}
impl UpdateMediaItemReq {
pub fn sanitize(&mut self) {
if let Some(ref mut v) = self.filename {
*v = sanitize_string(v);
}
self.alt_text = sanitize_option(self.alt_text.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MoveMediaReq {
/// 目标文件夹 IDNone 表示移到根目录)
pub folder_id: Option<Uuid>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BatchDeleteReq {
pub ids: Vec<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CropReq {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
pub version: i32,
}
// ---------------------------------------------------------------------------
// 文件夹 DTOs
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct FolderResp {
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
pub parent_id: Option<Uuid>,
pub sort_order: i32,
/// 子文件夹
pub children: Vec<FolderResp>,
/// 文件夹内文件数量
pub item_count: i64,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateFolderReq {
pub name: String,
pub parent_id: Option<Uuid>,
#[serde(default)]
pub sort_order: i32,
}
impl CreateFolderReq {
pub fn sanitize(&mut self) {
self.name = sanitize_string(&self.name);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateFolderReq {
pub name: Option<String>,
pub parent_id: Option<Uuid>,
pub sort_order: Option<i32>,
pub version: i32,
}
impl UpdateFolderReq {
pub fn sanitize(&mut self) {
if let Some(ref mut v) = self.name {
*v = sanitize_string(v);
}
}
}

View File

@@ -1,6 +1,7 @@
pub mod alert_dto;
pub mod appointment_dto;
pub mod article_dto;
pub mod banner_dto;
pub mod ble_gateway_dto;
pub mod care_plan_dto;
pub mod consent_dto;
@@ -11,6 +12,7 @@ pub mod doctor_dto;
pub mod follow_up_dto;
pub mod follow_up_template_dto;
pub mod health_data_dto;
pub mod media_dto;
pub mod medication_record_dto;
pub mod medication_reminder_dto;
pub mod patient_dto;

View File

@@ -0,0 +1,39 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "banner")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub media_item_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub subtitle: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub link_type: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub link_target: Option<String>,
pub sort_order: i32,
pub status: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub start_time: Option<DateTimeUtc>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub end_time: Option<DateTimeUtc>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "media_folder")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<Uuid>,
pub sort_order: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,39 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "media_item")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub folder_id: Option<Uuid>,
pub filename: String,
pub storage_path: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub thumbnail_path: Option<String>,
pub content_type: String,
pub file_size: i64,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub width: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub height: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub alt_text: Option<String>,
pub is_public: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -7,6 +7,7 @@ pub mod article_article_tag;
pub mod article_category;
pub mod article_revision;
pub mod article_tag;
pub mod banner;
pub mod ble_gateway;
pub mod blind_index;
pub mod care_plan;
@@ -32,6 +33,8 @@ pub mod handoff_log;
pub mod health_record;
pub mod health_trend;
pub mod lab_report;
pub mod media_folder;
pub mod media_item;
pub mod medication_record;
pub mod medication_reminder;
pub mod offline_event;

View File

@@ -0,0 +1,142 @@
use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use crate::dto::banner_dto::{
BannerResp, CreateBannerReq, PublicBannerResp, SortBannerReq, UpdateBannerReq,
};
use crate::service::banner_service;
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// 本地请求结构体
// ---------------------------------------------------------------------------
#[derive(Debug, serde::Deserialize)]
pub struct DeleteVersionReq {
pub version: i32,
}
#[derive(Debug, serde::Deserialize)]
pub struct BannerListQuery {
pub status: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct PublicBannerQuery {
pub tenant_id: Option<uuid::Uuid>,
}
// ---------------------------------------------------------------------------
// 管理端端点
// ---------------------------------------------------------------------------
/// GET /health/banners — 列出租户所有轮播图
pub async fn list_banners<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<BannerListQuery>,
) -> Result<Json<ApiResponse<Vec<BannerResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.banners.list")?;
let result = banner_service::list_banners(&state, ctx.tenant_id, params.status).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// POST /health/banners — 创建轮播图
pub async fn create_banner<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
mut req: Json<CreateBannerReq>,
) -> Result<Json<ApiResponse<BannerResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.banners.manage")?;
req.sanitize();
let result = banner_service::create_banner(&state, ctx.tenant_id, ctx.user_id, req.0).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// PUT /health/banners/{id} — 更新轮播图
pub async fn update_banner<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
mut req: Json<UpdateBannerReq>,
) -> Result<Json<ApiResponse<BannerResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.banners.manage")?;
req.sanitize();
let result =
banner_service::update_banner(&state, ctx.tenant_id, id, ctx.user_id, req.0).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// DELETE /health/banners/{id} — 软删除轮播图
pub async fn delete_banner<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
Json(req): Json<DeleteVersionReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.banners.manage")?;
banner_service::delete_banner(&state, ctx.tenant_id, id, ctx.user_id, req.version).await?;
Ok(Json(ApiResponse::ok(())))
}
/// PUT /health/banners/sort — 批量更新排序
pub async fn sort_banners<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<SortBannerReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.banners.manage")?;
banner_service::sort_banners(&state, ctx.tenant_id, req.items).await?;
Ok(Json(ApiResponse::ok(())))
}
// ---------------------------------------------------------------------------
// 公开端点(小程序 / 无需认证)
// ---------------------------------------------------------------------------
/// GET /public/banners — 公开轮播图列表
pub async fn list_public_banners<S>(
State(state): State<HealthState>,
Query(params): Query<PublicBannerQuery>,
headers: axum::http::HeaderMap,
) -> Result<Json<ApiResponse<Vec<PublicBannerResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
// 从 X-Tenant-Id 请求头或查询参数中解析租户 ID
let tenant_id = headers
.get("X-Tenant-Id")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<uuid::Uuid>().ok())
.or(params.tenant_id)
.ok_or_else(|| AppError::Validation("缺少 tenant_id".to_string()))?;
let base_url = "http://localhost:3000".to_string();
let result = banner_service::list_public_banners(&state, tenant_id, &base_url).await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -0,0 +1,280 @@
//! 媒体库 Handler — 12 个 HTTP 端点(文件 CRUD + 上传/裁剪/移动 + 文件夹管理)
use axum::Extension;
use axum::extract::{FromRef, Json, Multipart, Path, Query, State};
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::media_dto::{
BatchDeleteReq, CreateFolderReq, CropReq, FolderResp, MediaItemResp, MediaListParams,
MoveMediaReq, UpdateFolderReq, UpdateMediaItemReq,
};
use crate::service::media_service;
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// 本地请求结构体
// ---------------------------------------------------------------------------
#[derive(Debug, serde::Deserialize)]
pub struct DeleteVersionReq {
pub version: i32,
}
// ---------------------------------------------------------------------------
// 媒体文件 CRUD
// ---------------------------------------------------------------------------
/// 分页查询媒体文件列表
pub async fn list_media<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<MediaListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<MediaItemResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.media.list")?;
let result = media_service::list_media_items(&state, ctx.tenant_id, params).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 上传媒体文件multipart/form-data
pub async fn upload_media<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
mut multipart: Multipart,
) -> Result<Json<ApiResponse<MediaItemResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.media.manage")?;
let mut file_data = None;
let mut folder_id: Option<Uuid> = None;
let mut is_public = false;
let mut original_name = String::new();
let mut content_type = String::new();
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::Validation(format!("读取上传数据失败: {}", e)))?
{
match field.name().unwrap_or("") {
"file" => {
original_name = field.file_name().unwrap_or("file").to_string();
content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
file_data = Some(
field
.bytes()
.await
.map_err(|e| AppError::Validation(format!("读取文件数据失败: {}", e)))?,
);
}
"folder_id" => {
let text = field.text().await.unwrap_or_default();
folder_id = text.parse().ok();
}
"is_public" => {
let text = field.text().await.unwrap_or_default();
is_public = text == "true" || text == "1";
}
_ => {}
}
}
let data = file_data.ok_or_else(|| AppError::Validation("未找到上传文件".to_string()))?;
let upload_dir = std::env::var("UPLOAD_DIR").unwrap_or_else(|_| "./uploads".to_string());
let result = media_service::upload_media(
&state,
ctx.tenant_id,
Some(ctx.user_id),
&data,
&original_name,
&content_type,
folder_id,
is_public,
&upload_dir,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 获取单个媒体文件详情
pub async fn get_media<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<MediaItemResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.media.list")?;
let result = media_service::get_media_item(&state, ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 更新媒体文件元数据
pub async fn update_media<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(mut req): Json<UpdateMediaItemReq>,
) -> Result<Json<ApiResponse<MediaItemResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.media.manage")?;
req.sanitize();
let result =
media_service::update_media_item(&state, ctx.tenant_id, id, Some(ctx.user_id), req).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 软删除媒体文件
pub async fn delete_media<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<DeleteVersionReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.media.manage")?;
media_service::delete_media_item(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version)
.await?;
Ok(Json(ApiResponse::ok(())))
}
/// 移动媒体文件到指定文件夹
pub async fn move_media<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<MoveMediaReq>,
) -> Result<Json<ApiResponse<MediaItemResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.media.manage")?;
let result =
media_service::move_media(&state, ctx.tenant_id, id, Some(ctx.user_id), req).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 批量软删除媒体文件
pub async fn batch_delete_media<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<BatchDeleteReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.media.manage")?;
media_service::batch_delete(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
Ok(Json(ApiResponse::ok(())))
}
/// 裁剪图片并重新生成缩略图
pub async fn crop_media<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<CropReq>,
) -> Result<Json<ApiResponse<MediaItemResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.media.manage")?;
let result =
media_service::crop_media(&state, ctx.tenant_id, id, Some(ctx.user_id), req).await?;
Ok(Json(ApiResponse::ok(result)))
}
// ---------------------------------------------------------------------------
// 文件夹管理
// ---------------------------------------------------------------------------
/// 获取文件夹树形结构
pub async fn list_folders<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<FolderResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.media.list")?;
let result = media_service::list_folders(&state, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 创建文件夹
pub async fn create_folder<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(mut req): Json<CreateFolderReq>,
) -> Result<Json<ApiResponse<FolderResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.media.manage")?;
req.sanitize();
let result =
media_service::create_folder(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 更新文件夹信息
pub async fn update_folder<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(mut req): Json<UpdateFolderReq>,
) -> Result<Json<ApiResponse<FolderResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.media.manage")?;
req.sanitize();
let result =
media_service::update_folder(&state, ctx.tenant_id, id, Some(ctx.user_id), req).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 删除文件夹(仅空文件夹可删除)
pub async fn delete_folder<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<DeleteVersionReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.media.manage")?;
media_service::delete_folder(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -5,6 +5,7 @@ pub mod appointment_handler;
pub mod article_category_handler;
pub mod article_handler;
pub mod article_tag_handler;
pub mod banner_handler;
pub mod ble_gateway_handler;
pub mod care_plan_handler;
pub mod consent_handler;
@@ -20,6 +21,7 @@ pub mod family_proxy_handler;
pub mod follow_up_handler;
pub mod follow_up_template_handler;
pub mod health_data_handler;
pub mod media_handler;
pub mod medication_record_handler;
pub mod medication_reminder_handler;
pub mod patient_handler;

View File

@@ -7,13 +7,13 @@ use erp_core::module::{ErpModule, PermissionDescriptor};
use crate::handler::{
action_inbox_handler, alert_handler, alert_rule_handler, appointment_handler,
article_category_handler, article_handler, article_tag_handler, ble_gateway_handler,
care_plan_handler, consent_handler, consultation_handler, critical_alert_handler,
critical_value_threshold_handler, daily_monitoring_handler, device_handler,
device_reading_handler, diagnosis_handler, doctor_handler, family_proxy_handler,
follow_up_handler, follow_up_template_handler, health_data_handler, medication_record_handler,
medication_reminder_handler, patient_handler, points_handler, shift_handler, stats_handler,
vital_signs_daily_handler,
article_category_handler, article_handler, article_tag_handler, banner_handler,
ble_gateway_handler, care_plan_handler, consent_handler, consultation_handler,
critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler,
device_handler, device_reading_handler, diagnosis_handler, doctor_handler,
family_proxy_handler, follow_up_handler, follow_up_template_handler, health_data_handler,
media_handler, medication_record_handler, medication_reminder_handler, patient_handler,
points_handler, shift_handler, stats_handler, vital_signs_daily_handler,
};
pub struct HealthModule;
@@ -148,10 +148,15 @@ impl HealthModule {
crate::state::HealthState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new().route(
"/oauth/token",
axum::routing::post(crate::oauth::handler::token),
)
Router::new()
.route(
"/oauth/token",
axum::routing::post(crate::oauth::handler::token),
)
.route(
"/public/banners",
axum::routing::get(banner_handler::list_public_banners),
)
}
/// FHIR R4 只读路由(使用 OAuth client_credentials 认证)
@@ -502,6 +507,10 @@ impl HealthModule {
"/health/consultation-sessions/{id}/messages",
axum::routing::get(consultation_handler::list_messages),
)
.route(
"/health/consultation-sessions/{id}/messages/poll",
axum::routing::get(consultation_handler::poll_messages),
)
.route(
"/health/consultation-sessions/{id}/close",
axum::routing::put(consultation_handler::close_session),
@@ -884,6 +893,58 @@ impl HealthModule {
"/health/oauth/clients/{id}/regenerate-secret",
axum::routing::post(crate::oauth::handler::regenerate_secret),
)
// 媒体库
.route(
"/health/media",
axum::routing::get(media_handler::list_media),
)
.route(
"/health/media/upload",
axum::routing::post(media_handler::upload_media),
)
.route(
"/health/media/batch-delete",
axum::routing::post(media_handler::batch_delete_media),
)
.route(
"/health/media/{id}",
axum::routing::get(media_handler::get_media)
.put(media_handler::update_media)
.delete(media_handler::delete_media),
)
.route(
"/health/media/{id}/move",
axum::routing::post(media_handler::move_media),
)
.route(
"/health/media/{id}/crop",
axum::routing::post(media_handler::crop_media),
)
// 媒体文件夹
.route(
"/health/media-folders",
axum::routing::get(media_handler::list_folders).post(media_handler::create_folder),
)
.route(
"/health/media-folders/{id}",
axum::routing::put(media_handler::update_folder)
.delete(media_handler::delete_folder),
)
// 轮播图管理
.route(
"/health/banners",
axum::routing::get(banner_handler::list_banners)
.post(banner_handler::create_banner),
)
.route(
"/health/banners/sort",
axum::routing::put(banner_handler::sort_banners),
)
.route(
"/health/banners/{id}",
axum::routing::put(banner_handler::update_banner)
.delete(banner_handler::delete_banner),
)
// 护理计划
.route(
"/health/care-plans",
@@ -1487,6 +1548,32 @@ impl ErpModule for HealthModule {
description: "授权/撤销家庭成员健康数据访问".into(),
module: "health".into(),
},
// 媒体库
PermissionDescriptor {
code: "health.media.list".into(),
name: "查看媒体库".into(),
description: "查看媒体文件列表、文件夹和详情".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.media.manage".into(),
name: "管理媒体库".into(),
description: "上传/编辑/删除媒体文件、管理文件夹".into(),
module: "health".into(),
},
// 轮播图
PermissionDescriptor {
code: "health.banners.list".into(),
name: "查看轮播图".into(),
description: "查看轮播图列表和详情".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.banners.manage".into(),
name: "管理轮播图".into(),
description: "创建/编辑/删除/排序轮播图".into(),
module: "health".into(),
},
]
}

View File

@@ -0,0 +1,411 @@
//! 轮播图 Service — CRUD + 排序 + 签名 URL 公开端点
use chrono::Utc;
use hmac::{Hmac, Mac};
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, Condition, QueryOrder};
use sha2::Sha256;
use std::collections::HashMap;
use uuid::Uuid;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use crate::dto::banner_dto::*;
use crate::entity::{banner, media_item};
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
type HmacSha256 = Hmac<Sha256>;
/// 获取签名 URL 密钥:优先环境变量 ERP__STORAGE__SECRET_KEY回退到开发默认值
fn signing_secret() -> String {
std::env::var("ERP__STORAGE__SECRET_KEY")
.unwrap_or_else(|_| "dev-only-secret-key-change-in-production".to_string())
}
// ---------------------------------------------------------------------------
// 查询
// ---------------------------------------------------------------------------
/// 列出租户所有轮播图(未软删除),可选按状态筛选
pub async fn list_banners(
state: &HealthState,
tenant_id: Uuid,
status: Option<String>,
) -> HealthResult<Vec<BannerResp>> {
let mut query = banner::Entity::find()
.filter(banner::Column::TenantId.eq(tenant_id))
.filter(banner::Column::DeletedAt.is_null());
if let Some(ref s) = status {
query = query.filter(banner::Column::Status.eq(s.as_str()));
}
let banners = query
.order_by_asc(banner::Column::SortOrder)
.order_by_desc(banner::Column::CreatedAt)
.all(&state.db)
.await?;
// 批量加载关联的 media_item避免 N+1
let media_ids: Vec<Uuid> = banners.iter().map(|b| b.media_item_id).collect();
let media_map = load_media_map(state, &media_ids).await?;
let result: Vec<BannerResp> = banners
.into_iter()
.map(|b| banner_to_resp(&b, &media_map))
.collect();
Ok(result)
}
/// 创建轮播图
pub async fn create_banner(
state: &HealthState,
tenant_id: Uuid,
operator_id: Uuid,
req: CreateBannerReq,
) -> HealthResult<BannerResp> {
// 验证 media_item 存在且未删除
let media = media_item::Entity::find_by_id(req.media_item_id)
.one(&state.db)
.await?
.ok_or(HealthError::MediaNotFound)?;
if media.deleted_at.is_some() {
return Err(HealthError::Validation(
"关联的媒体文件已被删除".to_string(),
));
}
let id = Uuid::now_v7();
let now = Utc::now();
let model = banner::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
media_item_id: Set(req.media_item_id),
title: Set(req.title),
subtitle: Set(req.subtitle),
link_type: Set(req.link_type),
link_target: Set(req.link_target),
sort_order: Set(req.sort_order),
status: Set(req.status),
start_time: Set(req.start_time),
end_time: Set(req.end_time),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(Some(operator_id)),
updated_by: Set(Some(operator_id)),
deleted_at: Set(None),
version: Set(1),
};
let m = model.insert(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "banner.created", "banner")
.with_resource_id(m.id),
&state.db,
)
.await;
// 构造响应
let mut media_map = HashMap::new();
media_map.insert(media.id, media);
Ok(BannerResp {
id: m.id,
tenant_id: m.tenant_id,
media_item_id: m.media_item_id,
title: m.title,
subtitle: m.subtitle,
link_type: m.link_type,
link_target: m.link_target,
sort_order: m.sort_order,
status: m.status,
start_time: m.start_time,
end_time: m.end_time,
image_url: media_map
.get(&m.media_item_id)
.and_then(|mi| mi.storage_path.clone().into()),
thumbnail_url: media_map
.get(&m.media_item_id)
.and_then(|mi| mi.thumbnail_path.clone()),
media_deleted: media_map
.get(&m.media_item_id)
.is_some_and(|mi| mi.deleted_at.is_some()),
created_at: m.created_at,
updated_at: m.updated_at,
created_by: m.created_by,
updated_by: m.updated_by,
version: m.version,
})
}
/// 更新轮播图,带乐观锁
pub async fn update_banner(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Uuid,
req: UpdateBannerReq,
) -> HealthResult<BannerResp> {
let model = find_banner(state, tenant_id, id).await?;
let next_ver =
check_version(req.version, model.version).map_err(|_| HealthError::VersionMismatch)?;
// 如果更新了 media_item_id验证新的 media_item 存在
if let Some(new_media_id) = req.media_item_id {
let media = media_item::Entity::find_by_id(new_media_id)
.one(&state.db)
.await?
.ok_or(HealthError::MediaNotFound)?;
if media.deleted_at.is_some() {
return Err(HealthError::Validation(
"关联的媒体文件已被删除".to_string(),
));
}
}
let mut active: banner::ActiveModel = model.into();
if let Some(v) = req.media_item_id {
active.media_item_id = Set(v);
}
if let Some(v) = req.title {
active.title = Set(Some(v));
}
if let Some(v) = req.subtitle {
active.subtitle = Set(Some(v));
}
if let Some(v) = req.link_type {
active.link_type = Set(Some(v));
}
if let Some(v) = req.link_target {
active.link_target = Set(Some(v));
}
if let Some(v) = req.sort_order {
active.sort_order = Set(v);
}
if let Some(v) = req.status {
active.status = Set(v);
}
active.start_time = Set(req.start_time);
active.end_time = Set(req.end_time);
active.updated_at = Set(Utc::now());
active.updated_by = Set(Some(operator_id));
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "banner.updated", "banner")
.with_resource_id(m.id),
&state.db,
)
.await;
// 加载 media_item 信息
let media_map = load_media_map(state, &[m.media_item_id]).await?;
Ok(banner_to_resp(&m, &media_map))
}
/// 软删除轮播图
pub async fn delete_banner(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Uuid,
version: i32,
) -> HealthResult<()> {
let model = find_banner(state, tenant_id, id).await?;
let next_ver =
check_version(version, model.version).map_err(|_| HealthError::VersionMismatch)?;
let mut active: banner::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(Some(operator_id));
active.version = Set(next_ver);
active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "banner.deleted", "banner")
.with_resource_id(id),
&state.db,
)
.await;
Ok(())
}
/// 批量更新轮播图排序
pub async fn sort_banners(
state: &HealthState,
tenant_id: Uuid,
items: Vec<SortItem>,
) -> HealthResult<()> {
for item in items {
banner::Entity::update_many()
.col_expr(banner::Column::SortOrder, Expr::value(item.sort_order))
.col_expr(banner::Column::UpdatedAt, Expr::value(Utc::now()))
.filter(banner::Column::Id.eq(item.id))
.filter(banner::Column::TenantId.eq(tenant_id))
.filter(banner::Column::DeletedAt.is_null())
.exec(&state.db)
.await?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// 公开端点(小程序)
// ---------------------------------------------------------------------------
/// 查询当前生效的轮播图(公开端点),按 sort_order 升序
pub async fn list_public_banners(
state: &HealthState,
tenant_id: Uuid,
base_url: &str,
) -> HealthResult<Vec<PublicBannerResp>> {
let now = Utc::now();
let banners = banner::Entity::find()
.filter(banner::Column::TenantId.eq(tenant_id))
.filter(banner::Column::DeletedAt.is_null())
.filter(banner::Column::Status.eq("active"))
.filter(
Condition::any()
.add(banner::Column::StartTime.is_null())
.add(banner::Column::StartTime.lte(now)),
)
.filter(
Condition::any()
.add(banner::Column::EndTime.is_null())
.add(banner::Column::EndTime.gte(now)),
)
.order_by_asc(banner::Column::SortOrder)
.all(&state.db)
.await?;
let media_ids: Vec<Uuid> = banners.iter().map(|b| b.media_item_id).collect();
let media_map = load_media_map(state, &media_ids).await?;
let result: Vec<PublicBannerResp> = banners
.into_iter()
.filter_map(|b| {
let media = media_map.get(&b.media_item_id)?;
// 跳过已删除的媒体文件
if media.deleted_at.is_some() {
return None;
}
let (token, expires) =
generate_signed_url(&media.storage_path, &signing_secret(), 3600);
let clean_path = media.storage_path.trim_start_matches("./");
let image_url = format!(
"{}/{}?expires={}&token={}",
base_url.trim_end_matches('/'),
clean_path,
expires,
token
);
Some(PublicBannerResp {
id: b.id,
title: b.title,
subtitle: b.subtitle,
image_url: Some(image_url),
link_type: b.link_type,
link_target: b.link_target,
})
})
.collect();
Ok(result)
}
// ---------------------------------------------------------------------------
// 签名 URL
// ---------------------------------------------------------------------------
/// 生成 HMAC-SHA256 签名 URL token同步函数
pub fn generate_signed_url(path: &str, secret_key: &str, ttl_secs: u64) -> (String, i64) {
let expires = Utc::now().timestamp() + ttl_secs as i64;
let message = format!("{}\n{}", path, expires);
let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes()).expect("HMAC key length valid");
mac.update(message.as_bytes());
let token = hex::encode(mac.finalize().into_bytes());
(token, expires)
}
// ---------------------------------------------------------------------------
// 内部辅助函数
// ---------------------------------------------------------------------------
/// 查找轮播图(未软删除)
async fn find_banner(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
) -> HealthResult<banner::Model> {
banner::Entity::find()
.filter(banner::Column::Id.eq(id))
.filter(banner::Column::TenantId.eq(tenant_id))
.filter(banner::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or_else(|| HealthError::Validation("轮播图不存在".to_string()))
}
/// 批量加载 media_item返回 id -> Model 映射
async fn load_media_map(
state: &HealthState,
media_ids: &[Uuid],
) -> HealthResult<HashMap<Uuid, media_item::Model>> {
if media_ids.is_empty() {
return Ok(HashMap::new());
}
let items = media_item::Entity::find()
.filter(media_item::Column::Id.is_in(media_ids.iter().copied()))
.all(&state.db)
.await?;
let map: HashMap<Uuid, media_item::Model> = items.into_iter().map(|m| (m.id, m)).collect();
Ok(map)
}
/// 将 banner Model 转换为 BannerResp DTO
fn banner_to_resp(b: &banner::Model, media_map: &HashMap<Uuid, media_item::Model>) -> BannerResp {
let media = media_map.get(&b.media_item_id);
let media_deleted = media.is_some_and(|m| m.deleted_at.is_some());
BannerResp {
id: b.id,
tenant_id: b.tenant_id,
media_item_id: b.media_item_id,
title: b.title.clone(),
subtitle: b.subtitle.clone(),
link_type: b.link_type.clone(),
link_target: b.link_target.clone(),
sort_order: b.sort_order,
status: b.status.clone(),
start_time: b.start_time,
end_time: b.end_time,
image_url: media.map(|m| m.storage_path.clone()),
thumbnail_url: media.and_then(|m| m.thumbnail_path.clone()),
media_deleted,
created_at: b.created_at,
updated_at: b.updated_at,
created_by: b.created_by,
updated_by: b.updated_by,
version: b.version,
}
}

View File

@@ -0,0 +1,781 @@
//! 媒体库 Service — 文件上传/CRUD + 缩略图/裁剪 + 文件夹管理
use chrono::Utc;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use std::path::{Path, PathBuf};
use uuid::Uuid;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::media_dto::*;
use crate::entity::{banner, media_folder, media_item};
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// 媒体文件查询
// ---------------------------------------------------------------------------
/// 分页查询媒体文件,支持按文件夹/类型/关键词/公开状态筛选
pub async fn list_media_items(
state: &HealthState,
tenant_id: Uuid,
params: MediaListParams,
) -> HealthResult<PaginatedResponse<MediaItemResp>> {
let page = params.page.unwrap_or(1).max(1);
let limit = params.page_size.unwrap_or(20).min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = media_item::Entity::find()
.filter(media_item::Column::TenantId.eq(tenant_id))
.filter(media_item::Column::DeletedAt.is_null());
if let Some(fid) = params.folder_id {
query = query.filter(media_item::Column::FolderId.eq(fid));
}
if let Some(ref ct) = params.content_type {
query = query.filter(media_item::Column::ContentType.contains(ct));
}
if let Some(pub_flag) = params.is_public {
query = query.filter(media_item::Column::IsPublic.eq(pub_flag));
}
if let Some(ref kw) = params.keyword {
query = query.filter(media_item::Column::Filename.contains(kw));
}
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(media_item::Column::CreatedAt)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data: Vec<MediaItemResp> = models.into_iter().map(model_to_resp).collect();
Ok(PaginatedResponse {
data,
total,
page,
page_size: limit,
total_pages,
})
}
/// 获取单个媒体文件详情
pub async fn get_media_item(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
) -> HealthResult<MediaItemResp> {
let model = find_media_item(state, tenant_id, id).await?;
Ok(model_to_resp(model))
}
// ---------------------------------------------------------------------------
// 文件上传
// ---------------------------------------------------------------------------
/// 上传媒体文件:保存到磁盘 → 生成缩略图 → 创建 DB 记录
#[allow(clippy::too_many_arguments)]
pub async fn upload_media(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
file_data: &[u8],
filename: &str,
content_type: &str,
folder_id: Option<Uuid>,
is_public: bool,
upload_dir: &str,
) -> HealthResult<MediaItemResp> {
let id = Uuid::now_v7();
let now = Utc::now();
// 构造存储路径: {upload_dir}/{tenant_id}/{uuid}.ext
let ext = Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin");
let relative_path = format!(
"{}/{}/{}.{}",
upload_dir.trim_end_matches('/'),
tenant_id,
id,
ext
);
let storage_path = PathBuf::from(&relative_path);
// 确保目录存在
if let Some(parent) = storage_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| HealthError::Validation(format!("创建上传目录失败: {}", e)))?;
}
// 写入文件
tokio::fs::write(&storage_path, file_data)
.await
.map_err(|e| HealthError::Validation(format!("文件写入失败: {}", e)))?;
// 读取图片尺寸
let (width, height) = read_image_dimensions(&storage_path);
// 生成缩略图
let thumbnail_path = generate_thumbnail_path(&storage_path);
if let Some(thumb_dst) = &thumbnail_path {
if let Err(e) = std::fs::create_dir_all(thumb_dst.parent().unwrap_or(Path::new("."))) {
tracing::warn!("创建缩略图目录失败: {}", e);
}
if let Err(e) = generate_thumbnail(&storage_path, thumb_dst, 200) {
tracing::warn!("生成缩略图失败 (非致命): {}", e);
}
}
let model = media_item::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
folder_id: Set(folder_id),
filename: Set(filename.to_string()),
storage_path: Set(relative_path.clone()),
thumbnail_path: Set(thumbnail_path.map(|p| p.to_string_lossy().to_string())),
content_type: Set(content_type.to_string()),
file_size: Set(file_data.len() as i64),
width: Set(width),
height: Set(height),
alt_text: Set(None),
is_public: Set(is_public),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let m = model.insert(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "media_item.created", "media_item")
.with_resource_id(m.id),
&state.db,
)
.await;
Ok(model_to_resp(m))
}
// ---------------------------------------------------------------------------
// 媒体文件修改
// ---------------------------------------------------------------------------
/// 更新媒体文件元数据(文件名/alt/公开状态/文件夹),带乐观锁
pub async fn update_media_item(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
req: UpdateMediaItemReq,
) -> HealthResult<MediaItemResp> {
let model = find_media_item(state, tenant_id, id).await?;
let next_ver =
check_version(req.version, model.version).map_err(|_| HealthError::VersionMismatch)?;
let mut active: media_item::ActiveModel = model.into();
if let Some(v) = req.filename {
active.filename = Set(v);
}
if let Some(v) = req.alt_text {
active.alt_text = Set(Some(v));
}
if let Some(v) = req.is_public {
active.is_public = Set(v);
}
if let Some(v) = req.folder_id {
active.folder_id = Set(Some(v));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "media_item.updated", "media_item")
.with_resource_id(m.id),
&state.db,
)
.await;
Ok(model_to_resp(m))
}
/// 软删除媒体文件:标记 deleted_at + 级联停用关联 banner + 删除磁盘文件
pub async fn delete_media_item(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
version: i32,
) -> HealthResult<()> {
let model = find_media_item(state, tenant_id, id).await?;
let next_ver =
check_version(version, model.version).map_err(|_| HealthError::VersionMismatch)?;
// 软删除
let mut active: media_item::ActiveModel = model.clone().into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.update(&state.db).await?;
// 级联:关联的 banner 全部设为 inactive
banner::Entity::update_many()
.col_expr(banner::Column::Status, Expr::value("inactive"))
.filter(banner::Column::MediaItemId.eq(id))
.filter(banner::Column::TenantId.eq(tenant_id))
.exec(&state.db)
.await?;
// 删除磁盘文件(忽略文件不存在的错误)
let storage = PathBuf::from(&model.storage_path);
if storage.exists() {
let _ = tokio::fs::remove_file(&storage).await;
}
if let Some(ref thumb) = model.thumbnail_path {
let thumb_path = PathBuf::from(thumb);
if thumb_path.exists() {
let _ = tokio::fs::remove_file(&thumb_path).await;
}
}
audit_service::record(
AuditLog::new(tenant_id, operator_id, "media_item.deleted", "media_item")
.with_resource_id(id),
&state.db,
)
.await;
Ok(())
}
/// 批量软删除媒体文件
pub async fn batch_delete(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: BatchDeleteReq,
) -> HealthResult<()> {
if req.ids.is_empty() {
return Ok(());
}
let now = Utc::now();
media_item::Entity::update_many()
.col_expr(media_item::Column::DeletedAt, Expr::value(Some(now)))
.col_expr(media_item::Column::UpdatedAt, Expr::value(now))
.col_expr(media_item::Column::UpdatedBy, Expr::value(operator_id))
.filter(media_item::Column::Id.is_in(req.ids.clone()))
.filter(media_item::Column::TenantId.eq(tenant_id))
.filter(media_item::Column::DeletedAt.is_null())
.exec(&state.db)
.await?;
// 级联:停用关联 banner
banner::Entity::update_many()
.col_expr(banner::Column::Status, Expr::value("inactive"))
.filter(banner::Column::MediaItemId.is_in(req.ids.clone()))
.filter(banner::Column::TenantId.eq(tenant_id))
.exec(&state.db)
.await?;
audit_service::record(
AuditLog::new(
tenant_id,
operator_id,
"media_item.batch_deleted",
"media_item",
),
&state.db,
)
.await;
Ok(())
}
/// 移动媒体文件到指定文件夹None 表示根目录)
pub async fn move_media(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
req: MoveMediaReq,
) -> HealthResult<MediaItemResp> {
let model = find_media_item(state, tenant_id, id).await?;
let next_ver =
check_version(req.version, model.version).map_err(|_| HealthError::VersionMismatch)?;
let mut active: media_item::ActiveModel = model.into();
active.folder_id = Set(req.folder_id);
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "media_item.moved", "media_item")
.with_resource_id(m.id),
&state.db,
)
.await;
Ok(model_to_resp(m))
}
/// 裁剪图片并重新生成缩略图
pub async fn crop_media(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
req: CropReq,
) -> HealthResult<MediaItemResp> {
let model = find_media_item(state, tenant_id, id).await?;
let next_ver =
check_version(req.version, model.version).map_err(|_| HealthError::VersionMismatch)?;
// 裁剪图片
let src_path = PathBuf::from(&model.storage_path);
let cropped_id = Uuid::now_v7();
let ext = src_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png");
let cropped_relative = format!("uploads/{}/{}.{}", tenant_id, cropped_id, ext);
let cropped_path = PathBuf::from(&cropped_relative);
if let Some(parent) = cropped_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| HealthError::Validation(format!("创建目录失败: {}", e)))?;
}
crop_image(
&src_path,
&cropped_path,
req.x,
req.y,
req.width,
req.height,
)?;
// 更新尺寸
let (new_width, new_height) = read_image_dimensions(&cropped_path);
// 重新生成缩略图
let thumbnail_path = generate_thumbnail_path(&cropped_path);
if let Some(thumb_dst) = &thumbnail_path {
if let Err(e) = std::fs::create_dir_all(thumb_dst.parent().unwrap_or(Path::new("."))) {
tracing::warn!("创建缩略图目录失败: {}", e);
}
if let Err(e) = generate_thumbnail(&cropped_path, thumb_dst, 200) {
tracing::warn!("生成缩略图失败 (非致命): {}", e);
}
}
let mut active: media_item::ActiveModel = model.into();
active.storage_path = Set(cropped_relative);
active.thumbnail_path = Set(thumbnail_path.map(|p| p.to_string_lossy().to_string()));
active.width = Set(new_width);
active.height = Set(new_height);
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "media_item.cropped", "media_item")
.with_resource_id(m.id),
&state.db,
)
.await;
Ok(model_to_resp(m))
}
// ---------------------------------------------------------------------------
// 图片处理工具函数(同步)
// ---------------------------------------------------------------------------
/// 中心裁剪 + 缩放生成缩略图
pub fn generate_thumbnail(src: &Path, dst: &Path, size: u32) -> HealthResult<()> {
let mut img = image::open(src).map_err(|e| HealthError::Validation(e.to_string()))?;
let (w, h) = (img.width(), img.height());
let crop_size = w.min(h);
let x = (w - crop_size) / 2;
let y = (h - crop_size) / 2;
let thumb = img.crop(x, y, crop_size, crop_size).resize_exact(
size,
size,
image::imageops::FilterType::Lanczos3,
);
thumb
.save(dst)
.map_err(|e| HealthError::Validation(e.to_string()))?;
Ok(())
}
/// 按指定区域裁剪图片
pub fn crop_image(src: &Path, dst: &Path, x: u32, y: u32, w: u32, h: u32) -> HealthResult<()> {
let mut img = image::open(src).map_err(|e| HealthError::Validation(e.to_string()))?;
let cropped = img.crop(x, y, w, h);
cropped
.save(dst)
.map_err(|e| HealthError::Validation(e.to_string()))?;
Ok(())
}
// ---------------------------------------------------------------------------
// 文件夹管理
// ---------------------------------------------------------------------------
/// 获取文件夹树形结构(含每个文件夹的文件数量)
pub async fn list_folders(state: &HealthState, tenant_id: Uuid) -> HealthResult<Vec<FolderResp>> {
// 查询所有未删除文件夹
let folders = media_folder::Entity::find()
.filter(media_folder::Column::TenantId.eq(tenant_id))
.filter(media_folder::Column::DeletedAt.is_null())
.order_by_asc(media_folder::Column::SortOrder)
.order_by_asc(media_folder::Column::CreatedAt)
.all(&state.db)
.await?;
// 统计每个文件夹的文件数
let folder_ids: Vec<Uuid> = folders.iter().map(|f| f.id).collect();
let mut count_map: std::collections::HashMap<Uuid, i64> = std::collections::HashMap::new();
if !folder_ids.is_empty() {
// 逐文件夹计数SeaORM 没有 GROUP BY count 的便捷方法,用子查询方式)
for fid in &folder_ids {
let count = media_item::Entity::find()
.filter(media_item::Column::TenantId.eq(tenant_id))
.filter(media_item::Column::FolderId.eq(*fid))
.filter(media_item::Column::DeletedAt.is_null())
.count(&state.db)
.await?;
count_map.insert(*fid, count as i64);
}
}
// 构建扁平列表
let flat: Vec<FolderResp> = folders
.into_iter()
.map(|f| FolderResp {
id: f.id,
tenant_id: f.tenant_id,
name: f.name,
parent_id: f.parent_id,
sort_order: f.sort_order,
children: vec![],
item_count: count_map.get(&f.id).copied().unwrap_or(0),
created_at: f.created_at,
updated_at: f.updated_at,
version: f.version,
})
.collect();
// 组装树形结构
Ok(build_folder_tree(flat))
}
/// 创建文件夹
pub async fn create_folder(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreateFolderReq,
) -> HealthResult<FolderResp> {
let id = Uuid::now_v7();
let now = Utc::now();
let model = media_folder::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
name: Set(req.name),
parent_id: Set(req.parent_id),
sort_order: Set(req.sort_order),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let m = model.insert(&state.db).await?;
audit_service::record(
AuditLog::new(
tenant_id,
operator_id,
"media_folder.created",
"media_folder",
)
.with_resource_id(m.id),
&state.db,
)
.await;
Ok(FolderResp {
id: m.id,
tenant_id: m.tenant_id,
name: m.name,
parent_id: m.parent_id,
sort_order: m.sort_order,
children: vec![],
item_count: 0,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
})
}
/// 更新文件夹信息(名称/父级/排序),带乐观锁
pub async fn update_folder(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
req: UpdateFolderReq,
) -> HealthResult<FolderResp> {
let model = find_media_folder(state, tenant_id, id).await?;
let next_ver =
check_version(req.version, model.version).map_err(|_| HealthError::VersionMismatch)?;
let mut active: media_folder::ActiveModel = model.into();
if let Some(v) = req.name {
active.name = Set(v);
}
if let Some(v) = req.parent_id {
active.parent_id = Set(Some(v));
}
if let Some(v) = req.sort_order {
active.sort_order = Set(v);
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(
tenant_id,
operator_id,
"media_folder.updated",
"media_folder",
)
.with_resource_id(m.id),
&state.db,
)
.await;
Ok(FolderResp {
id: m.id,
tenant_id: m.tenant_id,
name: m.name,
parent_id: m.parent_id,
sort_order: m.sort_order,
children: vec![],
item_count: 0,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
})
}
/// 删除文件夹(仅当文件夹为空时允许删除)
pub async fn delete_folder(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
version: i32,
) -> HealthResult<()> {
let model = find_media_folder(state, tenant_id, id).await?;
let next_ver =
check_version(version, model.version).map_err(|_| HealthError::VersionMismatch)?;
// 检查是否包含文件
let item_count = media_item::Entity::find()
.filter(media_item::Column::TenantId.eq(tenant_id))
.filter(media_item::Column::FolderId.eq(id))
.filter(media_item::Column::DeletedAt.is_null())
.count(&state.db)
.await?;
if item_count > 0 {
return Err(HealthError::Validation(
"文件夹内仍有文件,无法删除".to_string(),
));
}
// 检查是否包含子文件夹
let child_count = media_folder::Entity::find()
.filter(media_folder::Column::TenantId.eq(tenant_id))
.filter(media_folder::Column::ParentId.eq(id))
.filter(media_folder::Column::DeletedAt.is_null())
.count(&state.db)
.await?;
if child_count > 0 {
return Err(HealthError::Validation(
"文件夹内仍有子文件夹,无法删除".to_string(),
));
}
// 软删除
let mut active: media_folder::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.update(&state.db).await?;
audit_service::record(
AuditLog::new(
tenant_id,
operator_id,
"media_folder.deleted",
"media_folder",
)
.with_resource_id(id),
&state.db,
)
.await;
Ok(())
}
// ---------------------------------------------------------------------------
// 内部辅助函数
// ---------------------------------------------------------------------------
/// 查找媒体文件(未删除)
async fn find_media_item(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
) -> HealthResult<media_item::Model> {
media_item::Entity::find()
.filter(media_item::Column::Id.eq(id))
.filter(media_item::Column::TenantId.eq(tenant_id))
.filter(media_item::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::MediaNotFound)
}
/// 查找文件夹(未删除)
async fn find_media_folder(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
) -> HealthResult<media_folder::Model> {
media_folder::Entity::find()
.filter(media_folder::Column::Id.eq(id))
.filter(media_folder::Column::TenantId.eq(tenant_id))
.filter(media_folder::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::MediaFolderNotFound)
}
/// 将实体模型转换为 DTO 响应
fn model_to_resp(m: media_item::Model) -> MediaItemResp {
MediaItemResp {
id: m.id,
tenant_id: m.tenant_id,
folder_id: m.folder_id,
filename: m.filename,
storage_path: m.storage_path,
thumbnail_path: m.thumbnail_path,
content_type: m.content_type,
file_size: m.file_size,
width: m.width,
height: m.height,
alt_text: m.alt_text,
is_public: m.is_public,
created_at: m.created_at,
updated_at: m.updated_at,
created_by: m.created_by,
updated_by: m.updated_by,
version: m.version,
}
}
/// 读取图片文件的宽高(非图片或读取失败返回 None
fn read_image_dimensions(path: &Path) -> (Option<i32>, Option<i32>) {
image::open(path)
.map(|img| (Some(img.width() as i32), Some(img.height() as i32)))
.unwrap_or((None, None))
}
/// 根据存储路径推导缩略图路径(同目录下加 _thumb 后缀)
fn generate_thumbnail_path(storage_path: &Path) -> Option<PathBuf> {
let stem = storage_path.file_stem()?.to_str()?;
let ext = storage_path.extension()?.to_str()?;
let parent = storage_path.parent()?;
Some(parent.join(format!("{}_thumb.{}", stem, ext)))
}
/// 将扁平文件夹列表组装成树形结构
fn build_folder_tree(flat: Vec<FolderResp>) -> Vec<FolderResp> {
use std::collections::HashMap;
// id -> (parent_id, index in flat)
let mut by_id: HashMap<Uuid, (Option<Uuid>, usize)> = HashMap::new();
for (i, f) in flat.iter().enumerate() {
by_id.insert(f.id, (f.parent_id, i));
}
// 收集根节点
let mut root_ids: Vec<Uuid> = Vec::new();
for f in &flat {
match f.parent_id {
None => root_ids.push(f.id),
Some(pid) if !by_id.contains_key(&pid) => root_ids.push(f.id),
_ => {}
}
}
// 递归构建
fn build_children(parent_id: Uuid, flat: &[FolderResp]) -> Vec<FolderResp> {
let mut children: Vec<FolderResp> = Vec::new();
for f in flat {
if f.parent_id == Some(parent_id) {
let mut node = f.clone();
node.children = build_children(f.id, flat);
children.push(node);
}
}
children.sort_by_key(|c| c.sort_order);
children
}
let mut result: Vec<FolderResp> = Vec::new();
for rid in root_ids {
if let Some(&(_, idx)) = by_id.get(&rid) {
let mut node = flat[idx].clone();
node.children = build_children(rid, &flat);
result.push(node);
}
}
result.sort_by_key(|c| c.sort_order);
result
}

View File

@@ -9,6 +9,7 @@ pub mod appointment_service;
pub mod article_category_service;
pub mod article_service;
pub mod article_tag_service;
pub mod banner_service;
pub mod ble_gateway_service;
pub mod care_plan_service;
pub mod consent_service;
@@ -25,6 +26,7 @@ pub mod follow_up_service;
pub mod follow_up_template_service;
pub mod health_data_service;
pub mod masking;
pub mod media_service;
pub mod medication_record_service;
pub mod medication_reminder_service;
pub mod patient_service;

View File

@@ -37,6 +37,9 @@ chrono.workspace = true
moka = { version = "0.12", features = ["sync"] }
metrics.workspace = true
metrics-exporter-prometheus.workspace = true
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
[dev-dependencies]
erp-auth = { workspace = true }

View File

@@ -60,6 +60,8 @@ is_enabled = true
[storage]
upload_dir = "./uploads"
max_file_size = "10MB"
# 签名 URL 密钥(生产环境必须通过 ERP__STORAGE__SECRET_KEY 环境变量设置)
secret_key = "dev-only-secret-key-change-in-production"
[rate_limit]
# Redis 不可达时是否拒绝请求fail-close。默认 true = 安全优先。

View File

@@ -135,6 +135,9 @@ mod m20260508_000130_fix_operator_permissions_and_nurse_devices;
mod m20260508_000131_fix_all_role_permissions;
mod m20260508_000132_fix_doctor_permissions_restore;
mod m20260510_000133_create_patient_role;
mod m20260510_000134_create_media_folder;
mod m20260510_000135_create_media_item;
mod m20260510_000136_create_banner;
pub struct Migrator;
@@ -277,6 +280,9 @@ impl MigratorTrait for Migrator {
Box::new(m20260508_000131_fix_all_role_permissions::Migration),
Box::new(m20260508_000132_fix_doctor_permissions_restore::Migration),
Box::new(m20260510_000133_create_patient_role::Migration),
Box::new(m20260510_000134_create_media_folder::Migration),
Box::new(m20260510_000135_create_media_item::Migration),
Box::new(m20260510_000136_create_banner::Migration),
]
}
}

View File

@@ -0,0 +1,99 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(MediaFolder::Table)
.col(
ColumnDef::new(MediaFolder::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(MediaFolder::TenantId).uuid().not_null())
.col(ColumnDef::new(MediaFolder::Name).string_len(100).not_null())
.col(ColumnDef::new(MediaFolder::ParentId).uuid().null())
.col(
ColumnDef::new(MediaFolder::SortOrder)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(MediaFolder::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(MediaFolder::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(MediaFolder::CreatedBy).uuid().null())
.col(ColumnDef::new(MediaFolder::UpdatedBy).uuid().null())
.col(
ColumnDef::new(MediaFolder::DeletedAt)
.timestamp_with_time_zone()
.null(),
)
.col(
ColumnDef::new(MediaFolder::Version)
.integer()
.not_null()
.default(1),
)
.foreign_key(
ForeignKey::create()
.name("fk_media_folder_parent")
.from(MediaFolder::Table, MediaFolder::ParentId)
.to(MediaFolder::Table, MediaFolder::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_media_folder_tenant_parent")
.table(MediaFolder::Table)
.col(MediaFolder::TenantId)
.col(MediaFolder::ParentId)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(MediaFolder::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum MediaFolder {
Table,
Id,
TenantId,
Name,
ParentId,
SortOrder,
CreatedAt,
UpdatedAt,
CreatedBy,
UpdatedBy,
DeletedAt,
Version,
}

View File

@@ -0,0 +1,149 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(MediaItem::Table)
.col(
ColumnDef::new(MediaItem::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(MediaItem::TenantId).uuid().not_null())
.col(ColumnDef::new(MediaItem::FolderId).uuid().null())
.col(
ColumnDef::new(MediaItem::Filename)
.string_len(255)
.not_null(),
)
.col(
ColumnDef::new(MediaItem::StoragePath)
.string_len(500)
.not_null()
.unique_key(),
)
.col(
ColumnDef::new(MediaItem::ThumbnailPath)
.string_len(500)
.null(),
)
.col(
ColumnDef::new(MediaItem::ContentType)
.string_len(100)
.not_null(),
)
.col(ColumnDef::new(MediaItem::FileSize).big_integer().not_null())
.col(ColumnDef::new(MediaItem::Width).integer().null())
.col(ColumnDef::new(MediaItem::Height).integer().null())
.col(ColumnDef::new(MediaItem::AltText).string_len(255).null())
.col(
ColumnDef::new(MediaItem::IsPublic)
.boolean()
.not_null()
.default(false),
)
.col(
ColumnDef::new(MediaItem::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(MediaItem::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(MediaItem::CreatedBy).uuid().null())
.col(ColumnDef::new(MediaItem::UpdatedBy).uuid().null())
.col(
ColumnDef::new(MediaItem::DeletedAt)
.timestamp_with_time_zone()
.null(),
)
.col(
ColumnDef::new(MediaItem::Version)
.integer()
.not_null()
.default(1),
)
.foreign_key(
ForeignKey::create()
.name("fk_media_item_folder")
.from(MediaItem::Table, MediaItem::FolderId)
.to(MediaFolderRef::Table, MediaFolderRef::Id)
.on_delete(ForeignKeyAction::SetNull),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_media_item_tenant_folder")
.table(MediaItem::Table)
.col(MediaItem::TenantId)
.col(MediaItem::FolderId)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_media_item_tenant_public")
.table(MediaItem::Table)
.col(MediaItem::TenantId)
.col(MediaItem::IsPublic)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(MediaItem::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum MediaItem {
Table,
Id,
TenantId,
FolderId,
Filename,
StoragePath,
ThumbnailPath,
ContentType,
FileSize,
Width,
Height,
AltText,
IsPublic,
CreatedAt,
UpdatedAt,
CreatedBy,
UpdatedBy,
DeletedAt,
Version,
}
/// 外键引用 media_folder 表
#[derive(Iden)]
enum MediaFolderRef {
#[iden = "media_folder"]
Table,
Id,
}

View File

@@ -0,0 +1,137 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Banner::Table)
.col(ColumnDef::new(Banner::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(Banner::TenantId).uuid().not_null())
.col(ColumnDef::new(Banner::MediaItemId).uuid().not_null())
.col(ColumnDef::new(Banner::Title).string_len(100).null())
.col(ColumnDef::new(Banner::Subtitle).string_len(255).null())
.col(ColumnDef::new(Banner::LinkType).string_len(20).null())
.col(ColumnDef::new(Banner::LinkTarget).string_len(500).null())
.col(
ColumnDef::new(Banner::SortOrder)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(Banner::Status)
.string_len(20)
.not_null()
.default("active"),
)
.col(
ColumnDef::new(Banner::StartTime)
.timestamp_with_time_zone()
.null(),
)
.col(
ColumnDef::new(Banner::EndTime)
.timestamp_with_time_zone()
.null(),
)
.col(
ColumnDef::new(Banner::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(Banner::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(Banner::CreatedBy).uuid().null())
.col(ColumnDef::new(Banner::UpdatedBy).uuid().null())
.col(
ColumnDef::new(Banner::DeletedAt)
.timestamp_with_time_zone()
.null(),
)
.col(
ColumnDef::new(Banner::Version)
.integer()
.not_null()
.default(1),
)
.foreign_key(
ForeignKey::create()
.name("fk_banner_media_item")
.from(Banner::Table, Banner::MediaItemId)
.to(MediaItemRef::Table, MediaItemRef::Id)
.on_delete(ForeignKeyAction::Restrict),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_banner_tenant_status")
.table(Banner::Table)
.col(Banner::TenantId)
.col(Banner::Status)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_banner_sort")
.table(Banner::Table)
.col(Banner::SortOrder)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Banner::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Banner {
Table,
Id,
TenantId,
MediaItemId,
Title,
Subtitle,
LinkType,
LinkTarget,
SortOrder,
Status,
StartTime,
EndTime,
CreatedAt,
UpdatedAt,
CreatedBy,
UpdatedBy,
DeletedAt,
Version,
}
/// 外键引用 media_item 表
#[derive(Iden)]
enum MediaItemRef {
#[iden = "media_item"]
Table,
Id,
}

View File

@@ -0,0 +1,192 @@
//! 媒体库 + 轮播图管理菜单种子数据 + 首页推荐文章分类
//!
//! 1. 插入"媒体库"和"轮播图管理"菜单到健康业务目录(运营分组)
//! 2. 创建对应权限码 health.media.list / health.banners.list
//! 3. 将权限绑定 admin / operator 角色
//! 4. 插入"首页推荐"文章分类到 article_category 表
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
let sys = "00000000-0000-0000-0000-000000000000";
// ================================================================
// Part 1: 插入"媒体库"和"轮播图管理"菜单
// ================================================================
// 运营分组: articles(40), points-rules(41), points-products(42),
// points-orders(43), offline-events(44)
// 媒体库 → 45, 轮播图管理 → 46
let menus: &[(&str, &str, &str, &str, i32)] = &[
(
"b0000003-0000-7000-8000-000000000033",
"媒体库",
"/health/media-library",
"PictureOutlined",
45,
),
(
"b0000003-0000-7000-8000-000000000034",
"轮播图管理",
"/health/banners",
"SwapOutlined",
46,
),
];
for &(id, title, path, icon, 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)
SELECT
'{id}'::uuid,
t.id,
(SELECT m.id FROM menus m WHERE m.path = '/health' AND m.tenant_id = t.id LIMIT 1),
'{title}',
'{path}',
'{icon}',
{sort},
true, 'page',
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),
1
FROM tenant t
WHERE NOT EXISTS (
SELECT 1 FROM menus m WHERE m.path = '{path}' AND m.tenant_id = t.id
)
"#
);
db.execute_unprepared(&sql).await?;
}
// ================================================================
// Part 2: 创建权限码
// ================================================================
let permissions: &[(&str, &str, &str, &str)] = &[
("health.media.list", "媒体库查看", "health", "media.list"),
(
"health.banners.list",
"轮播图查看",
"health",
"banners.list",
),
(
"health.media.manage",
"媒体库管理",
"health",
"media.manage",
),
(
"health.banners.manage",
"轮播图管理",
"health",
"banners.manage",
),
];
for &(code, name, resource, action) in permissions {
db.execute_unprepared(&format!(
r#"
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description,
created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT gen_random_uuid(), t.id, '{code}', '{name}', '{resource}', '{action}', '{name}',
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM tenant t
WHERE NOT EXISTS (
SELECT 1 FROM permissions p
WHERE p.code = '{code}' AND p.tenant_id = t.id AND p.deleted_at IS NULL
)
"#
)).await?;
}
// ================================================================
// Part 3: 绑定权限到 admin 和 operator 角色
// ================================================================
for role_code in &["admin", "operator"] {
db.execute_unprepared(&format!(
r#"
INSERT INTO role_permissions (role_id, permission_id, tenant_id, created_by, updated_by, version)
SELECT r.id, p.id, t.id, r.id, r.id, 1
FROM tenant t
JOIN roles r ON r.tenant_id = t.id AND r.code = '{role_code}' AND r.deleted_at IS NULL
JOIN permissions p ON p.tenant_id = t.id
AND p.code IN ('health.media.list', 'health.banners.list',
'health.media.manage', 'health.banners.manage')
AND p.deleted_at IS NULL
WHERE NOT EXISTS (
SELECT 1 FROM role_permissions rp
WHERE rp.permission_id = p.id AND rp.role_id = r.id
)
"#
)).await?;
}
// ================================================================
// Part 4: 插入"首页推荐"文章分类
// ================================================================
db.execute_unprepared(&format!(
r#"
INSERT INTO article_category (id, tenant_id, name, slug, parent_id, description, sort_order,
created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT gen_random_uuid(), t.id, '首页推荐', 'home-featured', NULL,
'小程序访客首页展示的推荐文章', 0,
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM tenant t
WHERE NOT EXISTS (
SELECT 1 FROM article_category ac
WHERE ac.slug = 'home-featured' AND ac.tenant_id = t.id AND ac.deleted_at IS NULL
)
"#
)).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// 删除菜单
for path in &["/health/media-library", "/health/banners"] {
db.execute_unprepared(&format!("DELETE FROM menus WHERE path = '{path}'"))
.await
.ok();
}
// 删除权限绑定
db.execute_unprepared(
"DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions
WHERE code IN ('health.media.list', 'health.banners.list',
'health.media.manage', 'health.banners.manage')
)",
)
.await
.ok();
// 删除权限
db.execute_unprepared(
"DELETE FROM permissions
WHERE code IN ('health.media.list', 'health.banners.list',
'health.media.manage', 'health.banners.manage')",
)
.await
.ok();
// 删除文章分类
db.execute_unprepared("DELETE FROM article_category WHERE slug = 'home-featured'")
.await
.ok();
Ok(())
}
}

View File

@@ -127,6 +127,20 @@ pub struct StorageConfig {
pub upload_dir: String,
/// 单文件最大大小(如 "10MB"
pub max_file_size: String,
/// 签名 URL 密钥HMAC-SHA256
#[serde(default = "default_secret_key")]
pub secret_key: String,
}
fn default_secret_key() -> String {
#[cfg(debug_assertions)]
{
"dev-only-secret-key-change-in-production".to_string()
}
#[cfg(not(debug_assertions))]
{
panic!("ERP__STORAGE__SECRET_KEY 必须设置(生产环境不允许使用默认签名密钥)")
}
}
impl StorageConfig {