Compare commits
6 Commits
515562e50b
...
release_v0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a01204bf6 | |||
| 83f8091e15 | |||
| 63a813925f | |||
| 8873c183e7 | |||
| 5d56a4bbe9 | |||
| 2c7dbada25 |
@@ -17,4 +17,5 @@ export const API_ENDPOINTS = {
|
|||||||
// 新增单个资源路径 /people
|
// 新增单个资源路径 /people
|
||||||
PEOPLE: '/people',
|
PEOPLE: '/people',
|
||||||
PEOPLE_BY_ID: (id: string) => `/people/${id}`,
|
PEOPLE_BY_ID: (id: string) => `/people/${id}`,
|
||||||
|
PEOPLE_REMARK_BY_ID: (id: string) => `/people/${id}/remark`,
|
||||||
} as const;
|
} as const;
|
||||||
@@ -121,6 +121,25 @@ export async function updatePeople(peopleId: string, people: People): Promise<Ap
|
|||||||
return put<ApiResponse>(API_ENDPOINTS.PEOPLE_BY_ID(peopleId), requestData);
|
return put<ApiResponse>(API_ENDPOINTS.PEOPLE_BY_ID(peopleId), requestData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加或更新人员备注
|
||||||
|
* @param peopleId 人员ID
|
||||||
|
* @param content 备注内容
|
||||||
|
* @returns Promise<ApiResponse>
|
||||||
|
*/
|
||||||
|
export async function addOrUpdateRemark(peopleId: string, content: string): Promise<ApiResponse> {
|
||||||
|
return post<ApiResponse>(API_ENDPOINTS.PEOPLE_REMARK_BY_ID(peopleId), { content });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除人员备注
|
||||||
|
* @param peopleId 人员ID
|
||||||
|
* @returns Promise<ApiResponse>
|
||||||
|
*/
|
||||||
|
export async function deleteRemark(peopleId: string): Promise<ApiResponse> {
|
||||||
|
return del<ApiResponse>(API_ENDPOINTS.PEOPLE_REMARK_BY_ID(peopleId));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量创建人员信息
|
* 批量创建人员信息
|
||||||
* @param peopleList 人员信息数组
|
* @param peopleList 人员信息数组
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface People {
|
|||||||
age?: number;
|
age?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
marital_status?: string;
|
marital_status?: string;
|
||||||
|
created_at?: number;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
cover?: string;
|
cover?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
162
src/components/BatchRegister.tsx
Normal file
162
src/components/BatchRegister.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Layout, Collapse, Form, Input, Button, Space, message, Spin } from 'antd'
|
||||||
|
import type { FormInstance } from 'antd'
|
||||||
|
import PeopleForm from './PeopleForm.tsx'
|
||||||
|
import InputDrawer from './InputDrawer.tsx'
|
||||||
|
import { createPeoplesBatch, type People } from '../apis'
|
||||||
|
|
||||||
|
const { Panel } = Collapse as any
|
||||||
|
const { Content } = Layout
|
||||||
|
|
||||||
|
type FormItem = { id: string; initialData?: any }
|
||||||
|
|
||||||
|
type Props = { inputOpen?: boolean; onCloseInput?: () => void; containerEl?: HTMLElement | null }
|
||||||
|
|
||||||
|
const BatchRegister: React.FC<Props> = ({ inputOpen = false, onCloseInput, containerEl }) => {
|
||||||
|
const [commonForm] = Form.useForm()
|
||||||
|
const [items, setItems] = React.useState<FormItem[]>([])
|
||||||
|
const instancesRef = React.useRef<Record<string, FormInstance>>({})
|
||||||
|
const [loading, setLoading] = React.useState(false)
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
if (loading) return
|
||||||
|
setItems((arr) => [...arr, { id: `${Date.now()}-${Math.random()}` }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
if (loading) return
|
||||||
|
setItems((arr) => arr.filter((x) => x.id !== id))
|
||||||
|
delete instancesRef.current[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPeople = (values: any, common: any): People => {
|
||||||
|
return {
|
||||||
|
name: values.name,
|
||||||
|
contact: values.contact || common.contact || undefined,
|
||||||
|
gender: values.gender,
|
||||||
|
age: values.age,
|
||||||
|
height: values.height || undefined,
|
||||||
|
marital_status: values.marital_status || undefined,
|
||||||
|
introduction: values.introduction || {},
|
||||||
|
match_requirement: values.match_requirement || undefined,
|
||||||
|
cover: values.cover || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
if (loading) return
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const common = await commonForm.validateFields().catch(() => ({}))
|
||||||
|
const ids = items.map((x) => x.id)
|
||||||
|
const forms = ids.map((id) => instancesRef.current[id]).filter(Boolean)
|
||||||
|
if (forms.length !== ids.length) {
|
||||||
|
setLoading(false)
|
||||||
|
message.error('表单未就绪')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const allValues: any[] = []
|
||||||
|
for (const f of forms) {
|
||||||
|
try {
|
||||||
|
const v = await f.validateFields()
|
||||||
|
allValues.push(v)
|
||||||
|
} catch (err: any) {
|
||||||
|
setLoading(false)
|
||||||
|
message.error('请完善全部表单后再提交')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const payload: People[] = allValues.map((v) => buildPeople(v, common))
|
||||||
|
const res = await createPeoplesBatch(payload)
|
||||||
|
const failedIdx: number[] = []
|
||||||
|
res.forEach((r, i) => {
|
||||||
|
if (!r || r.error_code !== 0) failedIdx.push(i)
|
||||||
|
})
|
||||||
|
const success = res.length - failedIdx.length
|
||||||
|
if (success > 0) message.success(`成功提交 ${success} 条`)
|
||||||
|
if (failedIdx.length > 0) {
|
||||||
|
message.error(`有 ${failedIdx.length} 条提交失败,请检查后重试`)
|
||||||
|
setItems((prev) => prev.filter((_, i) => failedIdx.includes(i)))
|
||||||
|
} else {
|
||||||
|
setItems([{ id: `${Date.now()}-${Math.random()}` }])
|
||||||
|
commonForm.resetFields()
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('提交失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Content className="main-content">
|
||||||
|
<div className="content-body">
|
||||||
|
<Collapse defaultActiveKey={["common"]}>
|
||||||
|
<Panel header="公共信息" key="common">
|
||||||
|
<Form form={commonForm} layout="vertical" size="large">
|
||||||
|
<Form.Item name="contact" label="联系人">
|
||||||
|
<Input placeholder="请输入联系人(可留空)" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Panel>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<div style={{ height: 16 }} />
|
||||||
|
|
||||||
|
<Collapse defaultActiveKey={items.map((x) => x.id)}>
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<Panel
|
||||||
|
header={`注册表单 #${idx + 1}`}
|
||||||
|
key={item.id}
|
||||||
|
extra={
|
||||||
|
<Button danger size="small" onClick={() => removeItem(item.id)} disabled={loading}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PeopleForm
|
||||||
|
hideSubmitButton
|
||||||
|
initialData={item.initialData}
|
||||||
|
onFormReady={(f) => (instancesRef.current[item.id] = f)}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
))}
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 16 }}>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={addItem} disabled={loading}>添加表单</Button>
|
||||||
|
</Space>
|
||||||
|
<Button type="primary" onClick={handleSubmit} loading={loading}>
|
||||||
|
{loading ? '提交中...' : '提交'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.08)' }}>
|
||||||
|
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 批量页右侧输入抽屉,挂载到标题栏下方容器 */}
|
||||||
|
<InputDrawer
|
||||||
|
open={inputOpen || false}
|
||||||
|
onClose={onCloseInput || (() => {})}
|
||||||
|
onResult={handleInputResult}
|
||||||
|
containerEl={containerEl}
|
||||||
|
showUpload
|
||||||
|
mode={'batch-image'}
|
||||||
|
/>
|
||||||
|
</Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BatchRegister
|
||||||
@@ -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;
|
||||||
@@ -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' }) => {
|
||||||
|
|||||||
@@ -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 中对齐 */
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import SiderMenu from './SiderMenu.tsx';
|
import SiderMenu from './SiderMenu.tsx';
|
||||||
import MainContent from './MainContent.tsx';
|
import MainContent from './MainContent.tsx';
|
||||||
import ResourceList from './ResourceList.tsx';
|
import ResourceList from './ResourceList.tsx';
|
||||||
|
import BatchRegister from './BatchRegister.tsx';
|
||||||
import TopBar from './TopBar.tsx';
|
import TopBar from './TopBar.tsx';
|
||||||
import '../styles/base.css';
|
import '../styles/base.css';
|
||||||
import '../styles/layout.css';
|
import '../styles/layout.css';
|
||||||
@@ -15,12 +16,15 @@ 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) => {
|
||||||
switch (path) {
|
switch (path) {
|
||||||
case '/resources':
|
case '/resources':
|
||||||
return 'menu1';
|
return 'menu1';
|
||||||
|
case '/batch-register':
|
||||||
|
return 'batch';
|
||||||
case '/menu2':
|
case '/menu2':
|
||||||
return 'menu2';
|
return 'menu2';
|
||||||
default:
|
default:
|
||||||
@@ -35,6 +39,9 @@ const LayoutWrapper: React.FC = () => {
|
|||||||
case 'home':
|
case 'home':
|
||||||
navigate('/');
|
navigate('/');
|
||||||
break;
|
break;
|
||||||
|
case 'batch':
|
||||||
|
navigate('/batch-register');
|
||||||
|
break;
|
||||||
case 'menu1':
|
case 'menu1':
|
||||||
navigate('/resources');
|
navigate('/resources');
|
||||||
break;
|
break;
|
||||||
@@ -54,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">
|
||||||
@@ -77,6 +84,16 @@ const LayoutWrapper: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/batch-register"
|
||||||
|
element={
|
||||||
|
<BatchRegister
|
||||||
|
inputOpen={inputOpen}
|
||||||
|
onCloseInput={() => setInputOpen(false)}
|
||||||
|
containerEl={layoutShellRef.current}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/resources"
|
path="/resources"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import ImageModal from './ImageModal.tsx';
|
|||||||
import PeopleForm from './PeopleForm.tsx';
|
import PeopleForm from './PeopleForm.tsx';
|
||||||
import { getPeoples } from '../apis';
|
import { getPeoples } from '../apis';
|
||||||
import type { People } from '../apis';
|
import type { People } from '../apis';
|
||||||
import { deletePeople, updatePeople } from '../apis/people';
|
import { addOrUpdateRemark, deletePeople, deleteRemark, updatePeople } from '../apis/people';
|
||||||
|
|
||||||
const { Content } = Layout;
|
const { Content } = Layout;
|
||||||
|
|
||||||
@@ -30,11 +30,23 @@ function transformPeoples(list: People[] = []): Resource[] {
|
|||||||
height: person.height,
|
height: person.height,
|
||||||
marital_status: person.marital_status,
|
marital_status: person.marital_status,
|
||||||
introduction: person.introduction || {},
|
introduction: person.introduction || {},
|
||||||
|
comments: person.comments || {},
|
||||||
contact: person.contact || '',
|
contact: person.contact || '',
|
||||||
cover: person.cover || '',
|
cover: person.cover || '',
|
||||||
|
created_at: person.created_at,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
function formatDate(timestamp: number | null | undefined): string {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const day = date.getDate().toString().padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 获取人员列表数据
|
// 获取人员列表数据
|
||||||
async function fetchResources(): Promise<Resource[]> {
|
async function fetchResources(): Promise<Resource[]> {
|
||||||
try {
|
try {
|
||||||
@@ -52,7 +64,12 @@ async function fetchResources(): Promise<Resource[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 转换数据格式以匹配组件期望的结构
|
// 转换数据格式以匹配组件期望的结构
|
||||||
return transformPeoples(response.data || []);
|
const transformed = transformPeoples(response.data || []);
|
||||||
|
|
||||||
|
// 按 created_at 排序
|
||||||
|
transformed.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||||||
|
|
||||||
|
return transformed;
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('获取人员列表失败:', error);
|
console.error('获取人员列表失败:', error);
|
||||||
@@ -590,6 +607,10 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
|||||||
const [editingRecord, setEditingRecord] = React.useState<Resource | null>(null);
|
const [editingRecord, setEditingRecord] = React.useState<Resource | null>(null);
|
||||||
const editFormRef = React.useRef<FormInstance | null>(null);
|
const editFormRef = React.useRef<FormInstance | null>(null);
|
||||||
|
|
||||||
|
// 备注编辑模态框状态
|
||||||
|
const [remarkModalVisible, setRemarkModalVisible] = React.useState(false);
|
||||||
|
const [editingRemark, setEditingRemark] = React.useState<{ recordId: string; content: string } | null>(null);
|
||||||
|
|
||||||
// 移动端编辑模式状态
|
// 移动端编辑模式状态
|
||||||
const [mobileEditing, setMobileEditing] = React.useState(false);
|
const [mobileEditing, setMobileEditing] = React.useState(false);
|
||||||
|
|
||||||
@@ -1031,6 +1052,53 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ color: '#9ca3af' }}>暂无介绍</div>
|
<div style={{ color: '#9ca3af' }}>暂无介绍</div>
|
||||||
)}
|
)}
|
||||||
|
{record.created_at && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#999', marginTop: '12px' }}>
|
||||||
|
{record.created_at ? '录入于: ' + formatDate(record.created_at) : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid #f0f0f0', margin: '12px 0' }} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5} style={{ color: 'var(--text-primary)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span>备注</span>
|
||||||
|
{record.comments && record.comments.remark ? (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => {
|
||||||
|
setEditingRemark({ recordId: record.id, content: record.comments.remark.content });
|
||||||
|
setRemarkModalVisible(true);
|
||||||
|
}}>修改</Button>
|
||||||
|
<Button size="small" danger onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await deleteRemark(record.id);
|
||||||
|
if (res.error_code === 0) {
|
||||||
|
message.success('清空成功');
|
||||||
|
await reloadResources();
|
||||||
|
} else {
|
||||||
|
message.error(res.error_info || '清空失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error('清空失败');
|
||||||
|
}
|
||||||
|
}}>清空</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Button size="small" onClick={() => {
|
||||||
|
setEditingRemark({ recordId: record.id, content: '' });
|
||||||
|
setRemarkModalVisible(true);
|
||||||
|
}}>添加</Button>
|
||||||
|
)}
|
||||||
|
</Typography.Title>
|
||||||
|
{record.comments && record.comments.remark ? (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#999', marginBottom: '8px' }}>
|
||||||
|
更新于: {formatDate(record.comments.remark.updated_at)}
|
||||||
|
</div>
|
||||||
|
<div>{record.comments.remark.content}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1125,6 +1193,44 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={remarkModalVisible}
|
||||||
|
title={editingRemark?.content ? "修改备注" : "添加备注"}
|
||||||
|
onCancel={() => {
|
||||||
|
setRemarkModalVisible(false);
|
||||||
|
setEditingRemark(null);
|
||||||
|
}}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!editingRemark) return;
|
||||||
|
try {
|
||||||
|
const res = await addOrUpdateRemark(editingRemark.recordId, editingRemark.content);
|
||||||
|
if (res.error_code === 0) {
|
||||||
|
message.success(editingRemark.content ? '修改成功' : '添加成功');
|
||||||
|
setRemarkModalVisible(false);
|
||||||
|
setEditingRemark(null);
|
||||||
|
await reloadResources();
|
||||||
|
} else {
|
||||||
|
message.error(res.error_info || '操作失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error('操作失败');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={4}
|
||||||
|
value={editingRemark?.content}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (editingRemark) {
|
||||||
|
setEditingRemark({ ...editingRemark, content: e.target.value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="请输入备注"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</Content>
|
</Content>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Layout, Menu, Grid, Drawer, Button } from 'antd';
|
import { Layout, Menu, Grid, Drawer, Button } from 'antd';
|
||||||
import { HeartOutlined, FormOutlined, UnorderedListOutlined, MenuOutlined } from '@ant-design/icons';
|
import { HeartOutlined, FormOutlined, UnorderedListOutlined, MenuOutlined, CopyOutlined } from '@ant-design/icons';
|
||||||
import './SiderMenu.css';
|
import './SiderMenu.css';
|
||||||
|
|
||||||
const { Sider } = Layout;
|
const { Sider } = Layout;
|
||||||
@@ -44,8 +44,9 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
|||||||
}, [selectedKey]);
|
}, [selectedKey]);
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ key: 'home', label: '注册', icon: <FormOutlined /> },
|
{ key: 'home', label: '录入资源', icon: <FormOutlined /> },
|
||||||
{ key: 'menu1', label: '列表', icon: <UnorderedListOutlined /> },
|
{ key: 'batch', label: '批量录入', icon: <CopyOutlined /> },
|
||||||
|
{ key: 'menu1', label: '资源列表', icon: <UnorderedListOutlined /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 移动端:使用 Drawer 覆盖主内容
|
// 移动端:使用 Drawer 覆盖主内容
|
||||||
|
|||||||
Reference in New Issue
Block a user