25 Commits

Author SHA1 Message Date
0a01204bf6 Release v0.2 2025-11-17 00:50:03 +08:00
83f8091e15 feat: support batch recognize images for batch register 2025-11-16 02:03:49 +08:00
63a813925f feat: support batch register resources 2025-11-15 16:37:33 +08:00
8873c183e7 feat: make remarks on people 2025-11-14 16:50:11 +08:00
5d56a4bbe9 feat: dispaly creation time of people in detail 2025-11-13 21:26:04 +08:00
2c7dbada25 Release v0.1 2025-11-13 00:06:48 +08:00
d95ec615cd fix: reset buttons for filter table have no effect 2025-11-12 23:21:30 +08:00
ca55ad5512 refactor: avoid to use some deprecated properities 2025-11-12 16:20:21 +08:00
dda8e13587 feat: support edit people 2025-11-12 14:59:36 +08:00
c61a106373 refactor: adapt to api changes of web service 2025-11-12 11:35:31 +08:00
86ad54cccb feat: resource list can display the cover of the resource 2025-10-31 01:15:59 +08:00
ae617114e0 feat: support provide people cover when register 2025-10-31 00:39:09 +08:00
f00ec0588c refactor: optimize the display for input image 2025-10-30 20:47:43 +08:00
1f138f5097 fix: allow add or paste more images 2025-10-30 17:58:36 +08:00
18ee8c1ac2 feat: support paste image for post input image 2025-10-30 17:34:10 +08:00
c3800541d4 chore: change the production base url to right domain 2025-10-30 06:55:31 +08:00
9c83ba38b7 refactor: the resource list display style in mobile 2025-10-29 17:46:22 +08:00
d9f4f643b9 feat: add delete operation of resource list 2025-10-29 16:54:26 +08:00
9269d77ef7 fix: menu side bar scroll with main content area 2025-10-29 16:21:12 +08:00
a8170f45be feat: support AI search in resource list page 2025-10-29 16:10:33 +08:00
38676ec2d0 feat: name column of resource list table supporting to search in PC 2025-10-29 08:52:54 +08:00
01855ba13d feat: support the linkage between the menu drawer and the ai input drawer in mobile 2025-10-29 01:18:31 +08:00
3de5d296a0 fix: ai input box not auto adapt width follow drawer 2025-10-29 00:30:31 +08:00
2cce67d350 refactor: font of detail in people in resource list 2025-10-28 21:31:20 +08:00
5038249c1a refactor: change the web site desc and register page logo 2025-10-28 21:17:07 +08:00
22 changed files with 1492 additions and 107 deletions

View File

@@ -1,2 +1,2 @@
# 生产环境配置 # 生产环境配置
VITE_API_BASE_URL=http://47.109.95.59:20080 VITE_API_BASE_URL=https://if.u.mamamiyear.site:20443

View File

@@ -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);
}
};
``` ```
## 错误处理 ## 错误处理

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;
} }
// 分页响应类型 // 分页响应类型

View File

@@ -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();

View 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

View File

@@ -1,10 +1,17 @@
import React from 'react'; import React from 'react';
import './HintText.css'; import './HintText.css';
const HintText: React.FC = () => { type Props = { showUpload?: boolean };
const HintText: React.FC<Props> = ({ showUpload = true }) => {
const text = showUpload
? ' · 支持多行输入文本、上传图片或粘贴剪贴板图片。'
: ' · 支持输入多行文本。';
return ( return (
<div className="hint-text"> <div>
Enter Shift+Enter <div className="hint-text">Tips:</div>
<div className="hint-text">{text}</div>
<div className="hint-text"> · Enter Shift+Enter </div>
</div> </div>
); );
}; };

View 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;
}

View 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;

View File

@@ -8,8 +8,11 @@
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
align-items: center; /* 居中内部内容 */ align-items: center; /* 居中内部内容 */
width: 100%;
} }
.input-drawer-title { .input-drawer-title {
font-size: 24px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
letter-spacing: 0.5px; letter-spacing: 0.5px;

View File

@@ -9,9 +9,11 @@ type Props = {
onClose: () => void; onClose: () => void;
onResult?: (data: any) => void; onResult?: (data: any) => void;
containerEl?: HTMLElement | null; // 抽屉挂载容器(用于放在标题栏下方) containerEl?: HTMLElement | null; // 抽屉挂载容器(用于放在标题栏下方)
showUpload?: boolean; // 透传到输入面板,控制图片上传按钮
mode?: 'input' | 'search' | 'batch-image'; // 透传到输入面板,控制工作模式
}; };
const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl }) => { const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl, showUpload = true, mode = 'input' }) => {
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = !screens.md; const isMobile = !screens.md;
const [topbarHeight, setTopbarHeight] = React.useState<number>(56); const [topbarHeight, setTopbarHeight] = React.useState<number>(56);
@@ -64,8 +66,8 @@ const InputDrawer: React.FC<Props> = ({ open, onClose, onResult, containerEl })
<div className="input-drawer-inner"> <div className="input-drawer-inner">
<div className="input-drawer-title">AI FIND U</div> <div className="input-drawer-title">AI FIND U</div>
<div className="input-drawer-box"> <div className="input-drawer-box">
<InputPanel onResult={handleResult} /> <InputPanel onResult={handleResult} showUpload={showUpload} mode={mode} />
<HintText /> <HintText showUpload={showUpload} />
</div> </div>
</div> </div>
</Drawer> </Drawer>

View File

@@ -20,6 +20,20 @@
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
} }
/* 禁用态:浅灰背景与文字,明确不可编辑 */
.input-panel .ant-input[disabled],
.input-panel .ant-input-disabled,
.input-panel .ant-input-outlined.ant-input-disabled {
background: #f3f4f6; /* gray-100 */
color: #9ca3af; /* gray-400 */
border-color: #e5e7eb; /* gray-200 */
cursor: not-allowed;
-webkit-text-fill-color: #9ca3af; /* Safari 禁用态颜色 */
}
.input-panel .ant-input[disabled]::placeholder {
color: #cbd5e1; /* gray-300 */
}
.input-actions { .input-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -28,4 +42,12 @@
} }
.input-actions .ant-btn-text { color: var(--text-secondary); } .input-actions .ant-btn-text { color: var(--text-secondary); }
.input-actions .ant-btn-text:hover { color: var(--color-primary-600); } .input-actions .ant-btn-text:hover { color: var(--color-primary-600); }
/* 左侧文件标签样式,保持短名及紧凑展示 */
.selected-image-tag {
max-width: none;
overflow: visible;
white-space: nowrap;
margin-right: 0;
}

View File

@@ -1,25 +1,91 @@
import React from 'react'; import React from 'react';
import { Input, Upload, message, Button, Spin } from 'antd'; import { Input, Upload, message, Button, Spin, Tag } from 'antd';
import { PictureOutlined, SendOutlined, LoadingOutlined } from '@ant-design/icons'; import { PictureOutlined, SendOutlined, LoadingOutlined, SearchOutlined } from '@ant-design/icons';
import { postInput, postInputImage } from '../apis'; import { postInput, postInputImage, getPeoples } from '../apis';
import './InputPanel.css'; import './InputPanel.css';
const { TextArea } = Input; const { TextArea } = Input;
interface InputPanelProps { interface InputPanelProps {
onResult?: (data: any) => void; onResult?: (data: any) => void;
showUpload?: boolean; // 是否显示图片上传按钮,默认显示
mode?: 'input' | 'search' | 'batch-image'; // 输入面板工作模式,新增批量图片模式
} }
const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => { 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);
// 批量模式不保留文本内容
// 不需要扩展名重命名,展示 image-序号
const send = async () => { const send = async () => {
const hasText = value.trim().length > 0; const trimmed = value.trim();
const hasImage = fileList.length > 0; const hasText = trimmed.length > 0;
if (!hasText && !hasImage) { const hasImage = showUpload && fileList.length > 0;
message.info('请输入内容或上传图片');
// 搜索模式:仅以文本触发检索,忽略图片
if (mode === 'search') {
if (!hasText) {
message.info('请输入内容');
return;
}
setLoading(true);
try {
console.log('检索文本:', trimmed);
const response = await getPeoples({ search: trimmed, top_k: 10 });
console.log('检索响应:', response);
if (response.error_code === 0) {
message.success('已获取检索结果');
onResult?.(response.data || []);
// 清空输入
setValue('');
setFileList([]);
} else {
message.error(response.error_info || '检索失败,请重试');
}
} catch (error) {
console.error('检索调用失败:', error);
message.error('网络错误,请检查连接后重试');
} finally {
setLoading(false);
}
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; return;
} }
@@ -39,8 +105,8 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => {
response = await postInputImage(file); response = await postInputImage(file);
} else { } else {
// 只有文本时,调用文本处理 API // 只有文本时,调用文本处理 API
console.log('处理文本:', value.trim()); console.log('处理文本:', trimmed);
response = await postInput(value.trim()); response = await postInput(trimmed);
} }
console.log('API响应:', response); console.log('API响应:', response);
@@ -78,6 +144,65 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => {
} }
}; };
// 处理剪贴板粘贴图片:将图片加入上传列表,复用现有上传流程
const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (!showUpload || loading) return;
const items = e.clipboardData?.items;
if (!items || items.length === 0) return;
if (mode === 'batch-image') {
const newEntries: any[] = [];
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/')) {
const entry = {
uid: `${Date.now()}-${Math.random()}`,
name: 'image',
status: 'done',
originFileObj: file,
} as any;
newEntries.push(entry);
}
}
}
if (newEntries.length > 0) {
e.preventDefault();
setValue('');
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('已添加剪贴板图片');
}
}
};
return ( return (
<div className="input-panel"> <div className="input-panel">
<Spin <Spin
@@ -85,34 +210,110 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => {
tip="正在处理中,请稍候..." tip="正在处理中,请稍候..."
indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />}
> >
{/** 根据禁用状态动态占位符文案 */}
{(() => {
return null;
})()}
<TextArea <TextArea
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => {
placeholder="请输入个人信息描述,或上传图片…" if (mode === 'batch-image') {
setValue('');
return;
}
setValue(e.target.value);
}}
placeholder={
mode === 'batch-image'
? '批量识别不支持输入文本,可添加或粘贴多张图片...'
: showUpload && fileList.length > 0
? '不可在添加图片时输入信息...'
: (showUpload ? '请输入个人信息描述,或上传图片…' : '请输入个人信息描述…')
}
autoSize={{ minRows: 6, maxRows: 12 }} autoSize={{ minRows: 6, maxRows: 12 }}
style={{ fontSize: 14 }} style={{ fontSize: 16 }}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
disabled={loading} onPaste={onPaste}
disabled={loading || (mode !== 'batch-image' && showUpload && fileList.length > 0)}
/> />
</Spin> </Spin>
<div className="input-actions"> <div className="input-actions">
<Upload {/* 左侧文件标签显示 */}
accept="image/*" {showUpload && fileList.length > 0 && (
beforeUpload={() => false} mode === 'batch-image' ? (
fileList={fileList} <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
onChange={({ fileList }) => setFileList(fileList as any)} {fileList.map((f, idx) => (
maxCount={9} <Tag
showUploadList={{ showPreviewIcon: false }} key={f.uid || idx}
disabled={loading} className="selected-image-tag"
> color="processing"
<Button type="text" icon={<PictureOutlined />} disabled={loading} /> closable
</Upload> 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 && (
<Upload
accept="image/*"
multiple={mode === 'batch-image'}
beforeUpload={() => false}
fileList={fileList}
onChange={({ fileList: nextFileList }) => {
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) {
setFileList([]);
return;
}
// 仅添加第一张
const first = nextFileList[0] as any;
const raw = first.originFileObj || first;
const renamed = { ...first, name: 'image', originFileObj: raw };
setValue('');
setFileList([renamed]);
}
}}
onRemove={(file) => {
setFileList((prev) => prev.filter((x) => x.uid !== (file as any).uid));
return true;
}}
showUploadList={false}
disabled={loading || (mode !== 'batch-image' && fileList.length >= 1)}
>
<Button type="text" icon={<PictureOutlined />} disabled={loading || (mode !== 'batch-image' && fileList.length >= 1)} />
</Upload>
)}
<Button <Button
type="primary" type="primary"
icon={loading ? <LoadingOutlined /> : <SendOutlined />} icon={loading ? <LoadingOutlined /> : (mode === 'search' ? <SearchOutlined /> : <SendOutlined />)}
onClick={send} onClick={send}
loading={loading} loading={loading}
disabled={loading} disabled={loading}
aria-label={mode === 'search' ? '搜索' : '发送'}
/> />
</div> </div>
</div> </div>

View File

@@ -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';
@@ -14,12 +15,16 @@ const LayoutWrapper: React.FC = () => {
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false); const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
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 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:
@@ -34,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;
@@ -52,12 +60,12 @@ const LayoutWrapper: React.FC = () => {
<Layout className="layout-wrapper app-root"> <Layout className="layout-wrapper app-root">
{/* 顶部标题栏,位于左侧菜单栏之上 */} {/* 顶部标题栏,位于左侧菜单栏之上 */}
<TopBar <TopBar
onToggleMenu={() => setMobileMenuOpen((v) => !v)} onToggleMenu={() => {setInputOpen(false); setMobileMenuOpen((v) => !v);}}
onToggleInput={() => isHome && setInputOpen((v) => !v)} onToggleInput={() => {if (isHome || isList || isBatch) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}}
isHome={isHome} showInput={isHome || isList || isBatch}
/> />
{/* 下方为主布局:左侧菜单 + 右侧内容 */} {/* 下方为主布局:左侧菜单 + 右侧内容 */}
<Layout ref={layoutShellRef as any}> <Layout ref={layoutShellRef as any} className="layout-shell">
<SiderMenu <SiderMenu
onNavigate={handleNavigate} onNavigate={handleNavigate}
selectedKey={selectedKey} selectedKey={selectedKey}
@@ -76,7 +84,26 @@ const LayoutWrapper: React.FC = () => {
/> />
} }
/> />
<Route path="/resources" element={<ResourceList />} /> <Route
path="/batch-register"
element={
<BatchRegister
inputOpen={inputOpen}
onCloseInput={() => setInputOpen(false)}
containerEl={layoutShellRef.current}
/>
}
/>
<Route
path="/resources"
element={
<ResourceList
inputOpen={inputOpen}
onCloseInput={() => setInputOpen(false)}
containerEl={layoutShellRef.current}
/>
}
/>
<Route path="/menu2" element={<div style={{ padding: 32, color: '#cbd5e1' }}>2</div>} /> <Route path="/menu2" element={<div style={{ padding: 32, color: '#cbd5e1' }}>2</div>} />
</Routes> </Routes>
</Layout> </Layout>

View File

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

View File

@@ -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>
<Form.Item> {!hideSubmitButton && (
<Button type="primary" htmlType="submit" loading={loading} block> <Form.Item>
{loading ? '提交中...' : '提交'} <Button type="primary" htmlType="submit" loading={loading} block>
</Button> {loading ? '提交中...' : '提交'}
</Form.Item> </Button>
</Form.Item>
)}
</Form> </Form>
</div> </div>
); );

View File

@@ -1,18 +1,51 @@
import React from 'react'; import React from 'react';
import { Layout, Typography, Table, Grid, InputNumber, Button, Space, Tag, message } 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 } 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 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 { addOrUpdateRemark, deletePeople, deleteRemark, updatePeople } from '../apis/people';
const { Content } = Layout; const { Content } = Layout;
// 数据类型定义 - 使用 API 中的 People 类型 // 数据类型定义 - 使用 API 中的 People 类型
export type DictValue = Record<string, string>; export type DictValue = Record<string, string>;
export type Resource = People; // 资源行类型:确保 id 一定存在且为 string避免在使用处出现 "string | undefined" 类型问题
export type Resource = Omit<People, 'id'> & { id: string };
// 统一转换 API 返回的人员列表为表格需要的结构
function transformPeoples(list: People[] = []): Resource[] {
return (list || []).map((person: any) => ({
id: person.id || `person-${Date.now()}-${Math.random()}`,
name: person.name || '未知',
gender: person.gender || '其他/保密',
age: person.age || 0,
height: person.height,
marital_status: person.marital_status,
introduction: person.introduction || {},
comments: person.comments || {},
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[]> {
@@ -31,16 +64,12 @@ async function fetchResources(): Promise<Resource[]> {
} }
// 转换数据格式以匹配组件期望的结构 // 转换数据格式以匹配组件期望的结构
return response.data?.map((person: any) => ({ const transformed = transformPeoples(response.data || []);
id: person.id || `person-${Date.now()}-${Math.random()}`,
name: person.name || '未知', // 按 created_at 排序
gender: person.gender || '其他/保密', transformed.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
age: person.age || 0,
height: person.height, return transformed;
marital_status: person.marital_status,
introduction: person.introduction || {},
contact: person.contact || '',
})) || [];
} catch (error: any) { } catch (error: any) {
console.error('获取人员列表失败:', error); console.error('获取人员列表失败:', error);
@@ -465,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 });
}} }}
> >
@@ -473,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 });
}} }}
> >
@@ -497,12 +529,90 @@ function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): Colum
} as ColumnType<Resource>; } as ColumnType<Resource>;
} }
const ResourceList: React.FC = () => { // 枚举筛选下拉(用于性别等枚举类列)
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 };
const ResourceList: React.FC<Props> = ({ inputOpen = false, onCloseInput, containerEl }) => {
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = !screens.md; const isMobile = !screens.md;
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [data, setData] = React.useState<Resource[]>([]); const [data, setData] = React.useState<Resource[]>([]);
const [pagination, setPagination] = React.useState<{ current: number; pageSize: number }>({ current: 1, pageSize: 10 }); const [pagination, setPagination] = React.useState<{ current: number; pageSize: number }>({ current: 1, pageSize: 10 });
// const [inputResult, setInputResult] = React.useState<any>(null);
const [swipedRowId, setSwipedRowId] = React.useState<string | 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 });
@@ -521,32 +631,112 @@ const ResourceList: React.FC = () => {
}; };
}, []); }, []);
const reloadResources = async () => {
setLoading(true);
const list = await fetchResources();
setData(list);
setLoading(false);
};
const confirmDelete = (id: string) => {
Modal.confirm({
title: '确认删除',
content: '删除后不可恢复,是否继续?',
okText: '确认',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
try {
const res = await deletePeople(id);
if (res.error_code === 0) {
message.success('删除成功');
} else {
message.error(res.error_info || '删除失败');
}
} catch (err: any) {
message.error('删除失败');
} finally {
await reloadResources();
}
},
});
};
const columns: ColumnsType<Resource> = [ const columns: ColumnsType<Resource> = [
{ {
title: '姓名', title: '姓名',
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
render: (text: string) => <span style={{ fontWeight: 600 }}>{text}</span>, filterIcon: <SearchOutlined />,
}, filterDropdown: ({ selectedKeys, setSelectedKeys, confirm }) => {
{ return (
title: '性别', <div className="byte-table-custom-filter">
dataIndex: 'gender', <Input.Search
key: 'gender', placeholder="搜索资源..."
filters: [ value={selectedKeys[0] || ""}
{ text: '男', value: '男' }, onChange={(e) => {
{ text: '女', value: '女' }, setSelectedKeys(e.target.value ? [e.target.value] : []);
{ text: '其他/保密', value: '其他/保密' }, }}
], onSearch={() => {
onFilter: (value: React.Key | boolean, record: Resource) => String(record.gender) === String(value), confirm();
render: (g: string) => { }}
const color = g === '男' ? 'blue' : g === '女' ? 'magenta' : 'default'; />
return <Tag color={color}>{g}</Tag>; </div>
);
},
onFilter: (filterValue: React.Key | boolean, record: Resource) => String(record.name).includes(String(filterValue)),
render: (text: string, record: Resource) => {
// 图片图标逻辑
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 genderIcon = g === '男'
? <ManOutlined style={{ color: '#1677ff' }} />
: g === '女'
? <WomanOutlined style={{ color: '#eb2f96' }} />
: <ExclamationCircleOutlined style={{ color: '#9ca3af' }} />;
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontWeight: 600 }}>{text}</span>
{genderIcon}
{pictureIcon}
</span>
);
}, },
}, },
buildNumberRangeFilter('age', '年龄'),
// 非移动端显示更多列 // 非移动端显示更多列
...(!isMobile ...(!isMobile
? [ ? [
buildEnumFilter('gender', '性别', [
{ label: '男', value: '男' },
{ label: '女', value: '女' },
{ label: '其他/保密', value: '其他/保密' },
]),
buildNumberRangeFilter('age', '年龄'),
buildNumberRangeFilter('height', '身高'), buildNumberRangeFilter('height', '身高'),
{ {
title: '婚姻状况', title: '婚姻状况',
@@ -559,8 +749,154 @@ const ResourceList: React.FC = () => {
title: '联系人', title: '联系人',
dataIndex: 'contact', dataIndex: 'contact',
key: 'contact', key: 'contact',
render: (v: string) => (v ? v : '-'), filterIcon: <SearchOutlined />,
filterDropdown: ({ selectedKeys, setSelectedKeys, confirm }) => {
return (
<div className="byte-table-custom-filter">
<Input.Search
placeholder="搜索联系人..."
value={selectedKeys[0] || ""}
onChange={(e) => {
setSelectedKeys(e.target.value ? [e.target.value] : []);
}}
onSearch={() => {
confirm();
}}
/>
</div>
);
},
onFilter: (filterValue: React.Key | boolean, record: Resource) => String(record.contact).includes(String(filterValue)),
render: (v: string, record: Resource) => {
if (!isMobile) return v ? v : '-';
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ flex: 1 }}>{v ? v : '-'}</span>
{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
type="primary"
danger
size="small"
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)}
/>
</div>
)}
</div>
);
},
} as ColumnType<Resource>, } as ColumnType<Resource>,
// 非移动端显示操作列
...(!isMobile
? ([{
title: '操作',
key: 'actions',
width: 80,
render: (_: any, record: Resource) => (
<Dropdown
trigger={["click"]}
menu={{
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',
label: '删除',
icon: (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 18,
height: 18,
borderRadius: 4,
backgroundColor: '#f5222d',
color: '#fff',
}}
>
<DeleteOutlined style={{ fontSize: 12 }} />
</span>
),
},
],
onClick: ({ key }) => {
if (key === 'delete') confirmDelete(record.id);
if (key === 'edit' && !isMobile) {
setEditingRecord(record);
setEditModalVisible(true);
}
},
}}
>
<Button type="text" icon={<EllipsisOutlined />} />
</Dropdown>
),
}] as ColumnsType<Resource>)
: ([] as ColumnsType<Resource>)),
]; ];
return ( return (
@@ -570,11 +906,104 @@ const ResourceList: React.FC = () => {
</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) =>
isMobile
? {
onTouchStart: (e) => {
const t = e.touches?.[0];
if (t) touchStartRef.current = { x: t.clientX, y: t.clientY };
},
onTouchEnd: (e) => {
const s = touchStartRef.current;
const t = e.changedTouches?.[0];
touchStartRef.current = null;
if (!s || !t) return;
const dx = t.clientX - s.x;
const dy = t.clientY - s.y;
if (Math.abs(dy) > 30) return; // 垂直滑动忽略
if (dx < -24) {
setSwipedRowId(record.id);
} else if (dx > 24) {
setSwipedRowId(null);
}
},
}
: ({} as any)
}
pagination={{ pagination={{
...pagination, ...pagination,
showSizeChanger: true, showSizeChanger: true,
@@ -590,9 +1019,10 @@ const ResourceList: React.FC = () => {
return ( return (
<div style={{ padding: '8px 24px' }}> <div style={{ padding: '8px 24px' }}>
{isMobile && ( {isMobile && (
<div style={{ display: 'flex', gap: 16, marginBottom: 12, color: '#cbd5e1' }}> <div style={{ display: 'flex', gap: 16, marginBottom: 12, color: '#434343ff' }}>
{record.height !== undefined && <div>: {record.height}</div>} {record.age !== undefined && <div><span style={{ color: '#000000ff', fontWeight: 600 }}>:</span> <span style={{ color: '#2d2d2dff' }}>{record.age}</span></div>}
{record.marital_status && <div>: {record.marital_status}</div>} {record.height !== undefined && <div><span style={{ color: '#000000ff', fontWeight: 600 }}>:</span> <span style={{ color: '#2d2d2dff' }}>{record.height}</span></div>}
{record.marital_status && <div><span style={{ color: '#000000ff', fontWeight: 600 }}>:</span> <span style={{ color: '#2d2d2dff' }}>{record.marital_status}</span></div>}
</div> </div>
)} )}
{intro.length > 0 ? ( {intro.length > 0 ? (
@@ -614,20 +1044,193 @@ const ResourceList: React.FC = () => {
wordBreak: 'break-word', wordBreak: 'break-word',
}} }}
> >
<span style={{ color: '#9ca3af' }}>{k}</span> <span style={{ color: '#000000ff', fontWeight: 600 }}>{k}</span>
<span style={{ color: '#e5e7eb' }}>{String(v)}</span> <span style={{ color: '#2d2d2dff' }}>{String(v)}</span>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<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>
); );
}, },
}} }}
/> />
</div> </div>
{/* 列表页右侧输入抽屉,挂载到标题栏下方容器 */}
<InputDrawer
open={inputOpen}
onClose={onCloseInput || (() => {})}
onResult={(list: any) => {
// setInputResult(list);
const mapped = transformPeoples(Array.isArray(list) ? list : []);
setData(mapped);
// 回到第一页,保证用户看到最新结果
setPagination((pg) => ({ current: 1, pageSize: pg.pageSize }));
}}
containerEl={containerEl}
showUpload={false}
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>
); );
}; };

View File

@@ -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 { CodeOutlined, HomeOutlined, 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;
@@ -19,6 +19,18 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
const [collapsed, setCollapsed] = React.useState(false); const [collapsed, setCollapsed] = React.useState(false);
const [selectedKeys, setSelectedKeys] = React.useState<string[]>(['home']); const [selectedKeys, setSelectedKeys] = React.useState<string[]>(['home']);
const [internalMobileOpen, setInternalMobileOpen] = React.useState(false); const [internalMobileOpen, setInternalMobileOpen] = React.useState(false);
const [topbarHeight, setTopbarHeight] = React.useState<number>(56);
React.useEffect(() => {
const update = () => {
const el = document.querySelector('.topbar') as HTMLElement | null;
const h = el?.clientHeight || 56;
setTopbarHeight(h);
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
React.useEffect(() => { React.useEffect(() => {
setCollapsed(isMobile); setCollapsed(isMobile);
@@ -32,9 +44,9 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
}, [selectedKey]); }, [selectedKey]);
const items = [ const items = [
{ key: 'home', label: '注册', icon: <HomeOutlined /> }, { key: 'home', label: '录入资源', icon: <FormOutlined /> },
{ key: 'menu1', label: '列表', icon: <UnorderedListOutlined /> }, { key: 'batch', label: '批量录入', icon: <CopyOutlined /> },
// { key: 'menu2', label: '菜单2', icon: <AppstoreOutlined /> }, { key: 'menu1', label: '资源列表', icon: <UnorderedListOutlined /> },
]; ];
// 移动端:使用 Drawer 覆盖主内容 // 移动端:使用 Drawer 覆盖主内容
@@ -58,10 +70,11 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
width="100%" width="100%"
open={open} open={open}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
styles={{ body: { padding: 0 } }} rootStyle={{ top: topbarHeight, height: `calc(100% - ${topbarHeight}px)` }}
styles={{ body: { padding: 0 }, header: { display: 'none' } }}
> >
<div className="sider-header"> <div className="sider-header">
<CodeOutlined style={{ fontSize: 22 }} /> <HeartOutlined style={{ fontSize: 22 }} />
<div> <div>
<div className="sider-title"></div> <div className="sider-title"></div>
<div className="sider-desc"></div> <div className="sider-desc"></div>
@@ -96,7 +109,7 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
theme="dark" theme="dark"
> >
<div className={`sider-header ${collapsed ? 'collapsed' : ''}`}> <div className={`sider-header ${collapsed ? 'collapsed' : ''}`}>
<CodeOutlined style={{ fontSize: 22 }} /> <HeartOutlined style={{ fontSize: 22 }} />
{!collapsed && ( {!collapsed && (
<div> <div>
<div className="sider-title"></div> <div className="sider-title"></div>

View File

@@ -6,10 +6,10 @@ import './TopBar.css';
type Props = { type Props = {
onToggleMenu?: () => void; onToggleMenu?: () => void;
onToggleInput?: () => void; onToggleInput?: () => void;
isHome?: boolean; showInput?: boolean;
}; };
const TopBar: React.FC<Props> = ({ onToggleMenu, onToggleInput, isHome }) => { const TopBar: React.FC<Props> = ({ onToggleMenu, onToggleInput, showInput }) => {
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = !screens.md; const isMobile = !screens.md;
@@ -28,7 +28,7 @@ const TopBar: React.FC<Props> = ({ onToggleMenu, onToggleInput, isHome }) => {
</div> </div>
<div className="topbar-right"> <div className="topbar-right">
{isHome && ( {showInput && (
<button className="icon-btn" onClick={onToggleInput} aria-label="打开/收起输入"> <button className="icon-btn" onClick={onToggleInput} aria-label="打开/收起输入">
<RobotOutlined /> <RobotOutlined />
</button> </button>

View File

@@ -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>,

View File

@@ -6,6 +6,13 @@
/* 消除 Ant Layout 默认白底,避免顶栏下方看到白边 */ /* 消除 Ant Layout 默认白底,避免顶栏下方看到白边 */
.layout-wrapper .ant-layout { background: transparent; } .layout-wrapper .ant-layout { background: transparent; }
/* 顶栏下的主布局容器:固定视口高度,隐藏外层滚动 */
.layout-shell {
height: calc(100vh - 56px);
overflow: hidden;
}
.layout-shell .ant-layout { height: 100%; }
/* 侧栏头部区域 */ /* 侧栏头部区域 */
.sider-header { .sider-header {
display: flex; display: flex;
@@ -28,6 +35,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
overflow: auto; /* 仅主内容区滚动 */
background: var(--bg-app); background: var(--bg-app);
} }
.content-body { .content-body {