feat(files): improve category and location layout
This commit is contained in:
parent
d7514f4e65
commit
9293f9e842
@ -41,6 +41,7 @@ class FileRecordResponse(BaseModel):
|
||||
mime_type: str
|
||||
file_ext: str
|
||||
category_id: str | None
|
||||
storage_directory: str
|
||||
created_at: datetime | None
|
||||
|
||||
@field_serializer("created_at")
|
||||
@ -56,6 +57,7 @@ class FileRecordResponse(BaseModel):
|
||||
mime_type=record.mime_type,
|
||||
file_ext=record.file_ext,
|
||||
category_id=record.category_id,
|
||||
storage_directory="data/files",
|
||||
created_at=record.created_at,
|
||||
)
|
||||
|
||||
|
||||
@ -166,6 +166,7 @@ export interface FileRecord {
|
||||
mime_type: string
|
||||
file_ext: string
|
||||
category_id: string | null
|
||||
storage_directory: string
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FileOutlined,
|
||||
FolderAddOutlined,
|
||||
FolderOutlined,
|
||||
PlusOutlined,
|
||||
@ -32,10 +31,10 @@ export default function FileCategoryTree({
|
||||
}: FileCategoryTreeProps) {
|
||||
const mapCategory = (category: FileCategory): DataNode => ({
|
||||
key: category.id,
|
||||
icon: <FolderOutlined style={{ color: '#d48806' }} />,
|
||||
title: (
|
||||
<div className="cat-tree-node">
|
||||
<span className="cat-tree-label">{category.name}</span>
|
||||
<FolderOutlined className="cat-tree-icon" />
|
||||
<span className="cat-tree-label" title={category.name}>{category.name}</span>
|
||||
<span className="cat-actions">
|
||||
<Tooltip title="新建子分类">
|
||||
<Button
|
||||
@ -84,12 +83,11 @@ export default function FileCategoryTree({
|
||||
|
||||
const treeData: DataNode[] = [{
|
||||
key: ALL_FILES_KEY,
|
||||
icon: <FileOutlined />,
|
||||
title: (
|
||||
<span style={{ fontWeight: 600, fontSize: fontSizes.emphasis }}>
|
||||
<FolderOutlined style={{ marginRight: 6 }} />
|
||||
全部文件
|
||||
</span>
|
||||
<div className="cat-tree-node cat-tree-node-all">
|
||||
<FolderOutlined className="cat-tree-icon" />
|
||||
<span className="cat-tree-label">全部文件</span>
|
||||
</div>
|
||||
),
|
||||
children: categories.map(mapCategory),
|
||||
}]
|
||||
@ -112,7 +110,6 @@ export default function FileCategoryTree({
|
||||
</div>
|
||||
<div className="files-category-scroll">
|
||||
<Tree
|
||||
showIcon
|
||||
defaultExpandAll
|
||||
treeData={treeData}
|
||||
selectedKeys={[selectedCategoryId ?? ALL_FILES_KEY]}
|
||||
|
||||
@ -7,16 +7,18 @@ import {
|
||||
ReloadOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Button, Popconfirm, Space, Table, Tag, Tooltip } from 'antd'
|
||||
import { Button, Grid, Popconfirm, Space, Table, Tag, Tooltip } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMemo } from 'react'
|
||||
import type { FileRecord } from '../../api'
|
||||
import type { FileCategory, FileRecord } from '../../api'
|
||||
import { colors, fontSizes } from '../../tokens'
|
||||
import { formatDateTime } from '../../utils/date'
|
||||
import { extensionColor, formatFileSize } from '../../utils/fileFormat'
|
||||
import { getCategoryPath } from '../../utils/fileTree'
|
||||
|
||||
interface FileTableProps {
|
||||
files: FileRecord[]
|
||||
categories: FileCategory[]
|
||||
loading: boolean
|
||||
categoryName: string
|
||||
onRefresh: () => void
|
||||
@ -27,6 +29,7 @@ interface FileTableProps {
|
||||
|
||||
export default function FileTable({
|
||||
files,
|
||||
categories,
|
||||
loading,
|
||||
categoryName,
|
||||
onRefresh,
|
||||
@ -34,12 +37,30 @@ export default function FileTable({
|
||||
onDownload,
|
||||
onDelete,
|
||||
}: FileTableProps) {
|
||||
const screens = Grid.useBreakpoint()
|
||||
const totalSize = useMemo(() => files.reduce((sum, file) => sum + file.file_size, 0), [files])
|
||||
const extensionCounts = useMemo(() => files.reduce<Record<string, number>>((counts, file) => {
|
||||
counts[file.file_ext] = (counts[file.file_ext] || 0) + 1
|
||||
return counts
|
||||
}, {}), [files])
|
||||
|
||||
const renderLocation = (record: FileRecord, mobile = false) => {
|
||||
const categoryPath = record.category_id
|
||||
? getCategoryPath(categories, record.category_id)
|
||||
: []
|
||||
const categoryLabel = categoryPath.length > 0 ? categoryPath.join(' / ') : '未分类'
|
||||
|
||||
return (
|
||||
<div className={`files-location-cell${mobile ? ' files-location-mobile' : ''}`}>
|
||||
<div className="files-category-path" title={categoryLabel}>
|
||||
<FolderOutlined />
|
||||
<span>{categoryLabel}</span>
|
||||
</div>
|
||||
<code title={record.storage_directory}>{record.storage_directory}</code>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: ColumnsType<FileRecord> = [
|
||||
{
|
||||
title: '文件名',
|
||||
@ -47,12 +68,22 @@ export default function FileTable({
|
||||
key: 'name',
|
||||
ellipsis: true,
|
||||
render: (name: string, record) => (
|
||||
<Space size={8} style={{ maxWidth: '100%' }}>
|
||||
<FileOutlined style={{ color: extensionColor(record.file_ext), fontSize: 16 }} />
|
||||
<span className="files-name-cell">{name}</span>
|
||||
</Space>
|
||||
<div className="files-name-stack">
|
||||
<div className="files-name-row">
|
||||
<FileOutlined style={{ color: extensionColor(record.file_ext), fontSize: 16 }} />
|
||||
<span className="files-name-cell">{name}</span>
|
||||
</div>
|
||||
{renderLocation(record, true)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '文件位置',
|
||||
key: 'location',
|
||||
width: 260,
|
||||
responsive: ['md'],
|
||||
render: (_, record) => renderLocation(record),
|
||||
},
|
||||
{
|
||||
title: '大小',
|
||||
dataIndex: 'file_size',
|
||||
@ -160,7 +191,7 @@ export default function FileTable({
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 620 }}
|
||||
scroll={screens.md ? { x: 900 } : undefined}
|
||||
pagination={files.length > 10 ? {
|
||||
pageSize: 15,
|
||||
showSizeChanger: true,
|
||||
|
||||
@ -164,15 +164,32 @@ body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cat-tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
.files-category-scroll .ant-tree-title {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cat-tree-node {
|
||||
display: grid;
|
||||
grid-template-columns: 16px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cat-tree-node-all {
|
||||
grid-template-columns: 16px minmax(0, 1fr);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cat-tree-icon {
|
||||
color: #d48806;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.cat-tree-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@ -238,15 +255,67 @@ body {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.files-name-cell {
|
||||
display: block;
|
||||
.files-name-stack {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.files-name-row {
|
||||
display: grid;
|
||||
grid-template-columns: 16px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.files-name-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.files-location-cell {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.files-category-path {
|
||||
display: grid;
|
||||
grid-template-columns: 14px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
color: #595959;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.files-category-path .anticon {
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.files-category-path span,
|
||||
.files-location-cell code {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.files-location-cell code {
|
||||
display: block;
|
||||
padding-left: 19px;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.files-location-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.files-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -293,3 +362,14 @@ body {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.files-location-mobile {
|
||||
display: grid;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.files-location-mobile code {
|
||||
padding-left: 19px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,6 +114,7 @@ export default function FilesPage() {
|
||||
/>
|
||||
<FileTable
|
||||
files={files}
|
||||
categories={categories}
|
||||
loading={loading}
|
||||
categoryName={selectedCategoryName}
|
||||
onRefresh={() => void refresh().catch(() => undefined)}
|
||||
|
||||
@ -18,6 +18,15 @@ export function categoryContains(category: FileCategory, id: string): boolean {
|
||||
return category.id === id || category.children.some((child) => categoryContains(child, id))
|
||||
}
|
||||
|
||||
export function getCategoryPath(categories: FileCategory[], id: string): string[] {
|
||||
for (const category of categories) {
|
||||
if (category.id === id) return [category.name]
|
||||
const nestedPath = getCategoryPath(category.children, id)
|
||||
if (nestedPath.length > 0) return [category.name, ...nestedPath]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function flattenCategoryOptions(
|
||||
categories: FileCategory[],
|
||||
depth = 0,
|
||||
|
||||
@ -134,6 +134,8 @@ def test_upload_download_and_delete_file(files_client):
|
||||
record = uploaded.json()
|
||||
assert record["original_name"] == "notes.txt"
|
||||
assert record["file_size"] == 5
|
||||
assert record["storage_directory"] == "data/files"
|
||||
assert client.get("/api/files").json()[0]["storage_directory"] == "data/files"
|
||||
assert len(list(files_dir.iterdir())) == 1
|
||||
|
||||
downloaded = client.get(f"/api/files/{record['id']}/download")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user