668 lines
31 KiB
TypeScript
668 lines
31 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import type { Asset, AssetCategory, AssetCategoryDef, AssetFileType } from '@/types';
|
|
import { assetsAPI } from '@/api';
|
|
import { formatBjTime } from '@/utils/datetime';
|
|
import './AssetsPage.css';
|
|
|
|
const fileTypeLabels: Record<string, { label: string; bg: string; color: string }> = {
|
|
word: { label: 'Word', bg: '#E8F1FF', color: '#2563EB' },
|
|
excel: { label: 'Excel', bg: '#E8F8EE', color: '#219B5E' },
|
|
ppt: { label: 'PPT', bg: '#FEF2E2', color: '#D97706' },
|
|
pdf: { label: 'PDF', bg: '#FDEBEB', color: '#DC2626' },
|
|
txt: { label: 'TXT', bg: '#F1F3F7', color: '#64748B' },
|
|
md: { label: 'MD', bg: '#E8EEF8', color: '#3B82F6' },
|
|
doc: { label: 'DOC', bg: '#E6F4EA', color: '#0D9488' },
|
|
other: { label: '其他', bg: '#F1F3F7', color: '#64748B' },
|
|
};
|
|
|
|
// SVG Icons
|
|
const CloseIcon = () => (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
);
|
|
|
|
const DownloadIcon = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
|
</svg>
|
|
);
|
|
|
|
const TrashIcon = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
|
</svg>
|
|
);
|
|
|
|
const getFileTypeInfo = (ft?: string) => {
|
|
return fileTypeLabels[ft || 'other'] || fileTypeLabels.other;
|
|
};
|
|
|
|
function formatSize(bytes?: number): string {
|
|
if (!bytes) return '--';
|
|
if (bytes < 1024) return `${bytes}B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
return formatBjTime(dateStr);
|
|
}
|
|
|
|
// 将文件扩展名或API返回的file_type映射到前端fileType标签
|
|
function mapFileTypeToLabel(ft: string): string {
|
|
const mapping: Record<string, string> = {
|
|
pdf: 'pdf', docx: 'word', doc: 'word', xlsx: 'excel', xls: 'excel',
|
|
pptx: 'ppt', ppt: 'ppt', csv: 'txt', md: 'md', txt: 'txt',
|
|
word: 'word', excel: 'excel',
|
|
};
|
|
return mapping[ft] || 'other';
|
|
}
|
|
|
|
// Preview Modal Component
|
|
interface PreviewModalProps {
|
|
asset: Asset;
|
|
onClose: () => void;
|
|
onDelete: (id: string) => void;
|
|
}
|
|
|
|
const PreviewModal: React.FC<PreviewModalProps> = ({ asset, onClose, onDelete }) => {
|
|
const [editing, setEditing] = useState(false);
|
|
const [editContent, setEditContent] = useState('');
|
|
const [showOnlinePreview, setShowOnlinePreview] = useState(false);
|
|
const navigate = useNavigate();
|
|
|
|
const isTextFile = asset.category === 'file' && ['txt', 'other', 'md', 'doc'].includes(asset.fileType || '');
|
|
const isImage = asset.category === 'image';
|
|
const isVideo = asset.category === 'video';
|
|
const isAudio = asset.category === 'audio';
|
|
const isTextEditable = asset.category === 'file' && (asset.fileType === 'txt' || asset.fileType === 'other' || asset.fileType === 'md' || asset.fileType === 'doc');
|
|
const isOfficeDoc = asset.category === 'file' && ['word', 'excel', 'ppt'].includes(asset.fileType || '');
|
|
const isPdf = asset.category === 'file' && asset.fileType === 'pdf';
|
|
|
|
const handleDownload = () => {
|
|
// Simulate download - in production would use asset.url
|
|
const a = document.createElement('a');
|
|
a.href = '#' + asset.id;
|
|
a.download = asset.name;
|
|
a.click();
|
|
};
|
|
|
|
const handleStartEdit = () => {
|
|
setEditContent(asset.description || '');
|
|
setEditing(true);
|
|
};
|
|
|
|
const handleSaveEdit = () => {
|
|
// In production, would call assetsAPI.updateAsset
|
|
setEditing(false);
|
|
};
|
|
|
|
// 在线预览内容渲染
|
|
const renderOnlinePreview = () => {
|
|
if (!showOnlinePreview) return null;
|
|
const ft = asset.fileType || 'other';
|
|
|
|
// PDF 使用浏览器原生预览
|
|
if (ft === 'pdf' && asset.url) {
|
|
return (
|
|
<div className="assets-preview-online">
|
|
<div className="assets-preview-online-toolbar">
|
|
<span>在线预览:{asset.name}</span>
|
|
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
|
|
关闭预览
|
|
</button>
|
|
</div>
|
|
<iframe
|
|
src={asset.url}
|
|
style={{width:'100%',height:'600px',border:'1px solid var(--color-border)',borderRadius:'0 0 var(--radius-md) var(--radius-md)'}}
|
|
title={`预览 ${asset.name}`}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Office文档(docx/xlsx/pptx) 使用微软Office Online查看器
|
|
if (asset.url && ['word', 'excel', 'ppt'].includes(ft)) {
|
|
const encodedUrl = encodeURIComponent(asset.url);
|
|
const officeViewerUrl = `https://view.officeapps.live.com/op/view.aspx?src=${encodedUrl}`;
|
|
return (
|
|
<div className="assets-preview-online">
|
|
<div className="assets-preview-online-toolbar">
|
|
<span>在线预览:{asset.name}</span>
|
|
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
|
|
关闭预览
|
|
</button>
|
|
</div>
|
|
<iframe
|
|
src={officeViewerUrl}
|
|
style={{width:'100%',height:'600px',border:'1px solid var(--color-border)',borderRadius:'0 0 var(--radius-md) var(--radius-md)'}}
|
|
title={`预览 ${asset.name}`}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 文本类文件的在线预览
|
|
if (['txt', 'word', 'other', 'md', 'doc'].includes(ft)) {
|
|
const previewContent = (asset as any).content || asset.description || '(文件内容预览)\n\n该文件暂无内容预览。\n\n--- 在线预览模式 ---\n您正在查看文件的只读预览。';
|
|
return (
|
|
<div className="assets-preview-online">
|
|
<div className="assets-preview-online-toolbar">
|
|
<span>在线预览:{asset.name}</span>
|
|
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
|
|
关闭预览
|
|
</button>
|
|
</div>
|
|
<div className="assets-preview-reader">
|
|
<pre style={{whiteSpace:'pre-wrap',fontFamily:'var(--font-mono)',fontSize:'var(--text-sm)',lineHeight:1.8,padding:'var(--space-xl)',color:'var(--color-text-primary)'}}>
|
|
{previewContent}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
return (
|
|
<div className="assets-modal-overlay" onClick={onClose}>
|
|
<div className="assets-modal" onClick={e => e.stopPropagation()}>
|
|
<div className="assets-modal-header">
|
|
<h3 className="assets-modal-title">{asset.name}</h3>
|
|
<button className="assets-modal-close" onClick={onClose}><CloseIcon /></button>
|
|
</div>
|
|
<div className="assets-modal-body">
|
|
<div className="assets-modal-meta">
|
|
<span className="assets-modal-type">
|
|
{asset.category === 'file' && (
|
|
<span className="asset-file-type" style={{ background: getFileTypeInfo(asset.fileType).bg, color: getFileTypeInfo(asset.fileType).color }}>
|
|
{getFileTypeInfo(asset.fileType).label}
|
|
</span>
|
|
)}
|
|
<span className="assets-modal-cat">{asset.category === 'image' ? '图片' : asset.category === 'video' ? '视频' : asset.category === 'audio' ? '音频' : asset.category === 'novel' ? '小说' : '文件'}</span>
|
|
</span>
|
|
{asset.size && <span className="assets-modal-size">{formatSize(asset.size)}</span>}
|
|
<span className="assets-modal-date">{formatDate(asset.updatedAt)}</span>
|
|
</div>
|
|
|
|
{/* Text preview */}
|
|
{isTextFile && !editing && !showOnlinePreview && (
|
|
<div className="assets-preview-text">
|
|
<pre className="assets-preview-code">
|
|
{(asset as any).content || asset.description || '(文件内容预览)\n\n这是一个文本类型的文件。\n内容由后端API提供。'}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{/* Text editing */}
|
|
{isTextEditable && editing && (
|
|
<div className="assets-edit-section">
|
|
<textarea
|
|
className="assets-edit-textarea"
|
|
value={editContent}
|
|
onChange={e => setEditContent(e.target.value)}
|
|
rows={12}
|
|
/>
|
|
<div className="assets-edit-actions">
|
|
<button className="assets-btn assets-btn-primary" onClick={handleSaveEdit}>保存</button>
|
|
<button className="assets-btn assets-btn-cancel" onClick={() => setEditing(false)}>取消</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Image preview */}
|
|
{isImage && !showOnlinePreview && (
|
|
<div className="assets-preview-image">
|
|
<img src={asset.url || ''} alt={asset.name} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
|
{!asset.url && (
|
|
<div style={{ padding: 40, textAlign: 'center', color: 'var(--color-text-tertiary)' }}>
|
|
无法加载图片预览
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* Image online preview */}
|
|
{isImage && showOnlinePreview && (
|
|
<div className="assets-preview-online">
|
|
<div className="assets-preview-online-toolbar">
|
|
<span>在线预览:{asset.name}</span>
|
|
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
|
|
关闭预览
|
|
</button>
|
|
</div>
|
|
<img src={asset.url || ''} alt={asset.name} style={{maxWidth:'100%',maxHeight:'500px',objectFit:'contain'}}
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Video preview */}
|
|
{isVideo && !showOnlinePreview && (
|
|
<div className="assets-preview-video">
|
|
<video controls src={asset.url || ''}>
|
|
您的浏览器不支持视频播放
|
|
</video>
|
|
</div>
|
|
)}
|
|
|
|
{/* Audio preview */}
|
|
{isAudio && !showOnlinePreview && (
|
|
<div className="assets-preview-audio">
|
|
<audio controls src={asset.url || ''}>
|
|
您的浏览器不支持音频播放
|
|
</audio>
|
|
</div>
|
|
)}
|
|
|
|
{/* Online Preview */}
|
|
{renderOnlinePreview()}
|
|
|
|
{/* Action buttons */}
|
|
<div className="assets-modal-actions">
|
|
{isTextFile && !editing && isTextEditable && (
|
|
<button className="assets-btn assets-btn-secondary" onClick={handleStartEdit}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
编辑
|
|
</button>
|
|
)}
|
|
{!editing && (
|
|
<button className="assets-btn assets-btn-secondary" onClick={handleDownload}>
|
|
<DownloadIcon />
|
|
下载
|
|
</button>
|
|
)}
|
|
{(isTextFile || isImage || isOfficeDoc || isPdf) && !editing && (
|
|
<button
|
|
className="assets-btn assets-btn-secondary"
|
|
onClick={() => setShowOnlinePreview(!showOnlinePreview)}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
{showOnlinePreview ? '关闭预览' : '在线预览'}
|
|
</button>
|
|
)}
|
|
{asset.category === 'novel' && (
|
|
<button className="assets-btn assets-btn-primary" onClick={() => navigate(`/assets/novel/${asset.id}`)}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
|
阅读
|
|
</button>
|
|
)}
|
|
<button className="assets-btn assets-btn-danger" onClick={() => { if (window.confirm('确定删除该资产?')) { onDelete(asset.id); onClose(); } }}>
|
|
<TrashIcon />
|
|
删除
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Create Asset Modal
|
|
interface CreateAssetModalProps {
|
|
onClose: () => void;
|
|
onCreated: () => void;
|
|
}
|
|
|
|
const CreateAssetModal: React.FC<CreateAssetModalProps> = ({ onClose, onCreated }) => {
|
|
const [name, setName] = useState('');
|
|
const [fileType, setFileType] = useState('txt');
|
|
const [content, setContent] = useState('');
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
const handleCreate = async () => {
|
|
if (!name.trim()) return;
|
|
setCreating(true);
|
|
try {
|
|
await assetsAPI.createAsset({
|
|
name: name.trim(),
|
|
category: 'file',
|
|
file_type: fileType,
|
|
content: content,
|
|
description: content.slice(0, 100),
|
|
});
|
|
onCreated();
|
|
onClose();
|
|
} catch (err) {
|
|
console.error('创建失败', err);
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="assets-modal-overlay" onClick={onClose}>
|
|
<div className="assets-modal assets-create-modal" onClick={e => e.stopPropagation()}>
|
|
<div className="assets-modal-header">
|
|
<h3 className="assets-modal-title">创建资产</h3>
|
|
<button className="assets-modal-close" onClick={onClose}><CloseIcon /></button>
|
|
</div>
|
|
<div className="assets-modal-body">
|
|
<div className="form-section">
|
|
<label className="form-label">名称 <span className="required">*</span></label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
placeholder="请输入资产名称"
|
|
value={name}
|
|
onChange={e => setName(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="form-section">
|
|
<label className="form-label">文件类型</label>
|
|
<select className="form-input" value={fileType} onChange={e => setFileType(e.target.value)}>
|
|
<option value="txt">TXT</option>
|
|
<option value="word">Word</option>
|
|
<option value="excel">Excel</option>
|
|
<option value="ppt">PPT</option>
|
|
<option value="pdf">PDF</option>
|
|
<option value="other">其他</option>
|
|
</select>
|
|
</div>
|
|
<div className="form-section">
|
|
<label className="form-label">内容</label>
|
|
<textarea
|
|
className="form-textarea"
|
|
placeholder="输入文件内容..."
|
|
value={content}
|
|
onChange={e => setContent(e.target.value)}
|
|
rows={6}
|
|
/>
|
|
</div>
|
|
<button
|
|
className="form-submit-btn"
|
|
onClick={handleCreate}
|
|
disabled={!name.trim() || creating}
|
|
>
|
|
{creating ? '创建中...' : '创建'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ===== Main AssetsPage Component =====
|
|
const AssetsPage: React.FC = () => {
|
|
const [activeCategory, setActiveCategory] = useState<string>('all');
|
|
const [subCategory, setSubCategory] = useState<string>('all');
|
|
const [assets, setAssets] = useState<Asset[]>([]);
|
|
const [categories, setCategories] = useState<AssetCategoryDef[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const navigate = useNavigate();
|
|
|
|
// 将后端返回的资产标准化:
|
|
// 1. 统一 fileType(兼容 file_type 字段)
|
|
// 2. 兼容遗留的文档类分类(doc/document 等)与带文件类型但分类异常的资产,
|
|
// 统一归入 file 分类,确保它们出现在「文件」页签及其对应的子类型(如 pdf)中。
|
|
const VALID_CATEGORIES = ['file', 'novel', 'image', 'video', 'audio', 'custom', 'other'];
|
|
const normalizeAsset = (item: any): Asset => {
|
|
const rawCat = item.category;
|
|
const ft = mapFileTypeToLabel(item.fileType || item.file_type || 'other');
|
|
let category = rawCat;
|
|
if (!VALID_CATEGORIES.includes(category)) {
|
|
category = (ft && ft !== 'other') ? 'file' : 'other';
|
|
}
|
|
return { ...item, category, fileType: ft };
|
|
};
|
|
|
|
// 一次性拉取当前用户全部资产(分页累加),分类与子类型筛选全部在前端完成,
|
|
// 避免后端按 category 过滤时漏掉历史遗留分类(如 doc)的资产。
|
|
const fetchAllAssets = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
let all: any[] = [];
|
|
let page = 1;
|
|
const pageSize = 100;
|
|
for (;;) {
|
|
const res = await assetsAPI.getAssets({ page, page_size: pageSize });
|
|
const data = (res.data as any) || {};
|
|
const items: any[] = data.items || [];
|
|
const total: number = data.total || 0;
|
|
all = all.concat(items);
|
|
if (items.length === 0 || all.length >= total) break;
|
|
page += 1;
|
|
}
|
|
const normalized = all.map(normalizeAsset);
|
|
// 按id去重
|
|
const seen = new Set<string>();
|
|
const deduped = normalized.filter(a => {
|
|
if (seen.has(a.id)) return false;
|
|
seen.add(a.id);
|
|
return true;
|
|
});
|
|
setAssets(deduped);
|
|
} catch (err: any) {
|
|
setError(err.message || '加载失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const DEFAULT_CATEGORIES: AssetCategoryDef[] = [
|
|
{ id: 'file', name: '文件', type: 'file', color: '#5B5FED', icon: 'file', isDefault: true },
|
|
{ id: 'image', name: '图片', type: 'image', color: '#219B5E', icon: 'image', isDefault: true },
|
|
{ id: 'video', name: '视频', type: 'video', color: '#D9383A', icon: 'video', isDefault: true },
|
|
{ id: 'audio', name: '音频', type: 'audio', color: '#8B5CF6', icon: 'audio', isDefault: true },
|
|
{ id: 'novel', name: '小说', type: 'novel', color: '#E8922C', icon: 'novel', isDefault: true },
|
|
{ id: 'other', name: '其他', type: 'other', color: '#64748B', icon: 'other', isDefault: true },
|
|
{ id: 'custom', name: '自定义', type: 'custom', color: '#EC4899', icon: 'custom', isDefault: true },
|
|
];
|
|
|
|
const fetchCategories = async () => {
|
|
try {
|
|
const res = await assetsAPI.getCategories();
|
|
const apiCats = res.data || [];
|
|
const merged: AssetCategoryDef[] = [...apiCats];
|
|
for (const def of DEFAULT_CATEGORIES) {
|
|
if (!merged.some((c: any) => c.type === def.type)) {
|
|
merged.push(def);
|
|
}
|
|
}
|
|
setCategories(merged);
|
|
} catch (err) {
|
|
console.error('分类加载失败', err);
|
|
setCategories(DEFAULT_CATEGORIES);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchCategories();
|
|
fetchAllAssets();
|
|
}, []);
|
|
|
|
// Filter by sub-category for files
|
|
const filtered = (() => {
|
|
// 如果是文件分类,先确保只显示文件类资产
|
|
let base = activeCategory === 'all' ? assets : assets.filter(a => a.category === activeCategory);
|
|
// 在文件分类下,按子分类再筛选
|
|
if (activeCategory === 'file' && subCategory !== 'all') {
|
|
base = base.filter(a => a.fileType === subCategory);
|
|
}
|
|
return base;
|
|
})();
|
|
|
|
const handleDelete = async (id: string) => {
|
|
try {
|
|
await assetsAPI.deleteAsset(id);
|
|
setAssets(prev => prev.filter(a => a.id !== id));
|
|
} catch (err) {
|
|
console.error('删除失败', err);
|
|
}
|
|
};
|
|
|
|
const showFileSubTabs = activeCategory === 'file';
|
|
|
|
return (
|
|
<div className="assets-page">
|
|
<div className="assets-header">
|
|
<h1 className="assets-title">我的资产</h1>
|
|
<span className="assets-count">{filtered.length} 项</span>
|
|
<button className="assets-create-btn" onClick={() => setShowCreate(true)}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
创建
|
|
</button>
|
|
</div>
|
|
|
|
{/* Category Tabs */}
|
|
<div className="assets-tabs">
|
|
<button className={`assets-tab ${activeCategory === 'all' ? 'active' : ''}`} onClick={() => { setActiveCategory('all'); setSubCategory('all'); }}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
|
全部
|
|
</button>
|
|
{categories.length > 0 ? categories.map(cat => (
|
|
<button
|
|
key={cat.id}
|
|
className={`assets-tab ${activeCategory === cat.type ? 'active' : ''}`}
|
|
onClick={() => { setActiveCategory(cat.type); setSubCategory('all'); }}
|
|
style={activeCategory === cat.type ? { '--tab-color': cat.color, '--tab-bg': cat.color + '18' } as React.CSSProperties : undefined}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
{cat.type === 'file' && <><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></>}
|
|
{cat.type === 'novel' && <><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></>}
|
|
{cat.type === 'image' && <><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></>}
|
|
{cat.type === 'video' && <><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></>}
|
|
{cat.type === 'audio' && <><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></>}
|
|
{cat.type === 'other' && <><circle cx="12" cy="12" r="10"/><line x1="8" y1="12" x2="16" y2="12"/></>}
|
|
{cat.type === 'custom' && <><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></>}
|
|
</svg>
|
|
{cat.name}
|
|
</button>
|
|
)) : (
|
|
<>
|
|
<button className={`assets-tab ${activeCategory === 'file' ? 'active' : ''}`} onClick={() => { setActiveCategory('file'); setSubCategory('all'); }} style={activeCategory === 'file' ? { '--tab-color': '#5B5FED', '--tab-bg': '#5B5FED18' } as React.CSSProperties : undefined}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
|
文件
|
|
</button>
|
|
<button className={`assets-tab ${activeCategory === 'image' ? 'active' : ''}`} onClick={() => { setActiveCategory('image'); setSubCategory('all'); }} style={activeCategory === 'image' ? { '--tab-color': '#219B5E', '--tab-bg': '#10B98118' } as React.CSSProperties : undefined}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
|
图片
|
|
</button>
|
|
<button className={`assets-tab ${activeCategory === 'video' ? 'active' : ''}`} onClick={() => { setActiveCategory('video'); setSubCategory('all'); }} style={activeCategory === 'video' ? { '--tab-color': '#D9383A', '--tab-bg': '#EF444418' } as React.CSSProperties : undefined}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></svg>
|
|
视频
|
|
</button>
|
|
<button className={`assets-tab ${activeCategory === 'audio' ? 'active' : ''}`} onClick={() => { setActiveCategory('audio'); setSubCategory('all'); }} style={activeCategory === 'audio' ? { '--tab-color': '#8B5CF6', '--tab-bg': '#8B5CF618' } as React.CSSProperties : undefined}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
音频
|
|
</button>
|
|
<button className={`assets-tab ${activeCategory === 'novel' ? 'active' : ''}`} onClick={() => { setActiveCategory('novel'); setSubCategory('all'); }} style={activeCategory === 'novel' ? { '--tab-color': '#E8922C', '--tab-bg': '#F59E0B18' } as React.CSSProperties : undefined}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
|
小说
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* File sub-tabs */}
|
|
{showFileSubTabs && (
|
|
<div className="assets-subtabs">
|
|
<button className={`assets-subtab ${subCategory === 'all' ? 'active' : ''}`} onClick={() => setSubCategory('all')}>全部文件</button>
|
|
{(['word', 'excel', 'ppt', 'pdf', 'txt', 'md', 'doc', 'other'] as AssetFileType[]).map(ft => (
|
|
<button key={ft} className={`assets-subtab ${subCategory === ft ? 'active' : ''}`} onClick={() => setSubCategory(ft)}>
|
|
{fileTypeLabels[ft].label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Asset list */}
|
|
<div className="assets-list">
|
|
{loading ? (
|
|
<div className="assets-loading">
|
|
<div className="audit-spinner" />
|
|
<span>加载中...</span>
|
|
</div>
|
|
) : error ? (
|
|
<div className="assets-empty">
|
|
<p>加载失败:{error}</p>
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="assets-empty">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--color-text-disabled)' }}><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
|
|
<p>暂无资产</p>
|
|
</div>
|
|
) : (
|
|
<div className={`assets-grid ${activeCategory === 'novel' ? 'novel-grid' : ''}`}>
|
|
{filtered.map(asset => {
|
|
if (asset.category === 'novel') {
|
|
return (
|
|
<div key={asset.id} className="asset-novel-card" onClick={() => setSelectedAsset(asset)}>
|
|
<div className="novel-card-cover">
|
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#E8922C', opacity: 0.6 }}><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
|
<span className="novel-card-badge">小说</span>
|
|
</div>
|
|
<div className="novel-card-info">
|
|
<h3 className="novel-card-title">{asset.name}</h3>
|
|
<p className="novel-card-desc">{asset.description}</p>
|
|
<div className="novel-card-meta">
|
|
<span>{asset.chapters?.length || 0} 章</span>
|
|
<span>·</span>
|
|
<span>{asset.chapters?.reduce((s, c) => s + c.wordCount, 0).toLocaleString() || 0} 字</span>
|
|
<span>·</span>
|
|
<span>{formatDate(asset.updatedAt)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div key={asset.id} className="asset-card" onClick={() => setSelectedAsset(asset)}>
|
|
<div className="asset-card-icon">
|
|
{asset.category === 'file' && (
|
|
<span className="asset-file-type" style={{ background: getFileTypeInfo(asset.fileType).bg, color: getFileTypeInfo(asset.fileType).color }}>
|
|
{getFileTypeInfo(asset.fileType).label}
|
|
</span>
|
|
)}
|
|
{asset.category === 'image' && (
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#219B5E" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
|
)}
|
|
{asset.category === 'video' && (
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#D9383A" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></svg>
|
|
)}
|
|
{asset.category === 'audio' && (
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#8B5CF6" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
)}
|
|
</div>
|
|
<div className="asset-card-body">
|
|
<h4 className="asset-card-name">{asset.name}</h4>
|
|
{asset.description && <p className="asset-card-desc">{asset.description}</p>}
|
|
<div className="asset-card-footer">
|
|
{asset.size && <span className="asset-card-size">{formatSize(asset.size)}</span>}
|
|
<span className="asset-card-date">{formatDate(asset.updatedAt)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Preview Modal */}
|
|
{selectedAsset && (
|
|
<PreviewModal
|
|
asset={selectedAsset}
|
|
onClose={() => setSelectedAsset(null)}
|
|
onDelete={handleDelete}
|
|
/>
|
|
)}
|
|
|
|
{/* Create Modal */}
|
|
{showCreate && (
|
|
<CreateAssetModal
|
|
onClose={() => setShowCreate(false)}
|
|
onCreated={() => fetchAllAssets()}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AssetsPage;
|