update: upload fix
This commit is contained in:
@@ -8,7 +8,17 @@ import Settings from "./pages/Settings";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
fontSize: 16,
|
||||
fontSizeSM: 14,
|
||||
fontSizeLG: 18,
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<CaseList />} />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Upload, List, Button, Card, Tag, message } from "antd";
|
||||
import { InboxOutlined, ThunderboltOutlined } from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Key } from "react";
|
||||
import { Upload, Table, Button, Tag, Alert, message, Space, Progress } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { InboxOutlined, ThunderboltOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { api, type ScreenshotItem } from "../services/api";
|
||||
|
||||
const { Dragger } = Upload;
|
||||
@@ -10,10 +12,24 @@ interface Props {
|
||||
onExtracted?: () => void;
|
||||
}
|
||||
|
||||
function statusTag(item: ScreenshotItem) {
|
||||
if (item.status === "extracted") return <Tag color="green">已识别</Tag>;
|
||||
if (item.status === "processing") return <Tag color="blue">识别中</Tag>;
|
||||
if (item.status === "failed") return <Tag color="red">失败</Tag>;
|
||||
return <Tag>待识别</Tag>;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null) {
|
||||
if (ms == null) return "-";
|
||||
if (ms < 1000) return `${ms} ms`;
|
||||
return `${(ms / 1000).toFixed(2)} s`;
|
||||
}
|
||||
|
||||
export default function ScreenshotUploader({ caseId, onExtracted }: Props) {
|
||||
const [screenshots, setScreenshots] = useState<ScreenshotItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [extractingId, setExtractingId] = useState<number | null>(null);
|
||||
const [extractingIds, setExtractingIds] = useState<number[]>([]);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||
|
||||
const loadScreenshots = async () => {
|
||||
try {
|
||||
@@ -35,73 +51,168 @@ export default function ScreenshotUploader({ caseId, onExtracted }: Props) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return false; // prevent default upload
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleExtract = async (screenshotId: number) => {
|
||||
setExtractingId(screenshotId);
|
||||
const handleExtractSingle = async (screenshotId: number) => {
|
||||
setExtractingIds((prev) => Array.from(new Set([...prev, screenshotId])));
|
||||
try {
|
||||
await api.screenshots.extract(caseId, screenshotId);
|
||||
message.success("识别完成");
|
||||
await loadScreenshots();
|
||||
onExtracted?.();
|
||||
message.success(`截图 ${screenshotId} 识别完成`);
|
||||
} catch (e: unknown) {
|
||||
const msg = e && typeof e === "object" && "response" in e
|
||||
? (e as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: "识别失败";
|
||||
message.error(msg || "识别失败");
|
||||
const detail =
|
||||
e && typeof e === "object" && "response" in e
|
||||
? (e as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: undefined;
|
||||
message.error(detail || `截图 ${screenshotId} 识别失败`);
|
||||
await loadScreenshots();
|
||||
} finally {
|
||||
setExtractingId(null);
|
||||
setExtractingIds((prev) => prev.filter((id) => id !== screenshotId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchExtract = async () => {
|
||||
const ids = selectedRowKeys.map((k) => Number(k)).filter((id) => Number.isFinite(id));
|
||||
if (!ids.length) {
|
||||
message.warning("请先勾选要识别的截图");
|
||||
return;
|
||||
}
|
||||
setExtractingIds((prev) => Array.from(new Set([...prev, ...ids])));
|
||||
const started = Date.now();
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await api.screenshots.extract(caseId, id);
|
||||
ok += 1;
|
||||
} catch {
|
||||
fail += 1;
|
||||
}
|
||||
await loadScreenshots();
|
||||
}
|
||||
setExtractingIds((prev) => prev.filter((id) => !ids.includes(id)));
|
||||
onExtracted?.();
|
||||
const elapsed = Date.now() - started;
|
||||
message.info(`批量识别结束:成功 ${ok},失败 ${fail},总耗时 ${(elapsed / 1000).toFixed(2)} s`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (caseId) loadScreenshots();
|
||||
if (!caseId) return;
|
||||
loadScreenshots();
|
||||
const timer = window.setInterval(loadScreenshots, 1500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [caseId]);
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (keys: Key[]) => setSelectedRowKeys(keys),
|
||||
getCheckboxProps: (record: ScreenshotItem) => ({
|
||||
disabled: record.status === "processing",
|
||||
}),
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ScreenshotItem> = useMemo(() => [
|
||||
{
|
||||
title: "截图",
|
||||
dataIndex: "filename",
|
||||
key: "filename",
|
||||
render: (v: string) => (
|
||||
<span style={{ display: "block", maxWidth: 260, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={v}>
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 110,
|
||||
render: (_, r) => statusTag(r),
|
||||
},
|
||||
{
|
||||
title: "识别进度",
|
||||
key: "progress",
|
||||
width: 280,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
<Progress percent={r.progress_percent || 0} size="small" status={r.status === "failed" ? "exception" : r.status === "extracted" ? "success" : "active"} />
|
||||
<div style={{ fontSize: 12, color: "#666" }}>{r.progress_detail || "-"}</div>
|
||||
{r.progress_step && <div style={{ fontSize: 12, color: "#999" }}>步骤: {r.progress_step}</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "耗时",
|
||||
key: "duration",
|
||||
width: 120,
|
||||
render: (_, r) => formatDuration(r.duration_ms),
|
||||
},
|
||||
{
|
||||
title: "错误信息",
|
||||
key: "error_message",
|
||||
render: (_, r) =>
|
||||
r.status === "failed" && r.error_message ? (
|
||||
<Alert type="error" showIcon message={r.error_message} />
|
||||
) : (
|
||||
"-"
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 120,
|
||||
render: (_, r) => (
|
||||
(r.status === "pending" || r.status === "failed") && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={r.status === "failed" ? <ReloadOutlined /> : <ThunderboltOutlined />}
|
||||
loading={extractingIds.includes(r.id)}
|
||||
onClick={() => handleExtractSingle(r.id)}
|
||||
>
|
||||
{r.status === "failed" ? "重试" : "识别"}
|
||||
</Button>
|
||||
)
|
||||
),
|
||||
},
|
||||
], [extractingIds]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dragger
|
||||
multiple
|
||||
accept=".png,.jpg,.jpeg,.webp"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => { handleUpload(file as File); return false; }}
|
||||
beforeUpload={(file) => {
|
||||
handleUpload(file as File);
|
||||
return false;
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p className="ant-upload-text">点击或拖拽账单截图到此处上传</p>
|
||||
<p className="ant-upload-hint">支持 png / jpg / webp,单次可多选</p>
|
||||
</Dragger>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="link" onClick={loadScreenshots} style={{ padding: 0 }}>刷新截图列表</Button>
|
||||
<List
|
||||
style={{ marginTop: 8 }}
|
||||
grid={{ gutter: 16, column: 4 }}
|
||||
dataSource={screenshots}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<Card size="small" title={item.filename}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Tag color={item.status === "extracted" ? "green" : item.status === "failed" ? "red" : "default"}>
|
||||
{item.status === "extracted" ? "已识别" : item.status === "failed" ? "失败" : "待识别"}
|
||||
</Tag>
|
||||
</div>
|
||||
{item.status === "pending" && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={extractingId === item.id}
|
||||
onClick={() => handleExtract(item.id)}
|
||||
>
|
||||
识别交易
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Space>
|
||||
<Button type="link" onClick={loadScreenshots} style={{ padding: 0 }}>刷新截图列表</Button>
|
||||
<Button type="primary" onClick={handleBatchExtract} disabled={!selectedRowKeys.length} loading={extractingIds.length > 0}>
|
||||
一键识别(已勾选)
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
style={{ marginTop: 8 }}
|
||||
rowKey="id"
|
||||
dataSource={screenshots}
|
||||
columns={columns}
|
||||
rowSelection={rowSelection}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,5 +3,7 @@
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Form, Input, Select, Button, Alert, Space, message } from "antd";
|
||||
import { Card, Form, Input, Select, Button, Alert, Space, Divider, Descriptions, message } from "antd";
|
||||
import {
|
||||
api,
|
||||
type RuntimeSettings,
|
||||
type ProviderKey,
|
||||
getApiBaseUrl,
|
||||
setApiBaseUrl,
|
||||
} from "../services/api";
|
||||
|
||||
const PROVIDER_OPTIONS = [
|
||||
{ label: "OpenAI", value: "openai" as ProviderKey },
|
||||
{ label: "Anthropic", value: "anthropic" as ProviderKey },
|
||||
{ label: "DeepSeek", value: "deepseek" as ProviderKey },
|
||||
{ label: "自定义(OpenAI兼容)", value: "custom_openai" as ProviderKey },
|
||||
];
|
||||
|
||||
export default function Settings() {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -20,9 +28,11 @@ export default function Settings() {
|
||||
setRuntime(data);
|
||||
form.setFieldsValue({
|
||||
system_api_base_url: getApiBaseUrl(),
|
||||
llm_provider: data.llm_provider,
|
||||
ocr_provider: data.ocr_provider,
|
||||
ocr_model: data.ocr_model,
|
||||
inference_provider: data.inference_provider,
|
||||
inference_model: data.inference_model,
|
||||
custom_openai_base_url: data.base_urls?.custom_openai || "",
|
||||
custom_openai_model: data.models?.custom_openai || "gpt-4o-mini",
|
||||
});
|
||||
} catch {
|
||||
message.error("加载设置失败");
|
||||
@@ -35,21 +45,15 @@ export default function Settings() {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const onFinish = async (values: {
|
||||
system_api_base_url?: string;
|
||||
llm_provider: "openai" | "anthropic" | "deepseek" | "custom_openai";
|
||||
openai_api_key?: string;
|
||||
anthropic_api_key?: string;
|
||||
deepseek_api_key?: string;
|
||||
custom_openai_api_key?: string;
|
||||
custom_openai_base_url?: string;
|
||||
custom_openai_model?: string;
|
||||
}) => {
|
||||
const onFinish = async (values: Record<string, string | undefined>) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
setApiBaseUrl(values.system_api_base_url || "");
|
||||
const payload = {
|
||||
llm_provider: values.llm_provider,
|
||||
const payload: Record<string, string | undefined> = {
|
||||
ocr_provider: values.ocr_provider,
|
||||
ocr_model: values.ocr_model?.trim() || undefined,
|
||||
inference_provider: values.inference_provider,
|
||||
inference_model: values.inference_model?.trim() || undefined,
|
||||
openai_api_key: values.openai_api_key?.trim() || undefined,
|
||||
anthropic_api_key: values.anthropic_api_key?.trim() || undefined,
|
||||
deepseek_api_key: values.deepseek_api_key?.trim() || undefined,
|
||||
@@ -59,7 +63,7 @@ export default function Settings() {
|
||||
};
|
||||
const data = await api.settings.update(payload);
|
||||
setRuntime(data);
|
||||
message.success("设置已保存并生效(含系统 API BaseURL)");
|
||||
message.success("设置已保存");
|
||||
} catch {
|
||||
message.error("保存失败");
|
||||
} finally {
|
||||
@@ -68,35 +72,42 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="LLM 设置" loading={loading}>
|
||||
<Card title="模型与接口设置" loading={loading}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="LLM API Key 仅在当前服务进程运行期内生效,不会自动写入磁盘。"
|
||||
message="API Key 仅在当前服务进程运行期内生效,不写入磁盘。OCR 模型用于从截图中提取交易,推理模型用于生成报告等文本推理任务。"
|
||||
/>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item
|
||||
label="系统 API BaseURL(前端请求后端)"
|
||||
name="system_api_base_url"
|
||||
extra="默认 /api;若前后端分离部署,可填如 http://127.0.0.1:8000/api"
|
||||
extra="默认 /api;前后端分离时填 http://127.0.0.1:8000/api"
|
||||
>
|
||||
<Input placeholder="/api 或 http://127.0.0.1:8000/api" />
|
||||
<Input placeholder="/api" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="默认模型提供商"
|
||||
name="llm_provider"
|
||||
rules={[{ required: true, message: "请选择提供商" }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "OpenAI", value: "openai" },
|
||||
{ label: "Anthropic", value: "anthropic" },
|
||||
{ label: "DeepSeek", value: "deepseek" },
|
||||
{ label: "自定义(OpenAI兼容)", value: "custom_openai" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider orientation="left">OCR 视觉模型(截图识别交易)</Divider>
|
||||
|
||||
<Form.Item label="OCR 提供商" name="ocr_provider" rules={[{ required: true }]}>
|
||||
<Select options={PROVIDER_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item label="OCR 模型名" name="ocr_model" extra="留空则使用该提供商的默认模型">
|
||||
<Input placeholder="如 gpt-4o / qwen-vl-max / ..." />
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="left">推理模型(报告生成等文本推理)</Divider>
|
||||
|
||||
<Form.Item label="推理提供商" name="inference_provider" rules={[{ required: true }]}>
|
||||
<Select options={PROVIDER_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item label="推理模型名" name="inference_model" extra="留空则使用该提供商的默认模型">
|
||||
<Input placeholder="如 gpt-4o-mini / deepseek-chat / ..." />
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="left">API Key 与厂商配置</Divider>
|
||||
|
||||
<Form.Item label="OpenAI API Key" name="openai_api_key">
|
||||
<Input.Password placeholder="sk-..." />
|
||||
</Form.Item>
|
||||
@@ -113,29 +124,33 @@ export default function Settings() {
|
||||
>
|
||||
<Input placeholder="https://api.xxx.com/v1" />
|
||||
</Form.Item>
|
||||
<Form.Item label="自定义厂商 Model" name="custom_openai_model">
|
||||
<Form.Item label="自定义厂商默认模型" name="custom_openai_model">
|
||||
<Input placeholder="gpt-4o-mini / qwen-vl-plus / ..." />
|
||||
</Form.Item>
|
||||
<Form.Item label="自定义厂商 API Key" name="custom_openai_api_key">
|
||||
<Input.Password placeholder="sk-..." />
|
||||
</Form.Item>
|
||||
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存设置</Button>
|
||||
<Button onClick={loadSettings}>刷新</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
{runtime && (
|
||||
<Card title="当前状态" size="small" style={{ marginTop: 16 }}>
|
||||
<div>系统 API BaseURL: {getApiBaseUrl()}</div>
|
||||
<div>当前提供商: {runtime.llm_provider}</div>
|
||||
<div>OpenAI Key: {runtime.has_keys.openai ? "已配置" : "未配置"}</div>
|
||||
<div>Anthropic Key: {runtime.has_keys.anthropic ? "已配置" : "未配置"}</div>
|
||||
<div>DeepSeek Key: {runtime.has_keys.deepseek ? "已配置" : "未配置"}</div>
|
||||
<div>自定义厂商 Key: {runtime.has_keys.custom_openai ? "已配置" : "未配置"}</div>
|
||||
<div>自定义厂商 BaseURL: {runtime.base_urls.custom_openai || "-"}</div>
|
||||
<Card title="当前生效配置" size="small" style={{ marginTop: 16 }}>
|
||||
<Descriptions column={2} size="small" bordered>
|
||||
<Descriptions.Item label="系统 API BaseURL">{getApiBaseUrl()}</Descriptions.Item>
|
||||
<Descriptions.Item label="自定义厂商 BaseURL">{runtime.base_urls.custom_openai || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="OCR 提供商">{runtime.ocr_provider}</Descriptions.Item>
|
||||
<Descriptions.Item label="OCR 模型">{runtime.ocr_model}</Descriptions.Item>
|
||||
<Descriptions.Item label="推理提供商">{runtime.inference_provider}</Descriptions.Item>
|
||||
<Descriptions.Item label="推理模型">{runtime.inference_model}</Descriptions.Item>
|
||||
<Descriptions.Item label="OpenAI Key">{runtime.has_keys.openai ? "已配置" : "未配置"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Anthropic Key">{runtime.has_keys.anthropic ? "已配置" : "未配置"}</Descriptions.Item>
|
||||
<Descriptions.Item label="DeepSeek Key">{runtime.has_keys.deepseek ? "已配置" : "未配置"}</Descriptions.Item>
|
||||
<Descriptions.Item label="自定义厂商 Key">{runtime.has_keys.custom_openai ? "已配置" : "未配置"}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -56,6 +56,13 @@ export interface ScreenshotItem {
|
||||
filename: string;
|
||||
file_path: string;
|
||||
status: string;
|
||||
progress_step: string | null;
|
||||
progress_percent: number;
|
||||
progress_detail: string | null;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -72,16 +79,24 @@ export interface FlowGraph {
|
||||
edges: Array<{ source: string; target: string; amount: number; count?: number }>;
|
||||
}
|
||||
|
||||
export type ProviderKey = "openai" | "anthropic" | "deepseek" | "custom_openai";
|
||||
|
||||
export interface RuntimeSettings {
|
||||
llm_provider: "openai" | "anthropic" | "deepseek" | "custom_openai";
|
||||
providers: Array<"openai" | "anthropic" | "deepseek" | "custom_openai">;
|
||||
models: Record<string, string>;
|
||||
ocr_provider: ProviderKey;
|
||||
ocr_model: string;
|
||||
inference_provider: ProviderKey;
|
||||
inference_model: string;
|
||||
providers: ProviderKey[];
|
||||
provider_defaults: Record<string, string>;
|
||||
base_urls: Record<string, string>;
|
||||
has_keys: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface RuntimeSettingsUpdate {
|
||||
llm_provider?: "openai" | "anthropic" | "deepseek" | "custom_openai";
|
||||
ocr_provider?: ProviderKey;
|
||||
ocr_model?: string;
|
||||
inference_provider?: ProviderKey;
|
||||
inference_model?: string;
|
||||
openai_api_key?: string;
|
||||
anthropic_api_key?: string;
|
||||
deepseek_api_key?: string;
|
||||
|
||||
Reference in New Issue
Block a user