feat: basic layout and pages

- add basic layout with sidebar and main container
- add three menus in sidebar
- add people form page for post people
- add text and image input for recognize people info
- add people table page for show peoples
This commit is contained in:
2025-10-21 11:47:28 +08:00
parent 7c28eda415
commit ddb04ff15e
20 changed files with 1284 additions and 102 deletions

View File

@@ -10,8 +10,12 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"antd": "^5.27.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",

View File

@@ -1,35 +1,8 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
import React from 'react';
import LayoutWrapper from './components/LayoutWrapper';
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
return <LayoutWrapper />;
}
export default App
export default App;

View File

@@ -0,0 +1,2 @@
/* 提示信息组件样式 */
/* 文字样式在 layout.css 的 .hint-text 中定义,此处预留扩展 */

View File

@@ -0,0 +1,12 @@
import React from 'react';
import './HintText.css';
const HintText: React.FC = () => {
return (
<div className="hint-text">
Enter Shift+Enter
</div>
);
};
export default HintText;

View File

@@ -0,0 +1,19 @@
/* 输入面板组件样式 */
.input-panel {
display: flex;
flex-direction: column;
gap: 8px;
}
.input-panel .ant-input-outlined,
.input-panel .ant-input {
background: rgba(255,255,255,0.04);
color: #e5e7eb;
}
.input-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { Input, Upload, message, Button } from 'antd';
import { PictureOutlined, SendOutlined } from '@ant-design/icons';
import './InputPanel.css';
const { TextArea } = Input;
const InputPanel: React.FC = () => {
const [value, setValue] = React.useState('');
const [fileList, setFileList] = React.useState<any[]>([]);
const send = () => {
const hasText = value.trim().length > 0;
const hasImage = fileList.length > 0;
if (!hasText && !hasImage) {
message.info('请输入内容或上传图片');
return;
}
// 此处替换为真实发送逻辑
console.log('发送内容:', { text: value, files: fileList });
setValue('');
setFileList([]);
message.success('已发送');
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
if (e.shiftKey) {
// Shift+Enter 换行(保持默认行为)
return;
}
// Enter 发送
e.preventDefault();
send();
}
};
return (
<div className="input-panel">
<TextArea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="请输入个人信息描述,或点击右侧上传图片…"
autoSize={{ minRows: 3, maxRows: 6 }}
onKeyDown={onKeyDown}
/>
<div className="input-actions">
<Upload
accept="image/*"
beforeUpload={() => false}
fileList={fileList}
onChange={({ fileList }) => setFileList(fileList as any)}
maxCount={9}
showUploadList={{ showPreviewIcon: false }}
>
<Button type="text" icon={<PictureOutlined />} />
</Upload>
<Button type="primary" icon={<SendOutlined />} onClick={send} />
</div>
</div>
);
};
export default InputPanel;

View File

@@ -0,0 +1,17 @@
/* 键值对动态输入组件样式 */
.kv-list {
margin-top: 8px;
}
.kv-row {
margin-bottom: 8px;
}
.kv-remove {
width: 100%;
}
.kv-list .ant-input {
background: rgba(255,255,255,0.03);
color: #e5e7eb;
}

View File

@@ -0,0 +1,113 @@
import React, { useEffect, useState, useRef } from 'react';
import { Row, Col, Input, Button } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import './KeyValueList.css';
export type DictValue = Record<string, string>;
type KeyValuePair = { id: string; k: string; v: string };
type Props = {
value?: DictValue;
onChange?: (value: DictValue) => void;
};
const KeyValueList: React.FC<Props> = ({ value, onChange }) => {
const [rows, setRows] = useState<KeyValuePair[]>([]);
useEffect(() => {
// 初始化时提供一行空输入;之后只合并父值,不再自动新增空行
const initializedRef = (KeyValueList as any)._initializedRef || { current: false };
(KeyValueList as any)._initializedRef = initializedRef;
setRows((prev) => {
const existingIdByKey = new Map(prev.filter((r) => r.k).map((r) => [r.k, r.id]));
const valuePairs: KeyValuePair[] = value
? Object.keys(value).map((key) => ({
id: existingIdByKey.get(key) || `${key}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
k: key,
v: value[key] ?? '',
}))
: [];
const blankRows = prev.filter((r) => !r.k);
let merged = [...valuePairs, ...blankRows];
if (!initializedRef.current && merged.length === 0) {
merged = [{ id: `row-${Date.now()}-${Math.random().toString(36).slice(2)}`, k: '', v: '' }];
}
initializedRef.current = true;
return merged;
});
}, [value]);
const emitChange = (nextRows: KeyValuePair[]) => {
const dict: DictValue = {};
nextRows.forEach((r) => {
if (r.k && r.k.trim() !== '') {
dict[r.k] = r.v ?? '';
}
});
onChange?.(dict);
};
const updateRow = (id: string, field: 'k' | 'v', val: string) => {
const next = rows.map((r) => (r.id === id ? { ...r, [field]: val } : r));
setRows(next);
emitChange(next);
};
const addRow = () => {
const next = [...rows, { id: `row-${Date.now()}-${Math.random().toString(36).slice(2)}`, k: '', v: '' }];
setRows(next);
// 不触发 onChange因为字典未变化空行不入字典
};
const removeRow = (id: string) => {
const removed = rows.find((r) => r.id === id);
const next = rows.filter((r) => r.id !== id);
setRows(next);
if (removed?.k && removed.k.trim() !== '') {
emitChange(next);
}
};
return (
<div className="kv-list">
{rows.map((r) => (
<div className="kv-row" key={r.id}>
<Row gutter={[12, 12]} align="middle">
<Col xs={24} md={10}>
<Input
size="large"
placeholder="键(例如:籍贯、职业)"
value={r.k}
onChange={(e) => updateRow(r.id, 'k', e.target.value)}
/>
</Col>
<Col xs={24} md={12}>
<Input
size="large"
placeholder="值(例如:北京、产品经理)"
value={r.v}
onChange={(e) => updateRow(r.id, 'v', e.target.value)}
/>
</Col>
<Col xs={24} md={2}>
<Button
className="kv-remove"
aria-label="删除"
icon={<DeleteOutlined />}
onClick={() => removeRow(r.id)}
/>
</Col>
</Row>
</div>
))}
<Button type="dashed" block icon={<PlusOutlined />} onClick={addRow}>
</Button>
</div>
);
};
export default KeyValueList;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { Layout } from 'antd';
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import SiderMenu from './SiderMenu.tsx';
import MainContent from './MainContent.tsx';
import ResourceList from './ResourceList.tsx';
import '../styles/base.css';
import '../styles/layout.css';
const LayoutWrapper: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const pathToKey = (path: string) => {
switch (path) {
case '/resources':
return 'menu1';
case '/menu2':
return 'menu2';
default:
return 'home';
}
};
const selectedKey = pathToKey(location.pathname);
const handleNavigate = (key: string) => {
switch (key) {
case 'home':
navigate('/');
break;
case 'menu1':
navigate('/resources');
break;
case 'menu2':
navigate('/menu2');
break;
default:
navigate('/');
break;
}
};
return (
<Layout className="layout-wrapper app-root">
<SiderMenu onNavigate={handleNavigate} selectedKey={selectedKey} />
<Layout>
<Routes>
<Route path="/" element={<MainContent />} />
<Route path="/resources" element={<ResourceList />} />
<Route path="/menu2" element={<div style={{ padding: 32, color: '#cbd5e1' }}>2</div>} />
</Routes>
</Layout>
</Layout>
);
};
export default LayoutWrapper;

View File

@@ -0,0 +1,2 @@
/* 主内容区组件样式(如需) */
/* 主要样式已在 layout.css 中定义,此处预留以便后续扩展 */

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Layout, Typography } from 'antd';
import PeopleForm from './PeopleForm.tsx';
import InputPanel from './InputPanel.tsx';
import HintText from './HintText.tsx';
import './MainContent.css';
const { Content } = Layout;
const MainContent: React.FC = () => {
return (
<Content className="main-content">
<div className="content-body">
<Typography.Title level={3} style={{ color: '#e5e7eb', marginBottom: 12 }}>
?
</Typography.Title>
<Typography.Paragraph style={{ color: '#a6adbb', marginBottom: 0 }}>
</Typography.Paragraph>
<PeopleForm />
</div>
<div className="input-panel-wrapper">
<InputPanel />
<HintText />
</div>
</Content>
);
};
export default MainContent;

View File

@@ -0,0 +1,26 @@
/* 人物信息录入表单样式 */
.people-form {
margin-top: 16px;
padding: 20px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
background: rgba(255,255,255,0.04);
}
.people-form .ant-form-item-label > label {
color: #cbd5e1;
}
.people-form .ant-input,
.people-form .ant-input-affix-wrapper,
.people-form .ant-select-selector,
.people-form .ant-input-number,
.people-form .ant-input-number-input {
background: rgba(255,255,255,0.03);
color: #e5e7eb;
}
.people-form .ant-select-selection-item,
.people-form .ant-select-selection-placeholder {
color: #cbd5e1;
}

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { Form, Input, Select, InputNumber, Button, message, Row, Col } from 'antd';
import './PeopleForm.css';
import KeyValueList from './KeyValueList.tsx'
const { TextArea } = Input;
const PeopleForm: React.FC = () => {
const [form] = Form.useForm();
const onFinish = (values: any) => {
// 暂时打印内容,模拟提交
console.log('People form submit:', values);
message.success('表单已提交');
form.resetFields();
};
return (
<div className="people-form">
<Form
form={form}
layout="vertical"
size="large"
onFinish={onFinish}
>
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="如:张三" />
</Form.Item>
<Row gutter={[12, 12]}>
<Col xs={24} md={6}>
<Form.Item name="gender" label="性别" rules={[{ required: true, message: '请选择性别' }]}>
<Select
placeholder="请选择性别"
options={[
{ label: '男', value: '男' },
{ label: '女', value: '女' },
{ label: '其他/保密', value: '其他/保密' },
]}
/>
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item name="age" label="年龄" rules={[{ required: true, message: '请输入年龄' }]}>
<InputNumber min={0} max={120} style={{ width: '100%' }} placeholder="如28" />
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item name="height" label="身高(cm)">
<InputNumber
min={0}
max={250}
style={{ width: '100%' }}
placeholder="如175可留空"
/>
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item name="marital_status" label="婚姻状况">
<Input placeholder="可自定义输入,例如:未婚、已婚、离异等" />
</Form.Item>
</Col>
</Row>
<Form.Item name="introduction" label="个人介绍(键值对)">
<KeyValueList />
</Form.Item>
<Form.Item name="match_requirement" label="择偶要求">
<TextArea autoSize={{ minRows: 3, maxRows: 6 }} placeholder="例如:性格开朗、三观一致等" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form.Item>
</Form>
</div>
);
};
export default PeopleForm;

View File

@@ -0,0 +1,612 @@
import React from 'react';
import { Layout, Typography, Table, Grid, InputNumber, Button, Space, Tag } from 'antd';
import type { ColumnsType, ColumnType } from 'antd/es/table';
import type { FilterDropdownProps } from 'antd/es/table/interface';
import type { TableProps } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import './MainContent.css';
const { Content } = Layout;
// 数据类型定义
export type DictValue = Record<string, string>;
export type Resource = {
id: string;
name: string;
gender: '男' | '女' | '其他/保密' | string;
age: number;
height?: number;
marital_status?: string;
introduction?: DictValue;
};
// 模拟从后端获取资源列表(真实环境替换为实际接口)
async function fetchResources(): Promise<Resource[]> {
try {
const res = await fetch('/api/resources');
if (!res.ok) throw new Error('network');
const data = await res.json();
return data as Resource[];
} catch (e) {
// 回退到 mock 数据,便于本地开发
return [
{
id: '1',
name: '张三',
gender: '男',
age: 28,
height: 175,
marital_status: '未婚',
introduction: {
: '北京',
: '产品经理',
: '本科',
: '跑步、旅行',
: '数据分析、原型设计',
: '保持好奇心,持续迭代',
: '目标感强,善于跨团队沟通与协调。',
},
},
{
id: '2',
name: '李四',
gender: '女',
age: 26,
height: 165,
marital_status: '已婚',
introduction: {
: '上海',
: 'UI 设计师',
: '硕士',
: '知名互联网公司',
: '阅读、咖啡、插画',
: '视觉系统、组件库设计',
: '注重细节,强调一致性与可访问性。',
},
},
{
id: '3',
name: '王五',
gender: '其他/保密',
age: 31,
height: 180,
marital_status: '保密',
introduction: {
: '成都',
: '摄影师',
: '本科',
: '旅行拍摄、登山',
: '人像、风光摄影',
: '用镜头记录真实与美',
: '对光线敏感,善于捕捉转瞬即逝的情绪。',
},
},
{
id: '4',
name: '赵六',
gender: '男',
age: 22,
height: 178,
marital_status: '未婚',
introduction: {
: '西安',
: '前端开发',
: '本科',
: 'React、TypeScript、Vite',
: '阿里云开发者认证',
: '电商用户中心、数据可视化面板',
: '爱钻研,对性能优化和 DX 有热情。爱钻研,对性能优化和 DX 有热情。爱钻研,对性能优化和 DX 有热情。爱钻研,对性能优化和 DX 有热情。爱钻研,对性能优化和 DX 有热情。爱钻研,对性能优化和 DX 有热情。爱钻研,对性能优化和 DX 有热情。',
2: '电商用户中心、数据可视化面板',
3: '电商用户中心、数据可视化面板',
4: '电商用户中心、数据可视化面板',
5: '电商用户中心、数据可视化面板',
6: '电商用户中心、数据可视化面板',
},
},
{
id: '5',
name: '周杰',
gender: '男',
age: 35,
height: 182,
marital_status: '已婚',
introduction: {
: '杭州',
: '后端开发',
: '硕士',
: 'Go、gRPC、K8s',
: '平台基础设施',
: '偏工程化,注重稳定性与可观测性。',
},
},
{
id: '6',
name: '吴敏',
gender: '女',
age: 29,
height: 168,
marital_status: '未婚',
introduction: {
: '南京',
: '数据分析师',
: '本科',
: 'SQL、Python、Tableau',
: '用户增长与留存',
: '以数据驱动决策,关注可解释性。',
},
},
{
id: '7',
name: '郑辉',
gender: '男',
age: 41,
height: 170,
marital_status: '离异',
introduction: {
: '长沙',
: '产品运营',
: '本科',
: '篮球、播客',
: '活动策划、社区运营',
: '擅长整合资源并解决复杂协调问题。',
},
},
{
id: '8',
name: '王芳',
gender: '女',
age: 33,
height: 160,
marital_status: '已婚',
introduction: {
: '青岛',
: '市场经理',
: '本科',
: '品牌策略、内容营销',
: '微博、抖音、知乎',
: '善于讲故事并构建品牌资产。',
},
},
{
id: '9',
name: '刘洋',
gender: '男',
age: 24,
height: 172,
marital_status: '未婚',
introduction: {
: '合肥',
: '测试工程师',
: '本科',
: '自动化测试、性能测试',
: '对边界条件敏感,追求高覆盖与低误报。',
},
},
{
id: '10',
name: '陈晨',
gender: '女',
age: 27,
height: 163,
marital_status: '未婚',
introduction: {
: '武汉',
: '人力资源',
: '本科',
: '招聘、绩效与组织发展',
: '重视文化与组织氛围建设。',
},
},
{
id: '11',
name: '孙琪',
gender: '女',
age: 38,
height: 166,
marital_status: '已婚',
introduction: {
: '重庆',
: '项目经理',
: '硕士',
: '进度与风险管理',
: '优秀 PM 奖',
: '以结果为导向,兼顾团队士气。',
},
},
{
id: '12',
name: '朱莉',
gender: '女',
age: 30,
height: 158,
marital_status: '未婚',
introduction: {
: '厦门',
: '内容编辑',
: '本科',
: '选题、采访与写作',
: '字斟句酌,以小见大',
: '具叙事力,关注读者反馈。',
},
},
{
id: '13',
name: '黄磊',
gender: '男',
age: 45,
height: 176,
marital_status: '已婚',
introduction: {
: '广州',
: '架构师',
: '硕士',
: '分布式系统、架构治理',
: '关注可扩展性与长期维护成本。',
},
},
{
id: '14',
name: '高远',
gender: '男',
age: 32,
height: 181,
marital_status: '未婚',
introduction: {
: '沈阳',
: '算法工程师',
: '博士',
: '推荐系统、CTR 预估',
: '多模态融合',
: '注重泛化与鲁棒性。',
},
},
{
id: '15',
name: '曹宁',
gender: '男',
age: 28,
height: 169,
marital_status: '未婚',
introduction: {
: '苏州',
: '运维工程师',
: '本科',
: 'Linux、CI/CD、监控',
: '追求稳定与自动化。',
},
},
{
id: '16',
name: '韩梅',
gender: '女',
age: 34,
height: 162,
marital_status: '已婚',
introduction: {
: '大连',
: '销售总监',
: '本科',
: '大客户拓展、谈判',
: '结果导向,善于建立信任。',
},
},
{
id: '17',
name: '秦川',
gender: '男',
age: 52,
height: 174,
marital_status: '已婚',
introduction: {
: '洛阳',
: '财务主管',
: '本科',
: '成本控制、风险合规',
: '稳健务实,注重细节。',
},
},
{
id: '18',
name: '何静',
gender: '女',
age: 23,
height: 159,
marital_status: '未婚',
introduction: {
: '昆明',
: '新媒体运营',
: '本科',
: '短视频策划、社群',
: '创意活跃,执行力强。',
},
},
{
id: '19',
name: '吕博',
gender: '男',
age: 36,
height: 183,
marital_status: '离异',
introduction: {
: '天津',
: '产品专家',
: '硕士',
: '竞品分析、商业化',
: '偏战略,长期主义者。',
},
},
{
id: '20',
name: '沈玉',
gender: '女',
age: 25,
height: 161,
marital_status: '未婚',
introduction: {
: '福州',
: '交互设计师',
: '本科',
: '流程设计、可用性测试',
: '以用户为中心,迭代驱动优化。',
},
},
{
id: '21',
name: '罗兰',
gender: '其他/保密',
age: 40,
height: 177,
marital_status: '保密',
introduction: {
: '呼和浩特',
: '翻译',
: '硕士',
: '中英法德',
: '严谨细致,语感强。',
},
},
{
id: '22',
name: '尹峰',
gender: '男',
age: 29,
height: 168,
marital_status: '未婚',
introduction: {
: '石家庄',
: '安全工程师',
: '本科',
: '渗透测试、代码审计',
: '对攻击面敏感,防守与预警并重。',
},
},
{
id: '23',
name: '邓雅',
gender: '女',
age: 37,
height: 167,
marital_status: '已婚',
introduction: {
: '宁波',
: '供应链经理',
: '硕士',
: '精益管理、库存优化',
: '注重流程化与跨部门协同。',
},
},
{
id: '24',
name: '侯哲',
gender: '男',
age: 21,
height: 171,
marital_status: '未婚',
introduction: {
: '桂林',
: '实习生',
: '本科在读',
: '前端基础、原型制作',
: '好学上进,快速吸收新知识。',
},
},
];
}
}
// 数字范围筛选下拉
function buildNumberRangeFilter<T extends Resource>(dataIndex: keyof T, label: string): ColumnType<T> {
return {
title: label,
dataIndex,
sorter: (a: Resource, b: Resource) => Number((a as any)[dataIndex] ?? 0) - Number((b as any)[dataIndex] ?? 0),
filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) => {
const [min, max] = String(selectedKeys?.[0] ?? ':').split(':');
const [localMin, setLocalMin] = React.useState<number | undefined>(min ? Number(min) : undefined);
const [localMax, setLocalMax] = React.useState<number | undefined>(max ? Number(max) : undefined);
return (
<div style={{ padding: 8 }}>
<Space direction="vertical" style={{ width: 200 }}>
<InputNumber
placeholder="最小值"
value={localMin}
onChange={(v) => setLocalMin(v ?? undefined)}
style={{ width: '100%' }}
/>
<InputNumber
placeholder="最大值"
value={localMax}
onChange={(v) => setLocalMax(v ?? undefined)}
style={{ width: '100%' }}
/>
<Space>
<Button
type="primary"
size="small"
icon={<SearchOutlined />}
onClick={() => {
const key = `${localMin ?? ''}:${localMax ?? ''}`;
setSelectedKeys?.([key]);
confirm?.();
}}
>
</Button>
<Button
size="small"
onClick={() => {
setSelectedKeys?.([]);
clearFilters?.();
}}
>
</Button>
</Space>
</Space>
</div>
);
},
onFilter: (filterValue: React.Key | boolean, record: Resource) => {
const [minStr, maxStr] = String(filterValue).split(':');
const min = minStr ? Number(minStr) : undefined;
const max = maxStr ? Number(maxStr) : undefined;
const val = Number((record as any)[dataIndex] ?? NaN);
if (Number.isNaN(val)) return false;
if (min !== undefined && val < min) return false;
if (max !== undefined && val > max) return false;
return true;
},
} as ColumnType<T>;
}
const ResourceList: React.FC = () => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [loading, setLoading] = React.useState(false);
const [data, setData] = React.useState<Resource[]>([]);
const [pageSize, setPageSize] = React.useState<number>(10);
const [pagination, setPagination] = React.useState<{ current: number; pageSize: number }>({ current: 1, pageSize: 10 });
const handleTableChange: TableProps<Resource>['onChange'] = (pg) => {
setPagination({ current: pg?.current ?? 1, pageSize: pg?.pageSize ?? 10 });
};
React.useEffect(() => {
let mounted = true;
setLoading(true);
fetchResources().then((list) => {
if (!mounted) return;
setData(list);
setLoading(false);
});
return () => {
mounted = false;
};
}, []);
const columns: ColumnsType<Resource> = [
{
title: '姓名',
dataIndex: 'name',
key: 'name',
render: (text: string) => <span style={{ fontWeight: 600 }}>{text}</span>,
},
{
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', '年龄'),
// 非移动端显示更多列
...(!isMobile
? [
buildNumberRangeFilter('height', '身高'),
{
title: '婚姻状况',
dataIndex: 'marital_status',
key: 'marital_status',
} as ColumnType<Resource>,
]
: []),
];
return (
<Content className="main-content">
<div className="content-body">
<Typography.Title level={3} style={{ color: '#e5e7eb', marginBottom: 12 }}>
</Typography.Title>
<Table<Resource>
rowKey="id"
loading={loading}
columns={columns}
dataSource={data}
pagination={{
...pagination,
showSizeChanger: true,
pageSizeOptions: [10, 25, 50, 100],
position: ['bottomRight'],
total: data.length,
showTotal: (total) => `总计 ${total}`,
}}
onChange={handleTableChange}
expandable={{
expandedRowRender: (record: Resource) => {
const intro = record.introduction ? Object.entries(record.introduction) : [];
return (
<div style={{ padding: '8px 24px' }}>
{isMobile && (
<div style={{ display: 'flex', gap: 16, marginBottom: 12, color: '#cbd5e1' }}>
{record.height !== undefined && <div>: {record.height}</div>}
{record.marital_status && <div>: {record.marital_status}</div>}
</div>
)}
{intro.length > 0 ? (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: 12,
}}
>
{intro.map(([k, v]) => (
<div
key={k}
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
alignItems: 'flex-start',
wordBreak: 'break-word',
}}
>
<span style={{ color: '#9ca3af' }}>{k}</span>
<span style={{ color: '#e5e7eb' }}>{v}</span>
</div>
))}
</div>
) : (
<div style={{ color: '#9ca3af' }}></div>
)}
</div>
);
},
}}
/>
</div>
</Content>
);
};
export default ResourceList;

View File

@@ -0,0 +1,19 @@
/* 侧边菜单组件局部样式 */
.sider-header { border-bottom: 1px solid rgba(255,255,255,0.08); }
/* 收起时图标水平居中(仅 PC 端 Sider 使用) */
.sider-header.collapsed { justify-content: center; }
/* 移动端汉堡触发按钮位置 */
.mobile-menu-trigger {
position: fixed;
left: 16px;
top: 16px;
z-index: 1100;
}
/* 移动端 Drawer 背景与滚动 */
.mobile-menu-drawer .ant-drawer-body {
background: #0f172a; /* 与页面暗色一致 */
padding: 0;
}

View File

@@ -0,0 +1,112 @@
import React from 'react';
import { Layout, Menu, Grid, Drawer, Button } from 'antd';
import { CodeOutlined, HomeOutlined, UnorderedListOutlined, AppstoreOutlined, MenuOutlined } from '@ant-design/icons';
import './SiderMenu.css';
const { Sider } = Layout;
// 新增:支持外部导航回调 + 受控选中态
type Props = { onNavigate?: (key: string) => void; selectedKey?: string };
const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey }) => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [collapsed, setCollapsed] = React.useState(false);
const [selectedKeys, setSelectedKeys] = React.useState<string[]>(['home']);
const [mobileOpen, setMobileOpen] = React.useState(false);
React.useEffect(() => {
setCollapsed(isMobile);
}, [isMobile]);
// 根据外部 selectedKey 同步选中态
React.useEffect(() => {
if (selectedKey) {
setSelectedKeys([selectedKey]);
}
}, [selectedKey]);
const items = [
{ key: 'home', label: '首页', icon: <HomeOutlined /> },
{ key: 'menu1', label: '资源列表', icon: <UnorderedListOutlined /> },
{ key: 'menu2', label: '菜单2', icon: <AppstoreOutlined /> },
];
// 移动端:使用 Drawer 覆盖主内容
if (isMobile) {
return (
<>
<Button
className="mobile-menu-trigger"
type="default"
icon={<MenuOutlined />}
onClick={() => setMobileOpen((open) => !open)}
/>
<Drawer
className="mobile-menu-drawer"
placement="left"
width="100%"
open={mobileOpen}
onClose={() => setMobileOpen(false)}
styles={{ body: { padding: 0 } }}
>
<div className="sider-header">
<CodeOutlined style={{ fontSize: 22 }} />
<div>
<div className="sider-title"></div>
<div className="sider-desc"></div>
</div>
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={selectedKeys}
onClick={({ key }) => {
const k = String(key);
setSelectedKeys([k]);
setMobileOpen(false); // 选择后自动收起
onNavigate?.(k);
}}
items={items}
/>
</Drawer>
</>
);
}
// PC 端:保持 Sider 行为不变
return (
<Sider
width={260}
collapsible
collapsed={collapsed}
onCollapse={(c) => setCollapsed(c)}
breakpoint="md"
collapsedWidth={64}
theme="dark"
>
<div className={`sider-header ${collapsed ? 'collapsed' : ''}`}>
<CodeOutlined style={{ fontSize: 22 }} />
{!collapsed && (
<div>
<div className="sider-title"></div>
<div className="sider-desc"></div>
</div>
)}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={selectedKeys}
onClick={({ key }) => {
const k = String(key);
setSelectedKeys([k]);
onNavigate?.(k);
}}
items={items}
/>
</Sider>
);
};
export default SiderMenu;

View File

@@ -1,68 +1,3 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
/* 保留最小化基础样式,具体视觉由 base.css 控制 */
html, body, #root { height: 100%; }
body { margin: 0; }

View File

@@ -1,10 +1,15 @@
import '@ant-design/v5-patch-for-react-19'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { BrowserRouter } from 'react-router-dom'
import 'antd/dist/reset.css'
import './styles/base.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)

36
src/styles/base.css Normal file
View File

@@ -0,0 +1,36 @@
/* 全局基础样式 */
@supports (font-variation-settings: normal) {
:root { font-synthesis-weight: none; }
}
html, body, #root {
height: 100%;
}
body {
margin: 0;
background: radial-gradient(1200px 600px at 70% -100px, rgba(92,124,255,0.25) 0%, rgba(92,124,255,0.06) 45%, transparent 60%),
radial-gradient(800px 400px at -200px 20%, rgba(120,220,255,0.15) 0%, rgba(120,220,255,0.06) 50%, transparent 70%),
#0f172a; /* slate-900 */
color: #e5e7eb; /* gray-200 */
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 让 Ant Design 的 Layout 充满视口 */
.app-root {
min-height: 100vh;
}
/* 统一滚动条样式(轻度) */
* {
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.2) transparent;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.2);
border-radius: 6px;
}

54
src/styles/layout.css Normal file
View File

@@ -0,0 +1,54 @@
/* 布局相关样式 */
.layout-wrapper {
min-height: 100vh;
}
/* 侧栏头部区域 */
.sider-header {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 16px 12px 16px;
color: #e5e7eb;
}
.sider-title {
font-weight: 600;
font-size: 16px;
}
.sider-desc {
font-size: 12px;
color: #a3a3a3;
}
/* 主内容区 */
.main-content {
display: flex;
flex-direction: column;
height: 100%;
}
.content-body {
flex: 1;
padding: 32px;
}
/* 输入面板固定底部 */
.input-panel-wrapper {
position: sticky;
bottom: 0;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.5) 0%, rgba(15, 23, 42, 0.9) 40%, rgba(15, 23, 42, 1) 100%);
backdrop-filter: blur(4px);
border-top: 1px solid rgba(255,255,255,0.08);
padding: 16px 24px 24px 24px;
}
.hint-text {
margin-top: 10px;
font-size: 12px;
color: #9ca3af;
}
/* 小屏优化:输入区域内边距更紧凑 */
@media (max-width: 768px) {
.content-body { padding: 16px; }
.input-panel-wrapper { padding: 12px 12px 16px 12px; }
}