Compare commits
12 Commits
main
...
09725acad7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09725acad7 | ||
|
|
7fcabd2e6b | ||
|
|
d2b79e4a1c | ||
|
|
b2c6d9c8c8 | ||
|
|
6bf8cc53f8 | ||
|
|
2c7d4a3d63 | ||
|
|
85bff6f267 | ||
|
|
1a459de4ad | ||
|
|
3a672636c0 | ||
|
|
a9bd850ce2 | ||
|
|
601d977438 | ||
|
|
603a986281 |
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 底部登录引导 */}
|
||||
|
||||
107
apps/web/src/api/health/banners.ts
Normal file
107
apps/web/src/api/health/banners.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
208
apps/web/src/api/health/media.ts
Normal file
208
apps/web/src/api/health/media.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
192
apps/web/src/components/MediaPicker/index.tsx
Normal file
192
apps/web/src/components/MediaPicker/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
308
apps/web/src/pages/health/MediaLibrary.tsx
Normal file
308
apps/web/src/pages/health/MediaLibrary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
113
crates/erp-health/src/dto/banner_dto.rs
Normal file
113
crates/erp-health/src/dto/banner_dto.rs
Normal 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>,
|
||||
}
|
||||
132
crates/erp-health/src/dto/media_dto.rs
Normal file
132
crates/erp-health/src/dto/media_dto.rs
Normal 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 {
|
||||
/// 目标文件夹 ID(None 表示移到根目录)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
39
crates/erp-health/src/entity/banner.rs
Normal file
39
crates/erp-health/src/entity/banner.rs
Normal 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 {}
|
||||
28
crates/erp-health/src/entity/media_folder.rs
Normal file
28
crates/erp-health/src/entity/media_folder.rs
Normal 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 {}
|
||||
39
crates/erp-health/src/entity/media_item.rs
Normal file
39
crates/erp-health/src/entity/media_item.rs
Normal 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 {}
|
||||
@@ -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;
|
||||
|
||||
142
crates/erp-health/src/handler/banner_handler.rs
Normal file
142
crates/erp-health/src/handler/banner_handler.rs
Normal 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)))
|
||||
}
|
||||
280
crates/erp-health/src/handler/media_handler.rs
Normal file
280
crates/erp-health/src/handler/media_handler.rs
Normal 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(())))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
411
crates/erp-health/src/service/banner_service.rs
Normal file
411
crates/erp-health/src/service/banner_service.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
781
crates/erp-health/src/service/media_service.rs
Normal file
781
crates/erp-health/src/service/media_service.rs
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 = 安全优先。
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user