diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 4df543ea..4f996ebc 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -7,6 +7,7 @@ import { Clock3, Download, FileText, + FolderOpen, History, Image as ImageIcon, LoaderCircle, @@ -19,7 +20,7 @@ import { X, Zap, } from 'lucide-react'; -import { cancelJob, clearHistory, createJobs, listJobs, retryJob } from './api'; +import { cancelJob, clearHistory, createJobs, listJobs, openMediaFolder, retryJob } from './api'; import type { DownloadJob, JobStatus, MediaItem } from './types'; const STATUS: Record = { @@ -146,6 +147,7 @@ export default function App() { const [submitting, setSubmitting] = useState(false); const [dragging, setDragging] = useState(false); const [notice, setNotice] = useState(''); + const [openingFolder, setOpeningFolder] = useState(false); const fileRef = useRef(null); const urls = useMemo(() => extractUrls(input), [input]); @@ -206,6 +208,17 @@ export default function App() { setNotice(`已清除 ${removed} 条历史记录`); } + async function showMediaFolder() { + setOpeningFolder(true); + try { + await openMediaFolder(); + } catch (error) { + setNotice(error instanceof Error ? error.message : '无法打开媒体文件夹'); + } finally { + setOpeningFolder(false); + } + } + const activeJobs = jobs.filter((job) => ['queued', 'resolving', 'downloading'].includes(job.status)); const historyJobs = jobs.filter((job) => ['completed', 'failed', 'canceled'].includes(job.status)); const visibleJobs = tab === 'queue' ? activeJobs : historyJobs; @@ -215,7 +228,19 @@ export default function App() {
𝕏X 媒体存档PUBLIC MEDIA ARCHIVE -
Cobalt 本地解析
+
+ +
Cobalt 本地解析
+
diff --git a/apps/client/src/api.ts b/apps/client/src/api.ts index 5132ba77..bcf0b09e 100644 --- a/apps/client/src/api.ts +++ b/apps/client/src/api.ts @@ -33,3 +33,7 @@ export function retryJob(id: string) { export function clearHistory() { return request<{ removed: number }>('/api/history', { method: 'DELETE' }); } + +export function openMediaFolder() { + return request<{ path: string }>('/api/open-media-folder', { method: 'POST' }); +} diff --git a/apps/client/src/styles.css b/apps/client/src/styles.css index 9c21baf2..5429f011 100644 --- a/apps/client/src/styles.css +++ b/apps/client/src/styles.css @@ -26,6 +26,10 @@ a { color: inherit; text-decoration: none; } .brand-mark { width: 37px; height: 37px; border-radius: 9px; display: grid; place-items: center; background: var(--ink); color: white; font-size: 19px; } .brand > span:last-child { display: grid; line-height: 1.05; } .brand small { font: 500 8px/1.7 Consolas, monospace; letter-spacing: .14em; color: var(--muted); } +.topbar-actions { display: flex; align-items: center; gap: 20px; } +.folder-button { border: 1px solid var(--line); background: var(--card); min-height: 32px; padding: 0 11px; display: flex; align-items: center; gap: 7px; cursor: pointer; color: var(--ink); font-size: 11px; } +.folder-button:hover:not(:disabled) { border-color: var(--ink); color: var(--accent); } +.folder-button:disabled { opacity: .6; cursor: wait; } .service-state { font: 500 11px Consolas, monospace; letter-spacing: .04em; color: var(--muted); display: flex; gap: 8px; align-items: center; } .service-state span { width: 7px; height: 7px; background: #3c9c64; border-radius: 50%; box-shadow: 0 0 0 4px rgba(60,156,100,.12); } main { width: min(1120px, calc(100% - 56px)); margin: 0 auto; } @@ -120,6 +124,7 @@ main { width: min(1120px, calc(100% - 56px)); margin: 0 auto; } @media (max-width: 760px) { .topbar { padding: 0 20px; } + .topbar-actions { gap: 0; } .service-state { display: none; } main { width: calc(100% - 28px); } .hero { padding: 52px 0 36px; display: block; } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index e676bf93..cde8b69c 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,4 +1,5 @@ import 'dotenv/config'; +import { spawn } from 'node:child_process'; import path from 'node:path'; import fs from 'node:fs'; import fsp from 'node:fs/promises'; @@ -107,6 +108,25 @@ app.post('/api/jobs/:id/retry', (req, res) => { app.delete('/api/history', async (_req, res) => res.json({ removed: await store.clearCompleted() })); +app.post('/api/open-media-folder', async (_req, res) => { + const platform = process.platform; + const command = platform === 'win32' ? 'explorer.exe' : platform === 'darwin' ? 'open' : 'xdg-open'; + + try { + await new Promise((resolve, reject) => { + const opener = spawn(command, [store.mediaDir], { detached: true, stdio: 'ignore' }); + opener.once('error', reject); + opener.once('spawn', () => { + opener.unref(); + resolve(); + }); + }); + return res.json({ path: store.mediaDir }); + } catch (error) { + return res.status(500).json({ error: error instanceof Error ? error.message : '无法打开媒体文件夹' }); + } +}); + app.get('/api/jobs/:jobId/media/:mediaId', async (req, res) => { const job = store.get(req.params.jobId); const media = job?.media.find((item) => item.id === req.params.mediaId);