feat: support batch recognize images for batch register

This commit is contained in:
2025-11-15 16:44:27 +08:00
parent 63a813925f
commit 83f8091e15
7 changed files with 199 additions and 97 deletions

View File

@@ -2,16 +2,19 @@ import React from 'react'
import { Layout, Collapse, Form, Input, Button, Space, message, Spin } from 'antd' import { Layout, Collapse, Form, Input, Button, Space, message, Spin } from 'antd'
import type { FormInstance } from 'antd' import type { FormInstance } from 'antd'
import PeopleForm from './PeopleForm.tsx' import PeopleForm from './PeopleForm.tsx'
import InputDrawer from './InputDrawer.tsx'
import { createPeoplesBatch, type People } from '../apis' import { createPeoplesBatch, type People } from '../apis'
const { Panel } = Collapse as any const { Panel } = Collapse as any
const { Content } = Layout const { Content } = Layout
type FormItem = { id: string } type FormItem = { id: string; initialData?: any }
const BatchRegister: React.FC = () => { type Props = { inputOpen?: boolean; onCloseInput?: () => void; containerEl?: HTMLElement | null }
const BatchRegister: React.FC<Props> = ({ inputOpen = false, onCloseInput, containerEl }) => {
const [commonForm] = Form.useForm() const [commonForm] = Form.useForm()
const [items, setItems] = React.useState<FormItem[]>([{ id: `${Date.now()}-${Math.random()}` }]) const [items, setItems] = React.useState<FormItem[]>([])
const instancesRef = React.useRef<Record<string, FormInstance>>({}) const instancesRef = React.useRef<Record<string, FormInstance>>({})
const [loading, setLoading] = React.useState(false) const [loading, setLoading] = React.useState(false)
@@ -40,6 +43,12 @@ const BatchRegister: React.FC = () => {
} }
} }
const handleInputResult = (list: any) => {
const arr = Array.isArray(list) ? list : [list]
const next: FormItem[] = arr.map((data: any) => ({ id: `${Date.now()}-${Math.random()}`, initialData: data }))
setItems((prev) => [...prev, ...next])
}
const handleSubmit = async () => { const handleSubmit = async () => {
if (loading) return if (loading) return
try { try {
@@ -89,7 +98,7 @@ const BatchRegister: React.FC = () => {
<Content className="main-content"> <Content className="main-content">
<div className="content-body"> <div className="content-body">
<Collapse defaultActiveKey={["common"]}> <Collapse defaultActiveKey={["common"]}>
<Panel header="公共属性" key="common"> <Panel header="公共信息" key="common">
<Form form={commonForm} layout="vertical" size="large"> <Form form={commonForm} layout="vertical" size="large">
<Form.Item name="contact" label="联系人"> <Form.Item name="contact" label="联系人">
<Input placeholder="请输入联系人(可留空)" /> <Input placeholder="请输入联系人(可留空)" />
@@ -113,6 +122,7 @@ const BatchRegister: React.FC = () => {
> >
<PeopleForm <PeopleForm
hideSubmitButton hideSubmitButton
initialData={item.initialData}
onFormReady={(f) => (instancesRef.current[item.id] = f)} onFormReady={(f) => (instancesRef.current[item.id] = f)}
/> />
</Panel> </Panel>
@@ -136,6 +146,15 @@ const BatchRegister: React.FC = () => {
</div> </div>
)} )}
</div> </div>
{/* 批量页右侧输入抽屉,挂载到标题栏下方容器 */}
<InputDrawer
open={inputOpen || false}
onClose={onCloseInput || (() => {})}
onResult={handleInputResult}
containerEl={containerEl}
showUpload
mode={'batch-image'}
/>
</Content> </Content>
) )
} }

View File

@@ -5,9 +5,15 @@ type Props = { showUpload?: boolean };
const HintText: React.FC<Props> = ({ showUpload = true }) => { const HintText: React.FC<Props> = ({ showUpload = true }) => {
const text = showUpload const text = showUpload
? '提示:支持输入多行文本、上传图片或粘贴剪贴板图片。按 Enter 发送Shift+Enter 换行。' ? ' · 支持多行输入文本、上传图片或粘贴剪贴板图片。'
: '提示:支持输入多行文本。按 Enter 发送Shift+Enter 换行。'; : ' · 支持输入多行文本。';
return <div className="hint-text">{text}</div>; return (
<div>
<div className="hint-text">Tips:</div>
<div className="hint-text">{text}</div>
<div className="hint-text"> · Enter Shift+Enter </div>
</div>
);
}; };
export default HintText; export default HintText;

View File

@@ -10,7 +10,7 @@ type Props = {
onResult?: (data: any) => void; onResult?: (data: any) => void;
containerEl?: HTMLElement | null; // 抽屉挂载容器(用于放在标题栏下方) containerEl?: HTMLElement | null; // 抽屉挂载容器(用于放在标题栏下方)
showUpload?: boolean; // 透传到输入面板,控制图片上传按钮 showUpload?: boolean; // 透传到输入面板,控制图片上传按钮
mode?: 'input' | 'search'; // 透传到输入面板,控制工作模式 mode?: 'input' | 'search' | 'batch-image'; // 透传到输入面板,控制工作模式
}; };
const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl, showUpload = true, mode = 'input' }) => { const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl, showUpload = true, mode = 'input' }) => {

View File

@@ -46,11 +46,8 @@
/* 左侧文件标签样式,保持短名及紧凑展示 */ /* 左侧文件标签样式,保持短名及紧凑展示 */
.selected-image-tag { .selected-image-tag {
max-width: 60%; max-width: none;
overflow: hidden; overflow: visible;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
margin-right: auto; /* 保持标签在左侧,按钮在右侧 */ margin-right: 0;
} }
/* 移除右侧容器样式,按钮直接在 input-actions 中对齐 */

View File

@@ -9,27 +9,16 @@ const { TextArea } = Input;
interface InputPanelProps { interface InputPanelProps {
onResult?: (data: any) => void; onResult?: (data: any) => void;
showUpload?: boolean; // 是否显示图片上传按钮,默认显示 showUpload?: boolean; // 是否显示图片上传按钮,默认显示
mode?: 'input' | 'search'; // 输入面板工作模式,默认为表单填写input mode?: 'input' | 'search' | 'batch-image'; // 输入面板工作模式,新增批量图片模式
} }
const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mode = 'input' }) => { const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mode = 'input' }) => {
const [value, setValue] = React.useState(''); const [value, setValue] = React.useState('');
const [fileList, setFileList] = React.useState<any[]>([]); const [fileList, setFileList] = React.useState<any[]>([]);
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [savedText, setSavedText] = React.useState<string>(''); // 批量模式不保留文本内容
// 统一显示短文件名image.{ext} // 不需要扩展名重命名,展示 image-序号
const getImageExt = (file: any): string => {
const type = file?.type || '';
if (typeof type === 'string' && type.startsWith('image/')) {
const sub = type.split('/')[1] || 'png';
return sub.toLowerCase();
}
const name = file?.name || '';
const dot = name.lastIndexOf('.');
const ext = dot >= 0 ? name.slice(dot + 1) : '';
return (ext || 'png').toLowerCase();
};
const send = async () => { const send = async () => {
const trimmed = value.trim(); const trimmed = value.trim();
@@ -66,6 +55,40 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mo
return; return;
} }
// 批量图片模式:循环调用图片识别 API
if (mode === 'batch-image') {
if (!hasImage) {
message.info('请添加至少一张图片');
return;
}
setLoading(true);
try {
const results: any[] = [];
for (let i = 0; i < fileList.length; i++) {
const f = fileList[i].originFileObj || fileList[i];
if (!f) continue;
const resp = await postInputImage(f);
if (resp && resp.error_code === 0 && resp.data) {
results.push(resp.data);
}
}
if (results.length > 0) {
message.success(`已识别 ${results.length} 张图片`);
onResult?.(results);
} else {
message.error('识别失败,请检查图片后重试');
}
setValue('');
setFileList([]);
} catch (error) {
console.error('批量识别失败:', error);
message.error('网络错误,请稍后重试');
} finally {
setLoading(false);
}
return;
}
setLoading(true); setLoading(true);
try { try {
let response; let response;
@@ -96,7 +119,6 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mo
// 清空输入 // 清空输入
setValue(''); setValue('');
setFileList([]); setFileList([]);
setSavedText('');
} else { } else {
message.error(response.error_info || '处理失败,请重试'); message.error(response.error_info || '处理失败,请重试');
} }
@@ -129,39 +151,55 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mo
const items = e.clipboardData?.items; const items = e.clipboardData?.items;
if (!items || items.length === 0) return; if (!items || items.length === 0) return;
let pastedImage: File | null = null; if (mode === 'batch-image') {
for (let i = 0; i < items.length; i++) { const newEntries: any[] = [];
const item = items[i]; for (let i = 0; i < items.length; i++) {
if (item.kind === 'file') { const item = items[i];
const file = item.getAsFile(); if (item.kind === 'file') {
if (file && file.type.startsWith('image/')) { const file = item.getAsFile();
pastedImage = file; if (file && file.type.startsWith('image/')) {
break; // 只取第一张 const entry = {
uid: `${Date.now()}-${Math.random()}`,
name: 'image',
status: 'done',
originFileObj: file,
} as any;
newEntries.push(entry);
}
} }
} }
} if (newEntries.length > 0) {
e.preventDefault();
if (pastedImage) { setValue('');
// 避免图片内容以文本方式粘贴进输入框 setFileList([...fileList, ...newEntries]);
e.preventDefault(); message.success(`已添加 ${newEntries.length} 张剪贴板图片`);
}
const ext = getImageExt(pastedImage); } else {
const name = `image.${ext}`; // 单图模式:仅添加第一张并替换已有
let firstImage: File | null = null;
const entry = { for (let i = 0; i < items.length; i++) {
uid: `${Date.now()}-${Math.random()}`, const item = items[i];
name, if (item.kind === 'file') {
status: 'done', const file = item.getAsFile();
originFileObj: pastedImage, if (file && file.type.startsWith('image/')) {
} as any; firstImage = file;
break;
// 仅保留一张:新图直接替换旧图 }
if (fileList.length === 0) { }
setSavedText(value); }
if (firstImage) {
e.preventDefault();
setValue('');
setFileList([
{
uid: `${Date.now()}-${Math.random()}`,
name: 'image',
status: 'done',
originFileObj: firstImage,
} as any,
]);
message.success('已添加剪贴板图片');
} }
setValue('');
setFileList([entry]);
message.success('已添加剪贴板图片');
} }
}; };
@@ -178,60 +216,95 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mo
})()} })()}
<TextArea <TextArea
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => {
if (mode === 'batch-image') {
setValue('');
return;
}
setValue(e.target.value);
}}
placeholder={ placeholder={
showUpload && fileList.length > 0 mode === 'batch-image'
? '不可在添加图片时输入信息...' ? '批量识别不支持输入文本,可添加或粘贴多张图片...'
: (showUpload ? '请输入个人信息描述,或上传图片…' : '请输入个人信息描述…') : showUpload && fileList.length > 0
? '不可在添加图片时输入信息...'
: (showUpload ? '请输入个人信息描述,或上传图片…' : '请输入个人信息描述…')
} }
autoSize={{ minRows: 6, maxRows: 12 }} autoSize={{ minRows: 6, maxRows: 12 }}
style={{ fontSize: 16 }} style={{ fontSize: 16 }}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onPaste={onPaste} onPaste={onPaste}
disabled={loading || (showUpload && fileList.length > 0)} disabled={loading || (mode !== 'batch-image' && showUpload && fileList.length > 0)}
/> />
</Spin> </Spin>
<div className="input-actions"> <div className="input-actions">
{/* 左侧文件标签显示 */} {/* 左侧文件标签显示 */}
{showUpload && fileList.length > 0 && ( {showUpload && fileList.length > 0 && (
<Tag mode === 'batch-image' ? (
className="selected-image-tag" <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
color="processing" {fileList.map((f, idx) => (
closable <Tag
onClose={() => { setFileList([]); setValue(savedText); setSavedText(''); }} key={f.uid || idx}
bordered={false} className="selected-image-tag"
> color="processing"
{`image.${new Date().getSeconds()}.${getImageExt(fileList[0]?.originFileObj || fileList[0])}`} closable
</Tag> onClose={() => {
const next = fileList.filter((x) => x !== f)
setFileList(next)
}}
bordered={false}
>
{`image-${idx + 1}`}
</Tag>
))}
</div>
) : (
<Tag
className="selected-image-tag"
color="processing"
closable
onClose={() => { setFileList([]) }}
bordered={false}
>
{'image'}
</Tag>
)
)} )}
{showUpload && ( {showUpload && (
<Upload <Upload
accept="image/*" accept="image/*"
multiple={false} multiple={mode === 'batch-image'}
beforeUpload={() => false} beforeUpload={() => false}
fileList={fileList} fileList={fileList}
onChange={({ file, fileList: nextFileList }) => { onChange={({ fileList: nextFileList }) => {
// 只保留最新一个,并重命名为 image.{ext} if (mode === 'batch-image') {
if (nextFileList.length === 0) { const normalized = nextFileList.map((entry: any) => {
setFileList([]); const raw = entry.originFileObj || entry;
return; return { ...entry, name: 'image', originFileObj: raw };
});
setValue('');
setFileList(normalized);
} else {
if (nextFileList.length === 0) {
setFileList([]);
return;
}
// 仅添加第一张
const first = nextFileList[0] as any;
const raw = first.originFileObj || first;
const renamed = { ...first, name: 'image', originFileObj: raw };
setValue('');
setFileList([renamed]);
} }
const latest = nextFileList[nextFileList.length - 1] as any;
const raw = latest.originFileObj || file; // UploadFile 或原始 File
const ext = getImageExt(raw);
const renamed = { ...latest, name: `image.${ext}` };
if (fileList.length === 0) {
setSavedText(value);
}
setValue('');
setFileList([renamed]);
}} }}
onRemove={() => { setFileList([]); setValue(savedText); setSavedText(''); return true; }} onRemove={(file) => {
maxCount={1} setFileList((prev) => prev.filter((x) => x.uid !== (file as any).uid));
return true;
}}
showUploadList={false} showUploadList={false}
disabled={loading} disabled={loading || (mode !== 'batch-image' && fileList.length >= 1)}
> >
<Button type="text" icon={<PictureOutlined />} disabled={loading} /> <Button type="text" icon={<PictureOutlined />} disabled={loading || (mode !== 'batch-image' && fileList.length >= 1)} />
</Upload> </Upload>
)} )}
<Button <Button

View File

@@ -16,6 +16,7 @@ const LayoutWrapper: React.FC = () => {
const [inputOpen, setInputOpen] = React.useState(false); const [inputOpen, setInputOpen] = React.useState(false);
const isHome = location.pathname === '/'; const isHome = location.pathname === '/';
const isList = location.pathname === '/resources'; const isList = location.pathname === '/resources';
const isBatch = location.pathname === '/batch-register';
const layoutShellRef = React.useRef<HTMLDivElement>(null); const layoutShellRef = React.useRef<HTMLDivElement>(null);
const pathToKey = (path: string) => { const pathToKey = (path: string) => {
@@ -60,8 +61,8 @@ const LayoutWrapper: React.FC = () => {
{/* 顶部标题栏,位于左侧菜单栏之上 */} {/* 顶部标题栏,位于左侧菜单栏之上 */}
<TopBar <TopBar
onToggleMenu={() => {setInputOpen(false); setMobileMenuOpen((v) => !v);}} onToggleMenu={() => {setInputOpen(false); setMobileMenuOpen((v) => !v);}}
onToggleInput={() => {if (isHome || isList) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}} onToggleInput={() => {if (isHome || isList || isBatch) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}}
showInput={isHome || isList} showInput={isHome || isList || isBatch}
/> />
{/* 下方为主布局:左侧菜单 + 右侧内容 */} {/* 下方为主布局:左侧菜单 + 右侧内容 */}
<Layout ref={layoutShellRef as any} className="layout-shell"> <Layout ref={layoutShellRef as any} className="layout-shell">
@@ -85,7 +86,13 @@ const LayoutWrapper: React.FC = () => {
/> />
<Route <Route
path="/batch-register" path="/batch-register"
element={<BatchRegister />} element={
<BatchRegister
inputOpen={inputOpen}
onCloseInput={() => setInputOpen(false)}
containerEl={layoutShellRef.current}
/>
}
/> />
<Route <Route
path="/resources" path="/resources"

View File

@@ -21,7 +21,7 @@ const MainContent: React.FC<Props> = ({ inputOpen = false, onCloseInput, contain
? ?
</Typography.Title> </Typography.Title>
<Typography.Paragraph style={{ color: 'var(--muted)', marginBottom: 0 }}> <Typography.Paragraph style={{ color: 'var(--muted)', marginBottom: 0 }}>
TA的信息
</Typography.Paragraph> </Typography.Paragraph>
<PeopleForm initialData={formData} /> <PeopleForm initialData={formData} />