Skip to content

Commit b7a32a4

Browse files
committed
feat(ui): 添加图片管理功能
- 在 `App.tsx` 中添加了新的路由 `/images`,对应 `ImagesPage` 组件。 - 新增了 `api/images.ts` 文件,定义了与图片上传、列表获取和删除相关的接口和请求函数。 - 在 `Layout.tsx` 中添加了导航链接,用于访问图片管理页面。 - 新增了 `pages/ImagesPage.tsx` 文件,实现了图片上传、展示、删除和复制URL、Markdown、HTML、BBCode等功能。 - 添加了 `CopyRow` 组件,用于在图片详情中展示可复制的链接信息。
1 parent 42bcd1e commit b7a32a4

4 files changed

Lines changed: 300 additions & 0 deletions

File tree

web/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import TokensPage from './pages/TokensPage'
1111
import SettingsPage from './pages/SettingsPage'
1212
import AdminPage from './pages/AdminPage'
1313
import PublicSharePage from './pages/PublicSharePage'
14+
import ImagesPage from './pages/ImagesPage'
1415

1516
export default function App() {
1617
const { token, loadUser } = useAuthStore()
@@ -32,6 +33,7 @@ export default function App() {
3233
<Route path="/files" element={<FileBrowserPage />} />
3334
<Route path="/files/folder/:folderId" element={<FileBrowserPage />} />
3435
<Route path="/shares" element={<SharesPage />} />
36+
<Route path="/images" element={<ImagesPage />} />
3537
<Route path="/tokens" element={<TokensPage />} />
3638
<Route path="/settings" element={<SettingsPage />} />
3739
<Route path="/admin" element={<AdminPage />} />

web/src/api/images.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { request, requestPaginated, PaginatedResponse } from './client'
2+
3+
export interface ImageInfo {
4+
id: string
5+
hash: string
6+
original_name: string
7+
url: string
8+
thumb_url: string
9+
markdown: string
10+
size: number
11+
original_size: number
12+
width: number
13+
height: number
14+
created_at: string
15+
}
16+
17+
export async function uploadImage(file: File): Promise<ImageInfo> {
18+
const form = new FormData()
19+
form.append('image', file)
20+
return request<ImageInfo>('/images', {
21+
method: 'POST',
22+
body: form,
23+
})
24+
}
25+
26+
export async function listImages(page = 1, perPage = 20): Promise<PaginatedResponse<ImageInfo>> {
27+
return requestPaginated<ImageInfo>(`/images?page=${page}&per_page=${perPage}`)
28+
}
29+
30+
export async function deleteImage(id: string): Promise<void> {
31+
return request<void>(`/images/${id}`, { method: 'DELETE' })
32+
}

web/src/components/Layout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
LogOut,
99
Shield,
1010
HardDrive,
11+
ImageIcon,
1112
} from 'lucide-react'
1213
import StorageBar from './StorageBar'
1314
import Avatar from './Avatar'
@@ -48,6 +49,10 @@ export default function Layout() {
4849
<Share2 className="w-4 h-4" />
4950
分享管理
5051
</NavLink>
52+
<NavLink to="/images" className={linkClass}>
53+
<ImageIcon className="w-4 h-4" />
54+
图床
55+
</NavLink>
5156
<NavLink to="/tokens" className={linkClass}>
5257
<Key className="w-4 h-4" />
5358
API Token

web/src/pages/ImagesPage.tsx

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import { ImageInfo, listImages, uploadImage, deleteImage } from '../api/images'
3+
import { formatFileSize, formatDateTime } from '../lib/format'
4+
import { Upload, Trash2, Copy, Check, X, ImageIcon, Link, ChevronLeft, ChevronRight } from 'lucide-react'
5+
6+
export default function ImagesPage() {
7+
const [images, setImages] = useState<ImageInfo[]>([])
8+
const [page, setPage] = useState(1)
9+
const [total, setTotal] = useState(0)
10+
const [loading, setLoading] = useState(false)
11+
const [uploading, setUploading] = useState(false)
12+
const [selected, setSelected] = useState<ImageInfo | null>(null)
13+
const [copied, setCopied] = useState('')
14+
const [error, setError] = useState('')
15+
const fileInputRef = useRef<HTMLInputElement>(null)
16+
const perPage = 20
17+
18+
const fetchImages = useCallback(async () => {
19+
setLoading(true)
20+
try {
21+
const res = await listImages(page, perPage)
22+
setImages(res.data)
23+
setTotal(res.meta.total)
24+
} catch (err) {
25+
setError(err instanceof Error ? err.message : '加载失败')
26+
} finally {
27+
setLoading(false)
28+
}
29+
}, [page])
30+
31+
useEffect(() => {
32+
fetchImages()
33+
}, [fetchImages])
34+
35+
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
36+
const files = e.target.files
37+
if (!files?.length) return
38+
39+
setUploading(true)
40+
setError('')
41+
try {
42+
for (const file of Array.from(files)) {
43+
await uploadImage(file)
44+
}
45+
setPage(1)
46+
await fetchImages()
47+
} catch (err) {
48+
setError(err instanceof Error ? err.message : '上传失败')
49+
} finally {
50+
setUploading(false)
51+
if (fileInputRef.current) fileInputRef.current.value = ''
52+
}
53+
}
54+
55+
const handleDelete = async (id: string) => {
56+
if (!confirm('确定删除这张图片?')) return
57+
try {
58+
await deleteImage(id)
59+
setSelected(null)
60+
await fetchImages()
61+
} catch (err) {
62+
setError(err instanceof Error ? err.message : '删除失败')
63+
}
64+
}
65+
66+
const copyToClipboard = (text: string, label: string) => {
67+
navigator.clipboard.writeText(text)
68+
setCopied(label)
69+
setTimeout(() => setCopied(''), 2000)
70+
}
71+
72+
const totalPages = Math.ceil(total / perPage)
73+
74+
return (
75+
<div className="p-6">
76+
<div className="flex items-center justify-between mb-6">
77+
<h2 className="text-xl font-semibold text-gray-900">图床</h2>
78+
<button
79+
onClick={() => fileInputRef.current?.click()}
80+
disabled={uploading}
81+
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors text-sm"
82+
>
83+
<Upload className="w-4 h-4" />
84+
{uploading ? '上传中...' : '上传图片'}
85+
</button>
86+
<input
87+
ref={fileInputRef}
88+
type="file"
89+
accept="image/jpeg,image/png,image/gif,image/webp"
90+
multiple
91+
onChange={handleUpload}
92+
className="hidden"
93+
/>
94+
</div>
95+
96+
{error && (
97+
<div className="text-sm text-red-600 bg-red-50 p-3 rounded-lg mb-4">{error}</div>
98+
)}
99+
100+
{loading && images.length === 0 ? (
101+
<div className="text-center text-gray-400 py-20">加载中...</div>
102+
) : images.length === 0 ? (
103+
<div className="text-center py-20">
104+
<ImageIcon className="w-12 h-12 text-gray-300 mx-auto mb-3" />
105+
<p className="text-gray-400">还没有上传图片</p>
106+
<p className="text-sm text-gray-400 mt-1">点击上方按钮上传第一张图片</p>
107+
</div>
108+
) : (
109+
<>
110+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
111+
{images.map((img) => (
112+
<div
113+
key={img.id}
114+
onClick={() => setSelected(img)}
115+
className="group relative aspect-square rounded-lg overflow-hidden bg-gray-100 cursor-pointer border border-gray-200 hover:border-blue-400 transition-colors"
116+
>
117+
<img
118+
src={img.thumb_url}
119+
alt={img.original_name}
120+
className="w-full h-full object-cover"
121+
loading="lazy"
122+
/>
123+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
124+
</div>
125+
))}
126+
</div>
127+
128+
{totalPages > 1 && (
129+
<div className="flex items-center justify-center gap-2 mt-6">
130+
<button
131+
onClick={() => setPage((p) => Math.max(1, p - 1))}
132+
disabled={page <= 1}
133+
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 transition-colors"
134+
>
135+
<ChevronLeft className="w-4 h-4" />
136+
</button>
137+
<span className="text-sm text-gray-600">
138+
{page} / {totalPages}
139+
</span>
140+
<button
141+
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
142+
disabled={page >= totalPages}
143+
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 transition-colors"
144+
>
145+
<ChevronRight className="w-4 h-4" />
146+
</button>
147+
</div>
148+
)}
149+
</>
150+
)}
151+
152+
{/* Detail modal */}
153+
{selected && (
154+
<div
155+
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
156+
onClick={() => setSelected(null)}
157+
>
158+
<div
159+
className="bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-auto"
160+
onClick={(e) => e.stopPropagation()}
161+
>
162+
<div className="flex items-center justify-between p-4 border-b border-gray-200">
163+
<h3 className="font-medium text-gray-900 truncate">{selected.original_name}</h3>
164+
<button
165+
onClick={() => setSelected(null)}
166+
className="p-1 hover:bg-gray-100 rounded transition-colors"
167+
>
168+
<X className="w-5 h-5 text-gray-400" />
169+
</button>
170+
</div>
171+
172+
<div className="p-4">
173+
<div className="bg-gray-50 rounded-lg p-2 mb-4 flex items-center justify-center" style={{ maxHeight: 400 }}>
174+
<img
175+
src={selected.url}
176+
alt={selected.original_name}
177+
className="max-w-full max-h-[380px] object-contain rounded"
178+
/>
179+
</div>
180+
181+
<div className="space-y-2 mb-4">
182+
<CopyRow
183+
label="URL"
184+
value={selected.url}
185+
copied={copied}
186+
onCopy={copyToClipboard}
187+
/>
188+
<CopyRow
189+
label="Markdown"
190+
value={selected.markdown}
191+
copied={copied}
192+
onCopy={copyToClipboard}
193+
/>
194+
<CopyRow
195+
label="HTML"
196+
value={`<img src="${selected.url}" alt="${selected.original_name}" />`}
197+
copied={copied}
198+
onCopy={copyToClipboard}
199+
/>
200+
<CopyRow
201+
label="BBCode"
202+
value={`[img]${selected.url}[/img]`}
203+
copied={copied}
204+
onCopy={copyToClipboard}
205+
/>
206+
</div>
207+
208+
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-gray-500 mb-4">
209+
<div>尺寸: {selected.width} x {selected.height}</div>
210+
<div>压缩后: {formatFileSize(selected.size)}</div>
211+
<div>原始大小: {formatFileSize(selected.original_size)}</div>
212+
<div>上传时间: {formatDateTime(selected.created_at)}</div>
213+
</div>
214+
215+
<button
216+
onClick={() => handleDelete(selected.id)}
217+
className="flex items-center gap-2 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
218+
>
219+
<Trash2 className="w-4 h-4" />
220+
删除图片
221+
</button>
222+
</div>
223+
</div>
224+
</div>
225+
)}
226+
</div>
227+
)
228+
}
229+
230+
function CopyRow({
231+
label,
232+
value,
233+
copied,
234+
onCopy,
235+
}: {
236+
label: string
237+
value: string
238+
copied: string
239+
onCopy: (text: string, label: string) => void
240+
}) {
241+
const isCopied = copied === label
242+
return (
243+
<div className="flex items-center gap-2">
244+
<span className="text-xs text-gray-400 w-16 shrink-0">{label}</span>
245+
<div className="flex-1 min-w-0 bg-gray-50 rounded px-2 py-1 text-sm text-gray-700 font-mono truncate">
246+
{value}
247+
</div>
248+
<button
249+
onClick={() => onCopy(value, label)}
250+
className="p-1.5 hover:bg-gray-100 rounded transition-colors shrink-0"
251+
title="复制"
252+
>
253+
{isCopied ? (
254+
<Check className="w-4 h-4 text-green-500" />
255+
) : (
256+
<Copy className="w-4 h-4 text-gray-400" />
257+
)}
258+
</button>
259+
</div>
260+
)
261+
}

0 commit comments

Comments
 (0)