Initial commit: snapAna 截图智能整理工具
包含 FastAPI 后端、React 前端、队列/OCR/标签/待办等完整功能。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>snapAna · 截图分析</title>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2813
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "snapana-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.451.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-window": "^1.8.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NavLink, Route, Routes, Navigate } from "react-router-dom";
|
||||
import { Home, Images, Shuffle, ListChecks, Settings as Cog, ListOrdered, Hash } from "lucide-react";
|
||||
|
||||
import HomePage from "@/pages/Home";
|
||||
import LibraryPage from "@/pages/Library";
|
||||
import ShufflePage from "@/pages/Shuffle";
|
||||
import TodosPage from "@/pages/Todos";
|
||||
import SettingsPage from "@/pages/Settings";
|
||||
import QueuePage from "@/pages/Queue";
|
||||
import TagsPage from "@/pages/Tags";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "首页", icon: Home },
|
||||
{ to: "/library", label: "库", icon: Images },
|
||||
{ to: "/tags", label: "标签", icon: Hash },
|
||||
{ to: "/queue", label: "队列", icon: ListOrdered },
|
||||
{ to: "/shuffle", label: "随机", icon: Shuffle },
|
||||
{ to: "/todos", label: "待办", icon: ListChecks },
|
||||
{ to: "/settings", label: "设置", icon: Cog },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<aside className="flex w-56 shrink-0 flex-col border-r border-slate-800 bg-slate-950/80">
|
||||
<div className="px-5 py-5">
|
||||
<div className="text-lg font-semibold tracking-wide text-white">snapAna</div>
|
||||
<div className="mt-1 text-xs text-slate-500">截图智能整理</div>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-1 px-3">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 rounded-md px-3 py-2 text-sm transition ${
|
||||
isActive
|
||||
? "bg-brand-600/20 text-white"
|
||||
: "text-slate-400 hover:bg-slate-800/60 hover:text-white"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon size={16} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-auto px-5 py-4 text-xs text-slate-500">
|
||||
v0.1 · 本地运行
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/library" element={<LibraryPage />} />
|
||||
<Route path="/tags" element={<TagsPage />} />
|
||||
<Route path="/queue" element={<QueuePage />} />
|
||||
<Route path="/shuffle" element={<ShufflePage />} />
|
||||
<Route path="/todos" element={<TodosPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type {
|
||||
Category,
|
||||
JobListResp,
|
||||
ListQuery,
|
||||
ListResp,
|
||||
ProviderConfig,
|
||||
ProviderConfigOut,
|
||||
ProviderTestResult,
|
||||
ScreenshotBrief,
|
||||
ScreenshotDetail,
|
||||
StatsResp,
|
||||
Tag,
|
||||
TagListResp,
|
||||
TodoItem,
|
||||
TodoListQuery,
|
||||
TodoListResp,
|
||||
WatchFolder,
|
||||
} from "@/types";
|
||||
|
||||
const BASE = "";
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init?: RequestInit & { params?: Record<string, unknown> }
|
||||
): Promise<T> {
|
||||
const { params, ...rest } = init ?? {};
|
||||
const url = new URL(BASE + path, window.location.origin);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === "") return;
|
||||
url.searchParams.append(key, String(value));
|
||||
});
|
||||
}
|
||||
const resp = await fetch(url.pathname + url.search, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(rest.headers ?? {}),
|
||||
},
|
||||
...rest,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let detail = resp.statusText;
|
||||
try {
|
||||
const data = await resp.json();
|
||||
detail = data.detail ?? detail;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (resp.status === 204) return undefined as T;
|
||||
return (await resp.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listScreenshots: (query: ListQuery) =>
|
||||
request<ListResp>("/api/screenshots", { params: query as Record<string, unknown> }),
|
||||
getScreenshot: (id: number) => request<ScreenshotDetail>(`/api/screenshots/${id}`),
|
||||
randomScreenshots: (params: { n?: number; category_id?: number }) =>
|
||||
request<ScreenshotBrief[]>("/api/screenshots/random", {
|
||||
params: params as Record<string, unknown>,
|
||||
}),
|
||||
stats: () => request<StatsResp>("/api/screenshots/stats"),
|
||||
reanalyze: (id: number) =>
|
||||
request<{ ok: boolean }>(`/api/screenshots/${id}/reanalyze`, { method: "POST" }),
|
||||
reocr: (id: number) =>
|
||||
request<{ ok: boolean; job_id?: number; message?: string }>(
|
||||
`/api/screenshots/${id}/reocr`,
|
||||
{ method: "POST" }
|
||||
),
|
||||
updateScreenshot: (id: number, payload: Partial<{ category_id: number | null; is_favorite: boolean; is_hidden: boolean; tags: string[] }>) =>
|
||||
request<ScreenshotDetail>(`/api/screenshots/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteScreenshot: (id: number) =>
|
||||
request<{ ok: boolean }>(`/api/screenshots/${id}`, { method: "DELETE" }),
|
||||
|
||||
listTodos: (params: TodoListQuery) =>
|
||||
request<TodoListResp>("/api/todos", { params: params as Record<string, unknown> }),
|
||||
todoSummary: () => request<Record<string, number>>("/api/todos/summary"),
|
||||
updateTodo: (id: number, payload: Partial<{ status: string; title: string; note: string }>) =>
|
||||
request<TodoItem>(`/api/todos/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteTodo: (id: number) =>
|
||||
request<{ ok: boolean }>(`/api/todos/${id}`, { method: "DELETE" }),
|
||||
|
||||
listWatchFolders: () => request<WatchFolder[]>("/api/watch/folders"),
|
||||
addWatchFolder: (payload: Omit<WatchFolder, "id">) =>
|
||||
request<WatchFolder>("/api/watch/folders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateWatchFolder: (id: number, payload: Omit<WatchFolder, "id">) =>
|
||||
request<WatchFolder>(`/api/watch/folders/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteWatchFolder: (id: number) =>
|
||||
request<{ ok: boolean }>(`/api/watch/folders/${id}`, { method: "DELETE" }),
|
||||
importNow: (payload: Omit<WatchFolder, "id">) =>
|
||||
request<{ ok: boolean }>("/api/watch/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
validateWatchPath: (path: string) =>
|
||||
request<{ ok: boolean; path: string; sample_image_count: number; samples: string[]; message: string }>(
|
||||
"/api/watch/validate-path",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path, enabled: true, recursive: true, is_sensitive: false }),
|
||||
}
|
||||
),
|
||||
queueStatus: () => request<Record<string, number>>("/api/watch/queue"),
|
||||
listJobs: (params: { status?: string; kind?: string; page?: number; size?: number }) =>
|
||||
request<JobListResp>("/api/watch/jobs", {
|
||||
params: params as Record<string, unknown>,
|
||||
}),
|
||||
retryFailedJobs: (jobIds?: number[]) =>
|
||||
request<{ ok: boolean; count: number }>("/api/watch/jobs/retry-failed", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(jobIds?.length ? { job_ids: jobIds } : {}),
|
||||
}),
|
||||
resetStaleJobs: (resetAll?: boolean) =>
|
||||
request<{ ok: boolean; count: number }>("/api/watch/jobs/reset-stale", {
|
||||
method: "POST",
|
||||
params: { reset_all: resetAll ? true : undefined },
|
||||
}),
|
||||
enqueueOcrFailed: (limit?: number) =>
|
||||
request<{ ok: boolean; count: number }>("/api/watch/jobs/enqueue-ocr-failed", {
|
||||
method: "POST",
|
||||
params: { limit: limit ?? 500 },
|
||||
}),
|
||||
|
||||
listCategories: () => request<Category[]>("/api/settings/categories"),
|
||||
createCategory: (payload: Omit<Category, "id">) =>
|
||||
request<{ id: number }>("/api/settings/categories", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateCategory: (id: number, payload: Omit<Category, "id">) =>
|
||||
request<{ ok: boolean }>(`/api/settings/categories/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteCategory: (id: number) =>
|
||||
request<{ ok: boolean }>(`/api/settings/categories/${id}`, { method: "DELETE" }),
|
||||
|
||||
listTags: (params?: { q?: string; page?: number; size?: number; sort?: string }) =>
|
||||
request<TagListResp>("/api/settings/tags", {
|
||||
params: (params ?? {}) as Record<string, unknown>,
|
||||
}),
|
||||
|
||||
getProvider: (key: "ocr_provider" | "vlm_provider") =>
|
||||
request<ProviderConfigOut | null>(`/api/settings/providers/${key}`),
|
||||
setProvider: (key: "ocr_provider" | "vlm_provider", payload: ProviderConfig) =>
|
||||
request<{ ok: boolean }>(`/api/settings/providers/${key}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
testProvider: (key: "ocr_provider" | "vlm_provider", payload: ProviderConfig) =>
|
||||
request<ProviderTestResult>(`/api/settings/providers/${key}/test`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
getRecognitionMode: () =>
|
||||
request<{ mode: string; options: string[] }>("/api/settings/recognition-mode"),
|
||||
setRecognitionMode: (mode: string) =>
|
||||
request<{ ok: boolean; mode: string }>("/api/settings/recognition-mode", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Star } from "lucide-react";
|
||||
import type { ScreenshotBrief } from "@/types";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
|
||||
interface Props {
|
||||
shot: ScreenshotBrief;
|
||||
onOpen: (id: number) => void;
|
||||
onToggleFav?: (shot: ScreenshotBrief) => void;
|
||||
}
|
||||
|
||||
export function Card({ shot, onOpen, onToggleFav }: Props) {
|
||||
const date = new Date(shot.captured_at).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onOpen(shot.id)}
|
||||
className="card-hover group relative flex w-full flex-col overflow-hidden rounded-xl border border-slate-800 bg-slate-900/40 text-left"
|
||||
>
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden bg-slate-800">
|
||||
{shot.thumb_url ? (
|
||||
<img
|
||||
src={shot.thumb_url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition group-hover:scale-[1.02]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-slate-500">
|
||||
无缩略图
|
||||
</div>
|
||||
)}
|
||||
{shot.category && (
|
||||
<span
|
||||
className="absolute left-2 top-2 inline-flex items-center rounded-full bg-black/55 px-2 py-0.5 text-[10px] text-white backdrop-blur"
|
||||
style={{
|
||||
borderLeft: `3px solid ${shot.category.color ?? "#6366f1"}`,
|
||||
}}
|
||||
>
|
||||
{shot.category.name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className={`absolute right-2 top-2 rounded-full p-1.5 transition ${
|
||||
shot.is_favorite
|
||||
? "bg-amber-400/90 text-slate-900"
|
||||
: "bg-black/40 text-slate-300 opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFav?.(shot);
|
||||
}}
|
||||
aria-label="收藏"
|
||||
>
|
||||
<Star size={14} fill={shot.is_favorite ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1 px-3 py-2">
|
||||
<div className="line-clamp-2 text-sm font-medium text-slate-100">
|
||||
{shot.ai_title || "(未生成标题)"}
|
||||
</div>
|
||||
<div className="mt-auto flex items-center justify-between text-[11px] text-slate-500">
|
||||
<span>{date}</span>
|
||||
<StatusBadge status={shot.ai_status} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FixedSizeGrid as Grid } from "react-window";
|
||||
|
||||
import type { ScreenshotBrief } from "@/types";
|
||||
import { Card } from "./Card";
|
||||
|
||||
interface Props {
|
||||
items: ScreenshotBrief[];
|
||||
onOpen: (id: number) => void;
|
||||
onToggleFav?: (shot: ScreenshotBrief) => void;
|
||||
}
|
||||
|
||||
const CARD_W = 260;
|
||||
const CARD_H = 280;
|
||||
const GAP = 16;
|
||||
|
||||
export function CardGrid({ items, onOpen, onToggleFav }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = useState({ w: 0, h: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setSize({ w: entry.contentRect.width, h: entry.contentRect.height });
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const colCount = useMemo(() => {
|
||||
const c = Math.max(1, Math.floor((size.w + GAP) / (CARD_W + GAP)));
|
||||
return c;
|
||||
}, [size.w]);
|
||||
|
||||
const rowCount = Math.ceil(items.length / colCount);
|
||||
const colWidth = (size.w - GAP) / Math.max(colCount, 1);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative h-full w-full">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-slate-500">
|
||||
没有匹配的截图
|
||||
</div>
|
||||
) : (
|
||||
<Grid
|
||||
columnCount={colCount}
|
||||
rowCount={rowCount}
|
||||
columnWidth={colWidth}
|
||||
rowHeight={CARD_H + GAP}
|
||||
height={size.h}
|
||||
width={size.w}
|
||||
itemKey={({ rowIndex, columnIndex }) => {
|
||||
const idx = rowIndex * colCount + columnIndex;
|
||||
const item = items[idx];
|
||||
return item ? `s-${item.id}` : `e-${rowIndex}-${columnIndex}`;
|
||||
}}
|
||||
>
|
||||
{({ rowIndex, columnIndex, style }) => {
|
||||
const idx = rowIndex * colCount + columnIndex;
|
||||
const item = items[idx];
|
||||
if (!item) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
paddingRight: GAP,
|
||||
paddingBottom: GAP,
|
||||
}}
|
||||
>
|
||||
<Card shot={item} onOpen={onOpen} onToggleFav={onToggleFav} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Grid>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Copy, ExternalLink, RefreshCw, Star, Trash2, X } from "lucide-react";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import type { ScreenshotDetail } from "@/types";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
|
||||
interface Props {
|
||||
id: number | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DetailPanel({ id, onClose }: Props) {
|
||||
const open = id !== null;
|
||||
const qc = useQueryClient();
|
||||
const detail = useQuery({
|
||||
queryKey: ["screenshot", id],
|
||||
queryFn: () => api.getScreenshot(id!),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (payload: Parameters<typeof api.updateScreenshot>[1]) =>
|
||||
api.updateScreenshot(id!, payload),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["screenshot", id] });
|
||||
qc.invalidateQueries({ queryKey: ["screenshots"] });
|
||||
},
|
||||
});
|
||||
|
||||
const reanalyze = useMutation({
|
||||
mutationFn: () => api.reanalyze(id!),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["screenshot", id] }),
|
||||
});
|
||||
|
||||
const reocr = useMutation({
|
||||
mutationFn: () => api.reocr(id!),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["screenshot", id] });
|
||||
qc.invalidateQueries({ queryKey: ["queue"] });
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => api.deleteScreenshot(id!),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["screenshots"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const cats = useQuery({ queryKey: ["categories"], queryFn: api.listCategories });
|
||||
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setTagInput("");
|
||||
}, [id]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const s = detail.data as ScreenshotDetail | undefined;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="glass relative flex h-full w-full max-w-4xl flex-col overflow-hidden border-l border-slate-800">
|
||||
<header className="flex items-center justify-between border-b border-slate-800 px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<span className="rounded bg-slate-800 px-2 py-0.5 text-xs">#{id}</span>
|
||||
{s && <StatusBadge status={s.ai_status} />}
|
||||
{s?.category && (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-xs"
|
||||
style={{
|
||||
backgroundColor: `${s.category.color ?? "#6366f1"}22`,
|
||||
color: s.category.color ?? "#a5b4fc",
|
||||
}}
|
||||
>
|
||||
{s.category.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn-ghost rounded p-1.5" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{s ? (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 items-center justify-center bg-black/40 p-4">
|
||||
<img
|
||||
src={s.file_url}
|
||||
alt={s.ai_title ?? "screenshot"}
|
||||
className="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[420px] shrink-0 overflow-y-auto border-l border-slate-800 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
className={`btn ${s.is_favorite ? "btn-primary" : ""}`}
|
||||
onClick={() => update.mutate({ is_favorite: !s.is_favorite })}
|
||||
>
|
||||
<Star size={14} /> {s.is_favorite ? "已收藏" : "收藏"}
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => reanalyze.mutate()}
|
||||
disabled={reanalyze.isPending}
|
||||
>
|
||||
<RefreshCw size={14} /> 重新分析
|
||||
</button>
|
||||
{s.ocr_status === "failed" && s.ai_status === "done" && (
|
||||
<button
|
||||
className="btn border-brand-600/50 text-brand-300"
|
||||
onClick={() => reocr.mutate()}
|
||||
disabled={reocr.isPending}
|
||||
>
|
||||
<RefreshCw size={14} /> 补跑 OCR
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
className="btn"
|
||||
href={s.file_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLink size={14} /> 原图
|
||||
</a>
|
||||
<button
|
||||
className="btn text-rose-300 hover:border-rose-500"
|
||||
onClick={() => {
|
||||
if (confirm("从库中移除(不删除原文件)?")) remove.mutate();
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} /> 移除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Section title="标题">
|
||||
<p className="text-base font-medium text-white">
|
||||
{s.ai_title || "(未生成标题)"}
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="AI 摘要">
|
||||
<p className="whitespace-pre-line text-sm text-slate-300">
|
||||
{s.ai_summary || "—"}
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{s.ai_suggestion && (
|
||||
<Section title="AI 建议">
|
||||
<p className="whitespace-pre-line text-sm text-amber-200/90">
|
||||
{s.ai_suggestion}
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section
|
||||
title="OCR 文本"
|
||||
right={
|
||||
<button
|
||||
className="btn-ghost rounded p-1"
|
||||
onClick={() => navigator.clipboard.writeText(s.ocr_text ?? "")}
|
||||
aria-label="复制 OCR 文本"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-slate-800 bg-slate-900/60 p-2 text-xs text-slate-300">
|
||||
{s.ocr_text || "(无)"}
|
||||
</pre>
|
||||
</Section>
|
||||
|
||||
<Section title="分类">
|
||||
<select
|
||||
className="input"
|
||||
value={s.category?.id ?? ""}
|
||||
onChange={(e) =>
|
||||
update.mutate({
|
||||
category_id: e.target.value ? Number(e.target.value) : null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{cats.data?.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
|
||||
<Section title="标签">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{s.tags.map((t) => (
|
||||
<span key={t.id} className="chip">
|
||||
#{t.name}
|
||||
<button
|
||||
className="text-slate-500 hover:text-rose-300"
|
||||
onClick={() =>
|
||||
update.mutate({
|
||||
tags: s.tags.filter((x) => x.id !== t.id).map((x) => x.name),
|
||||
})
|
||||
}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="新增标签后回车"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && tagInput.trim()) {
|
||||
update.mutate({
|
||||
tags: [
|
||||
...s.tags.map((t) => t.name),
|
||||
tagInput.trim(),
|
||||
],
|
||||
});
|
||||
setTagInput("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{s.todos.length > 0 && (
|
||||
<Section title="待办">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{s.todos.map((t) => (
|
||||
<li
|
||||
key={t.id}
|
||||
className="rounded-md border border-slate-800 bg-slate-900/60 p-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-100">{t.title}</span>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{t.kind} · {t.status}
|
||||
</span>
|
||||
</div>
|
||||
{t.note && (
|
||||
<div className="mt-1 text-xs text-slate-400">{t.note}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="元信息">
|
||||
<div className="grid grid-cols-2 gap-y-1 text-xs text-slate-400">
|
||||
<div>尺寸</div>
|
||||
<div className="text-slate-200">
|
||||
{s.width}×{s.height}
|
||||
</div>
|
||||
<div>体积</div>
|
||||
<div className="text-slate-200">
|
||||
{(s.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
<div>路径</div>
|
||||
<div className="break-all text-slate-200">{s.path}</div>
|
||||
<div>截取时间</div>
|
||||
<div className="text-slate-200">
|
||||
{new Date(s.captured_at).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-slate-500">
|
||||
加载中…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
right,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
right?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
{title}
|
||||
</span>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Search, Star, X } from "lucide-react";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import type { ListQuery } from "@/types";
|
||||
|
||||
interface Props {
|
||||
query: ListQuery;
|
||||
onChange: (next: ListQuery) => void;
|
||||
}
|
||||
|
||||
export function FilterBar({ query, onChange }: Props) {
|
||||
const cats = useQuery({ queryKey: ["categories"], queryFn: api.listCategories });
|
||||
const tags = useQuery({
|
||||
queryKey: ["tags", "top"],
|
||||
queryFn: () => api.listTags({ size: 30, sort: "count_desc" }),
|
||||
});
|
||||
|
||||
const patch = (next: Partial<ListQuery>) => onChange({ ...query, ...next, page: 1 });
|
||||
|
||||
return (
|
||||
<aside className="flex w-64 shrink-0 flex-col gap-5 overflow-y-auto border-r border-slate-800 bg-slate-950/60 px-4 py-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-400">关键词搜索</label>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500"
|
||||
/>
|
||||
<input
|
||||
className="input pl-7"
|
||||
placeholder="OCR / 标题 / 标签"
|
||||
value={query.q ?? ""}
|
||||
onChange={(e) => patch({ q: e.target.value })}
|
||||
/>
|
||||
{query.q && (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white"
|
||||
onClick={() => patch({ q: "" })}
|
||||
aria-label="清除"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-slate-400">分类</label>
|
||||
{query.category_id && (
|
||||
<button
|
||||
className="text-[11px] text-slate-500 hover:text-white"
|
||||
onClick={() => patch({ category_id: undefined })}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{cats.data?.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={`chip ${query.category_id === c.id ? "chip-active" : ""}`}
|
||||
onClick={() =>
|
||||
patch({ category_id: query.category_id === c.id ? undefined : c.id })
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ background: c.color ?? "#64748b" }}
|
||||
/>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-400">时间区间</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={query.date_from ? query.date_from.slice(0, 10) : ""}
|
||||
onChange={(e) =>
|
||||
patch({ date_from: e.target.value ? `${e.target.value}T00:00:00` : undefined })
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={query.date_to ? query.date_to.slice(0, 10) : ""}
|
||||
onChange={(e) =>
|
||||
patch({ date_to: e.target.value ? `${e.target.value}T23:59:59` : undefined })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-400">排序</label>
|
||||
<select
|
||||
className="input"
|
||||
value={query.sort ?? "captured_desc"}
|
||||
onChange={(e) => patch({ sort: e.target.value })}
|
||||
>
|
||||
<option value="captured_desc">截取时间(新→旧)</option>
|
||||
<option value="captured_asc">截取时间(旧→新)</option>
|
||||
<option value="imported_desc">导入时间(最新)</option>
|
||||
<option value="imported_asc">导入时间(最早)</option>
|
||||
<option value="title_asc">标题 A→Z</option>
|
||||
<option value="title_desc">标题 Z→A</option>
|
||||
<option value="size_desc">文件大小(大→小)</option>
|
||||
<option value="size_asc">文件大小(小→大)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-slate-400">状态</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{["", "done", "pending", "running", "failed"].map((st) => (
|
||||
<button
|
||||
key={st || "all"}
|
||||
className={`chip ${
|
||||
(query.status ?? "") === st ? "chip-active" : ""
|
||||
}`}
|
||||
onClick={() => patch({ status: st || undefined })}
|
||||
>
|
||||
{st === ""
|
||||
? "全部"
|
||||
: st === "done"
|
||||
? "已分析"
|
||||
: st === "pending"
|
||||
? "排队"
|
||||
: st === "running"
|
||||
? "分析中"
|
||||
: "失败"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`btn ${query.favorite ? "btn-primary" : ""}`}
|
||||
onClick={() => patch({ favorite: query.favorite ? undefined : true })}
|
||||
>
|
||||
<Star size={14} /> 仅看收藏
|
||||
</button>
|
||||
|
||||
{tags.data && tags.data.items.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-slate-400">热门标签</label>
|
||||
<Link to="/tags" className="text-[11px] text-brand-400 hover:underline">
|
||||
全部 →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.data.items.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`chip ${query.tag === t.name ? "chip-active" : ""}`}
|
||||
onClick={() => patch({ tag: query.tag === t.name ? undefined : t.name })}
|
||||
>
|
||||
#{t.name}
|
||||
<span className="opacity-50">{t.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
interface Props {
|
||||
status: string;
|
||||
}
|
||||
|
||||
const labelMap: Record<string, string> = {
|
||||
pending: "等待中",
|
||||
running: "分析中",
|
||||
done: "完成",
|
||||
failed: "失败",
|
||||
skipped: "跳过",
|
||||
};
|
||||
|
||||
const styleMap: Record<string, string> = {
|
||||
pending: "bg-slate-700/60 text-slate-300",
|
||||
running: "bg-amber-500/20 text-amber-300",
|
||||
done: "bg-emerald-500/20 text-emerald-300",
|
||||
failed: "bg-rose-500/20 text-rose-300",
|
||||
skipped: "bg-slate-700/40 text-slate-400",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: Props) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] ${
|
||||
styleMap[status] ?? styleMap.pending
|
||||
}`}
|
||||
>
|
||||
{labelMap[status] ?? status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.25);
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.45);
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(99, 102, 241, 0.6);
|
||||
box-shadow: 0 12px 32px -16px rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-800/60 px-3 py-1.5 text-sm text-slate-200 transition hover:border-brand-500 hover:bg-slate-800 hover:text-white;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply border-brand-500 bg-brand-600 text-white hover:bg-brand-500;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply border-transparent bg-transparent text-slate-400 hover:text-white;
|
||||
}
|
||||
.input {
|
||||
@apply w-full rounded-md border border-slate-700 bg-slate-900/70 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-brand-500 focus:outline-none;
|
||||
}
|
||||
.chip {
|
||||
@apply inline-flex items-center gap-1 rounded-full border border-slate-700 bg-slate-800/60 px-2.5 py-0.5 text-xs text-slate-300;
|
||||
}
|
||||
.chip-active {
|
||||
@apply border-brand-500 bg-brand-600/20 text-white;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 15_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { Sparkles, Images, ListChecks, Loader2 } from "lucide-react";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import { DetailPanel } from "@/components/DetailPanel";
|
||||
import { Card } from "@/components/Card";
|
||||
|
||||
export default function HomePage() {
|
||||
const stats = useQuery({ queryKey: ["stats"], queryFn: api.stats });
|
||||
const daily = useQuery({
|
||||
queryKey: ["random", 6],
|
||||
queryFn: () => api.randomScreenshots({ n: 6 }),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const todoSummary = useQuery({ queryKey: ["todo-summary"], queryFn: api.todoSummary });
|
||||
const queue = useQuery({
|
||||
queryKey: ["queue"],
|
||||
queryFn: api.queueStatus,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const [openId, setOpenId] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto px-8 py-6">
|
||||
<header className="mb-6 flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold text-white">
|
||||
<Sparkles size={22} className="text-brand-400" />
|
||||
欢迎回来
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
让 AI 帮你重新认识这些截图
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 text-sm">
|
||||
<Link to="/library" className="btn btn-primary">
|
||||
<Images size={14} /> 进入截图库
|
||||
</Link>
|
||||
<Link to="/todos" className="btn">
|
||||
<ListChecks size={14} /> 查看待办
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="mb-6 grid grid-cols-4 gap-4">
|
||||
<StatCard label="截图总数" value={stats.data?.total ?? 0} />
|
||||
<StatCard
|
||||
label="已分析"
|
||||
value={stats.data?.by_status?.done ?? 0}
|
||||
hint={`失败 ${stats.data?.by_status?.failed ?? 0} · 排队 ${stats.data?.by_status?.pending ?? 0}`}
|
||||
/>
|
||||
<StatCard
|
||||
label="待办"
|
||||
value={todoSummary.data?.pending ?? 0}
|
||||
hint={`已完成 ${todoSummary.data?.done ?? 0}`}
|
||||
/>
|
||||
<StatCard
|
||||
label="队列"
|
||||
value={queue.data?.pending ?? 0}
|
||||
hint={`运行中 ${queue.data?.running ?? 0} · 失败 ${queue.data?.failed ?? 0}`}
|
||||
icon={(queue.data?.running ?? 0) > 0 ? <Loader2 className="animate-spin" size={14} /> : undefined}
|
||||
to="/queue"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<div className="mb-3 flex items-end justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">每日回顾</h2>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => daily.refetch()}
|
||||
disabled={daily.isFetching}
|
||||
>
|
||||
换一批
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-6">
|
||||
{(daily.data ?? []).map((s) => (
|
||||
<Card key={s.id} shot={s} onOpen={setOpenId} />
|
||||
))}
|
||||
{(daily.data?.length ?? 0) === 0 && !daily.isLoading && (
|
||||
<div className="col-span-full rounded-md border border-dashed border-slate-700 p-8 text-center text-sm text-slate-500">
|
||||
库里还没有截图,先去
|
||||
<Link to="/settings" className="mx-1 text-brand-400 underline">
|
||||
设置
|
||||
</Link>
|
||||
里添加一个监听目录吧。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{stats.data && stats.data.by_category.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-3 text-lg font-semibold text-white">分类分布</h2>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{stats.data.by_category
|
||||
.filter((c) => c.id)
|
||||
.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
to={`/library`}
|
||||
className="flex items-center justify-between rounded-lg border border-slate-800 bg-slate-900/50 px-4 py-3 transition hover:border-brand-500"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm text-slate-200">
|
||||
<span
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ background: c.color ?? "#6366f1" }}
|
||||
/>
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-slate-100">{c.count}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<DetailPanel id={openId} onClose={() => setOpenId(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
icon,
|
||||
to,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
hint?: string;
|
||||
icon?: React.ReactNode;
|
||||
to?: string;
|
||||
}) {
|
||||
const inner = (
|
||||
<>
|
||||
<div className="text-xs text-slate-500">{label}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-2xl font-semibold text-white">
|
||||
{value.toLocaleString()}
|
||||
{icon}
|
||||
</div>
|
||||
{hint && <div className="mt-1 text-xs text-slate-500">{hint}</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="block rounded-xl border border-slate-800 bg-slate-900/50 px-5 py-4 transition hover:border-brand-500"
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 px-5 py-4">
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import { CardGrid } from "@/components/CardGrid";
|
||||
import { DetailPanel } from "@/components/DetailPanel";
|
||||
import { FilterBar } from "@/components/FilterBar";
|
||||
import type { ListQuery, ScreenshotBrief } from "@/types";
|
||||
|
||||
const PAGE_SIZE = 80;
|
||||
|
||||
function initialQuery(params: URLSearchParams): ListQuery {
|
||||
return {
|
||||
page: 1,
|
||||
size: PAGE_SIZE,
|
||||
tag: params.get("tag") ?? undefined,
|
||||
q: params.get("q") ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [query, setQuery] = useState<ListQuery>(() => initialQuery(searchParams));
|
||||
const [openId, setOpenId] = useState<number | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const listKey = useMemo(() => ["screenshots", query], [query]);
|
||||
const list = useQuery({
|
||||
queryKey: listKey,
|
||||
queryFn: () => api.listScreenshots({ ...query, size: PAGE_SIZE }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE_SIZE));
|
||||
|
||||
const toggleFav = useMutation({
|
||||
mutationFn: (shot: ScreenshotBrief) =>
|
||||
api.updateScreenshot(shot.id, { is_favorite: !shot.is_favorite }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["screenshots"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<FilterBar query={query} onChange={setQuery} />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-white">截图库</h1>
|
||||
<p className="text-xs text-slate-500">
|
||||
共 {list.data?.total ?? 0} 张
|
||||
{list.isFetching && <span className="ml-2 text-brand-400">加载中…</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
className="btn"
|
||||
disabled={(query.page ?? 1) <= 1}
|
||||
onClick={() => setQuery((q) => ({ ...q, page: Math.max(1, (q.page ?? 1) - 1) }))}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="text-xs text-slate-400">
|
||||
第 {query.page ?? 1} / {totalPages} 页
|
||||
</span>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={(query.page ?? 1) >= totalPages}
|
||||
onClick={() =>
|
||||
setQuery((q) => ({ ...q, page: Math.min(totalPages, (q.page ?? 1) + 1) }))
|
||||
}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
<CardGrid
|
||||
items={list.data?.items ?? []}
|
||||
onOpen={setOpenId}
|
||||
onToggleFav={(shot) => toggleFav.mutate(shot)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DetailPanel id={openId} onClose={() => setOpenId(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
|
||||
ListOrdered,
|
||||
|
||||
Loader2,
|
||||
|
||||
RefreshCw,
|
||||
|
||||
RotateCcw,
|
||||
|
||||
AlertCircle,
|
||||
|
||||
Image as ImageIcon,
|
||||
|
||||
} from "lucide-react";
|
||||
|
||||
|
||||
|
||||
import { api } from "@/api/client";
|
||||
|
||||
import { DetailPanel } from "@/components/DetailPanel";
|
||||
|
||||
import type { JobItem } from "@/types";
|
||||
|
||||
|
||||
|
||||
const STATUS_TABS: { key: string; label: string }[] = [
|
||||
|
||||
{ key: "failed", label: "失败" },
|
||||
|
||||
{ key: "running", label: "运行中" },
|
||||
|
||||
{ key: "pending", label: "排队中" },
|
||||
|
||||
{ key: "done", label: "已完成" },
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
|
||||
|
||||
/** 从路径取文件名,便于列表展示。 */
|
||||
|
||||
function basename(path?: string | null) {
|
||||
|
||||
if (!path) return "—";
|
||||
|
||||
const parts = path.replace(/\\/g, "/").split("/");
|
||||
|
||||
return parts[parts.length - 1] || path;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function QueuePage() {
|
||||
|
||||
const [status, setStatus] = useState("failed");
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [openId, setOpenId] = useState<number | null>(null);
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
|
||||
|
||||
const queue = useQuery({
|
||||
|
||||
queryKey: ["queue"],
|
||||
|
||||
queryFn: api.queueStatus,
|
||||
|
||||
refetchInterval: 5000,
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
const list = useQuery({
|
||||
|
||||
queryKey: ["jobs", status, page],
|
||||
|
||||
queryFn: () => api.listJobs({ status, page, size: PAGE_SIZE }),
|
||||
|
||||
placeholderData: (prev) => prev,
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE_SIZE));
|
||||
|
||||
|
||||
|
||||
const retryFailed = useMutation({
|
||||
|
||||
mutationFn: (jobIds?: number[]) => api.retryFailedJobs(jobIds),
|
||||
|
||||
onSuccess: () => {
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["jobs"] });
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["queue"] });
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["stats"] });
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
const resetStale = useMutation({
|
||||
|
||||
mutationFn: (all?: boolean) => api.resetStaleJobs(all),
|
||||
|
||||
onSuccess: () => {
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["jobs"] });
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["queue"] });
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
const enqueueOcr = useMutation({
|
||||
|
||||
mutationFn: () => api.enqueueOcrFailed(500),
|
||||
|
||||
onSuccess: () => {
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["jobs"] });
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["queue"] });
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
const onTabChange = (key: string) => {
|
||||
|
||||
setStatus(key);
|
||||
|
||||
setPage(1);
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div className="flex h-full flex-col px-8 py-6">
|
||||
|
||||
<header className="mb-4 flex flex-wrap items-end justify-between gap-4">
|
||||
|
||||
<div>
|
||||
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold text-white">
|
||||
|
||||
<ListOrdered size={22} className="text-brand-400" />
|
||||
|
||||
分析队列
|
||||
|
||||
</h1>
|
||||
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
|
||||
查看失败原因、OCR 补跑、复位僵尸任务
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
{(queue.data?.ocr_retryable ?? 0) > 0 && (
|
||||
|
||||
<button
|
||||
|
||||
className="btn border-brand-600/50 text-brand-300"
|
||||
|
||||
disabled={enqueueOcr.isPending}
|
||||
|
||||
onClick={() => enqueueOcr.mutate()}
|
||||
|
||||
>
|
||||
|
||||
<RefreshCw size={14} className={enqueueOcr.isPending ? "animate-spin" : ""} />
|
||||
|
||||
补跑 OCR 失败 ({queue.data?.ocr_retryable})
|
||||
|
||||
</button>
|
||||
|
||||
)}
|
||||
|
||||
{(queue.data?.running ?? 0) > 0 && (queue.data?.inflight ?? 0) === 0 && (
|
||||
|
||||
<button
|
||||
|
||||
className="btn border-amber-600/50 text-amber-300"
|
||||
|
||||
disabled={resetStale.isPending}
|
||||
|
||||
onClick={() => resetStale.mutate(true)}
|
||||
|
||||
>
|
||||
|
||||
<RotateCcw size={14} />
|
||||
|
||||
复位僵尸运行中 ({queue.data?.running})
|
||||
|
||||
</button>
|
||||
|
||||
)}
|
||||
|
||||
{(queue.data?.failed ?? 0) > 0 && (
|
||||
|
||||
<button
|
||||
|
||||
className="btn btn-primary"
|
||||
|
||||
disabled={retryFailed.isPending}
|
||||
|
||||
onClick={() => retryFailed.mutate(undefined)}
|
||||
|
||||
>
|
||||
|
||||
<RefreshCw size={14} className={retryFailed.isPending ? "animate-spin" : ""} />
|
||||
|
||||
重试全部失败 ({queue.data?.failed})
|
||||
|
||||
</button>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
|
||||
|
||||
<section className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-6">
|
||||
|
||||
<QueueStat label="排队" value={queue.data?.pending ?? 0} />
|
||||
|
||||
<QueueStat
|
||||
|
||||
label="运行中"
|
||||
|
||||
value={queue.data?.running ?? 0}
|
||||
|
||||
hint={`实际并发 ${queue.data?.inflight ?? 0}`}
|
||||
|
||||
spinning={(queue.data?.inflight ?? 0) > 0}
|
||||
|
||||
/>
|
||||
|
||||
<QueueStat label="已完成" value={queue.data?.done ?? 0} />
|
||||
|
||||
<QueueStat label="失败" value={queue.data?.failed ?? 0} accent="text-red-400" />
|
||||
|
||||
<QueueStat
|
||||
|
||||
label="OCR 待补"
|
||||
|
||||
value={queue.data?.ocr_retryable ?? 0}
|
||||
|
||||
hint={`队列中 ${queue.data?.ocr_pending ?? 0}`}
|
||||
|
||||
accent="text-amber-300"
|
||||
|
||||
/>
|
||||
|
||||
<QueueStat label="Worker" value={queue.data?.inflight ?? 0} hint="inflight" />
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
|
||||
{STATUS_TABS.map((t) => (
|
||||
|
||||
<button
|
||||
|
||||
key={t.key}
|
||||
|
||||
className={`chip ${status === t.key ? "chip-active" : ""}`}
|
||||
|
||||
onClick={() => onTabChange(t.key)}
|
||||
|
||||
>
|
||||
|
||||
{t.label}
|
||||
|
||||
<span className="opacity-60">{queue.data?.[t.key as keyof typeof queue.data] ?? 0}</span>
|
||||
|
||||
</button>
|
||||
|
||||
))}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 text-sm">
|
||||
|
||||
<button
|
||||
|
||||
className="btn"
|
||||
|
||||
disabled={page <= 1}
|
||||
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
|
||||
>
|
||||
|
||||
上一页
|
||||
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-slate-400">
|
||||
|
||||
第 {page} / {totalPages} 页 · 共 {list.data?.total ?? 0} 条
|
||||
|
||||
</span>
|
||||
|
||||
<button
|
||||
|
||||
className="btn"
|
||||
|
||||
disabled={page >= totalPages}
|
||||
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
|
||||
>
|
||||
|
||||
下一页
|
||||
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
||||
{list.isLoading && (
|
||||
|
||||
<div className="flex h-40 items-center justify-center text-sm text-slate-500">
|
||||
|
||||
<Loader2 className="mr-2 animate-spin" size={16} /> 加载中…
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{!list.isLoading && (list.data?.items.length ?? 0) === 0 && (
|
||||
|
||||
<div className="flex h-40 items-center justify-center text-sm text-slate-500">
|
||||
|
||||
当前状态下没有任务
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<ul className="grid gap-3">
|
||||
|
||||
{(list.data?.items ?? []).map((job) => (
|
||||
|
||||
<JobRow
|
||||
|
||||
key={job.id}
|
||||
|
||||
job={job}
|
||||
|
||||
onOpen={() => setOpenId(job.screenshot_id)}
|
||||
|
||||
onRetry={() => retryFailed.mutate([job.id])}
|
||||
|
||||
retrying={retryFailed.isPending}
|
||||
|
||||
/>
|
||||
|
||||
))}
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
{status === "pending" && (list.data?.total ?? 0) > PAGE_SIZE && (
|
||||
|
||||
<p className="mt-4 text-center text-xs text-slate-500">
|
||||
|
||||
排队任务较多,已分页展示;Worker 会按 id 顺序依次处理。
|
||||
|
||||
</p>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<DetailPanel id={openId} onClose={() => setOpenId(null)} />
|
||||
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function QueueStat({
|
||||
|
||||
label,
|
||||
|
||||
value,
|
||||
|
||||
hint,
|
||||
|
||||
spinning,
|
||||
|
||||
accent,
|
||||
|
||||
}: {
|
||||
|
||||
label: string;
|
||||
|
||||
value: number;
|
||||
|
||||
hint?: string;
|
||||
|
||||
spinning?: boolean;
|
||||
|
||||
accent?: string;
|
||||
|
||||
}) {
|
||||
|
||||
return (
|
||||
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-900/50 px-4 py-3">
|
||||
|
||||
<div className="text-xs text-slate-500">{label}</div>
|
||||
|
||||
<div className={`mt-1 flex items-center gap-2 text-xl font-semibold ${accent ?? "text-white"}`}>
|
||||
|
||||
{value.toLocaleString()}
|
||||
|
||||
{spinning && <Loader2 className="animate-spin text-brand-400" size={14} />}
|
||||
|
||||
</div>
|
||||
|
||||
{hint && <div className="mt-0.5 text-[10px] text-slate-500">{hint}</div>}
|
||||
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function JobRow({
|
||||
|
||||
job,
|
||||
|
||||
onOpen,
|
||||
|
||||
onRetry,
|
||||
|
||||
retrying,
|
||||
|
||||
}: {
|
||||
|
||||
job: JobItem;
|
||||
|
||||
onOpen: () => void;
|
||||
|
||||
onRetry: () => void;
|
||||
|
||||
retrying: boolean;
|
||||
|
||||
}) {
|
||||
|
||||
const statusColor =
|
||||
|
||||
job.status === "failed"
|
||||
|
||||
? "text-red-400"
|
||||
|
||||
: job.status === "running"
|
||||
|
||||
? "text-amber-400"
|
||||
|
||||
: job.status === "done"
|
||||
|
||||
? "text-emerald-400"
|
||||
|
||||
: "text-slate-400";
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<li className="rounded-lg border border-slate-800 bg-slate-900/50 p-4">
|
||||
|
||||
<div className="flex gap-4">
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="h-16 w-24 shrink-0 overflow-hidden rounded-md border border-slate-700 bg-slate-950"
|
||||
|
||||
onClick={onOpen}
|
||||
|
||||
>
|
||||
|
||||
{job.thumb_url ? (
|
||||
|
||||
<img src={job.thumb_url} alt="" className="h-full w-full object-cover" />
|
||||
|
||||
) : (
|
||||
|
||||
<div className="flex h-full items-center justify-center text-slate-600">
|
||||
|
||||
<ImageIcon size={20} />
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
</button>
|
||||
|
||||
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
|
||||
<span className="font-mono text-slate-500">#{job.id}</span>
|
||||
|
||||
<span className="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] uppercase text-slate-400">
|
||||
|
||||
{job.kind}
|
||||
|
||||
</span>
|
||||
|
||||
<span className={`font-medium ${statusColor}`}>{job.status}</span>
|
||||
|
||||
<span className="text-slate-500">重试 {job.retries}</span>
|
||||
|
||||
{job.ai_status && (
|
||||
|
||||
<span className="text-slate-500">AI {job.ai_status}</span>
|
||||
|
||||
)}
|
||||
|
||||
{job.ocr_status && (
|
||||
|
||||
<span className={job.ocr_status === "failed" ? "text-red-400" : "text-slate-500"}>
|
||||
|
||||
OCR {job.ocr_status}
|
||||
|
||||
</span>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="truncate text-sm font-medium text-slate-100">
|
||||
|
||||
{job.ai_title || basename(job.path)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="truncate text-xs text-slate-500" title={job.path ?? undefined}>
|
||||
|
||||
{job.path}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{job.last_error && (
|
||||
|
||||
<div className="mt-2 flex gap-2 rounded-md border border-red-900/50 bg-red-950/30 px-3 py-2 text-xs text-red-200">
|
||||
|
||||
<AlertCircle size={14} className="mt-0.5 shrink-0 text-red-400" />
|
||||
|
||||
<pre className="whitespace-pre-wrap break-all font-mono leading-relaxed">
|
||||
|
||||
{job.last_error}
|
||||
|
||||
</pre>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-[10px] text-slate-500">
|
||||
|
||||
{job.created_at && <span>创建 {fmtTime(job.created_at)}</span>}
|
||||
|
||||
{job.started_at && <span>开始 {fmtTime(job.started_at)}</span>}
|
||||
|
||||
{job.finished_at && <span>结束 {fmtTime(job.finished_at)}</span>}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-2">
|
||||
|
||||
<button className="btn" onClick={onOpen}>
|
||||
|
||||
详情
|
||||
|
||||
</button>
|
||||
|
||||
{job.status === "failed" && (
|
||||
|
||||
<button className="btn" disabled={retrying} onClick={onRetry}>
|
||||
|
||||
重试
|
||||
|
||||
</button>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</li>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
|
||||
return new Date(iso).toLocaleString("zh-CN", { hour12: false });
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FolderPlus, FolderOpen, RefreshCw, ShieldAlert, Trash2, Plug } from "lucide-react";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import type {
|
||||
Category,
|
||||
ProviderConfig,
|
||||
ProviderConfigOut,
|
||||
ProviderTestResult,
|
||||
RecognitionMode,
|
||||
WatchFolder,
|
||||
} from "@/types";
|
||||
import { RECOGNITION_MODE_LABELS } from "@/types";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto px-8 py-6">
|
||||
<h1 className="mb-6 text-2xl font-semibold text-white">设置</h1>
|
||||
<div className="flex flex-col gap-8">
|
||||
<WatchFolderSection />
|
||||
<RecognitionModeSection />
|
||||
<ProviderSection
|
||||
k="ocr_provider"
|
||||
title="OCR 引擎"
|
||||
desc="传统文字识别。在「传统 OCR / 混合」模式下使用;也可单独选「视觉模型识文」作为 OCR 引擎。"
|
||||
defaults={{ type: "tesseract", extra: { lang: "chi_sim+eng" } }}
|
||||
/>
|
||||
<ProviderSection
|
||||
k="vlm_provider"
|
||||
title="视觉 AI 模型"
|
||||
desc="多模态大模型:在「视觉 AI / 混合」模式下负责识文与分类摘要;支持 Ollama / GLM / MiniMax / OpenAI 等 OpenAI 兼容接口。"
|
||||
defaults={{
|
||||
type: "openai_compat",
|
||||
base_url: "http://localhost:11434/v1",
|
||||
model: "qwen2.5vl:7b",
|
||||
extra: {},
|
||||
}}
|
||||
/>
|
||||
<CategorySection />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecognitionModeSection() {
|
||||
const qc = useQueryClient();
|
||||
const cur = useQuery({
|
||||
queryKey: ["recognition-mode"],
|
||||
queryFn: api.getRecognitionMode,
|
||||
});
|
||||
const [mode, setMode] = useState<RecognitionMode>("hybrid");
|
||||
|
||||
useEffect(() => {
|
||||
if (cur.data?.mode && cur.data.mode in RECOGNITION_MODE_LABELS) {
|
||||
setMode(cur.data.mode as RecognitionMode);
|
||||
}
|
||||
}, [cur.data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.setRecognitionMode(mode),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["recognition-mode"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-5">
|
||||
<header className="mb-1 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">文字识别方式</h2>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</header>
|
||||
<p className="mb-4 text-xs text-slate-500">
|
||||
选择截图文字如何被提取:纯 OCR、纯视觉 AI,或两者结合(OCR 文本供 AI 参考,AI 同时看图)。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(RECOGNITION_MODE_LABELS) as RecognitionMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className={`chip ${mode === m ? "chip-active" : ""}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
{RECOGNITION_MODE_LABELS[m]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
{mode === "ocr" && "仅使用下方 OCR 引擎提取文字;视觉 AI 仍可用于分类/摘要(若已配置)。"}
|
||||
{mode === "vision" && "使用下方「视觉 AI 模型」从图片识文,不走 Tesseract 等传统 OCR。"}
|
||||
{mode === "hybrid" && "先用 OCR 引擎识文,再交给视觉 AI 联合分析;OCR 失败时会自动尝试视觉识文。"}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WatchFolderSection() {
|
||||
const qc = useQueryClient();
|
||||
const folders = useQuery({ queryKey: ["watch-folders"], queryFn: api.listWatchFolders });
|
||||
const [draft, setDraft] = useState<Omit<WatchFolder, "id">>({
|
||||
path: "",
|
||||
enabled: true,
|
||||
recursive: true,
|
||||
is_sensitive: false,
|
||||
});
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () => api.addWatchFolder(draft),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["watch-folders"] });
|
||||
setDraft({ ...draft, path: "" });
|
||||
},
|
||||
});
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number; payload: Omit<WatchFolder, "id"> }) =>
|
||||
api.updateWatchFolder(id, payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["watch-folders"] }),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => api.deleteWatchFolder(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["watch-folders"] }),
|
||||
});
|
||||
const reimport = useMutation({
|
||||
mutationFn: (folder: WatchFolder) =>
|
||||
api.importNow({
|
||||
path: folder.path,
|
||||
enabled: folder.enabled,
|
||||
recursive: folder.recursive,
|
||||
is_sensitive: folder.is_sensitive,
|
||||
}),
|
||||
});
|
||||
const [pathTestMsg, setPathTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const testPath = useMutation({
|
||||
mutationFn: () => api.validateWatchPath(draft.path),
|
||||
onSuccess: (data) => {
|
||||
setPathTestMsg({
|
||||
ok: true,
|
||||
text: `${data.message}(规范化路径: ${data.path})`,
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setPathTestMsg({ ok: false, text: err.message });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-5">
|
||||
<header className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">监听目录</h2>
|
||||
<span className="text-xs text-slate-500">
|
||||
支持本地路径、UNC 网络路径(如 \\NAS\\共享\\文件夹)
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<input
|
||||
className="input min-w-[280px] flex-1"
|
||||
placeholder="D:/Pictures 或 \\JIULUGNAS\\personal_folder\\Photos\\..."
|
||||
value={draft.path}
|
||||
onChange={(e) => {
|
||||
setDraft({ ...draft, path: e.target.value });
|
||||
setPathTestMsg(null);
|
||||
}}
|
||||
/>
|
||||
<label className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.recursive}
|
||||
onChange={(e) => setDraft({ ...draft, recursive: e.target.checked })}
|
||||
/>
|
||||
递归
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.is_sensitive}
|
||||
onChange={(e) => setDraft({ ...draft, is_sensitive: e.target.checked })}
|
||||
/>
|
||||
敏感目录(禁止上传云端)
|
||||
</label>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!draft.path || testPath.isPending}
|
||||
onClick={() => testPath.mutate()}
|
||||
>
|
||||
<Plug size={14} /> 测试路径
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!draft.path || add.isPending}
|
||||
onClick={() => add.mutate()}
|
||||
>
|
||||
<FolderPlus size={14} /> 添加
|
||||
</button>
|
||||
</div>
|
||||
{pathTestMsg && (
|
||||
<p
|
||||
className={`mb-3 text-xs ${pathTestMsg.ok ? "text-emerald-400" : "text-rose-400"}`}
|
||||
>
|
||||
{pathTestMsg.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="flex flex-col divide-y divide-slate-800">
|
||||
{(folders.data ?? []).map((f) => (
|
||||
<li key={f.id} className="flex items-center gap-2 py-2 text-sm">
|
||||
<FolderOpen size={14} className="text-slate-500" />
|
||||
<span className="flex-1 break-all text-slate-200">{f.path}</span>
|
||||
{f.is_sensitive && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-amber-500/20 px-2 py-0.5 text-[10px] text-amber-300">
|
||||
<ShieldAlert size={10} /> 敏感
|
||||
</span>
|
||||
)}
|
||||
<label className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={f.enabled}
|
||||
onChange={(e) =>
|
||||
update.mutate({
|
||||
id: f.id,
|
||||
payload: { ...f, enabled: e.target.checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={f.recursive}
|
||||
onChange={(e) =>
|
||||
update.mutate({
|
||||
id: f.id,
|
||||
payload: { ...f, recursive: e.target.checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
递归
|
||||
</label>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => reimport.mutate(f)}
|
||||
disabled={reimport.isPending}
|
||||
>
|
||||
<RefreshCw size={14} /> 重扫
|
||||
</button>
|
||||
<button
|
||||
className="btn text-rose-300 hover:border-rose-500"
|
||||
onClick={() => {
|
||||
if (confirm(`删除监听目录 ${f.path}?`)) remove.mutate(f.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{(folders.data?.length ?? 0) === 0 && (
|
||||
<li className="py-4 text-center text-xs text-slate-500">
|
||||
尚未添加监听目录
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderSection({
|
||||
k,
|
||||
title,
|
||||
desc,
|
||||
defaults,
|
||||
}: {
|
||||
k: "ocr_provider" | "vlm_provider";
|
||||
title: string;
|
||||
desc: string;
|
||||
defaults: ProviderConfig;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const cur = useQuery({ queryKey: ["provider", k], queryFn: () => api.getProvider(k) });
|
||||
const [draft, setDraft] = useState<ProviderConfig>(defaults);
|
||||
const mask: string | null | undefined = (cur.data as ProviderConfigOut | null | undefined)
|
||||
?.api_key_mask;
|
||||
|
||||
useEffect(() => {
|
||||
if (cur.data) {
|
||||
// 仅同步用户可编辑字段,避免把 api_key_mask 之类的派生字段灌进去
|
||||
const { type, base_url, api_key, model, extra } = cur.data;
|
||||
setDraft({
|
||||
...defaults,
|
||||
type: type || defaults.type,
|
||||
base_url: base_url ?? defaults.base_url ?? null,
|
||||
api_key: api_key ?? "",
|
||||
model: model ?? defaults.model ?? null,
|
||||
extra: { ...defaults.extra, ...(extra ?? {}) },
|
||||
});
|
||||
}
|
||||
}, [cur.data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.setProvider(k, draft),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["provider", k] }),
|
||||
});
|
||||
const [testResult, setTestResult] = useState<ProviderTestResult | null>(null);
|
||||
const testConn = useMutation({
|
||||
mutationFn: () => api.testProvider(k, draft),
|
||||
onSuccess: (data) => setTestResult(data),
|
||||
onError: (err: Error) =>
|
||||
setTestResult({ ok: false, message: err.message, detail: null, latency_ms: null }),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-5">
|
||||
<header className="mb-1 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => testConn.mutate()}
|
||||
disabled={testConn.isPending || draft.type === "none"}
|
||||
>
|
||||
<Plug size={14} /> {testConn.isPending ? "测试中…" : "测试连通性"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<p className="mb-4 text-xs text-slate-500">{desc}</p>
|
||||
{testResult && (
|
||||
<div
|
||||
className={`mb-4 rounded-md border px-3 py-2 text-xs ${
|
||||
testResult.ok
|
||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300"
|
||||
: "border-rose-500/40 bg-rose-500/10 text-rose-300"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{testResult.message}</div>
|
||||
{testResult.detail && (
|
||||
<div className="mt-1 opacity-80">{testResult.detail}</div>
|
||||
)}
|
||||
{testResult.latency_ms != null && (
|
||||
<div className="mt-1 opacity-60">耗时 {testResult.latency_ms} ms</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field label="Provider 类型">
|
||||
{k === "ocr_provider" ? (
|
||||
<select
|
||||
className="input"
|
||||
value={draft.type}
|
||||
onChange={(e) => {
|
||||
setDraft({ ...draft, type: e.target.value });
|
||||
setTestResult(null);
|
||||
}}
|
||||
>
|
||||
<option value="tesseract">Tesseract(本地)</option>
|
||||
<option value="paddleocr">PaddleOCR(本地,需安装)</option>
|
||||
<option value="http">HTTP API(自定义 OCR 服务)</option>
|
||||
<option value="vision">视觉模型识文(OpenAI 兼容)</option>
|
||||
<option value="none">不使用</option>
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
className="input"
|
||||
value={draft.type}
|
||||
onChange={(e) => {
|
||||
setDraft({ ...draft, type: e.target.value });
|
||||
setTestResult(null);
|
||||
}}
|
||||
>
|
||||
<option value="openai_compat">OpenAI 兼容(推荐)</option>
|
||||
<option value="none">不使用</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{(k === "vlm_provider" ||
|
||||
(k === "ocr_provider" &&
|
||||
(draft.type === "vision" || draft.type === "http"))) && (
|
||||
<VisionApiFields
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
mask={mask}
|
||||
showModel={draft.type !== "http" || k === "vlm_provider"}
|
||||
urlLabel={draft.type === "http" && k === "ocr_provider" ? "OCR API URL" : "Base URL"}
|
||||
urlPlaceholder={
|
||||
draft.type === "http" && k === "ocr_provider"
|
||||
? "https://your-ocr-service/recognize"
|
||||
: "https://api.openai.com/v1"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{k === "ocr_provider" && draft.type === "tesseract" && (
|
||||
<>
|
||||
<Field label="语言(lang)">
|
||||
<input
|
||||
className="input"
|
||||
value={String(draft.extra.lang ?? "chi_sim+eng")}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
extra: { ...draft.extra, lang: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="tesseract 路径(可选)">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="C:/Program Files/Tesseract-OCR/tesseract.exe"
|
||||
value={String(draft.extra.cmd ?? "")}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
extra: { ...draft.extra, cmd: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{k === "ocr_provider" && draft.type === "paddleocr" && (
|
||||
<Field label="语言(lang)">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="ch / en / ..."
|
||||
value={String(draft.extra.lang ?? "ch")}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
extra: { ...draft.extra, lang: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{k === "ocr_provider" && draft.type === "http" && (
|
||||
<Field label="响应文本字段(text_path)">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="text 或 data.text"
|
||||
value={String(draft.extra.text_path ?? "text")}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
extra: { ...draft.extra, text_path: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 视觉 / HTTP OCR 共用的 URL、Model、API Key 表单项 */
|
||||
function VisionApiFields({
|
||||
draft,
|
||||
setDraft,
|
||||
mask,
|
||||
showModel = true,
|
||||
urlLabel = "Base URL",
|
||||
urlPlaceholder = "https://api.openai.com/v1",
|
||||
}: {
|
||||
draft: ProviderConfig;
|
||||
setDraft: (v: ProviderConfig) => void;
|
||||
mask?: string | null;
|
||||
showModel?: boolean;
|
||||
urlLabel?: string;
|
||||
urlPlaceholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Field label={urlLabel}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={urlPlaceholder}
|
||||
value={draft.base_url ?? ""}
|
||||
onChange={(e) => setDraft({ ...draft, base_url: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
{showModel && (
|
||||
<Field label="Model">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="gpt-4o-mini / glm-4v-flash / qwen2.5vl:7b"
|
||||
value={draft.model ?? ""}
|
||||
onChange={(e) => setDraft({ ...draft, model: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="API Key(留空则保留原值)">
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder={mask ? `已配置:${mask}` : "sk-..."}
|
||||
value={draft.api_key ?? ""}
|
||||
onChange={(e) => setDraft({ ...draft, api_key: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CategorySection() {
|
||||
const qc = useQueryClient();
|
||||
const cats = useQuery({ queryKey: ["categories"], queryFn: api.listCategories });
|
||||
const [draft, setDraft] = useState<Omit<Category, "id">>({
|
||||
name: "",
|
||||
color: "#6366f1",
|
||||
prompt_hint: "",
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => api.createCategory(draft),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["categories"] });
|
||||
setDraft({ name: "", color: "#6366f1", prompt_hint: "" });
|
||||
},
|
||||
});
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number; payload: Omit<Category, "id"> }) =>
|
||||
api.updateCategory(id, payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["categories"] }),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => api.deleteCategory(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["categories"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-5">
|
||||
<header className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">分类管理</h2>
|
||||
<span className="text-xs text-slate-500">名称会作为提示词提供给 AI</span>
|
||||
</header>
|
||||
|
||||
<div className="mb-4 grid grid-cols-[1fr_120px_2fr_auto] gap-2">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="分类名"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="color"
|
||||
value={draft.color ?? "#6366f1"}
|
||||
onChange={(e) => setDraft({ ...draft, color: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="提示词(可选)"
|
||||
value={draft.prompt_hint ?? ""}
|
||||
onChange={(e) => setDraft({ ...draft, prompt_hint: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!draft.name || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
新增
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col divide-y divide-slate-800">
|
||||
{(cats.data ?? []).map((c) => (
|
||||
<CategoryRow
|
||||
key={c.id}
|
||||
cat={c}
|
||||
onSave={(payload) => update.mutate({ id: c.id, payload })}
|
||||
onDelete={() => {
|
||||
if (confirm(`删除分类 ${c.name}?`)) remove.mutate(c.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryRow({
|
||||
cat,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
cat: Category;
|
||||
onSave: (p: Omit<Category, "id">) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<Omit<Category, "id">>({
|
||||
name: cat.name,
|
||||
color: cat.color ?? "#6366f1",
|
||||
prompt_hint: cat.prompt_hint ?? "",
|
||||
});
|
||||
useEffect(() => {
|
||||
setDraft({
|
||||
name: cat.name,
|
||||
color: cat.color ?? "#6366f1",
|
||||
prompt_hint: cat.prompt_hint ?? "",
|
||||
});
|
||||
}, [cat]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<li className="grid grid-cols-[1fr_120px_2fr_auto] gap-2 py-2">
|
||||
<input
|
||||
className="input"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="color"
|
||||
value={draft.color ?? "#6366f1"}
|
||||
onChange={(e) => setDraft({ ...draft, color: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.prompt_hint ?? ""}
|
||||
onChange={(e) => setDraft({ ...draft, prompt_hint: e.target.value })}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
onSave(draft);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button className="btn" onClick={() => setEditing(false)}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="grid grid-cols-[1fr_120px_2fr_auto] items-center gap-2 py-2 text-sm">
|
||||
<span className="flex items-center gap-2 text-slate-100">
|
||||
<span
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ background: cat.color ?? "#6366f1" }}
|
||||
/>
|
||||
{cat.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{cat.color}</span>
|
||||
<span className="text-xs text-slate-400">{cat.prompt_hint || "—"}</span>
|
||||
<div className="flex gap-1">
|
||||
<button className="btn" onClick={() => setEditing(true)}>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className="btn text-rose-300 hover:border-rose-500"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-slate-400">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Shuffle, ExternalLink, Star } from "lucide-react";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import { DetailPanel } from "@/components/DetailPanel";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
|
||||
export default function ShufflePage() {
|
||||
const [openId, setOpenId] = useState<number | null>(null);
|
||||
const random = useQuery({
|
||||
queryKey: ["random-one"],
|
||||
queryFn: () => api.randomScreenshots({ n: 1 }),
|
||||
});
|
||||
const shot = random.data?.[0];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col px-8 py-6">
|
||||
<header className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold text-white">
|
||||
<Shuffle size={20} className="text-brand-400" />
|
||||
随机展示
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500">在过往截图中挑一张看看,发现意外的回忆</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => random.refetch()}
|
||||
disabled={random.isFetching}
|
||||
>
|
||||
<Shuffle size={14} /> 再来一张
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{!shot ? (
|
||||
<div className="flex flex-1 items-center justify-center text-slate-500">
|
||||
{random.isLoading ? "加载中…" : "暂无截图"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 gap-6 overflow-hidden">
|
||||
<div className="flex flex-1 items-center justify-center rounded-xl border border-slate-800 bg-black/30 p-4">
|
||||
<img
|
||||
src={`/api/screenshots/${shot.id}/file`}
|
||||
alt={shot.ai_title ?? "screenshot"}
|
||||
className="max-h-full max-w-full rounded-md shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
<aside className="w-[360px] shrink-0 overflow-y-auto rounded-xl border border-slate-800 bg-slate-900/50 px-5 py-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<StatusBadge status={shot.ai_status} />
|
||||
{shot.category && (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-xs"
|
||||
style={{
|
||||
backgroundColor: `${shot.category.color ?? "#6366f1"}22`,
|
||||
color: shot.category.color ?? "#a5b4fc",
|
||||
}}
|
||||
>
|
||||
{shot.category.name}
|
||||
</span>
|
||||
)}
|
||||
{shot.is_favorite && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-400/20 px-2 py-0.5 text-xs text-amber-300">
|
||||
<Star size={12} /> 收藏
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
{shot.ai_title || "(未生成标题)"}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{new Date(shot.captured_at).toLocaleString("zh-CN", { hour12: false })}
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button className="btn" onClick={() => setOpenId(shot.id)}>
|
||||
查看详情
|
||||
</button>
|
||||
<a
|
||||
className="btn"
|
||||
href={`/api/screenshots/${shot.id}/file`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLink size={14} /> 原图
|
||||
</a>
|
||||
</div>
|
||||
{shot.tags.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{shot.tags.map((t) => (
|
||||
<span key={t.id} className="chip">
|
||||
#{t.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
<DetailPanel id={openId} onClose={() => setOpenId(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Hash, Search } from "lucide-react";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
|
||||
const PAGE_SIZE = 80;
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "count_desc", label: "使用最多" },
|
||||
{ value: "count_asc", label: "使用最少" },
|
||||
{ value: "name_asc", label: "名称 A→Z" },
|
||||
{ value: "name_desc", label: "名称 Z→A" },
|
||||
];
|
||||
|
||||
export default function TagsPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [sort, setSort] = useState("count_desc");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ["tags", search, sort, page],
|
||||
queryFn: () => api.listTags({ q: search || undefined, sort, page, size: PAGE_SIZE }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE_SIZE));
|
||||
|
||||
const onSearch = () => {
|
||||
setSearch(q.trim());
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const items = useMemo(() => list.data?.items ?? [], [list.data?.items]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col px-8 py-6">
|
||||
<header className="mb-4 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold text-white">
|
||||
<Hash size={22} className="text-brand-400" />
|
||||
全部标签
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
共 {list.data?.total ?? 0} 个标签 · 点击跳转到截图库筛选
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500"
|
||||
/>
|
||||
<input
|
||||
className="input w-56 pl-7"
|
||||
placeholder="搜索标签名…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onSearch()}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn" onClick={onSearch}>
|
||||
搜索
|
||||
</button>
|
||||
<select className="input w-36" value={sort} onChange={(e) => { setSort(e.target.value); setPage(1); }}>
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mb-3 flex items-center justify-end gap-2 text-sm">
|
||||
<button className="btn" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
上一页
|
||||
</button>
|
||||
<span className="text-xs text-slate-400">
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{list.isLoading && (
|
||||
<div className="flex h-40 items-center justify-center text-sm text-slate-500">加载中…</div>
|
||||
)}
|
||||
{!list.isLoading && items.length === 0 && (
|
||||
<div className="flex h-40 items-center justify-center text-sm text-slate-500">暂无标签</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((t) => (
|
||||
<Link
|
||||
key={t.id}
|
||||
to={`/library?tag=${encodeURIComponent(t.name)}`}
|
||||
className="chip hover:border-brand-500"
|
||||
>
|
||||
#{t.name}
|
||||
<span className="opacity-50">{t.count}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { ListChecks, Check, X, Image as ImageIcon, Search } from "lucide-react";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import { DetailPanel } from "@/components/DetailPanel";
|
||||
import type { TodoListQuery } from "@/types";
|
||||
|
||||
const STATUS_TABS: { key: string; label: string }[] = [
|
||||
{ key: "pending", label: "待办" },
|
||||
{ key: "doing", label: "进行中" },
|
||||
{ key: "done", label: "已完成" },
|
||||
{ key: "dropped", label: "已搁置" },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
export default function TodosPage() {
|
||||
const [status, setStatus] = useState<string>("pending");
|
||||
const [qInput, setQInput] = useState("");
|
||||
const [query, setQuery] = useState<TodoListQuery>({ page: 1, size: PAGE_SIZE });
|
||||
const [openId, setOpenId] = useState<number | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const summary = useQuery({ queryKey: ["todo-summary"], queryFn: api.todoSummary });
|
||||
const list = useQuery({
|
||||
queryKey: ["todos", status, query],
|
||||
queryFn: () => api.listTodos({ ...query, status, size: PAGE_SIZE }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE_SIZE));
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number; payload: Parameters<typeof api.updateTodo>[1] }) =>
|
||||
api.updateTodo(id, payload),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["todos"] });
|
||||
qc.invalidateQueries({ queryKey: ["todo-summary"] });
|
||||
},
|
||||
});
|
||||
|
||||
const onTabChange = (key: string) => {
|
||||
setStatus(key);
|
||||
setQuery((prev) => ({ ...prev, page: 1 }));
|
||||
};
|
||||
|
||||
const onSearch = () => {
|
||||
setQuery((prev) => ({ ...prev, q: qInput.trim() || undefined, page: 1 }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col px-8 py-6">
|
||||
<header className="mb-4 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold text-white">
|
||||
<ListChecks size={22} className="text-brand-400" />
|
||||
待办清单
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500">
|
||||
AI 从截图中识别出的「待看 / 待读 / 想试试」内容 · 共 {list.data?.total ?? 0} 条
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500"
|
||||
/>
|
||||
<input
|
||||
className="input w-52 pl-7"
|
||||
placeholder="搜索标题/备注…"
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onSearch()}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn" onClick={onSearch}>
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{STATUS_TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
className={`chip ${status === t.key ? "chip-active" : ""}`}
|
||||
onClick={() => onTabChange(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
<span className="opacity-60">{summary.data?.[t.key] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2 text-sm">
|
||||
<button
|
||||
className="btn"
|
||||
disabled={(query.page ?? 1) <= 1}
|
||||
onClick={() => setQuery((prev) => ({ ...prev, page: Math.max(1, (prev.page ?? 1) - 1) }))}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="text-xs text-slate-400">
|
||||
第 {query.page ?? 1} / {totalPages} 页
|
||||
</span>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={(query.page ?? 1) >= totalPages}
|
||||
onClick={() =>
|
||||
setQuery((prev) => ({ ...prev, page: Math.min(totalPages, (prev.page ?? 1) + 1) }))
|
||||
}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{(list.data?.items.length ?? 0) === 0 && !list.isLoading && (
|
||||
<div className="flex h-40 items-center justify-center text-sm text-slate-500">
|
||||
暂无内容
|
||||
</div>
|
||||
)}
|
||||
{list.isLoading && (
|
||||
<div className="flex h-40 items-center justify-center text-sm text-slate-500">加载中…</div>
|
||||
)}
|
||||
<ul className="grid gap-3 lg:grid-cols-2">
|
||||
{(list.data?.items ?? []).map((t) => (
|
||||
<li
|
||||
key={t.id}
|
||||
className="group rounded-lg border border-slate-800 bg-slate-900/50 p-4 transition hover:border-brand-500"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between text-[10px] text-slate-500">
|
||||
<span>{t.kind ?? "待办"}</span>
|
||||
<span>{new Date(t.created_at).toLocaleString("zh-CN", { hour12: false })}</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-slate-100">{t.title}</div>
|
||||
{t.note && (
|
||||
<div className="mt-1 line-clamp-3 text-xs text-slate-400">{t.note}</div>
|
||||
)}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button className="btn" onClick={() => setOpenId(t.screenshot_id)}>
|
||||
<ImageIcon size={14} /> 看原图
|
||||
</button>
|
||||
{status !== "done" && (
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => update.mutate({ id: t.id, payload: { status: "done" } })}
|
||||
>
|
||||
<Check size={14} /> 完成
|
||||
</button>
|
||||
)}
|
||||
{status !== "dropped" && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => update.mutate({ id: t.id, payload: { status: "dropped" } })}
|
||||
>
|
||||
<X size={14} /> 搁置
|
||||
</button>
|
||||
)}
|
||||
{status !== "pending" && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => update.mutate({ id: t.id, payload: { status: "pending" } })}
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<DetailPanel id={openId} onClose={() => setOpenId(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
export interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
prompt_hint?: string | null;
|
||||
}
|
||||
|
||||
export interface ScreenshotBrief {
|
||||
id: number;
|
||||
path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
captured_at: string;
|
||||
thumb_url?: string | null;
|
||||
ai_title?: string | null;
|
||||
ai_status: string;
|
||||
ocr_status: string;
|
||||
is_favorite: boolean;
|
||||
category?: Category | null;
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
export interface TodoItem {
|
||||
id: number;
|
||||
title: string;
|
||||
note?: string | null;
|
||||
kind?: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
completed_at?: string | null;
|
||||
screenshot_id: number;
|
||||
}
|
||||
|
||||
export interface ScreenshotDetail extends ScreenshotBrief {
|
||||
file_url: string;
|
||||
size: number;
|
||||
ocr_text?: string | null;
|
||||
ai_summary?: string | null;
|
||||
ai_suggestion?: string | null;
|
||||
todos: TodoItem[];
|
||||
}
|
||||
|
||||
export interface ListResp {
|
||||
items: ScreenshotBrief[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface WatchFolder {
|
||||
id: number;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
recursive: boolean;
|
||||
is_sensitive: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderConfig {
|
||||
type: string;
|
||||
base_url?: string | null;
|
||||
api_key?: string | null;
|
||||
model?: string | null;
|
||||
extra: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 读取 Provider 时后端会附带 api_key_mask,用于 UI 提示。 */
|
||||
export interface ProviderConfigOut extends ProviderConfig {
|
||||
api_key_mask?: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderTestResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
detail?: string | null;
|
||||
latency_ms?: number | null;
|
||||
}
|
||||
|
||||
export interface StatsResp {
|
||||
total: number;
|
||||
by_status: Record<string, number>;
|
||||
by_category: { id: number; name: string; color?: string | null; count: number }[];
|
||||
by_month: { month: string; count: number }[];
|
||||
queue: Record<string, number>;
|
||||
}
|
||||
|
||||
export type RecognitionMode = "ocr" | "vision" | "hybrid";
|
||||
|
||||
export const RECOGNITION_MODE_LABELS: Record<RecognitionMode, string> = {
|
||||
ocr: "传统 OCR",
|
||||
vision: "视觉 AI 识文",
|
||||
hybrid: "混合(OCR + 视觉 AI)",
|
||||
};
|
||||
|
||||
export interface ListQuery {
|
||||
q?: string;
|
||||
category_id?: number;
|
||||
tag?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
favorite?: boolean;
|
||||
status?: string;
|
||||
sort?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface JobItem {
|
||||
id: number;
|
||||
screenshot_id: number;
|
||||
kind: string;
|
||||
status: string;
|
||||
retries: number;
|
||||
last_error?: string | null;
|
||||
created_at: string;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
thumb_url?: string | null;
|
||||
path?: string | null;
|
||||
ai_title?: string | null;
|
||||
ai_status?: string | null;
|
||||
ocr_status?: string | null;
|
||||
}
|
||||
|
||||
export interface TodoListResp {
|
||||
items: TodoItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface TagListResp {
|
||||
items: (Tag & { count: number })[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface TodoListQuery {
|
||||
status?: string;
|
||||
kind?: string;
|
||||
q?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface JobListResp {
|
||||
items: JobItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: "#eef2ff",
|
||||
100: "#e0e7ff",
|
||||
400: "#818cf8",
|
||||
500: "#6366f1",
|
||||
600: "#4f46e5",
|
||||
700: "#4338ca",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"useDefineForClassFields": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:8765",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user