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 mime_type: str
file_ext: str file_ext: str
category_id: str | None category_id: str | None
storage_directory: str
created_at: datetime | None created_at: datetime | None
@field_serializer("created_at") @field_serializer("created_at")
@ -56,6 +57,7 @@ class FileRecordResponse(BaseModel):
mime_type=record.mime_type, mime_type=record.mime_type,
file_ext=record.file_ext, file_ext=record.file_ext,
category_id=record.category_id, category_id=record.category_id,
storage_directory="data/files",
created_at=record.created_at, created_at=record.created_at,
) )

View File

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

View File

@ -1,7 +1,6 @@
import { import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
FileOutlined,
FolderAddOutlined, FolderAddOutlined,
FolderOutlined, FolderOutlined,
PlusOutlined, PlusOutlined,
@ -32,10 +31,10 @@ export default function FileCategoryTree({
}: FileCategoryTreeProps) { }: FileCategoryTreeProps) {
const mapCategory = (category: FileCategory): DataNode => ({ const mapCategory = (category: FileCategory): DataNode => ({
key: category.id, key: category.id,
icon: <FolderOutlined style={{ color: '#d48806' }} />,
title: ( title: (
<div className="cat-tree-node"> <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"> <span className="cat-actions">
<Tooltip title="新建子分类"> <Tooltip title="新建子分类">
<Button <Button
@ -84,12 +83,11 @@ export default function FileCategoryTree({
const treeData: DataNode[] = [{ const treeData: DataNode[] = [{
key: ALL_FILES_KEY, key: ALL_FILES_KEY,
icon: <FileOutlined />,
title: ( title: (
<span style={{ fontWeight: 600, fontSize: fontSizes.emphasis }}> <div className="cat-tree-node cat-tree-node-all">
<FolderOutlined style={{ marginRight: 6 }} /> <FolderOutlined className="cat-tree-icon" />
<span className="cat-tree-label"></span>
</span> </div>
), ),
children: categories.map(mapCategory), children: categories.map(mapCategory),
}] }]
@ -112,7 +110,6 @@ export default function FileCategoryTree({
</div> </div>
<div className="files-category-scroll"> <div className="files-category-scroll">
<Tree <Tree
showIcon
defaultExpandAll defaultExpandAll
treeData={treeData} treeData={treeData}
selectedKeys={[selectedCategoryId ?? ALL_FILES_KEY]} selectedKeys={[selectedCategoryId ?? ALL_FILES_KEY]}

View File

@ -7,16 +7,18 @@ import {
ReloadOutlined, ReloadOutlined,
UploadOutlined, UploadOutlined,
} from '@ant-design/icons' } 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 type { ColumnsType } from 'antd/es/table'
import { useMemo } from 'react' import { useMemo } from 'react'
import type { FileRecord } from '../../api' import type { FileCategory, FileRecord } from '../../api'
import { colors, fontSizes } from '../../tokens' import { colors, fontSizes } from '../../tokens'
import { formatDateTime } from '../../utils/date' import { formatDateTime } from '../../utils/date'
import { extensionColor, formatFileSize } from '../../utils/fileFormat' import { extensionColor, formatFileSize } from '../../utils/fileFormat'
import { getCategoryPath } from '../../utils/fileTree'
interface FileTableProps { interface FileTableProps {
files: FileRecord[] files: FileRecord[]
categories: FileCategory[]
loading: boolean loading: boolean
categoryName: string categoryName: string
onRefresh: () => void onRefresh: () => void
@ -27,6 +29,7 @@ interface FileTableProps {
export default function FileTable({ export default function FileTable({
files, files,
categories,
loading, loading,
categoryName, categoryName,
onRefresh, onRefresh,
@ -34,12 +37,30 @@ export default function FileTable({
onDownload, onDownload,
onDelete, onDelete,
}: FileTableProps) { }: FileTableProps) {
const screens = Grid.useBreakpoint()
const totalSize = useMemo(() => files.reduce((sum, file) => sum + file.file_size, 0), [files]) const totalSize = useMemo(() => files.reduce((sum, file) => sum + file.file_size, 0), [files])
const extensionCounts = useMemo(() => files.reduce<Record<string, number>>((counts, file) => { const extensionCounts = useMemo(() => files.reduce<Record<string, number>>((counts, file) => {
counts[file.file_ext] = (counts[file.file_ext] || 0) + 1 counts[file.file_ext] = (counts[file.file_ext] || 0) + 1
return counts return counts
}, {}), [files]) }, {}), [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> = [ const columns: ColumnsType<FileRecord> = [
{ {
title: '文件名', title: '文件名',
@ -47,12 +68,22 @@ export default function FileTable({
key: 'name', key: 'name',
ellipsis: true, ellipsis: true,
render: (name: string, record) => ( render: (name: string, record) => (
<Space size={8} style={{ maxWidth: '100%' }}> <div className="files-name-stack">
<div className="files-name-row">
<FileOutlined style={{ color: extensionColor(record.file_ext), fontSize: 16 }} /> <FileOutlined style={{ color: extensionColor(record.file_ext), fontSize: 16 }} />
<span className="files-name-cell">{name}</span> <span className="files-name-cell">{name}</span>
</Space> </div>
{renderLocation(record, true)}
</div>
), ),
}, },
{
title: '文件位置',
key: 'location',
width: 260,
responsive: ['md'],
render: (_, record) => renderLocation(record),
},
{ {
title: '大小', title: '大小',
dataIndex: 'file_size', dataIndex: 'file_size',
@ -160,7 +191,7 @@ export default function FileTable({
columns={columns} columns={columns}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
scroll={{ x: 620 }} scroll={screens.md ? { x: 900 } : undefined}
pagination={files.length > 10 ? { pagination={files.length > 10 ? {
pageSize: 15, pageSize: 15,
showSizeChanger: true, showSizeChanger: true,

View File

@ -164,15 +164,32 @@ body {
flex: 1; flex: 1;
} }
.cat-tree-node { .files-category-scroll .ant-tree-title {
display: flex; display: block;
align-items: center;
justify-content: space-between;
gap: 4px;
width: 100%; width: 100%;
min-width: 0; 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 { .cat-tree-label {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@ -238,15 +255,67 @@ body {
margin-left: auto; margin-left: auto;
} }
.files-name-cell { .files-name-stack {
display: block; display: grid;
gap: 5px;
min-width: 0; 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; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-weight: 500; 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 { .files-empty {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -293,3 +362,14 @@ body {
margin-left: auto; 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 <FileTable
files={files} files={files}
categories={categories}
loading={loading} loading={loading}
categoryName={selectedCategoryName} categoryName={selectedCategoryName}
onRefresh={() => void refresh().catch(() => undefined)} 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)) 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( export function flattenCategoryOptions(
categories: FileCategory[], categories: FileCategory[],
depth = 0, depth = 0,

View File

@ -134,6 +134,8 @@ def test_upload_download_and_delete_file(files_client):
record = uploaded.json() record = uploaded.json()
assert record["original_name"] == "notes.txt" assert record["original_name"] == "notes.txt"
assert record["file_size"] == 5 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 assert len(list(files_dir.iterdir())) == 1
downloaded = client.get(f"/api/files/{record['id']}/download") downloaded = client.get(f"/api/files/{record['id']}/download")