mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
feat(storage): link files to their locations
This commit is contained in:
@@ -45,7 +45,7 @@ describe('storage usage breakdown projection', () => {
|
||||
type: 'application/pdf',
|
||||
size,
|
||||
dirtype: DirType.FILE,
|
||||
parent: '',
|
||||
parent: name === 'alpha.pdf' ? 'Reports/2026' : '',
|
||||
object: name,
|
||||
storageId: 'storage-1',
|
||||
status: ObjectStatus.ACTIVE,
|
||||
@@ -54,6 +54,10 @@ describe('storage usage breakdown projection', () => {
|
||||
|
||||
const byName = await usage.listItems(orgId, 'documents', 1, 2, 'name', 'asc')
|
||||
expect(byName.items.map((item) => item.name)).toEqual(['alpha.pdf', 'bravo.pdf'])
|
||||
expect(byName.items[0]).toMatchObject({
|
||||
path: 'Reports/2026/alpha.pdf',
|
||||
parentPath: 'Reports/2026',
|
||||
})
|
||||
expect(byName.total).toBe(3)
|
||||
|
||||
const bySize = await usage.listItems(orgId, 'documents', 1, 3, 'size', 'desc')
|
||||
|
||||
@@ -47,6 +47,17 @@ function itemCategoryCondition(category: StorageUsageCategory) {
|
||||
return and(isNull(matters.trashedAt), sql`${expression} = ${category}`)
|
||||
}
|
||||
|
||||
function joinPath(parent: string, name: string) {
|
||||
return parent ? `${parent}/${name}` : name
|
||||
}
|
||||
|
||||
function splitPath(path: string) {
|
||||
const separator = path.lastIndexOf('/')
|
||||
return separator === -1
|
||||
? { name: path, parentPath: '' }
|
||||
: { name: path.slice(separator + 1), parentPath: path.slice(0, separator) }
|
||||
}
|
||||
|
||||
export function initialStorageUsageProjectionQueries(db: Database, orgId: string, now: Date) {
|
||||
return STORAGE_USAGE_CATEGORIES.map((category) =>
|
||||
db
|
||||
@@ -103,14 +114,19 @@ export function createStorageUsageBreakdownRepo(db: Database): StorageUsageBreak
|
||||
db.select({ count: count() }).from(imageHostings).where(where),
|
||||
])
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.path,
|
||||
type: row.mime,
|
||||
size: row.size,
|
||||
updatedAt: row.createdAt.toISOString(),
|
||||
source: 'image_hosting' as const,
|
||||
})),
|
||||
items: rows.map((row) => {
|
||||
const location = splitPath(row.path)
|
||||
return {
|
||||
id: row.id,
|
||||
name: location.name,
|
||||
path: row.path,
|
||||
parentPath: location.parentPath,
|
||||
type: row.mime,
|
||||
size: row.size,
|
||||
updatedAt: row.createdAt.toISOString(),
|
||||
source: 'image_hosting' as const,
|
||||
}
|
||||
}),
|
||||
total: totals[0]?.count ?? 0,
|
||||
}
|
||||
}
|
||||
@@ -136,6 +152,8 @@ export function createStorageUsageBreakdownRepo(db: Database): StorageUsageBreak
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
path: joinPath(row.parent, row.name),
|
||||
parentPath: row.parent,
|
||||
type: row.type,
|
||||
size: row.size ?? 0,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
|
||||
@@ -26,6 +26,8 @@ const usageSchema = z
|
||||
const itemSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
parentPath: z.string(),
|
||||
type: z.string(),
|
||||
size: z.number().int(),
|
||||
updatedAt: z.string(),
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface StorageUsageResponse {
|
||||
export interface StorageUsageItem {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
parentPath: string
|
||||
type: string
|
||||
size: number
|
||||
updatedAt: string
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ChevronRight,
|
||||
File,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Image,
|
||||
Images,
|
||||
Loader2,
|
||||
@@ -62,11 +63,13 @@ export function StorageCleanupDialog({
|
||||
category,
|
||||
breakdowns,
|
||||
onCategoryChange,
|
||||
onOpenLocation,
|
||||
onOpenChange,
|
||||
}: {
|
||||
category: StorageUsageCategory | null
|
||||
breakdowns: StorageUsageBreakdown[]
|
||||
onCategoryChange: (category: StorageUsageCategory) => void
|
||||
onOpenLocation: (item: StorageUsageItem) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
@@ -302,7 +305,7 @@ export function StorageCleanupDialog({
|
||||
align="right"
|
||||
onChange={changeSort}
|
||||
/>
|
||||
<TableHead className="h-8 w-11">
|
||||
<TableHead className="h-8 w-[72px]">
|
||||
<span className="sr-only">{t('common.actions')}</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -310,6 +313,12 @@ export function StorageCleanupDialog({
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
const selected = selectedIds.has(item.id)
|
||||
const locationLabel =
|
||||
item.source === 'trash'
|
||||
? t('storage.openTrash')
|
||||
: item.source === 'image_hosting'
|
||||
? t('storage.openImageHosting')
|
||||
: t('storage.openContainingFolder')
|
||||
return (
|
||||
<TableRow key={item.id} data-state={selected ? 'selected' : undefined}>
|
||||
<TableCell className="w-10 px-2 py-1.5 text-center">
|
||||
@@ -328,8 +337,8 @@ export function StorageCleanupDialog({
|
||||
<p className="truncate text-xs font-medium" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground md:hidden">
|
||||
{formatMimeType(item.type)}
|
||||
<p className="truncate text-[11px] text-muted-foreground" title={`/${item.path}`}>
|
||||
/{item.path}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -347,15 +356,27 @@ export function StorageCleanupDialog({
|
||||
{formatSize(item.size)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 pr-2 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={t('storage.deleteFile', { name: item.name })}
|
||||
onClick={() => setPendingDeleteItems([item])}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<div className="flex justify-end gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 text-muted-foreground"
|
||||
aria-label={locationLabel}
|
||||
title={locationLabel}
|
||||
onClick={() => onOpenLocation(item)}
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={t('storage.deleteFile', { name: item.name })}
|
||||
onClick={() => setPendingDeleteItems([item])}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
@@ -1905,6 +1905,9 @@
|
||||
"storage.columnSize": "Size",
|
||||
"storage.deleteFile": "Delete {{name}}",
|
||||
"storage.deleteSelected": "Delete selected",
|
||||
"storage.openContainingFolder": "Open containing folder",
|
||||
"storage.openTrash": "Open Trash",
|
||||
"storage.openImageHosting": "Open Image hosting",
|
||||
"storage.deleteConfirmTitle": "Permanently delete files?",
|
||||
"storage.deleteConfirmDescription": "{{count}} files using {{size}} will be permanently deleted. This cannot be undone.",
|
||||
"storage.deletePermanently": "Delete permanently",
|
||||
|
||||
@@ -1905,6 +1905,9 @@
|
||||
"storage.columnSize": "大小",
|
||||
"storage.deleteFile": "删除 {{name}}",
|
||||
"storage.deleteSelected": "删除所选",
|
||||
"storage.openContainingFolder": "打开所在目录",
|
||||
"storage.openTrash": "打开回收站",
|
||||
"storage.openImageHosting": "打开图床",
|
||||
"storage.deleteConfirmTitle": "永久删除文件?",
|
||||
"storage.deleteConfirmDescription": "将永久删除 {{count}} 个文件并释放 {{size}} 空间。此操作无法撤销。",
|
||||
"storage.deletePermanently": "永久删除",
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from '@/lib/api'
|
||||
import { StoragePage } from './storage'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ navigate: vi.fn() }))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => (values ? `${key}:${Object.values(values).join('/')}` : key),
|
||||
@@ -23,6 +25,7 @@ vi.mock('react-i18next', () => ({
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
createFileRoute: () => (options: unknown) => options,
|
||||
useNavigate: () => mocks.navigate,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
@@ -130,6 +133,8 @@ describe('StoragePage', () => {
|
||||
{
|
||||
id: 'photo-1',
|
||||
name: 'one.jpg',
|
||||
path: 'Photos/one.jpg',
|
||||
parentPath: 'Photos',
|
||||
type: 'image/jpeg',
|
||||
size: 100,
|
||||
updatedAt: '2026-07-23T00:00:00.000Z',
|
||||
@@ -150,6 +155,33 @@ describe('StoragePage', () => {
|
||||
await waitFor(() => expect(listStorageUsageItems).toHaveBeenCalledWith('photos', 1, 20, 'name', 'desc'))
|
||||
})
|
||||
|
||||
it('shows the full path and opens the containing folder', async () => {
|
||||
vi.mocked(listStorageUsageItems).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'document-1',
|
||||
name: 'report.pdf',
|
||||
path: 'Work/Reports/report.pdf',
|
||||
parentPath: 'Work/Reports',
|
||||
type: 'application/pdf',
|
||||
size: 100,
|
||||
updatedAt: '2026-07-23T00:00:00.000Z',
|
||||
source: 'files',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
|
||||
renderPage()
|
||||
const documents = await screen.findAllByText('storage.category.documents')
|
||||
fireEvent.click(documents[1])
|
||||
expect(await screen.findByText('/Work/Reports/report.pdf')).toBeTruthy()
|
||||
fireEvent.click(screen.getByLabelText('storage.openContainingFolder'))
|
||||
expect(mocks.navigate).toHaveBeenCalledWith({ to: '/files', search: { path: 'Work/Reports' } })
|
||||
})
|
||||
|
||||
it('opens storage plans in a modal instead of the primary page', async () => {
|
||||
renderPage()
|
||||
const button = await screen.findByText('storage.expandStorage')
|
||||
@@ -166,6 +198,8 @@ describe('StoragePage', () => {
|
||||
{
|
||||
id: 'photo-1',
|
||||
name: 'one.jpg',
|
||||
path: 'Photos/one.jpg',
|
||||
parentPath: 'Photos',
|
||||
type: 'image/jpeg',
|
||||
size: 100,
|
||||
updatedAt: '2026-07-23T00:00:00.000Z',
|
||||
@@ -174,6 +208,8 @@ describe('StoragePage', () => {
|
||||
{
|
||||
id: 'photo-2',
|
||||
name: 'two.png',
|
||||
path: 'Photos/two.png',
|
||||
parentPath: 'Photos',
|
||||
type: 'image/png',
|
||||
size: 200,
|
||||
updatedAt: '2026-07-23T00:00:00.000Z',
|
||||
@@ -208,6 +244,8 @@ describe('StoragePage', () => {
|
||||
{
|
||||
id: `${category}-1`,
|
||||
name: `${category}.png`,
|
||||
path: `${category}/${category}.png`,
|
||||
parentPath: category,
|
||||
type: 'image/png',
|
||||
size: 100,
|
||||
updatedAt: '2026-07-23T00:00:00.000Z',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { StorageUsageCategory } from '@shared/types'
|
||||
import type { StorageUsageCategory, StorageUsageItem } from '@shared/types'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { ChevronRight, Cloud } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -20,6 +20,7 @@ export const Route = createFileRoute('/_authenticated/storage')({
|
||||
|
||||
export function StoragePage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { data: activeOrg } = useActiveOrganization()
|
||||
const orgId = activeOrg?.id ?? ''
|
||||
const [selectedCategory, setSelectedCategory] = useState<StorageUsageCategory | null>(null)
|
||||
@@ -66,6 +67,19 @@ export function StoragePage() {
|
||||
setPlansOpen(false)
|
||||
}
|
||||
|
||||
function openFileLocation(item: StorageUsageItem) {
|
||||
setSelectedCategory(null)
|
||||
if (item.source === 'trash') {
|
||||
navigate({ to: '/trash' })
|
||||
return
|
||||
}
|
||||
if (item.source === 'image_hosting') {
|
||||
navigate({ to: '/image-host' })
|
||||
return
|
||||
}
|
||||
navigate({ to: '/files', search: item.parentPath ? { path: item.parentPath } : {} })
|
||||
}
|
||||
|
||||
if (usageQuery.isLoading) {
|
||||
return <p className="py-20 text-center text-muted-foreground">{t('common.loading')}</p>
|
||||
}
|
||||
@@ -182,6 +196,7 @@ export function StoragePage() {
|
||||
category={selectedCategory}
|
||||
breakdowns={displayBreakdowns}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
onOpenLocation={openFileLocation}
|
||||
onOpenChange={(open) => !open && setSelectedCategory(null)}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user