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
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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]}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user