Initial commit: snapAna 截图智能整理工具

包含 FastAPI 后端、React 前端、队列/OCR/标签/待办等完整功能。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wjl
2026-05-27 15:45:50 +08:00
commit 5c028d7952
76 changed files with 10467 additions and 0 deletions
+12
View File
@@ -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>
+2813
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+68
View File
@@ -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>
);
}
+176
View File
@@ -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 }),
}),
};
+70
View File
@@ -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>
);
}
+81
View File
@@ -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>
);
}
+311
View File
@@ -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>
);
}
+176
View File
@@ -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"> AZ</option>
<option value="title_desc"> ZA</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>
);
}
+31
View File
@@ -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>
);
}
+67
View File
@@ -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;
}
+26
View File
@@ -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>
);
+166
View File
@@ -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>
);
}
+88
View File
@@ -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>
);
}
+673
View File
@@ -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 });
}
+689
View File
@@ -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 AIOCR 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>
);
}
+103
View File
@@ -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>
);
}
+115
View File
@@ -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>
);
}
+177
View File
@@ -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>
);
}
+157
View File
@@ -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;
}
+23
View File
@@ -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;
+24
View File
@@ -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"]
}
+21
View File
@@ -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,
},
},
},
});