Compare commits
12 Commits
f00ec0588c
...
release_v0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a01204bf6 | |||
| 83f8091e15 | |||
| 63a813925f | |||
| 8873c183e7 | |||
| 5d56a4bbe9 | |||
| 2c7dbada25 | |||
| d95ec615cd | |||
| ca55ad5512 | |||
| dda8e13587 | |||
| c61a106373 | |||
| 86ad54cccb | |||
| ae617114e0 |
@@ -91,6 +91,7 @@ import {
|
|||||||
getPeoples,
|
getPeoples,
|
||||||
searchPeoples,
|
searchPeoples,
|
||||||
deletePeople,
|
deletePeople,
|
||||||
|
updatePeople,
|
||||||
getPeoplesPaginated
|
getPeoplesPaginated
|
||||||
} from '@/apis';
|
} from '@/apis';
|
||||||
|
|
||||||
@@ -154,6 +155,20 @@ const removePeople = async (peopleId: string) => {
|
|||||||
console.error('删除失败:', error);
|
console.error('删除失败:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 更新人员
|
||||||
|
const updateOnePeople = async (peopleId: string) => {
|
||||||
|
const peopleData = {
|
||||||
|
name: '李四',
|
||||||
|
age: 28,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const response = await updatePeople(peopleId, peopleData);
|
||||||
|
console.log('更新成功:', response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
## 错误处理
|
## 错误处理
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ export const API_CONFIG = {
|
|||||||
|
|
||||||
// API 端点
|
// API 端点
|
||||||
export const API_ENDPOINTS = {
|
export const API_ENDPOINTS = {
|
||||||
INPUT: '/input',
|
INPUT: '/recognition/input',
|
||||||
INPUT_IMAGE: '/input_image',
|
INPUT_IMAGE: '/recognition/image',
|
||||||
|
// 人员列表查询仍为 /peoples
|
||||||
PEOPLES: '/peoples',
|
PEOPLES: '/peoples',
|
||||||
PEOPLE_BY_ID: (id: string) => `/peoples/${id}`,
|
// 新增单个资源路径 /people
|
||||||
|
PEOPLE: '/people',
|
||||||
|
PEOPLE_BY_ID: (id: string) => `/people/${id}`,
|
||||||
|
PEOPLE_REMARK_BY_ID: (id: string) => `/people/${id}/remark`,
|
||||||
} as const;
|
} as const;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// 人员管理相关 API
|
// 人员管理相关 API
|
||||||
|
|
||||||
import { get, post, del } from './request';
|
import { get, post, del, put } from './request';
|
||||||
import { API_ENDPOINTS } from './config';
|
import { API_ENDPOINTS } from './config';
|
||||||
import type {
|
import type {
|
||||||
PostPeopleRequest,
|
PostPeopleRequest,
|
||||||
@@ -15,10 +15,11 @@ import type {
|
|||||||
* @param people 人员信息对象
|
* @param people 人员信息对象
|
||||||
* @returns Promise<ApiResponse>
|
* @returns Promise<ApiResponse>
|
||||||
*/
|
*/
|
||||||
export async function createPeople(people: Record<string, any>): Promise<ApiResponse> {
|
export async function createPeople(people: People): Promise<ApiResponse> {
|
||||||
const requestData: PostPeopleRequest = { people };
|
const requestData: PostPeopleRequest = { people };
|
||||||
console.log('创建人员请求数据:', requestData);
|
console.log('创建人员请求数据:', requestData);
|
||||||
return post<ApiResponse>(API_ENDPOINTS.PEOPLES, requestData);
|
// 创建接口改为 /people
|
||||||
|
return post<ApiResponse>(API_ENDPOINTS.PEOPLE, requestData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,13 +110,43 @@ export async function deletePeople(peopleId: string): Promise<ApiResponse> {
|
|||||||
return del<ApiResponse>(API_ENDPOINTS.PEOPLE_BY_ID(peopleId));
|
return del<ApiResponse>(API_ENDPOINTS.PEOPLE_BY_ID(peopleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新人员信息
|
||||||
|
* @param peopleId 人员ID
|
||||||
|
* @param people 人员信息对象
|
||||||
|
* @returns Promise<ApiResponse>
|
||||||
|
*/
|
||||||
|
export async function updatePeople(peopleId: string, people: People): Promise<ApiResponse> {
|
||||||
|
const requestData: PostPeopleRequest = { people };
|
||||||
|
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 人员信息数组
|
||||||
* @returns Promise<ApiResponse[]>
|
* @returns Promise<ApiResponse[]>
|
||||||
*/
|
*/
|
||||||
export async function createPeoplesBatch(
|
export async function createPeoplesBatch(
|
||||||
peopleList: Record<string, any>[]
|
peopleList: People[]
|
||||||
): Promise<ApiResponse[]> {
|
): Promise<ApiResponse[]> {
|
||||||
const promises = peopleList.map(people => createPeople(people));
|
const promises = peopleList.map(people => createPeople(people));
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
|
|||||||
@@ -45,12 +45,14 @@ export interface GetPeoplesParams {
|
|||||||
export interface People {
|
export interface People {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
contact?: string;
|
||||||
gender?: string;
|
gender?: string;
|
||||||
age?: number;
|
age?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
marital_status?: string;
|
marital_status?: string;
|
||||||
contact?: string;
|
created_at?: number;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
|
cover?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页响应类型
|
// 分页响应类型
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export async function postInputImageWithProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
// 后端要求字段名为 image
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
|
|||||||
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;
|
||||||
65
src/components/ImageModal.css
Normal file
65
src/components/ImageModal.css
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/* PC端图片弹窗样式 */
|
||||||
|
.desktop-image-modal .ant-modal-wrap {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-image-modal .ant-modal {
|
||||||
|
top: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
transform: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-image-modal .ant-modal-content {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端图片弹窗样式 */
|
||||||
|
.mobile-image-modal .ant-modal-wrap {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
padding: 64px 0 0 0 !important; /* 顶部留出标题栏空间 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-image-modal .ant-modal {
|
||||||
|
top: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
transform: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
position: relative !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-image-modal .ant-modal-content {
|
||||||
|
border-radius: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
max-height: calc(100vh - 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-image-modal .ant-modal-body {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保图片容器不会溢出 */
|
||||||
|
.image-modal-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-modal-container img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
190
src/components/ImageModal.tsx
Normal file
190
src/components/ImageModal.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Modal, Spin } from 'antd';
|
||||||
|
import { CloseOutlined } from '@ant-design/icons';
|
||||||
|
import './ImageModal.css';
|
||||||
|
|
||||||
|
interface ImageModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
imageUrl: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片缓存
|
||||||
|
const imageCache = new Set<string>();
|
||||||
|
|
||||||
|
const ImageModal: React.FC<ImageModalProps> = ({ visible, imageUrl, onClose }) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||||
|
|
||||||
|
// 检测是否为移动端
|
||||||
|
useEffect(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth <= 768);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', checkMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 预加载图片
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && imageUrl) {
|
||||||
|
// 如果图片已缓存,直接显示
|
||||||
|
if (imageCache.has(imageUrl)) {
|
||||||
|
setImageLoaded(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setImageLoaded(false);
|
||||||
|
setImageError(false);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
imageCache.add(imageUrl);
|
||||||
|
setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight });
|
||||||
|
setImageLoaded(true);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
setImageError(true);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
img.src = imageUrl;
|
||||||
|
}
|
||||||
|
}, [visible, imageUrl]);
|
||||||
|
|
||||||
|
// 重置状态当弹窗关闭时
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setLoading(false);
|
||||||
|
setImageLoaded(false);
|
||||||
|
setImageError(false);
|
||||||
|
setImageDimensions(null);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// 计算移动端弹窗高度
|
||||||
|
const getMobileModalHeight = () => {
|
||||||
|
if (imageLoaded && imageDimensions) {
|
||||||
|
// 如果图片已加载,根据图片比例自适应高度
|
||||||
|
const availableHeight = window.innerHeight - 64; // 减去标题栏高度
|
||||||
|
const availableWidth = window.innerWidth;
|
||||||
|
|
||||||
|
// 计算图片按宽度100%显示时的高度
|
||||||
|
const aspectRatio = imageDimensions.height / imageDimensions.width;
|
||||||
|
const calculatedHeight = availableWidth * aspectRatio;
|
||||||
|
|
||||||
|
// 确保高度不超过可用空间的90%
|
||||||
|
const maxHeight = availableHeight * 0.9;
|
||||||
|
const finalHeight = Math.min(calculatedHeight, maxHeight);
|
||||||
|
|
||||||
|
return `${finalHeight}px`;
|
||||||
|
}
|
||||||
|
// 图片未加载时,使用默认高度(除标题栏外的33%)
|
||||||
|
return 'calc((100vh - 64px) * 0.33)';
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalStyle = isMobile ? {
|
||||||
|
// 移动端居中显示,不设置top
|
||||||
|
paddingBottom: 0,
|
||||||
|
margin: 0,
|
||||||
|
} : {
|
||||||
|
// PC端不设置top,让centered属性处理居中
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalBodyStyle = isMobile ? {
|
||||||
|
padding: 0,
|
||||||
|
height: getMobileModalHeight(),
|
||||||
|
minHeight: 'calc((100vh - 64px) * 0.33)', // 最小高度为33%
|
||||||
|
maxHeight: 'calc(100vh - 64px)', // 最大高度不超过可视区域
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
} : {
|
||||||
|
padding: 0,
|
||||||
|
height: '66vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={visible}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
closable={false}
|
||||||
|
width={isMobile ? '100vw' : '66vw'}
|
||||||
|
style={modalStyle}
|
||||||
|
styles={{
|
||||||
|
body: modalBodyStyle,
|
||||||
|
mask: { backgroundColor: 'rgba(0, 0, 0, 0.8)' },
|
||||||
|
}}
|
||||||
|
centered={true} // 移动端和PC端都居中显示
|
||||||
|
destroyOnHidden
|
||||||
|
wrapClassName={isMobile ? 'mobile-image-modal' : 'desktop-image-modal'}
|
||||||
|
>
|
||||||
|
{/* 自定义关闭按钮 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
zIndex: 1000,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 20,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'all 0.3s',
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图片内容 */}
|
||||||
|
<div className="image-modal-container">
|
||||||
|
{loading && (
|
||||||
|
<Spin size="large" style={{ color: '#fff' }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imageError && (
|
||||||
|
<div style={{ color: '#fff', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>📷</div>
|
||||||
|
<div>图片加载失败</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imageLoaded && !loading && !imageError && (
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="预览图片"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageModal;
|
||||||
@@ -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,40 +151,56 @@ 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') {
|
||||||
|
const newEntries: any[] = [];
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const item = items[i];
|
const item = items[i];
|
||||||
if (item.kind === 'file') {
|
if (item.kind === 'file') {
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (file && file.type.startsWith('image/')) {
|
if (file && file.type.startsWith('image/')) {
|
||||||
pastedImage = file;
|
|
||||||
break; // 只取第一张
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pastedImage) {
|
|
||||||
// 避免图片内容以文本方式粘贴进输入框
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const ext = getImageExt(pastedImage);
|
|
||||||
const name = `image.${ext}`;
|
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
uid: `${Date.now()}-${Math.random()}`,
|
uid: `${Date.now()}-${Math.random()}`,
|
||||||
name,
|
name: 'image',
|
||||||
status: 'done',
|
status: 'done',
|
||||||
originFileObj: pastedImage,
|
originFileObj: file,
|
||||||
} as any;
|
} as any;
|
||||||
|
newEntries.push(entry);
|
||||||
// 仅保留一张:新图直接替换旧图
|
|
||||||
if (fileList.length === 0) {
|
|
||||||
setSavedText(value);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newEntries.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
setValue('');
|
setValue('');
|
||||||
setFileList([entry]);
|
setFileList([...fileList, ...newEntries]);
|
||||||
|
message.success(`已添加 ${newEntries.length} 张剪贴板图片`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 单图模式:仅添加第一张并替换已有
|
||||||
|
let firstImage: File | null = null;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file && file.type.startsWith('image/')) {
|
||||||
|
firstImage = file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstImage) {
|
||||||
|
e.preventDefault();
|
||||||
|
setValue('');
|
||||||
|
setFileList([
|
||||||
|
{
|
||||||
|
uid: `${Date.now()}-${Math.random()}`,
|
||||||
|
name: 'image',
|
||||||
|
status: 'done',
|
||||||
|
originFileObj: firstImage,
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
message.success('已添加剪贴板图片');
|
message.success('已添加剪贴板图片');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -178,9 +216,17 @@ 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 && fileList.length > 0
|
||||||
? '不可在添加图片时输入信息...'
|
? '不可在添加图片时输入信息...'
|
||||||
: (showUpload ? '请输入个人信息描述,或上传图片…' : '请输入个人信息描述…')
|
: (showUpload ? '请输入个人信息描述,或上传图片…' : '请输入个人信息描述…')
|
||||||
}
|
}
|
||||||
@@ -188,50 +234,77 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult, showUpload = true, mo
|
|||||||
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 && (
|
||||||
|
mode === 'batch-image' ? (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{fileList.map((f, idx) => (
|
||||||
|
<Tag
|
||||||
|
key={f.uid || idx}
|
||||||
|
className="selected-image-tag"
|
||||||
|
color="processing"
|
||||||
|
closable
|
||||||
|
onClose={() => {
|
||||||
|
const next = fileList.filter((x) => x !== f)
|
||||||
|
setFileList(next)
|
||||||
|
}}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
{`image-${idx + 1}`}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Tag
|
<Tag
|
||||||
className="selected-image-tag"
|
className="selected-image-tag"
|
||||||
color="processing"
|
color="processing"
|
||||||
closable
|
closable
|
||||||
onClose={() => { setFileList([]); setValue(savedText); setSavedText(''); }}
|
onClose={() => { setFileList([]) }}
|
||||||
bordered={false}
|
bordered={false}
|
||||||
>
|
>
|
||||||
{`image.${new Date().getSeconds()}.${getImageExt(fileList[0]?.originFileObj || fileList[0])}`}
|
{'image'}
|
||||||
</Tag>
|
</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') {
|
||||||
|
const normalized = nextFileList.map((entry: any) => {
|
||||||
|
const raw = entry.originFileObj || entry;
|
||||||
|
return { ...entry, name: 'image', originFileObj: raw };
|
||||||
|
});
|
||||||
|
setValue('');
|
||||||
|
setFileList(normalized);
|
||||||
|
} else {
|
||||||
if (nextFileList.length === 0) {
|
if (nextFileList.length === 0) {
|
||||||
setFileList([]);
|
setFileList([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const latest = nextFileList[nextFileList.length - 1] as any;
|
// 仅添加第一张
|
||||||
const raw = latest.originFileObj || file; // UploadFile 或原始 File
|
const first = nextFileList[0] as any;
|
||||||
const ext = getImageExt(raw);
|
const raw = first.originFileObj || first;
|
||||||
const renamed = { ...latest, name: `image.${ext}` };
|
const renamed = { ...first, name: 'image', originFileObj: raw };
|
||||||
if (fileList.length === 0) {
|
|
||||||
setSavedText(value);
|
|
||||||
}
|
|
||||||
setValue('');
|
setValue('');
|
||||||
setFileList([renamed]);
|
setFileList([renamed]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onRemove={(file) => {
|
||||||
|
setFileList((prev) => prev.filter((x) => x.uid !== (file as any).uid));
|
||||||
|
return true;
|
||||||
}}
|
}}
|
||||||
onRemove={() => { setFileList([]); setValue(savedText); setSavedText(''); return true; }}
|
|
||||||
maxCount={1}
|
|
||||||
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} />
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Form, Input, Select, InputNumber, Button, message, Row, Col } from 'antd';
|
import { Form, Input, Select, InputNumber, Button, message, Row, Col } from 'antd';
|
||||||
|
import type { FormInstance } from 'antd';
|
||||||
import './PeopleForm.css';
|
import './PeopleForm.css';
|
||||||
import KeyValueList from './KeyValueList.tsx'
|
import KeyValueList from './KeyValueList.tsx'
|
||||||
import { createPeople } from '../apis';
|
import { createPeople, type People } from '../apis';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
interface PeopleFormProps {
|
interface PeopleFormProps {
|
||||||
initialData?: any;
|
initialData?: any;
|
||||||
|
// 编辑模式下由父组件控制提交,隐藏内部提交按钮
|
||||||
|
hideSubmitButton?: boolean;
|
||||||
|
// 暴露 AntD Form 实例给父组件,用于在外部触发校验与取值
|
||||||
|
onFormReady?: (form: FormInstance) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PeopleForm: React.FC<PeopleFormProps> = ({ initialData }) => {
|
const PeopleForm: React.FC<PeopleFormProps> = ({ initialData, hideSubmitButton = false, onFormReady }) => {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -24,6 +29,7 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData }) => {
|
|||||||
|
|
||||||
if (initialData.name) formData.name = initialData.name;
|
if (initialData.name) formData.name = initialData.name;
|
||||||
if (initialData.contact) formData.contact = initialData.contact;
|
if (initialData.contact) formData.contact = initialData.contact;
|
||||||
|
if (initialData.cover) formData.cover = initialData.cover;
|
||||||
if (initialData.gender) formData.gender = initialData.gender;
|
if (initialData.gender) formData.gender = initialData.gender;
|
||||||
if (initialData.age) formData.age = initialData.age;
|
if (initialData.age) formData.age = initialData.age;
|
||||||
if (initialData.height) formData.height = initialData.height;
|
if (initialData.height) formData.height = initialData.height;
|
||||||
@@ -35,23 +41,31 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData }) => {
|
|||||||
form.setFieldsValue(formData);
|
form.setFieldsValue(formData);
|
||||||
|
|
||||||
// 显示成功消息
|
// 显示成功消息
|
||||||
message.success('已自动填充表单,请检查并确认信息');
|
// message.success('已自动填充表单,请检查并确认信息');
|
||||||
}
|
}
|
||||||
}, [initialData, form]);
|
}, [initialData, form]);
|
||||||
|
|
||||||
|
// 将表单实例暴露给父组件
|
||||||
|
useEffect(() => {
|
||||||
|
onFormReady?.(form);
|
||||||
|
// 仅在首次挂载时调用一次
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onFinish = async (values: any) => {
|
const onFinish = async (values: any) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const peopleData = {
|
const peopleData: People = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
|
contact: values.contact || undefined,
|
||||||
gender: values.gender,
|
gender: values.gender,
|
||||||
age: values.age,
|
age: values.age,
|
||||||
height: values.height || undefined,
|
height: values.height || undefined,
|
||||||
marital_status: values.marital_status || undefined,
|
marital_status: values.marital_status || undefined,
|
||||||
introduction: values.introduction || {},
|
introduction: values.introduction || {},
|
||||||
match_requirement: values.match_requirement || undefined,
|
match_requirement: values.match_requirement || undefined,
|
||||||
contact: values.contact || undefined,
|
cover: values.cover || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('提交人员数据:', peopleData);
|
console.log('提交人员数据:', peopleData);
|
||||||
@@ -105,6 +119,14 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData }) => {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col xs={24}>
|
||||||
|
<Form.Item name="cover" label="人物封面">
|
||||||
|
<Input placeholder="请输入图片链接(可留空)" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Row gutter={[12, 12]}>
|
<Row gutter={[12, 12]}>
|
||||||
<Col xs={24} md={6}>
|
<Col xs={24} md={6}>
|
||||||
<Form.Item name="gender" label="性别" rules={[{ required: true, message: '请选择性别' }]}>
|
<Form.Item name="gender" label="性别" rules={[{ required: true, message: '请选择性别' }]}>
|
||||||
@@ -151,11 +173,13 @@ const PeopleForm: React.FC<PeopleFormProps> = ({ initialData }) => {
|
|||||||
<TextArea autoSize={{ minRows: 3, maxRows: 6 }} placeholder="例如:性格开朗、三观一致等" />
|
<TextArea autoSize={{ minRows: 3, maxRows: 6 }} placeholder="例如:性格开朗、三观一致等" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{!hideSubmitButton && (
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||||
{loading ? '提交中...' : '提交'}
|
{loading ? '提交中...' : '提交'}
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Layout, Typography, Table, Grid, InputNumber, Button, Space, Tag, message, Modal, Dropdown, Input } from 'antd';
|
import { Layout, Typography, Table, Grid, InputNumber, Button, Space, Tag, message, Modal, Dropdown, Input, Select } from 'antd';
|
||||||
|
import type { FormInstance } from 'antd';
|
||||||
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
||||||
import type { FilterDropdownProps } from 'antd/es/table/interface';
|
import type { FilterDropdownProps } from 'antd/es/table/interface';
|
||||||
import type { TableProps } from 'antd';
|
import type { TableProps } from 'antd';
|
||||||
import { SearchOutlined, EllipsisOutlined, DeleteOutlined, ManOutlined, WomanOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
import { SearchOutlined, EllipsisOutlined, DeleteOutlined, ManOutlined, WomanOutlined, ExclamationCircleOutlined, PictureOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
import './MainContent.css';
|
import './MainContent.css';
|
||||||
import InputDrawer from './InputDrawer.tsx';
|
import InputDrawer from './InputDrawer.tsx';
|
||||||
|
import ImageModal from './ImageModal.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 } from '../apis/people';
|
import { addOrUpdateRemark, deletePeople, deleteRemark, updatePeople } from '../apis/people';
|
||||||
|
|
||||||
const { Content } = Layout;
|
const { Content } = Layout;
|
||||||
|
|
||||||
@@ -27,10 +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 || '',
|
||||||
|
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 {
|
||||||
@@ -48,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);
|
||||||
@@ -473,7 +494,7 @@ function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): Colum
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const key = `${localMin ?? ''}:${localMax ?? ''}`;
|
const key = `${localMin ?? ''}:${localMax ?? ''}`;
|
||||||
setSelectedKeys?.([key]);
|
setSelectedKeys?.([key]);
|
||||||
confirm?.();
|
confirm?.({ closeDropdown: true });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
筛选
|
筛选
|
||||||
@@ -481,8 +502,11 @@ function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): Colum
|
|||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setLocalMin(undefined);
|
||||||
|
setLocalMax(undefined);
|
||||||
setSelectedKeys?.([]);
|
setSelectedKeys?.([]);
|
||||||
clearFilters?.();
|
clearFilters?.();
|
||||||
|
confirm?.({ closeDropdown: true });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
@@ -505,6 +529,64 @@ function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): Colum
|
|||||||
} as ColumnType<Resource>;
|
} as ColumnType<Resource>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 枚举筛选下拉(用于性别等枚举类列)
|
||||||
|
function buildEnumFilter(dataIndex: keyof Resource, label: string, options: Array<{ label: string; value: string }>): ColumnType<Resource> {
|
||||||
|
return {
|
||||||
|
title: label,
|
||||||
|
dataIndex,
|
||||||
|
key: String(dataIndex),
|
||||||
|
filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) => {
|
||||||
|
const [val, setVal] = React.useState<string | undefined>(
|
||||||
|
(selectedKeys && selectedKeys[0] ? String(selectedKeys[0]) : undefined)
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="byte-table-custom-filter" style={{ padding: 8 }}>
|
||||||
|
<Space direction="vertical" style={{ width: 200 }}>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder={`请选择${label}`}
|
||||||
|
value={val}
|
||||||
|
onChange={(v) => setVal(v)}
|
||||||
|
options={options}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedKeys?.(val ? [val] : []);
|
||||||
|
confirm?.({ closeDropdown: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setVal(undefined);
|
||||||
|
setSelectedKeys?.([]);
|
||||||
|
clearFilters?.();
|
||||||
|
confirm?.({ closeDropdown: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onFilter: (filterValue: React.Key | boolean, record: Resource) =>
|
||||||
|
String((record as any)[dataIndex]) === String(filterValue),
|
||||||
|
render: (g: string) => {
|
||||||
|
const color = g === '男' ? 'blue' : g === '女' ? 'magenta' : 'default';
|
||||||
|
return <Tag color={color}>{g}</Tag>;
|
||||||
|
},
|
||||||
|
} as ColumnType<Resource>;
|
||||||
|
}
|
||||||
|
|
||||||
type Props = { inputOpen?: boolean; onCloseInput?: () => void; containerEl?: HTMLElement | null };
|
type Props = { inputOpen?: boolean; onCloseInput?: () => void; containerEl?: HTMLElement | null };
|
||||||
|
|
||||||
const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, containerEl }) => {
|
const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, containerEl }) => {
|
||||||
@@ -517,6 +599,21 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
|||||||
const [swipedRowId, setSwipedRowId] = React.useState<string | null>(null);
|
const [swipedRowId, setSwipedRowId] = React.useState<string | null>(null);
|
||||||
const touchStartRef = React.useRef<{ x: number; y: number } | null>(null);
|
const touchStartRef = React.useRef<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
// 图片弹窗状态
|
||||||
|
const [imageModalVisible, setImageModalVisible] = React.useState(false);
|
||||||
|
const [currentImageUrl, setCurrentImageUrl] = React.useState('');
|
||||||
|
// 编辑弹窗状态(仅桌面端)
|
||||||
|
const [editModalVisible, setEditModalVisible] = React.useState(false);
|
||||||
|
const [editingRecord, setEditingRecord] = React.useState<Resource | 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 handleTableChange: TableProps<Resource>['onChange'] = (pg) => {
|
const handleTableChange: TableProps<Resource>['onChange'] = (pg) => {
|
||||||
setPagination({ current: pg?.current ?? 1, pageSize: pg?.pageSize ?? 10 });
|
setPagination({ current: pg?.current ?? 1, pageSize: pg?.pageSize ?? 10 });
|
||||||
};
|
};
|
||||||
@@ -589,17 +686,44 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
|||||||
},
|
},
|
||||||
onFilter: (filterValue: React.Key | boolean, record: Resource) => String(record.name).includes(String(filterValue)),
|
onFilter: (filterValue: React.Key | boolean, record: Resource) => String(record.name).includes(String(filterValue)),
|
||||||
render: (text: string, record: Resource) => {
|
render: (text: string, record: Resource) => {
|
||||||
if (!isMobile) return <span style={{ fontWeight: 600 }}>{text}</span>;
|
// 图片图标逻辑
|
||||||
|
const hasCover = record.cover && record.cover.trim() !== '';
|
||||||
|
const pictureIcon = (
|
||||||
|
<PictureOutlined
|
||||||
|
style={{
|
||||||
|
color: hasCover ? '#1677ff' : '#9ca3af',
|
||||||
|
cursor: hasCover ? 'pointer' : 'default'
|
||||||
|
}}
|
||||||
|
onClick={hasCover ? () => {
|
||||||
|
setCurrentImageUrl(record.cover);
|
||||||
|
setImageModalVisible(true);
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
// 桌面端:姓名后面跟图片图标
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>{text}</span>
|
||||||
|
{pictureIcon}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端:姓名 + 性别图标 + 图片图标
|
||||||
const g = record.gender;
|
const g = record.gender;
|
||||||
const icon = g === '男'
|
const genderIcon = g === '男'
|
||||||
? <ManOutlined style={{ color: '#1677ff' }} />
|
? <ManOutlined style={{ color: '#1677ff' }} />
|
||||||
: g === '女'
|
: g === '女'
|
||||||
? <WomanOutlined style={{ color: '#eb2f96' }} />
|
? <WomanOutlined style={{ color: '#eb2f96' }} />
|
||||||
: <ExclamationCircleOutlined style={{ color: '#9ca3af' }} />;
|
: <ExclamationCircleOutlined style={{ color: '#9ca3af' }} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
<span style={{ fontWeight: 600 }}>{text}</span>
|
<span style={{ fontWeight: 600 }}>{text}</span>
|
||||||
{icon}
|
{genderIcon}
|
||||||
|
{pictureIcon}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -607,21 +731,11 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
|||||||
// 非移动端显示更多列
|
// 非移动端显示更多列
|
||||||
...(!isMobile
|
...(!isMobile
|
||||||
? [
|
? [
|
||||||
{
|
buildEnumFilter('gender', '性别', [
|
||||||
title: '性别',
|
{ label: '男', value: '男' },
|
||||||
dataIndex: 'gender',
|
{ label: '女', value: '女' },
|
||||||
key: 'gender',
|
{ label: '其他/保密', value: '其他/保密' },
|
||||||
filters: [
|
]),
|
||||||
{ text: '男', value: '男' },
|
|
||||||
{ text: '女', value: '女' },
|
|
||||||
{ text: '其他/保密', value: '其他/保密' },
|
|
||||||
],
|
|
||||||
onFilter: (value: React.Key | boolean, record: Resource) => String(record.gender) === String(value),
|
|
||||||
render: (g: string) => {
|
|
||||||
const color = g === '男' ? 'blue' : g === '女' ? 'magenta' : 'default';
|
|
||||||
return <Tag color={color}>{g}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
buildNumberRangeFilter('age', '年龄'),
|
buildNumberRangeFilter('age', '年龄'),
|
||||||
buildNumberRangeFilter('height', '身高'),
|
buildNumberRangeFilter('height', '身高'),
|
||||||
{
|
{
|
||||||
@@ -659,13 +773,55 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span style={{ flex: 1 }}>{v ? v : '-'}</span>
|
<span style={{ flex: 1 }}>{v ? v : '-'}</span>
|
||||||
{swipedRowId === record.id && (
|
{swipedRowId === record.id && (
|
||||||
|
<div style={{ display: 'inline-flex', gap: 8 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: '#1677ff',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditOutlined style={{ fontSize: 12 }} />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingRecord(record);
|
||||||
|
setMobileEditing(true);
|
||||||
|
setSwipedRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
danger
|
danger
|
||||||
size="small"
|
size="small"
|
||||||
icon={<DeleteOutlined />}
|
icon={
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: '#f5222d',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteOutlined style={{ fontSize: 12 }} />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
onClick={() => confirmDelete(record.id)}
|
onClick={() => confirmDelete(record.id)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -682,6 +838,30 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
|||||||
trigger={["click"]}
|
trigger={["click"]}
|
||||||
menu={{
|
menu={{
|
||||||
items: [
|
items: [
|
||||||
|
...(!isMobile
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'edit',
|
||||||
|
label: '编辑',
|
||||||
|
icon: (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: '#1677ff',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditOutlined style={{ fontSize: 12 }} />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: '删除',
|
label: '删除',
|
||||||
@@ -705,6 +885,10 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
|||||||
],
|
],
|
||||||
onClick: ({ key }) => {
|
onClick: ({ key }) => {
|
||||||
if (key === 'delete') confirmDelete(record.id);
|
if (key === 'delete') confirmDelete(record.id);
|
||||||
|
if (key === 'edit' && !isMobile) {
|
||||||
|
setEditingRecord(record);
|
||||||
|
setEditModalVisible(true);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -722,11 +906,80 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
|||||||
资源列表
|
资源列表
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
|
||||||
|
{isMobile && mobileEditing && (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4} style={{ color: 'var(--text-primary)' }}>
|
||||||
|
编辑资源
|
||||||
|
</Typography.Title>
|
||||||
|
<PeopleForm
|
||||||
|
initialData={editingRecord || undefined}
|
||||||
|
hideSubmitButton
|
||||||
|
onFormReady={(f) => (editFormRef.current = f)}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginTop: 12 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const form = editFormRef.current;
|
||||||
|
if (!form) {
|
||||||
|
message.error('表单未就绪');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const peopleData: People = {
|
||||||
|
name: values.name,
|
||||||
|
contact: values.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,
|
||||||
|
};
|
||||||
|
if (!editingRecord) {
|
||||||
|
message.error('缺少当前编辑的人员信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await updatePeople(editingRecord.id, peopleData);
|
||||||
|
if (res.error_code === 0) {
|
||||||
|
message.success('更新成功');
|
||||||
|
setMobileEditing(false);
|
||||||
|
setEditingRecord(null);
|
||||||
|
await reloadResources();
|
||||||
|
} else {
|
||||||
|
message.error(res.error_info || '更新失败');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.errorFields) {
|
||||||
|
message.error('请完善表单后再保存');
|
||||||
|
} else {
|
||||||
|
message.error('更新失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setMobileEditing(false);
|
||||||
|
setEditingRecord(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Table<Resource>
|
<Table<Resource>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
|
style={{ display: isMobile && mobileEditing ? 'none' : undefined }}
|
||||||
onRow={(record) =>
|
onRow={(record) =>
|
||||||
isMobile
|
isMobile
|
||||||
? {
|
? {
|
||||||
@@ -799,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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -820,6 +1120,117 @@ const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, contai
|
|||||||
showUpload={false}
|
showUpload={false}
|
||||||
mode={'search'}
|
mode={'search'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 图片预览弹窗 */}
|
||||||
|
<ImageModal
|
||||||
|
visible={imageModalVisible}
|
||||||
|
imageUrl={currentImageUrl}
|
||||||
|
onClose={() => {
|
||||||
|
setImageModalVisible(false);
|
||||||
|
setCurrentImageUrl('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 编辑弹窗,仅桌面端显示 */}
|
||||||
|
{!isMobile && (
|
||||||
|
<Modal
|
||||||
|
open={editModalVisible}
|
||||||
|
title="编辑"
|
||||||
|
width="50%"
|
||||||
|
style={{ minWidth: 768 }}
|
||||||
|
onCancel={() => {
|
||||||
|
setEditModalVisible(false);
|
||||||
|
setEditingRecord(null);
|
||||||
|
}}
|
||||||
|
onOk={async () => {
|
||||||
|
try {
|
||||||
|
const form = editFormRef.current;
|
||||||
|
if (!form) {
|
||||||
|
message.error('表单未就绪');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const peopleData: People = {
|
||||||
|
name: values.name,
|
||||||
|
contact: values.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,
|
||||||
|
};
|
||||||
|
if (!editingRecord) {
|
||||||
|
message.error('缺少当前编辑的人员信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await updatePeople(editingRecord.id, peopleData);
|
||||||
|
if (res.error_code === 0) {
|
||||||
|
message.success('更新成功');
|
||||||
|
setEditModalVisible(false);
|
||||||
|
setEditingRecord(null);
|
||||||
|
await reloadResources();
|
||||||
|
} else {
|
||||||
|
message.error(res.error_info || '更新失败');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.errorFields) {
|
||||||
|
message.error('请完善表单后再确认');
|
||||||
|
} else {
|
||||||
|
message.error('更新失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
destroyOnHidden
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<PeopleForm
|
||||||
|
initialData={editingRecord || undefined}
|
||||||
|
hideSubmitButton
|
||||||
|
onFormReady={(f) => (editFormRef.current = f)}
|
||||||
|
/>
|
||||||
|
</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 覆盖主内容
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import App from './App.tsx'
|
|||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter
|
||||||
|
future={{
|
||||||
|
v7_relativeSplatPath: true,
|
||||||
|
v7_startTransition: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
Reference in New Issue
Block a user