This commit is contained in:
2025-12-13 14:46:21 +08:00
parent 4b44c7ec86
commit cb36ed5d50

View File

@@ -0,0 +1,684 @@
import React, { useState, useEffect } from 'react';
import { Upload, Image as ImageIcon, Video, X } from 'lucide-react';
import * as mediaService from '../services/mediaService';
/**
* 家属端媒体库界面
* 上传、打标、配置触发策略
*/
export const MediaLibrary: React.FC = () => {
const [selectedMedia, setSelectedMedia] = useState<mediaService.Media | null>(null);
const [showUploader, setShowUploader] = useState(false);
const [mediaItems, setMediaItems] = useState<mediaService.Media[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadPreviewUrl, setUploadPreviewUrl] = useState<string | null>(null);
const [uploadTitle, setUploadTitle] = useState('');
const [uploadDescription, setUploadDescription] = useState('');
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
// 编辑表单状态
const [editTitle, setEditTitle] = useState('');
const [editTags, setEditTags] = useState<string[]>([]);
const [newTag, setNewTag] = useState('');
const [editTimeWindows, setEditTimeWindows] = useState<string[]>([]);
const [editMoods, setEditMoods] = useState<string[]>([]);
const [editOccasion, setEditOccasion] = useState('');
const [editCooldown, setEditCooldown] = useState(60);
const familyId = 'family_001'; // 实际使用时从用户上下文获取
// 加载媒体列表
useEffect(() => {
loadMedia();
}, []);
// 当选中媒体时,初始化编辑表单
useEffect(() => {
if (selectedMedia) {
setEditTitle(selectedMedia.title);
setEditTags(selectedMedia.tags || []);
setEditTimeWindows(selectedMedia.time_windows || []);
setEditMoods(selectedMedia.moods || []);
setEditOccasion(selectedMedia.occasions?.[0] || '');
setEditCooldown(selectedMedia.cooldown || 60);
}
}, [selectedMedia]);
const loadMedia = async () => {
try {
setLoading(true);
const data = await mediaService.getFamilyMedia(familyId);
setMediaItems(data);
} catch (error) {
console.error('加载媒体列表失败:', error);
showToast('加载媒体列表失败', 'error');
} finally {
setLoading(false);
}
};
const showToast = (message: string, type: 'success' | 'error' | 'warning') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
// 处理文件选择
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
setUploadFile(file);
// 创建预览URL
const previewUrl = URL.createObjectURL(file);
setUploadPreviewUrl(previewUrl);
// 自动填充标题(使用文件名,去掉扩展名)
if (!uploadTitle) {
const fileName = file.name.replace(/\.[^/.]+$/, '');
setUploadTitle(fileName);
}
}
};
// 清理预览URL以避免内存泄漏
useEffect(() => {
return () => {
if (uploadPreviewUrl) {
URL.revokeObjectURL(uploadPreviewUrl);
}
};
}, [uploadPreviewUrl]);
// 处理上传
const handleUpload = async () => {
if (!uploadFile || !uploadTitle.trim()) {
showToast('请选择文件并填写标题', 'warning');
return;
}
try {
setUploading(true);
await mediaService.uploadMedia({
file: uploadFile,
family_id: familyId,
title: uploadTitle,
description: uploadDescription,
});
showToast('上传成功', 'success');
setShowUploader(false);
setUploadFile(null);
setUploadPreviewUrl(null);
setUploadTitle('');
setUploadDescription('');
// 重新加载媒体列表
await loadMedia();
} catch (error) {
console.error('上传失败:', error);
showToast(error instanceof Error ? error.message : '上传失败', 'error');
} finally {
setUploading(false);
}
};
// 保存媒体策略
const handleSavePolicy = async () => {
if (!selectedMedia) return;
try {
await mediaService.updateMedia(selectedMedia.id, {
title: editTitle,
tags: editTags,
time_windows: editTimeWindows,
moods: editMoods,
occasions: editOccasion ? [editOccasion] : [],
cooldown: editCooldown,
priority: selectedMedia.priority || 5,
});
showToast('保存成功', 'success');
setSelectedMedia(null);
await loadMedia();
} catch (error) {
console.error('保存失败:', error);
showToast(error instanceof Error ? error.message : '保存失败', 'error');
}
};
// 添加标签
const handleAddTag = () => {
if (newTag.trim() && !editTags.includes(newTag.trim())) {
setEditTags([...editTags, newTag.trim()]);
setNewTag('');
}
};
// 删除标签
const handleRemoveTag = (tagToRemove: string) => {
setEditTags(editTags.filter(tag => tag !== tagToRemove));
};
// 切换时段
const handleToggleTimeWindow = (timeWindow: string) => {
if (editTimeWindows.includes(timeWindow)) {
setEditTimeWindows(editTimeWindows.filter(tw => tw !== timeWindow));
} else {
setEditTimeWindows([...editTimeWindows, timeWindow]);
}
};
// 切换心境
const handleToggleMood = (mood: string) => {
if (editMoods.includes(mood)) {
setEditMoods(editMoods.filter(m => m !== mood));
} else {
setEditMoods([...editMoods, mood]);
}
};
// 删除媒体
const handleDeleteMedia = async (mediaId: number) => {
if (!confirm('确定要删除这个媒体吗?')) return;
try {
await mediaService.deleteMedia(mediaId);
showToast('删除成功', 'success');
setSelectedMedia(null);
await loadMedia();
} catch (error) {
console.error('删除失败:', error);
showToast(error instanceof Error ? error.message : '删除失败', 'error');
}
};
return (
<>
<div className="min-h-screen bg-gray-50">
{/* 顶部导航 */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<button
onClick={() => setShowUploader(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors flex items-center gap-2"
>
<Upload size={18} />
</button>
</div>
</div>
</div>
{/* 主要内容区 */}
<div className="max-w-7xl mx-auto px-6 py-8">
{/* 步骤提示 */}
<div className="mb-8">
<div className="flex items-center justify-between max-w-3xl mx-auto">
{['上传', '打标', '触发策略'].map((step, index) => (
<div key={index} className="flex items-center">
<div className="flex flex-col items-center">
<div className="w-10 h-10 rounded-full bg-primary-600 text-white flex items-center justify-center font-bold">
{index + 1}
</div>
<span className="text-sm mt-2 text-gray-700">{step}</span>
</div>
{index < 2 && (
<div className="w-32 h-1 bg-primary-200 mx-4" />
)}
</div>
))}
</div>
</div>
{/* 加载状态 */}
{loading && (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
)}
{/* 媒体网格 */}
{!loading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{mediaItems.map((item) => (
<div
key={item.id}
className="card p-0 overflow-hidden cursor-pointer hover:shadow-xl transition-shadow"
onClick={() => setSelectedMedia(item)}
>
{/* 缩略图 */}
<div className="bg-gray-200 aspect-video flex items-center justify-center relative overflow-hidden">
{item.media_type === 'photo' ? (
<>
<img
src={mediaService.getMediaUrl(item.file_path)}
alt={item.title}
className="w-full h-full object-cover"
onError={(e) => {
// 加载失败时显示占位图标
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
{/* 加载失败时的占位图标 */}
<div className="hidden absolute inset-0 flex items-center justify-center bg-gray-200">
<ImageIcon size={48} className="text-gray-400" />
</div>
</>
) : (
// 视频显示缩略图或图标
item.thumbnail_path ? (
<>
<img
src={mediaService.getThumbnailUrl(item.thumbnail_path)}
alt={item.title}
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
<div className="hidden absolute inset-0 flex items-center justify-center bg-gradient-to-br from-purple-100 to-blue-100">
<Video size={64} className="text-purple-500" />
</div>
{/* 播放图标叠加 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-12 h-12 bg-black bg-opacity-50 rounded-full flex items-center justify-center">
<div className="w-0 h-0 border-t-8 border-t-transparent border-l-12 border-l-white border-b-8 border-b-transparent ml-1"></div>
</div>
</div>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-purple-100 to-blue-100">
<Video size={64} className="text-purple-500" />
</div>
)
)}
<div className="absolute top-2 right-2 px-2 py-1 bg-black bg-opacity-60 text-white text-xs rounded">
{item.media_type === 'photo' ? '照片' : '视频'}
</div>
{/* 播放次数 */}
{item.play_count > 0 && (
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black bg-opacity-60 text-white text-xs rounded">
{item.play_count}
</div>
)}
</div>
{/* 信息区 */}
<div className="p-4">
<h3 className="font-medium text-gray-900 mb-2">{item.title}</h3>
<div className="flex flex-wrap gap-1 mb-3">
{item.tags.map((tag, index) => (
<span
key={index}
className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded"
>
{tag}
</span>
))}
</div>
<p className="text-xs text-gray-500">
{new Date(item.created_at).toLocaleDateString()}
</p>
</div>
</div>
))}
{/* 上传占位卡 */}
<div
onClick={() => setShowUploader(true)}
className="card p-0 overflow-hidden cursor-pointer hover:shadow-xl transition-shadow border-2 border-dashed border-gray-300 hover:border-primary-400"
>
<div className="aspect-video flex flex-col items-center justify-center text-gray-400 hover:text-primary-600 transition-colors">
<Upload size={48} />
<span className="mt-2 text-sm font-medium"></span>
</div>
</div>
</div>
)}
</div>
{/* 媒体详情侧边栏 */}
{selectedMedia && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex justify-end animate-fade-in">
<div className="bg-white w-full max-w-2xl h-full overflow-y-auto">
<div className="sticky top-0 bg-white border-b p-6 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900"></h2>
<button
onClick={() => setSelectedMedia(null)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X size={24} />
</button>
</div>
<div className="p-6 space-y-6">
{/* 基本信息 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
{/* 人物标签 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
/
</label>
<div className="flex flex-wrap gap-2 mb-2">
{editTags.map((tag, index) => (
<span
key={index}
className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm flex items-center gap-1"
>
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="hover:text-blue-900"
>
×
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
placeholder="添加标签..."
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTag();
}
}}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<button
onClick={handleAddTag}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
</div>
</div>
{/* 触发策略 */}
<div className="border-t pt-6">
<h3 className="text-lg font-bold text-gray-900 mb-4"></h3>
{/* 时段选择 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="grid grid-cols-2 gap-2">
{[
{ label: '早餐后 (07:00-09:00)', value: '07:00-09:00' },
{ label: '午后 (14:00-17:00)', value: '14:00-17:00' },
{ label: '晚间 (19:00-21:00)', value: '19:00-21:00' },
{ label: '睡前 (21:00-22:00)', value: '21:00-22:00' },
].map((time, index) => (
<label
key={index}
className={`flex items-center gap-2 p-3 border rounded-lg cursor-pointer transition-colors ${
editTimeWindows.includes(time.value)
? 'bg-blue-50 border-blue-300'
: 'hover:bg-gray-50'
}`}
>
<input
type="checkbox"
className="rounded"
checked={editTimeWindows.includes(time.value)}
onChange={() => handleToggleTimeWindow(time.value)}
/>
<span className="text-sm">{time.label}</span>
</label>
))}
</div>
</div>
{/* 心境选择 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="grid grid-cols-2 gap-2">
{[
{ label: '开心', value: 'happy' },
{ label: '平静', value: 'calm' },
{ label: '难过', value: 'sad' },
{ label: '焦虑', value: 'anxious' },
].map((mood, index) => (
<label
key={index}
className={`flex items-center gap-2 p-3 border rounded-lg cursor-pointer transition-colors ${
editMoods.includes(mood.value)
? 'bg-purple-50 border-purple-300'
: 'hover:bg-gray-50'
}`}
>
<input
type="checkbox"
className="rounded"
checked={editMoods.includes(mood.value)}
onChange={() => handleToggleMood(mood.value)}
/>
<span className="text-sm">{mood.label}</span>
</label>
))}
</div>
</div>
{/* 特殊场合 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
value={editOccasion}
onChange={(e) => setEditOccasion(e.target.value)}
>
<option value=""></option>
<option value="birthday"></option>
<option value="anniversary"></option>
<option value="medication_reward"></option>
</select>
</div>
{/* 冷却时间 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
value={editCooldown}
onChange={(e) => setEditCooldown(Number(e.target.value))}
>
<option value="30">30 </option>
<option value="60">1 </option>
<option value="120">2 </option>
<option value="1440">1 </option>
</select>
</div>
</div>
{/* 保存和删除按钮 */}
<div className="flex gap-3">
<button
onClick={() => handleDeleteMedia(selectedMedia.id)}
className="px-6 py-3 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors font-medium"
>
</button>
<button
onClick={handleSavePolicy}
className="flex-1 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors font-medium"
>
</button>
</div>
</div>
</div>
</div>
)}
{/* 上传对话框 */}
{showUploader && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-6 animate-fade-in">
<div className="bg-white rounded-2xl max-w-2xl w-full p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900"></h2>
<button
onClick={() => {
setShowUploader(false);
setUploadFile(null);
setUploadPreviewUrl(null);
setUploadTitle('');
setUploadDescription('');
}}
className="p-2 hover:bg-gray-100 rounded-lg"
>
<X size={24} />
</button>
</div>
{/* 文件选择和预览 */}
<div className="mb-4">
<label className="border-2 border-dashed border-gray-300 rounded-xl overflow-hidden hover:border-primary-400 transition-colors cursor-pointer block">
<input
type="file"
accept="image/*,video/*"
onChange={handleFileSelect}
className="hidden"
/>
{uploadFile && uploadPreviewUrl ? (
<div>
{/* 预览区域 */}
<div className="bg-gray-100 aspect-video flex items-center justify-center relative overflow-hidden">
{uploadFile.type.startsWith('image/') ? (
<img
src={uploadPreviewUrl}
alt="预览"
className="w-full h-full object-contain"
/>
) : uploadFile.type.startsWith('video/') ? (
<video
src={uploadPreviewUrl}
className="w-full h-full object-contain"
controls
/>
) : null}
</div>
{/* 文件信息 */}
<div className="p-4 bg-white">
<p className="text-primary-600 font-medium mb-1">{uploadFile.name}</p>
<p className="text-sm text-gray-500">
{(uploadFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
) : (
<div className="p-12 text-center">
<Upload size={48} className="mx-auto text-gray-400 mb-4" />
<p className="text-gray-700 font-medium mb-1">
</p>
<p className="text-sm text-gray-500">
JPGPNGMP4 100MB
</p>
</div>
)}
</label>
</div>
{/* 标题输入 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={uploadTitle}
onChange={(e) => setUploadTitle(e.target.value)}
placeholder="例如:小米生日视频"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
{/* 描述输入 */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<textarea
value={uploadDescription}
onChange={(e) => setUploadDescription(e.target.value)}
placeholder="添加一些描述信息..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
/>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowUploader(false);
setUploadFile(null);
setUploadPreviewUrl(null);
setUploadTitle('');
setUploadDescription('');
}}
className="flex-1 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
disabled={uploading}
>
</button>
<button
onClick={handleUpload}
disabled={uploading || !uploadFile || !uploadTitle.trim()}
className="flex-1 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploading ? '上传中...' : '开始上传'}
</button>
</div>
</div>
</div>
)}
{/* Toast 提示 */}
{toast && (
<div className="fixed bottom-4 right-4 z-50 animate-fade-in">
<div
className={`px-6 py-3 rounded-lg shadow-lg text-white ${
toast.type === 'success'
? 'bg-green-600'
: toast.type === 'error'
? 'bg-red-600'
: 'bg-yellow-600'
}`}
>
{toast.message}
</div>
</div>
)}
</div>
</>
);
};