Compare commits
10 Commits
a05bd23766
...
c3800541d4
| Author | SHA1 | Date | |
|---|---|---|---|
| c3800541d4 | |||
| 9c83ba38b7 | |||
| d9f4f643b9 | |||
| 9269d77ef7 | |||
| a8170f45be | |||
| 38676ec2d0 | |||
| 01855ba13d | |||
| 3de5d296a0 | |||
| 2cce67d350 | |||
| 5038249c1a |
@@ -1,2 +1,2 @@
|
|||||||
# 生产环境配置
|
# 生产环境配置
|
||||||
VITE_API_BASE_URL=http://47.109.95.59:20080
|
VITE_API_BASE_URL=https://if.u.mamamiyear.site:20443
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import './HintText.css';
|
import './HintText.css';
|
||||||
|
|
||||||
const HintText: React.FC = () => {
|
type Props = { showUpload?: boolean };
|
||||||
return (
|
|
||||||
<div className="hint-text">
|
const HintText: React.FC<Props> = ({ showUpload = true }) => {
|
||||||
提示:支持输入多行文本与上传图片。按 Enter 发送,Shift+Enter 换行。
|
const text = showUpload
|
||||||
</div>
|
? '提示:支持输入多行文本与上传图片。按 Enter 发送,Shift+Enter 换行。'
|
||||||
);
|
: '提示:支持输入多行文本。按 Enter 发送,Shift+Enter 换行。';
|
||||||
|
return <div className="hint-text">{text}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HintText;
|
export default HintText;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'; // 透传到输入面板,控制工作模式
|
||||||
};
|
};
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -1,25 +1,54 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Input, Upload, message, Button, Spin } from 'antd';
|
import { Input, Upload, message, Button, Spin } 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'; // 输入面板工作模式,默认为表单填写(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +68,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);
|
||||||
@@ -88,7 +117,7 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => {
|
|||||||
<TextArea
|
<TextArea
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
placeholder="请输入个人信息描述,或上传图片…"
|
placeholder={showUpload ? '请输入个人信息描述,或上传图片…' : '请输入个人信息描述…'}
|
||||||
autoSize={{ minRows: 6, maxRows: 12 }}
|
autoSize={{ minRows: 6, maxRows: 12 }}
|
||||||
style={{ fontSize: 14 }}
|
style={{ fontSize: 14 }}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
@@ -96,23 +125,26 @@ const InputPanel: React.FC<InputPanelProps> = ({ onResult }) => {
|
|||||||
/>
|
/>
|
||||||
</Spin>
|
</Spin>
|
||||||
<div className="input-actions">
|
<div className="input-actions">
|
||||||
<Upload
|
{showUpload && (
|
||||||
accept="image/*"
|
<Upload
|
||||||
beforeUpload={() => false}
|
accept="image/*"
|
||||||
fileList={fileList}
|
beforeUpload={() => false}
|
||||||
onChange={({ fileList }) => setFileList(fileList as any)}
|
fileList={fileList}
|
||||||
maxCount={9}
|
onChange={({ fileList }) => setFileList(fileList as any)}
|
||||||
showUploadList={{ showPreviewIcon: false }}
|
maxCount={9}
|
||||||
disabled={loading}
|
showUploadList={{ showPreviewIcon: false }}
|
||||||
>
|
disabled={loading}
|
||||||
<Button type="text" icon={<PictureOutlined />} disabled={loading} />
|
>
|
||||||
</Upload>
|
<Button type="text" icon={<PictureOutlined />} disabled={loading} />
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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 layoutShellRef = React.useRef<HTMLDivElement>(null);
|
const layoutShellRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const pathToKey = (path: string) => {
|
const pathToKey = (path: string) => {
|
||||||
@@ -52,12 +53,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) {setMobileMenuOpen(false); setInputOpen((v) => !v);}}}
|
||||||
isHome={isHome}
|
showInput={isHome || isList}
|
||||||
/>
|
/>
|
||||||
{/* 下方为主布局:左侧菜单 + 右侧内容 */}
|
{/* 下方为主布局:左侧菜单 + 右侧内容 */}
|
||||||
<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 +77,16 @@ const LayoutWrapper: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/resources" element={<ResourceList />} />
|
<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>
|
||||||
|
|||||||
@@ -1,18 +1,35 @@
|
|||||||
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 } 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 } from '@ant-design/icons';
|
||||||
import './MainContent.css';
|
import './MainContent.css';
|
||||||
|
import InputDrawer from './InputDrawer.tsx';
|
||||||
import { getPeoples } from '../apis';
|
import { getPeoples } from '../apis';
|
||||||
import type { People } from '../apis';
|
import type { People } from '../apis';
|
||||||
|
import { deletePeople } 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 || {},
|
||||||
|
contact: person.contact || '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// 获取人员列表数据
|
// 获取人员列表数据
|
||||||
async function fetchResources(): Promise<Resource[]> {
|
async function fetchResources(): Promise<Resource[]> {
|
||||||
@@ -31,16 +48,7 @@ async function fetchResources(): Promise<Resource[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 转换数据格式以匹配组件期望的结构
|
// 转换数据格式以匹配组件期望的结构
|
||||||
return response.data?.map((person: any) => ({
|
return transformPeoples(response.data || []);
|
||||||
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 || {},
|
|
||||||
contact: person.contact || '',
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('获取人员列表失败:', error);
|
console.error('获取人员列表失败:', error);
|
||||||
@@ -497,12 +505,17 @@ function buildNumberRangeFilter(dataIndex: keyof Resource, label: string): Colum
|
|||||||
} as ColumnType<Resource>;
|
} as ColumnType<Resource>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ResourceList: React.FC = () => {
|
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 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 +534,95 @@ 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) => {
|
||||||
|
if (!isMobile) return <span style={{ fontWeight: 600 }}>{text}</span>;
|
||||||
|
const g = record.gender;
|
||||||
|
const icon = 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>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
buildNumberRangeFilter('age', '年龄'),
|
|
||||||
// 非移动端显示更多列
|
// 非移动端显示更多列
|
||||||
...(!isMobile
|
...(!isMobile
|
||||||
? [
|
? [
|
||||||
|
{
|
||||||
|
title: '性别',
|
||||||
|
dataIndex: 'gender',
|
||||||
|
key: 'gender',
|
||||||
|
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('height', '身高'),
|
buildNumberRangeFilter('height', '身高'),
|
||||||
{
|
{
|
||||||
title: '婚姻状况',
|
title: '婚姻状况',
|
||||||
@@ -559,8 +635,84 @@ 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 && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => confirmDelete(record.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
} as ColumnType<Resource>,
|
} as ColumnType<Resource>,
|
||||||
|
// 非移动端显示操作列
|
||||||
|
...(!isMobile
|
||||||
|
? ([{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 80,
|
||||||
|
render: (_: any, record: Resource) => (
|
||||||
|
<Dropdown
|
||||||
|
trigger={["click"]}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="text" icon={<EllipsisOutlined />} />
|
||||||
|
</Dropdown>
|
||||||
|
),
|
||||||
|
}] as ColumnsType<Resource>)
|
||||||
|
: ([] as ColumnsType<Resource>)),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -575,6 +727,30 @@ const ResourceList: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
|
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 +766,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,8 +791,8 @@ 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>
|
||||||
@@ -628,6 +805,21 @@ const ResourceList: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</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'}
|
||||||
|
/>
|
||||||
</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 { CodeOutlined, HomeOutlined, UnorderedListOutlined, MenuOutlined } from '@ant-design/icons';
|
import { HeartOutlined, FormOutlined, UnorderedListOutlined, MenuOutlined } 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,8 @@ 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: 'menu1', label: '列表', icon: <UnorderedListOutlined /> },
|
||||||
// { key: 'menu2', label: '菜单2', icon: <AppstoreOutlined /> },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 移动端:使用 Drawer 覆盖主内容
|
// 移动端:使用 Drawer 覆盖主内容
|
||||||
@@ -58,10 +69,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 +108,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user