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:
@@ -10,8 +10,12 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^19.1.1",
|
||||||
"react-dom": "^19.1.1"
|
"react-dom": "^19.1.1",
|
||||||
|
"react-router-dom": "^6.30.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
|
|||||||
35
src/App.tsx
35
src/App.tsx
@@ -1,35 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import React from 'react';
|
||||||
import reactLogo from './assets/react.svg'
|
import LayoutWrapper from './components/LayoutWrapper';
|
||||||
import viteLogo from '/vite.svg'
|
|
||||||
import './App.css'
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [count, setCount] = useState(0)
|
return <LayoutWrapper />;
|
||||||
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|||||||
2
src/components/HintText.css
Normal file
2
src/components/HintText.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/* 提示信息组件样式 */
|
||||||
|
/* 文字样式在 layout.css 的 .hint-text 中定义,此处预留扩展 */
|
||||||
12
src/components/HintText.tsx
Normal file
12
src/components/HintText.tsx
Normal 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;
|
||||||
19
src/components/InputPanel.css
Normal file
19
src/components/InputPanel.css
Normal 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;
|
||||||
|
}
|
||||||
64
src/components/InputPanel.tsx
Normal file
64
src/components/InputPanel.tsx
Normal 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;
|
||||||
17
src/components/KeyValueList.css
Normal file
17
src/components/KeyValueList.css
Normal 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;
|
||||||
|
}
|
||||||
113
src/components/KeyValueList.tsx
Normal file
113
src/components/KeyValueList.tsx
Normal 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;
|
||||||
58
src/components/LayoutWrapper.tsx
Normal file
58
src/components/LayoutWrapper.tsx
Normal 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;
|
||||||
2
src/components/MainContent.css
Normal file
2
src/components/MainContent.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/* 主内容区组件样式(如需) */
|
||||||
|
/* 主要样式已在 layout.css 中定义,此处预留以便后续扩展 */
|
||||||
32
src/components/MainContent.tsx
Normal file
32
src/components/MainContent.tsx
Normal 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;
|
||||||
26
src/components/PeopleForm.css
Normal file
26
src/components/PeopleForm.css
Normal 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;
|
||||||
|
}
|
||||||
87
src/components/PeopleForm.tsx
Normal file
87
src/components/PeopleForm.tsx
Normal 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;
|
||||||
612
src/components/ResourceList.tsx
Normal file
612
src/components/ResourceList.tsx
Normal 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;
|
||||||
19
src/components/SiderMenu.css
Normal file
19
src/components/SiderMenu.css
Normal 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;
|
||||||
|
}
|
||||||
112
src/components/SiderMenu.tsx
Normal file
112
src/components/SiderMenu.tsx
Normal 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;
|
||||||
@@ -1,68 +1,3 @@
|
|||||||
:root {
|
/* 保留最小化基础样式,具体视觉由 base.css 控制 */
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
html, body, #root { height: 100%; }
|
||||||
line-height: 1.5;
|
body { margin: 0; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import '@ant-design/v5-patch-for-react-19'
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
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'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
36
src/styles/base.css
Normal file
36
src/styles/base.css
Normal 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
54
src/styles/layout.css
Normal 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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user