feat(files): improve category and location layout

This commit is contained in:
sinohqb 2026-07-17 18:13:43 +08:00
parent d7514f4e65
commit 9293f9e842
8 changed files with 146 additions and 23 deletions

View File

@ -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,
)

View File

@ -166,6 +166,7 @@ export interface FileRecord {
mime_type: string
file_ext: string
category_id: string | null
storage_directory: string
created_at: string | null
}

View File

@ -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]}

View File

@ -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,

View File

@ -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;
}
}

View File

@ -114,6 +114,7 @@ export default function FilesPage() {
/>
<FileTable
files={files}
categories={categories}
loading={loading}
categoryName={selectedCategoryName}
onRefresh={() => void refresh().catch(() => undefined)}

View File

@ -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,

View File

@ -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")