新功能: - 为TrackSegment添加匹配规则字段,支持固定素材和AI分类两种规则 - 实现SegmentMatchingRuleEditor组件,支持在模板详情页面编辑片段匹配规则 - 添加update_segment_matching_rule和get_segment_matching_rule API接口 - 扩展前端类型定义和服务函数以支持匹配规则操作 修复: - 修复数据库迁移逻辑导致每次重启清空素材和轨道数据的问题 - 为模板表和轨道片段表迁移添加条件检查,只在必要时执行 - 修正matching_rule字段的默认值格式,匹配Rust枚举序列化格式 - 完善轨道片段表重建时的字段迁移逻辑 技术改进: - 数据库schema更新,添加matching_rule列到track_segments表 - 优化数据库迁移性能,避免不必要的表重建操作 - 增强错误处理和日志输出,便于问题排查 文件变更: - 后端: template_service.rs, template.rs, database.rs, template_commands.rs, lib.rs - 前端: SegmentMatchingRuleEditor.tsx, TemplateDetailModal.tsx, templateStore.ts, template.ts
589 lines
26 KiB
TypeScript
589 lines
26 KiB
TypeScript
import React, { useState } from 'react';
|
||
import { X, Calendar, Clock, Monitor, Layers, FileText, Image, Video, Music, Type, Sparkles, CheckCircle, XCircle, AlertCircle, Upload, Cloud } from 'lucide-react';
|
||
import { Template, TemplateMaterial, TemplateMaterialType, TrackType } from '../../types/template';
|
||
import { SegmentMatchingRuleEditor } from './SegmentMatchingRuleEditor';
|
||
|
||
interface TemplateDetailModalProps {
|
||
template: Template;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||
template,
|
||
onClose,
|
||
}) => {
|
||
const [activeTab, setActiveTab] = useState<'overview' | 'materials' | 'tracks'>('overview');
|
||
|
||
// 格式化时长
|
||
const formatDuration = (microseconds: number) => {
|
||
const seconds = Math.floor(microseconds / 1000000);
|
||
const minutes = Math.floor(seconds / 60);
|
||
const remainingSeconds = seconds % 60;
|
||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||
};
|
||
|
||
// 格式化时长(精确到毫秒)
|
||
const formatDurationWithMs = (microseconds: number) => {
|
||
const totalMs = Math.floor(microseconds / 1000);
|
||
const seconds = Math.floor(totalMs / 1000);
|
||
const ms = totalMs % 1000;
|
||
const minutes = Math.floor(seconds / 60);
|
||
const remainingSeconds = seconds % 60;
|
||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
|
||
};
|
||
|
||
// 格式化文件大小
|
||
const formatFileSize = (bytes?: number) => {
|
||
if (!bytes) return '未知';
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||
};
|
||
|
||
// 格式化日期
|
||
const formatDate = (dateString: string) => {
|
||
return new Date(dateString).toLocaleString('zh-CN');
|
||
};
|
||
|
||
// 获取素材类型图标
|
||
const getMaterialIcon = (type: TemplateMaterialType) => {
|
||
switch (type) {
|
||
case TemplateMaterialType.Video:
|
||
return <Video className="w-4 h-4 text-blue-600" />;
|
||
case TemplateMaterialType.Audio:
|
||
return <Music className="w-4 h-4 text-green-600" />;
|
||
case TemplateMaterialType.Image:
|
||
return <Image className="w-4 h-4 text-purple-600" />;
|
||
case TemplateMaterialType.Text:
|
||
return <Type className="w-4 h-4 text-orange-600" />;
|
||
case TemplateMaterialType.Effect:
|
||
return <Sparkles className="w-4 h-4 text-pink-600" />;
|
||
default:
|
||
return <FileText className="w-4 h-4 text-gray-600" />;
|
||
}
|
||
};
|
||
|
||
// 获取轨道类型图标
|
||
const getTrackIcon = (type: TrackType) => {
|
||
switch (type) {
|
||
case TrackType.Video:
|
||
return <Video className="w-4 h-4 text-blue-600" />;
|
||
case TrackType.Audio:
|
||
return <Music className="w-4 h-4 text-green-600" />;
|
||
case TrackType.Text:
|
||
return <Type className="w-4 h-4 text-orange-600" />;
|
||
default:
|
||
return <Layers className="w-4 h-4 text-gray-600" />;
|
||
}
|
||
};
|
||
|
||
|
||
|
||
// 检查文件是否存在(基于数据库字段和路径)
|
||
const getFileExistenceInfo = (material: any) => {
|
||
const hasRemoteFile = material.remote_url && material.remote_url.trim() !== '';
|
||
|
||
if (hasRemoteFile) {
|
||
return {
|
||
icon: <Cloud className="w-4 h-4 text-blue-600" />,
|
||
text: '云端文件',
|
||
color: 'text-blue-600'
|
||
};
|
||
} else if (material.file_exists) {
|
||
return {
|
||
icon: <CheckCircle className="w-4 h-4 text-green-600" />,
|
||
text: '本地文件存在',
|
||
color: 'text-green-600'
|
||
};
|
||
} else {
|
||
return {
|
||
icon: <XCircle className="w-4 h-4 text-red-600" />,
|
||
text: '文件不存在',
|
||
color: 'text-red-600'
|
||
};
|
||
}
|
||
};
|
||
|
||
// 获取上传状态(基于数据库字段)
|
||
const getUploadStatusInfo = (material: any) => {
|
||
if (material.upload_success) {
|
||
return {
|
||
icon: <CheckCircle className="w-4 h-4 text-green-600" />,
|
||
text: '上传成功',
|
||
color: 'text-green-600'
|
||
};
|
||
} else {
|
||
// 根据 upload_status 显示具体状态
|
||
switch (material.upload_status) {
|
||
case 'Uploading':
|
||
return {
|
||
icon: <Upload className="w-4 h-4 text-blue-600" />,
|
||
text: '上传中',
|
||
color: 'text-blue-600'
|
||
};
|
||
case 'Failed':
|
||
|
||
case 'Pending':
|
||
return {
|
||
icon: <AlertCircle className="w-4 h-4 text-yellow-600" />,
|
||
text: '待上传',
|
||
color: 'text-yellow-600'
|
||
};
|
||
case 'Skipped':
|
||
return {
|
||
icon: <AlertCircle className="w-4 h-4 text-gray-600" />,
|
||
text: '已跳过',
|
||
color: 'text-gray-600'
|
||
};
|
||
default:
|
||
return {
|
||
icon: <AlertCircle className="w-4 h-4 text-gray-600" />,
|
||
text: '未上传',
|
||
color: 'text-gray-600'
|
||
};
|
||
}
|
||
}
|
||
};
|
||
|
||
// 解析文字素材的文本内容和样式
|
||
const parseTextContent = (metadata: string | null) => {
|
||
if (!metadata) return null;
|
||
try {
|
||
const parsed = JSON.parse(metadata);
|
||
const obj = {
|
||
content: tryParse(parsed.content || null),
|
||
font_family: parsed.font_family || null,
|
||
font_size: parsed.font_size || null,
|
||
color: parsed.color || null
|
||
};
|
||
console.log({ obj })
|
||
return obj;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const tryParse = (str: any) => {
|
||
try {
|
||
if (typeof str === 'string') {
|
||
return JSON.parse(str)
|
||
}
|
||
return str;
|
||
} catch (e) {
|
||
return str;
|
||
}
|
||
}
|
||
|
||
// 解析片段属性信息
|
||
const parseSegmentProperties = (properties: string | null) => {
|
||
if (!properties) return null;
|
||
|
||
try {
|
||
const parsed = JSON.parse(properties);
|
||
return {
|
||
speed: parsed.speed || 1.0,
|
||
source_timerange: parsed.source_timerange || null,
|
||
target_timerange: parsed.target_timerange || null,
|
||
speed_info: parsed.speed_info || null,
|
||
visible: parsed.visible,
|
||
volume: parsed.volume
|
||
};
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const tabs = [
|
||
{ id: 'overview', label: '概览', icon: Monitor },
|
||
{ id: 'materials', label: '素材', icon: FileText },
|
||
{ id: 'tracks', label: '轨道', icon: Layers },
|
||
];
|
||
|
||
const getMaterialName = (material: TemplateMaterial) => {
|
||
if (material.material_type === TemplateMaterialType.Text) {
|
||
return parseTextContent(material.metadata!)?.content?.text
|
||
}
|
||
return material.name
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl mx-4 max-h-[90vh] overflow-hidden">
|
||
{/* 模态框头部 */}
|
||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||
<div>
|
||
<h2 className="text-xl font-semibold text-gray-900">{template.name}</h2>
|
||
{template.description && (
|
||
<p className="text-sm text-gray-600 mt-1">{template.description}</p>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="p-1 rounded-full hover:bg-gray-100 transition-colors"
|
||
>
|
||
<X className="w-5 h-5 text-gray-500" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 标签页导航 */}
|
||
<div className="border-b border-gray-200">
|
||
<nav className="flex space-x-8 px-6">
|
||
{tabs.map((tab) => {
|
||
const Icon = tab.icon;
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveTab(tab.id as any)}
|
||
className={`flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors ${activeTab === tab.id
|
||
? 'border-blue-500 text-blue-600'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<Icon className="w-4 h-4 mr-2" />
|
||
{tab.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
</div>
|
||
|
||
{/* 标签页内容 */}
|
||
<div className="p-6 overflow-y-auto max-h-[60vh]">
|
||
{activeTab === 'overview' && (
|
||
<div className="space-y-6">
|
||
{/* 基本信息 */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div className="bg-gray-50 p-4 rounded-lg">
|
||
<div className="flex items-center mb-2">
|
||
<Monitor className="w-4 h-4 text-gray-600 mr-2" />
|
||
<span className="text-sm font-medium text-gray-700">分辨率</span>
|
||
</div>
|
||
<div className="text-lg font-semibold text-gray-900">
|
||
{template.canvas_config.width}×{template.canvas_config.height}
|
||
</div>
|
||
<div className="text-xs text-gray-500">{template.canvas_config.ratio}</div>
|
||
</div>
|
||
|
||
<div className="bg-gray-50 p-4 rounded-lg">
|
||
<div className="flex items-center mb-2">
|
||
<Clock className="w-4 h-4 text-gray-600 mr-2" />
|
||
<span className="text-sm font-medium text-gray-700">时长</span>
|
||
</div>
|
||
<div className="text-lg font-semibold text-gray-900">
|
||
{formatDuration(template.duration)}
|
||
</div>
|
||
<div className="text-xs text-gray-500">{template.fps} FPS</div>
|
||
</div>
|
||
|
||
<div className="bg-gray-50 p-4 rounded-lg">
|
||
<div className="flex items-center mb-2">
|
||
<FileText className="w-4 h-4 text-gray-600 mr-2" />
|
||
<span className="text-sm font-medium text-gray-700">素材</span>
|
||
</div>
|
||
<div className="text-lg font-semibold text-gray-900">
|
||
{template.materials.length}
|
||
</div>
|
||
<div className="text-xs text-gray-500">个素材</div>
|
||
</div>
|
||
|
||
<div className="bg-gray-50 p-4 rounded-lg">
|
||
<div className="flex items-center mb-2">
|
||
<Layers className="w-4 h-4 text-gray-600 mr-2" />
|
||
<span className="text-sm font-medium text-gray-700">轨道</span>
|
||
</div>
|
||
<div className="text-lg font-semibold text-gray-900">
|
||
{template.tracks.length}
|
||
</div>
|
||
<div className="text-xs text-gray-500">个轨道</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 模板信息 */}
|
||
<div className="bg-gray-50 p-4 rounded-lg">
|
||
<h3 className="text-sm font-medium text-gray-700 mb-3">模板信息</h3>
|
||
<div className="grid grid-cols-1 gap-3 text-sm">
|
||
<div className="flex items-center">
|
||
<FileText className="w-4 h-4 text-gray-500 mr-2" />
|
||
<span className="text-gray-600">模板ID:</span>
|
||
<span className="ml-1 font-mono text-blue-600 text-xs">{template.id}</span>
|
||
</div>
|
||
{template.source_file_path && (
|
||
<div className="flex items-center">
|
||
<FileText className="w-4 h-4 text-gray-500 mr-2" />
|
||
<span className="text-gray-600">源文件:</span>
|
||
<span className="ml-1 text-gray-900 break-all">{template.source_file_path}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 创建信息 */}
|
||
<div className="bg-gray-50 p-4 rounded-lg">
|
||
<h3 className="text-sm font-medium text-gray-700 mb-3">创建信息</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||
<div className="flex items-center">
|
||
<Calendar className="w-4 h-4 text-gray-500 mr-2" />
|
||
<span className="text-gray-600">创建时间:</span>
|
||
<span className="ml-1 text-gray-900">{formatDate(template.created_at)}</span>
|
||
</div>
|
||
<div className="flex items-center">
|
||
<Calendar className="w-4 h-4 text-gray-500 mr-2" />
|
||
<span className="text-gray-600">更新时间:</span>
|
||
<span className="ml-1 text-gray-900">{formatDate(template.updated_at)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'materials' && (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-lg font-medium text-gray-900">
|
||
素材列表 ({template.materials.length})
|
||
</h3>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{template.materials.map((material) => {
|
||
const uploadStatus = getUploadStatusInfo(material);
|
||
const fileExistence = getFileExistenceInfo(material);
|
||
|
||
return (
|
||
<div key={material.id} className="bg-gray-50 p-4 rounded-lg">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex items-start space-x-3">
|
||
{getMaterialIcon(material.material_type)}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm font-medium text-gray-900 truncate">
|
||
{getMaterialName(material)}
|
||
</div>
|
||
<div className="text-xs text-gray-500 mt-1 space-y-1">
|
||
<div>ID: <span className="font-mono text-blue-600">{material.id}</span></div>
|
||
<div>原始ID: <span className="font-mono text-purple-600">{material.original_id}</span></div>
|
||
<div>类型: {material.material_type}</div>
|
||
|
||
{/* 文件存在状态 */}
|
||
<div className="flex items-center space-x-1">
|
||
{fileExistence.icon}
|
||
<span className={fileExistence.color}>{fileExistence.text}</span>
|
||
</div>
|
||
|
||
{/* 上传状态 */}
|
||
<div className="flex items-center space-x-1">
|
||
{uploadStatus.icon}
|
||
<span className={uploadStatus.color}>{uploadStatus.text}</span>
|
||
</div>
|
||
</div>
|
||
{/* 文字素材显示文本内容 */}
|
||
{material.material_type === 'Text' && material.metadata && (() => {
|
||
const textData = parseTextContent(material.metadata);
|
||
return textData ? (
|
||
<div className="text-xs text-gray-700 bg-gray-100 p-2 rounded mt-1">
|
||
<div className="font-medium text-gray-600 mb-1">文本内容:</div>
|
||
<div className="break-words mb-2">
|
||
{textData.content?.text || '无文本内容'}
|
||
</div>
|
||
{(textData.font_family || textData.font_size || textData.color) && (
|
||
<div className="text-xs text-gray-500 space-y-1">
|
||
{textData.font_family && (
|
||
<div>字体: {textData.font_family}</div>
|
||
)}
|
||
{textData.font_size && (
|
||
<div>大小: {textData.font_size}px</div>
|
||
)}
|
||
{textData.color && (
|
||
<div>颜色: {textData.color}</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-xs text-gray-500 bg-gray-100 p-2 rounded mt-1">
|
||
无法解析文本内容
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* 非文字素材显示文件路径 */}
|
||
{material.material_type !== 'Text' && material.original_path && (
|
||
<div className="text-xs text-gray-500 break-all mt-1">
|
||
路径: {material.original_path}
|
||
</div>
|
||
)}
|
||
{material.remote_url && (
|
||
<div className="text-xs text-gray-500 break-all mt-1">
|
||
云端URL: {material.remote_url}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-right text-xs text-gray-500">
|
||
{material.duration && (
|
||
<div>时长: {formatDuration(material.duration)}</div>
|
||
)}
|
||
{material.file_size && (
|
||
<div>大小: {formatFileSize(material.file_size)}</div>
|
||
)}
|
||
{material.width && material.height && (
|
||
<div>尺寸: {material.width}×{material.height}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'tracks' && (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-lg font-medium text-gray-900">
|
||
轨道列表 ({template.tracks.length})
|
||
</h3>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
{template.tracks.map((track) => (
|
||
<div key={track.id} className="bg-gray-50 p-4 rounded-lg">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center space-x-2">
|
||
{getTrackIcon(track.track_type)}
|
||
<div className="flex flex-col">
|
||
<span className="text-sm font-medium text-gray-900">
|
||
{track.name}
|
||
</span>
|
||
<div className="text-xs text-gray-500">
|
||
<span>ID: <span className="font-mono text-green-600">{track.id}</span></span>
|
||
<span className="ml-3">类型: {track.track_type}</span>
|
||
<span className="ml-3">索引: {track.track_index}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span className="text-xs text-gray-500">
|
||
{track.segments.length} 个片段
|
||
</span>
|
||
</div>
|
||
|
||
{track.segments.length > 0 && (
|
||
<div className="space-y-2">
|
||
{track.segments.map((segment) => {
|
||
const segmentProps = parseSegmentProperties(segment.properties || null);
|
||
|
||
return (
|
||
<div key={segment.id} className="bg-white p-3 rounded border">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-sm text-gray-900">{segment.name}</span>
|
||
<span className="text-xs text-gray-500">
|
||
{formatDurationWithMs(segment.start_time)} - {formatDurationWithMs(segment.end_time)}
|
||
</span>
|
||
</div>
|
||
<div className="text-xs text-gray-500 space-y-1">
|
||
<div>
|
||
<span>片段ID: <span className="font-mono text-orange-600">{segment.id}</span></span>
|
||
<span className="ml-3">索引: {segment.segment_index}</span>
|
||
</div>
|
||
<div className="flex items-center space-x-4">
|
||
<span>时长: {formatDurationWithMs(segment.duration)}</span>
|
||
{segmentProps?.speed && segmentProps.speed !== 1.0 && (
|
||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||
segmentProps.speed > 1.0
|
||
? 'bg-red-100 text-red-800'
|
||
: 'bg-green-100 text-green-800'
|
||
}`}>
|
||
速度: {segmentProps.speed.toFixed(3)}x {segmentProps.speed > 1.0 ? '(加速)' : '(减速)'}
|
||
</span>
|
||
)}
|
||
{segmentProps?.speed === 1.0 && (
|
||
<span className="bg-gray-100 text-gray-600 px-2 py-0.5 rounded text-xs">正常速度</span>
|
||
)}
|
||
</div>
|
||
{/* 播放速度不影响片段时长,移除实际播放时长显示 */}
|
||
{/* 时间轴位置信息 */}
|
||
{segmentProps?.target_timerange && (
|
||
<div className="bg-blue-50 p-2 rounded mt-1">
|
||
<div className="text-xs font-medium text-blue-700 mb-1">时间轴位置:</div>
|
||
<div className="text-xs text-blue-600 space-y-0.5">
|
||
<div>开始: {formatDurationWithMs(segmentProps.target_timerange.start)}</div>
|
||
<div>时长: {formatDurationWithMs(segmentProps.target_timerange.duration)}</div>
|
||
<div>结束: {formatDurationWithMs(segmentProps.target_timerange.start + segmentProps.target_timerange.duration)}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 源素材时间范围 */}
|
||
{segmentProps?.source_timerange && (
|
||
<div className="bg-purple-50 p-2 rounded mt-1">
|
||
<div className="text-xs font-medium text-purple-700 mb-1">源素材时间范围:</div>
|
||
<div className="text-xs text-purple-600 space-y-0.5">
|
||
<div>开始位置: {formatDurationWithMs(segmentProps.source_timerange.start)}</div>
|
||
<div>截取时长: {formatDurationWithMs(segmentProps.source_timerange.duration)}</div>
|
||
<div>结束位置: {formatDurationWithMs(segmentProps.source_timerange.start + segmentProps.source_timerange.duration)}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* 其他属性信息 */}
|
||
{(segmentProps?.visible !== undefined || segmentProps?.volume !== undefined) && (
|
||
<div className="bg-gray-50 p-2 rounded mt-1">
|
||
<div className="text-xs font-medium text-gray-700 mb-1">其他属性:</div>
|
||
<div className="text-xs text-gray-600 space-y-0.5">
|
||
{segmentProps?.visible !== undefined && (
|
||
<div>可见性: {segmentProps.visible ? '可见' : '隐藏'}</div>
|
||
)}
|
||
{segmentProps?.volume !== undefined && (
|
||
<div>音量: {(segmentProps.volume * 100).toFixed(0)}%</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 匹配规则编辑器 */}
|
||
<div className="bg-yellow-50 p-2 rounded mt-1">
|
||
<SegmentMatchingRuleEditor
|
||
segmentId={segment.id}
|
||
currentRule={segment.matching_rule}
|
||
onRuleUpdated={(newRule) => {
|
||
// 可以在这里添加更新后的回调逻辑
|
||
console.log('片段匹配规则已更新:', segment.id, newRule);
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{segment.template_material_id && (
|
||
<div>
|
||
使用素材ID: <span className="font-mono text-blue-600">{segment.template_material_id}</span>
|
||
</div>
|
||
)}
|
||
{!segment.template_material_id && (
|
||
<div className="text-gray-400">未关联素材</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 模态框底部 */}
|
||
<div className="flex items-center justify-end p-6 border-t border-gray-200">
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||
>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|