生产管理系统 - 企业管理和用户管理 列表重构
This commit is contained in:
19
AGENTS.md
Normal file
19
AGENTS.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
The primary Vite application lives in `src/`, with feature folders such as `components/ai`, `components/auth`, and `components/dashboard` grouping screens, while shared primitives stay in `components/ui` alongside the `cn` helper. Domain utilities and integrations sit in `lib/`, typed contracts in `types/`, and global styles in `styles/`. Reference notes, migration guides, and UX briefs belong in `docs/`. Build artifacts are staged in `build/` and `bundles/` - regenerate them instead of editing in place. The `crop-x/` workspace hosts the API-driven Next.js toolchain, and `nextjs-frontend/` contains a Jest-enabled prototype; treat each as an isolated package with its own dependencies.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
Install dependencies with `npm install` at the repository root before any work. Launch the Vite dev server via `npm run dev` and build production bundles with `npm run build`. When touching the Next.js workspace, switch into `crop-x/` (or `nextjs-frontend/`) and run the same `npm run dev` / `npm run build` loop; lint with `npm run lint` and regenerate OpenAPI clients through `npm run generate-client`. The Next.js prototype keeps Jest wired - execute `npm test` or `npm run coverage` from `nextjs-frontend/` to validate UI contracts.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
Author components as TypeScript function components, export them with PascalCase names, and keep props camelCase. Align layout and spacing with Tailwind classes; reach for `components/ui` primitives before adding bespoke markup. Follow the established single-quote, semicolon-terminated formatting visible in `src/App.tsx`. In the Next.js packages, run Prettier (`npm run prettier`) when formatting cross-file changes and keep ESLint green before opening a review.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
Automated coverage is concentrated in `nextjs-frontend/__tests__` using Jest and Testing Library; mirror existing file naming (`*.test.tsx`) when adding scenarios. For the Vite app, smoke the key dashboards after significant UI changes: authentication, machinery, fields, operations, assets, AI models, and irrigation. Document manual steps or screenshots in the PR when you touch flows without automated tests.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
Commit messages currently follow the "<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ϵͳ - <summary>" prefix; keep that Chinese tag and supply a short, imperative summary. Group related changes per commit so reviewers can bisect easily. Pull requests should describe the user-visible impact, list any config or schema updates, and attach before/after captures for UI adjustments. Link tracking tickets where possible and flag required environment variables (`VITE_API_BASE_URL`, etc.) if they change.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
Never commit secrets - use `.env.local` entries for API hosts and credentials, and document new keys in `docs/` instead. When adding third-party scripts, load them through vetted helpers like `lib/mapLoader` to keep CSP compliance intact. Review generated OpenAPI clients before shipping to ensure endpoints line up with the deployed backend.
|
||||||
@@ -234,6 +234,56 @@ import {
|
|||||||
getTenantAuditLogsApiV1TenantsAuditLogsGet,
|
getTenantAuditLogsApiV1TenantsAuditLogsGet,
|
||||||
} from "@/lib/api/sdk.gen"; 这个引入和用法。
|
} from "@/lib/api/sdk.gen"; 这个引入和用法。
|
||||||
|
|
||||||
|
### 11.Next.js 文件命名规范原则
|
||||||
|
|
||||||
|
**规范要求:**
|
||||||
|
所有文件名必须严格遵循 Next.js 的文件命名规范,确保路由系统和页面组件能够正确识别。
|
||||||
|
|
||||||
|
**规范标准:**
|
||||||
|
|
||||||
|
#### 页面文件命名规范
|
||||||
|
- **页面文件**:必须使用 `page.tsx` 作为页面文件名
|
||||||
|
- **布局文件**:必须使用 `layout.tsx` 作为布局文件名
|
||||||
|
- **加载状态**:必须使用 `loading.tsx` 作为加载状态文件名
|
||||||
|
- **错误处理**:必须使用 `error.tsx` 作为错误处理文件名
|
||||||
|
- **未找到页面**:必须使用 `not-found.tsx` 作为404页面文件名
|
||||||
|
|
||||||
|
#### 路由参数文件命名规范
|
||||||
|
- **动态路由**:使用 `[param].tsx` 格式,如 `[id].tsx`
|
||||||
|
- **可选参数**:使用 `[[param]].tsx` 格式
|
||||||
|
- **全部匹配**:使用 `[...param].tsx` 格式
|
||||||
|
- **可选全部匹配**:使用 `[[...param]].tsx` 格式
|
||||||
|
|
||||||
|
#### 组件和工具文件命名规范
|
||||||
|
- **React 组件**:使用 PascalCase 命名,如 `UserProfile.tsx`
|
||||||
|
- **工具函数**:使用 camelCase 命名,如 `dateUtils.ts`
|
||||||
|
- **类型定义**:使用 camelCase 命名,如 `userTypes.ts`
|
||||||
|
- **常量文件**:使用 UPPER_SNAKE_CASE 命名,如 `API_CONSTANTS.ts`
|
||||||
|
|
||||||
|
**示例目录结构:**
|
||||||
|
```
|
||||||
|
src/app/(app)/land-information/
|
||||||
|
├── layout.tsx # 布局文件
|
||||||
|
├── page.tsx # 主页面
|
||||||
|
├── loading.tsx # 加载状态
|
||||||
|
├── error.tsx # 错误处理
|
||||||
|
└── archive/
|
||||||
|
├── page.tsx # 归档页面
|
||||||
|
├── statistics/
|
||||||
|
│ ├── page.tsx # 统计页面
|
||||||
|
│ └── components/
|
||||||
|
│ ├── FilterPanel.tsx # 组件:PascalCase
|
||||||
|
│ └── statisticsReducer.tsx # 工具文件:camelCase
|
||||||
|
└── [id]/
|
||||||
|
└── page.tsx # 动态路由页面
|
||||||
|
```
|
||||||
|
|
||||||
|
**实施要点:**
|
||||||
|
- 严格遵循文件命名规范,不得随意修改文件名
|
||||||
|
- 页面文件必须使用 `page.tsx`,不能使用其他名称
|
||||||
|
- 动态路由参数必须使用方括号 `[param]` 格式
|
||||||
|
- 组件和工具文件遵循通用的 JavaScript/TypeScript 命名规范
|
||||||
|
|
||||||
## path:land-information/archive/statistics,name:统计分析页面开发经验与问题解决
|
## path:land-information/archive/statistics,name:统计分析页面开发经验与问题解决
|
||||||
|
|
||||||
### 问题1:图表横轴显示不完整
|
### 问题1:图表横轴显示不完整
|
||||||
@@ -820,3 +870,608 @@ engine.addMarker(marker);
|
|||||||
- **接口标准化**:统一的接口设计,便于功能模块替换
|
- **接口标准化**:统一的接口设计,便于功能模块替换
|
||||||
- **配置化开发**:支持通过配置文件调整功能和行为
|
- **配置化开发**:支持通过配置文件调整功能和行为
|
||||||
- 理解了复杂应用中的组件分层和职责划分
|
- 理解了复杂应用中的组件分层和职责划分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## path:src/components/common/searchFormPagination,name:搜索、表格、分页三合一组件使用心得
|
||||||
|
|
||||||
|
### 组件概述
|
||||||
|
|
||||||
|
SearchFormPagination 是一个高度可配置的复合组件,集成了搜索表单、数据表格和分页功能。该组件采用了现代React开发模式,通过配置驱动的方式实现复杂数据展示页面的快速开发。
|
||||||
|
|
||||||
|
### 架构设计
|
||||||
|
|
||||||
|
#### 1. 组件层次结构
|
||||||
|
|
||||||
|
```
|
||||||
|
SearchFormPagination (主组件)
|
||||||
|
├── SearchFormComponent (搜索表单)
|
||||||
|
│ ├── Input (文本搜索框)
|
||||||
|
│ └── Select (下拉选择框)
|
||||||
|
├── Card (表格容器)
|
||||||
|
│ ├── Table (数据表格)
|
||||||
|
│ │ ├── TableHeader (表头)
|
||||||
|
│ │ └── TableBody (表体)
|
||||||
|
│ └── PaginationComponent (分页组件)
|
||||||
|
└── LoadingOverlay (加载遮罩)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 核心文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/common/searchFormPagination/
|
||||||
|
├── index.ts # 主组件导出
|
||||||
|
├── page.tsx # SearchFormPagination主组件
|
||||||
|
├── components/
|
||||||
|
│ ├── SearchFormComponent.tsx # 搜索表单组件
|
||||||
|
│ ├── PaginationComponent.tsx # 分页组件
|
||||||
|
│ └── searchFormPaginationReducer.tsx # 状态管理(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 核心功能特性
|
||||||
|
|
||||||
|
#### 1. 搜索表单功能
|
||||||
|
|
||||||
|
**防抖搜索机制**:
|
||||||
|
```tsx
|
||||||
|
// 关键实现:300ms防抖,避免频繁API调用
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
onFiltersChangeRef.current(localFilters);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [localFilters]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**多字段配置支持**:
|
||||||
|
```tsx
|
||||||
|
const searchFields: SearchFieldConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'search',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '搜索企业名称、编码...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audit_status',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'all',
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '全部状态' },
|
||||||
|
{ value: 'draft', label: '草稿' },
|
||||||
|
// ...更多选项
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 表格展示功能
|
||||||
|
|
||||||
|
**动态列配置**:
|
||||||
|
```tsx
|
||||||
|
const columns: TableColumnConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: '企业名称',
|
||||||
|
sortable: true, // 支持排序
|
||||||
|
render: (value, row) => (
|
||||||
|
<div className="font-medium">{value}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: '状态',
|
||||||
|
render: (value) => getStatusBadge(value), // 自定义渲染
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**加载状态处理**:
|
||||||
|
```tsx
|
||||||
|
// 表格加载遮罩 - 提升用户体验
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 bg-white/50 dark:bg-black/50 backdrop-blur-sm z-10">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin" />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 分页功能
|
||||||
|
|
||||||
|
**完整分页配置**:
|
||||||
|
```tsx
|
||||||
|
interface PaginationConfig {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
hasPrev: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
<PaginationComponent
|
||||||
|
pagination={pagination}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onSizeChange={handleSizeChange}
|
||||||
|
sizeOptions={[10, 30, 50, 100]} // 可配置每页条数
|
||||||
|
showSizeSelector={true}
|
||||||
|
showPageInfo={true}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**智能分页逻辑**:
|
||||||
|
- 当只有一页数据时,分页按钮隐藏但每页条数选择器仍显示
|
||||||
|
- 支持页码跳转和快速导航
|
||||||
|
- 分页操作时保持当前搜索条件
|
||||||
|
|
||||||
|
### 使用示例
|
||||||
|
|
||||||
|
#### 完整调用示例
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { SearchFormPagination } from '@/components/common/searchFormPagination';
|
||||||
|
|
||||||
|
export default function EnterpriseManagement() {
|
||||||
|
const [enterprises, setEnterprises] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
page: 1,
|
||||||
|
size: 10,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
hasNext: false,
|
||||||
|
hasPrev: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 搜索字段配置
|
||||||
|
const searchFields = [
|
||||||
|
{
|
||||||
|
key: 'search',
|
||||||
|
label: '搜索',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '搜索企业名称、编码...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audit_status',
|
||||||
|
label: '审核状态',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'all',
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '全部状态' },
|
||||||
|
{ value: 'draft', label: '草稿' },
|
||||||
|
{ value: 'pending', label: '待审核' },
|
||||||
|
{ value: 'approved', label: '审核通过' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 表格列配置
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: '企业名称',
|
||||||
|
sortable: true,
|
||||||
|
render: (value) => <div className="font-medium">{value}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'auditStatus',
|
||||||
|
label: '审核状态',
|
||||||
|
render: (value) => getAuditStatusBadge(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: '操作',
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={() => handleView(row)}>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleEdit(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 数据加载函数
|
||||||
|
const loadEnterprises = useCallback(async (params) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetchTenants(params);
|
||||||
|
setEnterprises(response.data);
|
||||||
|
setPagination({
|
||||||
|
page: response.page,
|
||||||
|
size: response.size,
|
||||||
|
total: response.total,
|
||||||
|
totalPages: response.total_pages,
|
||||||
|
hasNext: response.has_next,
|
||||||
|
hasPrev: response.has_prev,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load enterprises:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 搜索处理
|
||||||
|
const handleSearch = useCallback((filters) => {
|
||||||
|
loadEnterprises({
|
||||||
|
filters,
|
||||||
|
pagination: { page: 1, size: pagination.size },
|
||||||
|
});
|
||||||
|
}, [loadEnterprises, pagination.size]);
|
||||||
|
|
||||||
|
// 分页处理
|
||||||
|
const handlePageChange = useCallback((page) => {
|
||||||
|
setPagination(prev => ({ ...prev, page }));
|
||||||
|
loadEnterprises({
|
||||||
|
pagination: { page, size: pagination.size },
|
||||||
|
filters: searchFilters,
|
||||||
|
});
|
||||||
|
}, [loadEnterprises, pagination.size]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SearchFormPagination
|
||||||
|
formTitle="企业列表"
|
||||||
|
formRightContent={<Button onClick={handleCreate}>新建企业</Button>}
|
||||||
|
searchFields={searchFields}
|
||||||
|
columns={columns}
|
||||||
|
data={enterprises}
|
||||||
|
loading={loading}
|
||||||
|
error={null}
|
||||||
|
pagination={pagination}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onSizeChange={handleSizeChange}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
emptyIcon={<Building2 className="w-12 h-12" />}
|
||||||
|
emptyText="暂无企业数据"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 接口定义
|
||||||
|
|
||||||
|
#### SearchFieldConfig - 搜索字段配置
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface SearchFieldConfig {
|
||||||
|
key: string; // 字段标识
|
||||||
|
label: string; // 显示标签
|
||||||
|
type: 'text' | 'select'; // 字段类型
|
||||||
|
placeholder?: string; // 占位符文本
|
||||||
|
options?: Array<{ value: string; label: string }>; // 下拉选项
|
||||||
|
defaultValue?: string; // 默认值
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TableColumnConfig - 表格列配置
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface TableColumnConfig {
|
||||||
|
key: string; // 数据字段名
|
||||||
|
label: string; // 表头显示文本
|
||||||
|
sortable?: boolean; // 是否支持排序
|
||||||
|
width?: string; // 列宽设置
|
||||||
|
render?: (value: any, row: any, index: number) => React.ReactNode; // 自定义渲染
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SearchFormPaginationProps - 主组件属性
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface SearchFormPaginationProps<T = any> {
|
||||||
|
// 搜索配置
|
||||||
|
searchFields: SearchFieldConfig[];
|
||||||
|
onSearch?: (filters: Record<string, string>) => void;
|
||||||
|
|
||||||
|
// 表格配置
|
||||||
|
columns: TableColumnConfig[];
|
||||||
|
data?: T[];
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
|
||||||
|
// 分页配置
|
||||||
|
pagination?: PaginationConfig;
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
onSizeChange?: (size: number) => void;
|
||||||
|
onSort?: (sortBy: string, sortOrder: 'asc' | 'desc') => void;
|
||||||
|
|
||||||
|
// UI配置
|
||||||
|
formTitle?: string;
|
||||||
|
formRightContent?: React.ReactNode;
|
||||||
|
emptyIcon?: React.ReactNode;
|
||||||
|
emptyText?: string;
|
||||||
|
showSizeSelector?: boolean;
|
||||||
|
showPageInfo?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 最佳实践
|
||||||
|
|
||||||
|
#### 1. 性能优化
|
||||||
|
|
||||||
|
**使用 useCallback 优化函数引用**:
|
||||||
|
```tsx
|
||||||
|
// ✅ 正确做法:使用 useCallback 避免重复渲染
|
||||||
|
const handleSearch = useCallback((filters) => {
|
||||||
|
loadEnterprises({ filters });
|
||||||
|
}, [loadEnterprises]);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback((page) => {
|
||||||
|
loadEnterprises({ page, size: pagination.size });
|
||||||
|
}, [loadEnterprises, pagination.size]);
|
||||||
|
|
||||||
|
// ❌ 错误做法:每次渲染都创建新函数
|
||||||
|
const handleSearch = (filters) => {
|
||||||
|
loadEnterprises({ filters });
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**统一数据重载函数**:
|
||||||
|
```tsx
|
||||||
|
// ✅ 推荐:统一的数据重载逻辑,避免代码重复
|
||||||
|
const reloadData = useCallback(() => {
|
||||||
|
const reloadParams = {
|
||||||
|
filters: searchFilters,
|
||||||
|
pagination: {
|
||||||
|
page: pagination.page,
|
||||||
|
size: pagination.size
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadEnterprises(reloadParams);
|
||||||
|
}, [loadEnterprises, searchFilters, pagination]);
|
||||||
|
|
||||||
|
// 在多个地方使用
|
||||||
|
const handleCreateSuccess = () => reloadData();
|
||||||
|
const confirmStatusChange = async () => {
|
||||||
|
await enableTenant(tenantId);
|
||||||
|
reloadData();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 状态管理
|
||||||
|
|
||||||
|
**合理的状态依赖**:
|
||||||
|
```tsx
|
||||||
|
// ✅ 正确:包含所有必要的依赖
|
||||||
|
const handlePageChange = useCallback((page: number) => {
|
||||||
|
loadEnterprises({
|
||||||
|
filters: searchFilters, // 确保搜索条件不会丢失
|
||||||
|
pagination: { page, size: pagination.size }
|
||||||
|
});
|
||||||
|
}, [loadEnterprises, searchFilters, pagination.size]);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 错误处理
|
||||||
|
|
||||||
|
**完善的错误状态处理**:
|
||||||
|
```tsx
|
||||||
|
<SearchFormPagination
|
||||||
|
// ...
|
||||||
|
error={error}
|
||||||
|
// 组件会自动显示错误状态
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 扩展性设计
|
||||||
|
|
||||||
|
**新增字段的简单步骤**:
|
||||||
|
1. 在 `searchFields` 数组中添加新配置
|
||||||
|
2. 确保后端API支持新的查询参数
|
||||||
|
3. 无需修改任何组件逻辑
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 添加新的下拉框只需一行配置
|
||||||
|
{
|
||||||
|
key: 'enterprise_status',
|
||||||
|
label: '企业状态',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'all',
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '全部状态' },
|
||||||
|
{ value: 'active', label: '启用' },
|
||||||
|
{ value: 'inactive', label: '禁用' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 技术特点
|
||||||
|
|
||||||
|
#### 1. 类型安全
|
||||||
|
- 完整的 TypeScript 类型定义
|
||||||
|
- 泛型支持,确保数据类型一致性
|
||||||
|
- 严格的接口约束
|
||||||
|
|
||||||
|
#### 2. 用户体验优化
|
||||||
|
- 防抖搜索,避免频繁请求
|
||||||
|
- 加载状态遮罩,提供视觉反馈
|
||||||
|
- 分页状态保持,避免搜索条件丢失
|
||||||
|
- 响应式设计,适配不同屏幕尺寸
|
||||||
|
|
||||||
|
#### 3. 可维护性
|
||||||
|
- 配置驱动,减少硬编码
|
||||||
|
- 组件化设计,职责单一
|
||||||
|
- 完善的错误处理机制
|
||||||
|
|
||||||
|
#### 4. 可扩展性
|
||||||
|
- 插件化的字段配置
|
||||||
|
- 自定义渲染函数支持
|
||||||
|
- 多种配置选项
|
||||||
|
|
||||||
|
### 常见问题解决
|
||||||
|
|
||||||
|
#### 1. 分页后搜索条件丢失
|
||||||
|
|
||||||
|
**问题**:切换页码或每页条数时,搜索条件被重置
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```tsx
|
||||||
|
const handlePageChange = useCallback((page) => {
|
||||||
|
// 确保传递当前的搜索条件
|
||||||
|
loadEnterprises({
|
||||||
|
filters: searchFilters, // 关键:传递搜索条件
|
||||||
|
pagination: { page, size: pagination.size }
|
||||||
|
});
|
||||||
|
}, [loadEnterprises, searchFilters, pagination.size]);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 频繁API调用问题
|
||||||
|
|
||||||
|
**问题**:用户快速输入时触发过多API请求
|
||||||
|
|
||||||
|
**解决方案**:组件内置300ms防抖机制,无需额外处理
|
||||||
|
|
||||||
|
#### 3. 加载状态处理
|
||||||
|
|
||||||
|
**问题**:数据加载时用户体验不佳
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```tsx
|
||||||
|
<SearchFormPagination
|
||||||
|
loading={loading} // 自动显示加载遮罩和状态
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 性能优化最佳实践
|
||||||
|
|
||||||
|
#### 1. 事件驱动模式
|
||||||
|
|
||||||
|
**原则**:避免使用setTimeout,尽可能减少useEffect,使用事件驱动来实现状态更新。
|
||||||
|
|
||||||
|
**最佳实践**:
|
||||||
|
```tsx
|
||||||
|
// ❌ 避免写法:使用setTimeout和过多useEffect
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
loadData();
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > 1) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
// ✅ 推荐写法:事件驱动,直接调用
|
||||||
|
const handleSearch = useCallback((filters) => {
|
||||||
|
setSearchFilters(filters);
|
||||||
|
loadData({ filters, pagination: { page: 1, size: pagination.size } });
|
||||||
|
}, [loadData, pagination.size]);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback((page) => {
|
||||||
|
setPagination(prev => ({ ...prev, page }));
|
||||||
|
loadData({ filters: searchFilters, pagination: { page, size: pagination.size } });
|
||||||
|
}, [loadData, searchFilters, pagination.size]);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 函数依赖优化
|
||||||
|
|
||||||
|
**原则**:减少useCallback和useMemo的依赖项,通过参数传递而非依赖外部状态。
|
||||||
|
|
||||||
|
**最佳实践**:
|
||||||
|
```tsx
|
||||||
|
// ❌ 避免写法:过多依赖项导致函数频繁重新创建
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
// 依赖filters, pagination, sortBy等
|
||||||
|
}, [filters, pagination, sortBy]);
|
||||||
|
|
||||||
|
// ✅ 推荐写法:无依赖项,通过参数传递
|
||||||
|
const loadData = useCallback(async (params) => {
|
||||||
|
// 使用params.filters, params.pagination等
|
||||||
|
}, []); // 空依赖数组
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 搜索防抖优化
|
||||||
|
|
||||||
|
**原则**:下拉框选择立即触发,文本输入使用防抖,避免不必要的延迟。
|
||||||
|
|
||||||
|
**实现方式**:
|
||||||
|
```tsx
|
||||||
|
// SearchFormComponent中的优化实现
|
||||||
|
const handleInputChange = (key: string, value: string, fieldType: 'text' | 'select') => {
|
||||||
|
const newFilters = { ...localFilters, [key]: value };
|
||||||
|
setLocalFilters(newFilters);
|
||||||
|
|
||||||
|
// 下拉框选择立即触发查询,文本输入使用防抖
|
||||||
|
if (fieldType === 'select') {
|
||||||
|
onFiltersChangeRef.current(newFilters); // 立即执行
|
||||||
|
}
|
||||||
|
// 文本输入的防抖在useEffect中处理
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 组件设计原则
|
||||||
|
|
||||||
|
#### 1. 排序功能简化
|
||||||
|
|
||||||
|
**设计决策**:SearchFormPagination组件不再支持表头排序功能。
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- 简化组件复杂度,提高性能
|
||||||
|
- 减少不必要的交互,专注核心功能(搜索、展示、分页)
|
||||||
|
- 避免排序逻辑与业务逻辑耦合
|
||||||
|
|
||||||
|
**替代方案**:
|
||||||
|
- 如需排序功能,在业务页面层面实现
|
||||||
|
- 通过下拉框或其他UI控件提供排序选项
|
||||||
|
|
||||||
|
#### 2. 接口简化
|
||||||
|
|
||||||
|
**删除的排序相关接口**:
|
||||||
|
```tsx
|
||||||
|
// ❌ 已删除的接口
|
||||||
|
interface TableColumnConfig {
|
||||||
|
sortable?: boolean; // 删除
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchFormPaginationProps {
|
||||||
|
sortBy?: string; // 删除
|
||||||
|
sortOrder?: 'asc' | 'desc'; // 删除
|
||||||
|
onSort?: (sortBy: string, sortOrder: 'asc' | 'desc') => void; // 删除
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 表头渲染简化
|
||||||
|
|
||||||
|
**删除的排序交互**:
|
||||||
|
- 删除了表头的点击事件处理
|
||||||
|
- 删除了排序箭头图标显示
|
||||||
|
- 删除了鼠标悬停样式效果
|
||||||
|
|
||||||
|
### 重构指南
|
||||||
|
|
||||||
|
如果要重构或基于此组件开发新功能,请遵循以下原则:
|
||||||
|
|
||||||
|
1. **保持接口兼容性**:不要破坏现有的props接口
|
||||||
|
2. **扩展而非修改**:通过新的配置项而非修改现有逻辑来添加功能
|
||||||
|
3. **类型安全**:确保所有新功能都有完整的TypeScript类型定义
|
||||||
|
4. **测试覆盖**:新功能应该有相应的测试用例
|
||||||
|
5. **文档更新**:及时更新使用文档和接口说明
|
||||||
|
6. **性能优先**:采用事件驱动模式,避免不必要的useEffect和setTimeout
|
||||||
|
7. **功能专注**:保持组件职责单一,避免功能过度复杂化
|
||||||
|
|
||||||
|
### 总结
|
||||||
|
|
||||||
|
SearchFormPagination 组件通过配置驱动的方式,极大地简化了复杂数据展示页面的开发工作。其核心优势在于:
|
||||||
|
|
||||||
|
- **高度可配置**:通过配置而非代码实现功能定制
|
||||||
|
- **性能优化**:事件驱动模式,无setTimeout依赖,最小化useEffect使用
|
||||||
|
- **用户体验**:下拉框立即响应,文本输入智能防抖
|
||||||
|
- **易于扩展**:新增功能只需要修改配置,无需修改组件逻辑
|
||||||
|
- **类型安全**:完整的 TypeScript 支持
|
||||||
|
- **功能专注**:专注搜索、展示、分页核心功能,避免过度设计
|
||||||
|
|
||||||
|
该组件可以作为项目中所有数据展示页面的标准解决方案,显著提升开发效率和代码质量。
|
||||||
@@ -2,20 +2,20 @@
|
|||||||
* filekorolheader: 企业审核页面 - 企业注册审核管理页面
|
* filekorolheader: 企业审核页面 - 企业注册审核管理页面
|
||||||
* 功能:企业审核列表、搜索筛选、审核操作、详情查看
|
* 功能:企业审核列表、搜索筛选、审核操作、详情查看
|
||||||
* 路径:/central-config/tenant/enterprise-audit
|
* 路径:/central-config/tenant/enterprise-audit
|
||||||
* 规范:遵循crop-x/docs/开发项目规范.md,使用useReducer状态管理,API集成,模块化组件
|
* 规范:遵循crop-x/docs/开发项目规范.md,使用useReducer状态管理,API集成,模块化组件,SearchFormPagination重构
|
||||||
*/
|
*/
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useReducer, useEffect, useMemo, useRef } from 'react';
|
import { useReducer, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Building2, RefreshCw } from 'lucide-react';
|
import { Building2, RefreshCw, Eye, Check, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
import { SearchFormPagination, type SearchFieldConfig, type TableColumnConfig } from '@/components/common/searchFormPagination';
|
||||||
import { fetchTenantsForAudit, auditTenant, transformTenantData, TenantsQueryParams, Enterprise } from './components/enterpriseAuditApi';
|
import { fetchTenantsForAudit, auditTenant, transformTenantData, TenantsQueryParams, Enterprise } from './components/enterpriseAuditApi';
|
||||||
import { AuditStatsCards } from './components/AuditStatsCards';
|
import { AuditStatsCards } from './components/AuditStatsCards';
|
||||||
import { AuditSearchAndFilter } from './components/AuditSearchAndFilter';
|
|
||||||
import { EnterpriseAuditTable } from './components/EnterpriseAuditTable';
|
|
||||||
import { EnterpriseDetailDialog } from './components/EnterpriseDetailDialog';
|
import { EnterpriseDetailDialog } from './components/EnterpriseDetailDialog';
|
||||||
import { AuditPagination } from './components/AuditPagination';
|
|
||||||
|
|
||||||
// 审核状态管理
|
// 审核状态管理
|
||||||
interface AuditState {
|
interface AuditState {
|
||||||
@@ -119,21 +119,133 @@ export default function EnterpriseAuditPage() {
|
|||||||
const [state, dispatch] = useReducer(auditReducer, initialState);
|
const [state, dispatch] = useReducer(auditReducer, initialState);
|
||||||
const isFirstLoad = useRef(true);
|
const isFirstLoad = useRef(true);
|
||||||
|
|
||||||
// 加载企业数据
|
// 搜索字段配置
|
||||||
const loadEnterprises = async (resetPage = false) => {
|
const searchFields: SearchFieldConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'search',
|
||||||
|
label: '搜索',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '搜索企业名称、编码...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audit_status',
|
||||||
|
label: '审核状态',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'all',
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '全部状态' },
|
||||||
|
{ value: '待审核', label: '待审核' },
|
||||||
|
{ value: '已通过', label: '已通过' },
|
||||||
|
{ value: '已驳回', label: '已驳回' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 表格列配置
|
||||||
|
const columns: TableColumnConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: '企业名称',
|
||||||
|
sortable: false, // 禁用排序
|
||||||
|
render: (value: string) => (
|
||||||
|
<div className="font-medium text-foreground">{value}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'code',
|
||||||
|
label: '企业编码',
|
||||||
|
sortable: false, // 禁用排序
|
||||||
|
render: (value: string) => (
|
||||||
|
<div className="font-mono text-sm text-muted-foreground">{value}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'auditStatus',
|
||||||
|
label: '审核状态',
|
||||||
|
sortable: false, // 禁用排序
|
||||||
|
render: (value: string) => {
|
||||||
|
const statusConfig = {
|
||||||
|
'待审核': { label: '待审核', variant: 'default' as const, className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
|
||||||
|
'已通过': { label: '已通过', variant: 'default' as const, className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
||||||
|
'已驳回': { label: '已驳回', variant: 'default' as const, className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[value as keyof typeof statusConfig] || statusConfig['待审核'];
|
||||||
|
return (
|
||||||
|
<Badge className={`font-light ${config.className}`}>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contactPerson',
|
||||||
|
label: '联系人',
|
||||||
|
sortable: false, // 禁用排序
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contactPhone',
|
||||||
|
label: '联系电话',
|
||||||
|
sortable: false, // 禁用排序
|
||||||
|
render: (value: string) => (
|
||||||
|
<div className="font-mono text-sm">{value || '-'}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
label: '创建时间',
|
||||||
|
sortable: false, // 禁用排序
|
||||||
|
render: (value: string) => (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{value ? new Date(value).toLocaleDateString('zh-CN') : '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: '操作',
|
||||||
|
sortable: false, // 操作列不能排序
|
||||||
|
render: (_: any, row: Enterprise) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleViewDetail(row)}
|
||||||
|
className="h-8 px-2"
|
||||||
|
>
|
||||||
|
<Eye className="w-3 h-3 mr-1" />
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 加载企业数据 - 移除依赖项,通过参数传递状态
|
||||||
|
const loadEnterprises = useCallback(async (params?: {
|
||||||
|
filters?: Record<string, string>;
|
||||||
|
pagination?: { page: number; size: number };
|
||||||
|
sort?: { sortBy?: string; sortOrder: 'asc' | 'desc' };
|
||||||
|
resetPage?: boolean;
|
||||||
|
}) => {
|
||||||
try {
|
try {
|
||||||
dispatch({ type: 'SET_LOADING', payload: true });
|
dispatch({ type: 'SET_LOADING', payload: true });
|
||||||
|
|
||||||
const params: TenantsQueryParams = {
|
const finalParams: TenantsQueryParams = {
|
||||||
search: state.filters.search || undefined,
|
search: (params?.filters?.search ?? state.filters.search) || undefined,
|
||||||
audit_status: state.filters.audit_status === 'all' ? undefined : state.filters.audit_status,
|
audit_status: params?.filters?.audit_status ?? state.filters.audit_status,
|
||||||
page: resetPage ? 1 : state.pagination.page,
|
page: params?.resetPage ? 1 : (params?.pagination?.page || state.pagination.page),
|
||||||
size: state.pagination.size,
|
size: params?.pagination?.size || state.pagination.size,
|
||||||
order_by: state.sortBy,
|
order_by: params?.sort?.sortBy,
|
||||||
sort_order: state.sortOrder,
|
sort_order: params?.sort?.sortOrder,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetchTenantsForAudit(params);
|
// 处理audit_status,如果为'all'则不传该参数
|
||||||
|
if (finalParams.audit_status === 'all') {
|
||||||
|
finalParams.audit_status = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchTenantsForAudit(finalParams);
|
||||||
const transformedData = response.data.map(transformTenantData);
|
const transformedData = response.data.map(transformTenantData);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -156,56 +268,48 @@ export default function EnterpriseAuditPage() {
|
|||||||
dispatch({ type: 'SET_ERROR', payload: errorMessage });
|
dispatch({ type: 'SET_ERROR', payload: errorMessage });
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
}, []); // 移除所有依赖,使用参数传递状态变化
|
||||||
|
|
||||||
// 首次加载数据
|
// 首次加载数据 - 使用事件驱动,避免useEffect
|
||||||
useEffect(() => {
|
const initializeData = useCallback(() => {
|
||||||
if (isFirstLoad.current) {
|
if (isFirstLoad.current) {
|
||||||
isFirstLoad.current = false;
|
isFirstLoad.current = false;
|
||||||
loadEnterprises(true);
|
loadEnterprises({ resetPage: true });
|
||||||
}
|
}
|
||||||
}, []);
|
}, [loadEnterprises]);
|
||||||
|
|
||||||
// 监听筛选和排序变化(排除首次加载)
|
// 页面加载时初始化数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFirstLoad.current) {
|
initializeData();
|
||||||
const timer = setTimeout(() => {
|
}, []); // 只在组件挂载时执行一次
|
||||||
loadEnterprises(true);
|
|
||||||
}, 300);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [state.filters.search, state.filters.audit_status, state.sortBy, state.sortOrder]);
|
|
||||||
|
|
||||||
// 分页加载
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isFirstLoad.current && state.pagination.page > 1) {
|
|
||||||
loadEnterprises(false);
|
|
||||||
}
|
|
||||||
}, [state.pagination.page]);
|
|
||||||
|
|
||||||
// 计算统计数据
|
// 计算统计数据
|
||||||
const stats = useMemo(() => ({
|
const stats = useMemo(() => ({
|
||||||
total: state.pagination.total,
|
total: state.pagination.total,
|
||||||
pending: state.enterprises.filter(e => e.auditStatus === 'pending').length,
|
pending: state.enterprises.filter(e => e.auditStatus === '待审核').length,
|
||||||
approved: state.enterprises.filter(e => e.auditStatus === 'approved').length,
|
approved: state.enterprises.filter(e => e.auditStatus === '已通过').length,
|
||||||
rejected: state.enterprises.filter(e => e.auditStatus === 'rejected').length,
|
rejected: state.enterprises.filter(e => e.auditStatus === '已驳回').length,
|
||||||
}), [state.enterprises, state.pagination.total]);
|
}), [state.enterprises, state.pagination.total]);
|
||||||
|
|
||||||
// 事件处理器
|
// 事件处理器
|
||||||
const handleSearch = (value: string) => {
|
const handleSearch = useCallback((filters: Record<string, string>) => {
|
||||||
dispatch({ type: 'SET_FILTERS', payload: { search: value } });
|
dispatch({ type: 'SET_FILTERS', payload: filters });
|
||||||
};
|
loadEnterprises({
|
||||||
|
filters,
|
||||||
|
pagination: { page: 1, size: state.pagination.size }
|
||||||
|
});
|
||||||
|
}, [loadEnterprises, state.pagination.size]);
|
||||||
|
|
||||||
const handleAuditStatusFilter = (value: string) => {
|
const handleSort = useCallback((sortBy: string, sortOrder: 'asc' | 'desc') => {
|
||||||
dispatch({ type: 'SET_FILTERS', payload: { audit_status: value === 'all' ? 'all' : value } });
|
dispatch({ type: 'SET_SORT', payload: { sortBy, sortOrder } });
|
||||||
};
|
loadEnterprises({
|
||||||
|
filters: state.filters,
|
||||||
|
sort: { sortBy, sortOrder },
|
||||||
|
resetPage: true
|
||||||
|
});
|
||||||
|
}, [loadEnterprises, state.filters]);
|
||||||
|
|
||||||
const handleSort = (sortBy?: string) => {
|
const handlePageChange = useCallback((page: number) => {
|
||||||
const newSortOrder = state.sortBy === sortBy && state.sortOrder === 'desc' ? 'asc' : 'desc';
|
|
||||||
dispatch({ type: 'SET_SORT', payload: { sortBy, sortOrder: newSortOrder } });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
// 边界检查,确保页码在有效范围内
|
// 边界检查,确保页码在有效范围内
|
||||||
if (page < 1) {
|
if (page < 1) {
|
||||||
page = 1;
|
page = 1;
|
||||||
@@ -213,13 +317,25 @@ export default function EnterpriseAuditPage() {
|
|||||||
page = state.pagination.totalPages;
|
page = state.pagination.totalPages;
|
||||||
}
|
}
|
||||||
dispatch({ type: 'SET_PAGINATION', payload: { page } });
|
dispatch({ type: 'SET_PAGINATION', payload: { page } });
|
||||||
};
|
loadEnterprises({
|
||||||
|
filters: state.filters,
|
||||||
|
pagination: { page, size: state.pagination.size }
|
||||||
|
});
|
||||||
|
}, [loadEnterprises, state.filters, state.pagination.size, state.pagination.totalPages]);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleSizeChange = useCallback((size: number) => {
|
||||||
|
dispatch({ type: 'SET_PAGINATION', payload: { size, page: 1 } });
|
||||||
|
loadEnterprises({
|
||||||
|
filters: state.filters,
|
||||||
|
pagination: { page: 1, size }
|
||||||
|
});
|
||||||
|
}, [loadEnterprises, state.filters]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
dispatch({ type: 'REFRESH_DATA' });
|
dispatch({ type: 'REFRESH_DATA' });
|
||||||
loadEnterprises(true);
|
loadEnterprises({ resetPage: true });
|
||||||
toast.success('数据已刷新');
|
toast.success('数据已刷新');
|
||||||
};
|
}, [loadEnterprises]);
|
||||||
|
|
||||||
const handleViewDetail = (enterprise: Enterprise) => {
|
const handleViewDetail = (enterprise: Enterprise) => {
|
||||||
dispatch({ type: 'SET_SELECTED_ENTERPRISE', payload: enterprise });
|
dispatch({ type: 'SET_SELECTED_ENTERPRISE', payload: enterprise });
|
||||||
@@ -257,10 +373,8 @@ export default function EnterpriseAuditPage() {
|
|||||||
dispatch({ type: 'TOGGLE_DETAIL_DIALOG', payload: false });
|
dispatch({ type: 'TOGGLE_DETAIL_DIALOG', payload: false });
|
||||||
toast.success('审核通过');
|
toast.success('审核通过');
|
||||||
|
|
||||||
// 1秒后刷新列表
|
// 立即刷新列表,无需延迟
|
||||||
setTimeout(() => {
|
loadEnterprises({ resetPage: true });
|
||||||
loadEnterprises(true);
|
|
||||||
}, 1000);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Approve failed:', error);
|
console.error('Approve failed:', error);
|
||||||
const errorMessage = error instanceof Error ? error.message : '审核通过失败';
|
const errorMessage = error instanceof Error ? error.message : '审核通过失败';
|
||||||
@@ -300,10 +414,8 @@ export default function EnterpriseAuditPage() {
|
|||||||
dispatch({ type: 'TOGGLE_DETAIL_DIALOG', payload: false });
|
dispatch({ type: 'TOGGLE_DETAIL_DIALOG', payload: false });
|
||||||
toast.success('已驳回');
|
toast.success('已驳回');
|
||||||
|
|
||||||
// 1秒后刷新列表
|
// 立即刷新列表,无需延迟
|
||||||
setTimeout(() => {
|
loadEnterprises({ resetPage: true });
|
||||||
loadEnterprises(true);
|
|
||||||
}, 1000);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Reject failed:', error);
|
console.error('Reject failed:', error);
|
||||||
const errorMessage = error instanceof Error ? error.message : '审核驳回失败';
|
const errorMessage = error instanceof Error ? error.message : '审核驳回失败';
|
||||||
@@ -321,45 +433,47 @@ export default function EnterpriseAuditPage() {
|
|||||||
<p className="text-muted-foreground">管理企业注册与变更审核流程</p>
|
<p className="text-muted-foreground">管理企业注册与变更审核流程</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* 统计卡片 - 保留原有功能 */}
|
||||||
<AuditStatsCards
|
<AuditStatsCards
|
||||||
enterprises={state.enterprises}
|
enterprises={state.enterprises}
|
||||||
loading={state.loading}
|
loading={state.loading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 搜索和筛选 */}
|
{/* 搜索、表格和分页 - 使用重构后的组件 */}
|
||||||
<AuditSearchAndFilter
|
<SearchFormPagination
|
||||||
searchKeyword={state.filters.search}
|
formTitle="企业列表"
|
||||||
onSearchChange={(value) => dispatch({ type: 'SET_FILTERS', payload: { search: value } })}
|
formRightContent={
|
||||||
statusFilter={state.filters.audit_status}
|
<Button variant="outline" onClick={handleRefresh} disabled={state.loading}>
|
||||||
onStatusFilterChange={(value) => dispatch({ type: 'SET_FILTERS', payload: { audit_status: value } })}
|
<RefreshCw className={`w-4 h-4 mr-2 ${state.loading ? 'animate-spin' : ''}`} />
|
||||||
onRefresh={handleRefresh}
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
searchFields={searchFields}
|
||||||
|
columns={columns}
|
||||||
|
data={state.enterprises}
|
||||||
loading={state.loading}
|
loading={state.loading}
|
||||||
|
error={state.error}
|
||||||
|
pagination={state.pagination}
|
||||||
|
sortBy={state.sortBy}
|
||||||
|
sortOrder={state.sortOrder}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onSizeChange={handleSizeChange}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onSort={handleSort}
|
||||||
|
emptyIcon={<Building2 className="w-12 h-12" />}
|
||||||
|
emptyText="暂无企业审核数据"
|
||||||
|
showSizeSelector={true}
|
||||||
|
showPageInfo={true}
|
||||||
|
sizeOptions={[10, 20, 50, 100]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 企业列表 */}
|
{/* 企业详情对话框 - 保留原有功能 */}
|
||||||
<EnterpriseAuditTable
|
|
||||||
enterprises={state.enterprises}
|
|
||||||
loading={state.loading}
|
|
||||||
onViewDetails={handleViewDetail}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 分页 */}
|
|
||||||
{state.pagination.total > 0 && (
|
|
||||||
<AuditPagination
|
|
||||||
pagination={state.pagination}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
loading={state.loading}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 企业详情对话框 */}
|
|
||||||
<EnterpriseDetailDialog
|
<EnterpriseDetailDialog
|
||||||
open={state.showDetailDialog}
|
open={state.showDetailDialog}
|
||||||
onOpenChange={(open) => dispatch({ type: 'TOGGLE_DETAIL_DIALOG', payload: open })}
|
onOpenChange={(open) => dispatch({ type: 'TOGGLE_DETAIL_DIALOG', payload: open })}
|
||||||
enterprise={state.selectedEnterprise}
|
enterprise={state.selectedEnterprise}
|
||||||
auditReason={state.auditReason}
|
auditReason={state.auditReason}
|
||||||
onAuditReasonChange={(reason) => dispatch({ type: 'SET_AUDIT_REASON', payload: reason })}
|
onAuditReasonChange={handleAuditReasonChange}
|
||||||
onApprove={handleApprove}
|
onApprove={handleApprove}
|
||||||
onReject={handleReject}
|
onReject={handleReject}
|
||||||
loading={state.actionLoading}
|
loading={state.actionLoading}
|
||||||
|
|||||||
@@ -304,19 +304,33 @@ export default function EnterpriseManagement() {
|
|||||||
});
|
});
|
||||||
}, [loadEnterprises, pagination.size]);
|
}, [loadEnterprises, pagination.size]);
|
||||||
|
|
||||||
|
// 统一的数据重载函数 - 避免重复代码
|
||||||
|
const reloadData = useCallback(() => {
|
||||||
|
const reloadParams = {
|
||||||
|
filters: searchFilters,
|
||||||
|
pagination: {
|
||||||
|
page: pagination.page,
|
||||||
|
size: pagination.size
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadEnterprises(reloadParams);
|
||||||
|
}, [loadEnterprises, searchFilters, pagination]);
|
||||||
|
|
||||||
const handlePageChange = useCallback((page: number) => {
|
const handlePageChange = useCallback((page: number) => {
|
||||||
setPagination(prev => ({ ...prev, page }));
|
setPagination(prev => ({ ...prev, page }));
|
||||||
loadEnterprises({
|
loadEnterprises({
|
||||||
|
filters: searchFilters,
|
||||||
pagination: { page, size: pagination.size }
|
pagination: { page, size: pagination.size }
|
||||||
});
|
});
|
||||||
}, [loadEnterprises, pagination.size]);
|
}, [loadEnterprises, searchFilters, pagination.size]);
|
||||||
|
|
||||||
const handleSizeChange = useCallback((size: number) => {
|
const handleSizeChange = useCallback((size: number) => {
|
||||||
setPagination(prev => ({ ...prev, size, page: 1 }));
|
setPagination(prev => ({ ...prev, size, page: 1 }));
|
||||||
loadEnterprises({
|
loadEnterprises({
|
||||||
|
filters: searchFilters,
|
||||||
pagination: { page: 1, size }
|
pagination: { page: 1, size }
|
||||||
});
|
});
|
||||||
}, [loadEnterprises]);
|
}, [loadEnterprises, searchFilters]);
|
||||||
|
|
||||||
// 初始化数据加载
|
// 初始化数据加载
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
@@ -366,15 +380,7 @@ export default function EnterpriseManagement() {
|
|||||||
dispatch({ type: 'TOGGLE_STATUS_DIALOG', payload: false });
|
dispatch({ type: 'TOGGLE_STATUS_DIALOG', payload: false });
|
||||||
|
|
||||||
// 重新加载数据来反映状态变化
|
// 重新加载数据来反映状态变化
|
||||||
const reloadParams: any = {
|
reloadData();
|
||||||
filters: searchFilters,
|
|
||||||
pagination: {
|
|
||||||
page: pagination.page,
|
|
||||||
size: pagination.size
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadEnterprises(reloadParams);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Status change failed:', error);
|
console.error('Status change failed:', error);
|
||||||
const errorMessage = error instanceof Error ? error.message : '状态更新失败';
|
const errorMessage = error instanceof Error ? error.message : '状态更新失败';
|
||||||
@@ -391,13 +397,7 @@ export default function EnterpriseManagement() {
|
|||||||
|
|
||||||
const handleCreateSuccess = () => {
|
const handleCreateSuccess = () => {
|
||||||
// 创建成功后重新加载数据,保持当前搜索条件和分页状态
|
// 创建成功后重新加载数据,保持当前搜索条件和分页状态
|
||||||
loadEnterprises({
|
reloadData();
|
||||||
filters: searchFilters,
|
|
||||||
pagination: {
|
|
||||||
page: pagination.page,
|
|
||||||
size: pagination.size
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 操作按钮配置
|
// 操作按钮配置
|
||||||
|
|||||||
@@ -2,25 +2,27 @@
|
|||||||
* filekorolheader: 用户管理页面 - 用户查询和管理页面
|
* filekorolheader: 用户管理页面 - 用户查询和管理页面
|
||||||
* 功能:用户列表查询、搜索筛选、详情查看、用户管理
|
* 功能:用户列表查询、搜索筛选、详情查看、用户管理
|
||||||
* 路径:/central-config/tenant/user-management
|
* 路径:/central-config/tenant/user-management
|
||||||
* 规范:遵循crop-x/docs/开发项目规范.md,使用useReducer状态管理,API集成,shadcn语义化样式
|
* 规范:遵循crop-x/docs/开发项目规范.md,使用SearchFormPagination公共组件,shadcn语义化样式
|
||||||
*/
|
*/
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useReducer, useEffect } from 'react';
|
import { useReducer, useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Eye, Edit, Lock, UserX, UserCheck } from 'lucide-react';
|
||||||
|
import { UserDetailDialog } from './components/UserDetailDialog';
|
||||||
|
import { SearchFormPagination, SearchFieldConfig, TableColumnConfig } from '@/components/common/searchFormPagination';
|
||||||
|
|
||||||
import { fetchUsers, transformUserData, UsersQueryParams, User, UsersApiResponse, PaginationState } from './components/userManagementApi';
|
import { fetchUsers, transformUserData, UsersQueryParams, User, UsersApiResponse, PaginationState } from './components/userManagementApi';
|
||||||
import { UserManagementHeader } from './components/UserManagementHeader';
|
import { UserManagementHeader } from './components/UserManagementHeader';
|
||||||
import { UserManagementStatsCards } from './components/UserManagementStatsCards';
|
import { UserManagementStatsCards } from './components/UserManagementStatsCards';
|
||||||
import { UserManagementFilters } from './components/UserManagementFilters';
|
import { UserFilters } from './types';
|
||||||
import { UserList } from './components/UserList';
|
|
||||||
import { UserDetailDialog } from './components/UserDetailDialog';
|
// 移除了Enterprise的引用,因为新实现中不再需要
|
||||||
import { Enterprise, UserFilters } from './types';
|
|
||||||
|
|
||||||
// 用户管理状态管理
|
// 用户管理状态管理
|
||||||
interface UserManagementState {
|
interface UserManagementState {
|
||||||
users: User[];
|
users: User[];
|
||||||
enterprises: Enterprise[];
|
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
pagination: PaginationState;
|
pagination: PaginationState;
|
||||||
@@ -38,7 +40,6 @@ type UserManagementAction =
|
|||||||
| { type: 'SET_FILTERS'; payload: Partial<UserFilters> }
|
| { type: 'SET_FILTERS'; payload: Partial<UserFilters> }
|
||||||
| { type: 'SET_SORT'; payload: { sortBy?: string; sortOrder: 'asc' | 'desc' } }
|
| { type: 'SET_SORT'; payload: { sortBy?: string; sortOrder: 'asc' | 'desc' } }
|
||||||
| { type: 'SET_PAGINATION'; payload: Partial<PaginationState> }
|
| { type: 'SET_PAGINATION'; payload: Partial<PaginationState> }
|
||||||
| { type: 'SET_ENTERPRISES'; payload: Enterprise[] }
|
|
||||||
| { type: 'SET_SELECTED_USER'; payload: User | null }
|
| { type: 'SET_SELECTED_USER'; payload: User | null }
|
||||||
| { type: 'TOGGLE_DETAIL_DIALOG'; payload: boolean }
|
| { type: 'TOGGLE_DETAIL_DIALOG'; payload: boolean }
|
||||||
| { type: 'REFRESH_DATA' };
|
| { type: 'REFRESH_DATA' };
|
||||||
@@ -63,8 +64,6 @@ const userManagementReducer = (state: UserManagementState, action: UserManagemen
|
|||||||
return { ...state, sortBy: action.payload.sortBy, sortOrder: action.payload.sortOrder };
|
return { ...state, sortBy: action.payload.sortBy, sortOrder: action.payload.sortOrder };
|
||||||
case 'SET_PAGINATION':
|
case 'SET_PAGINATION':
|
||||||
return { ...state, pagination: { ...state.pagination, ...action.payload } };
|
return { ...state, pagination: { ...state.pagination, ...action.payload } };
|
||||||
case 'SET_ENTERPRISES':
|
|
||||||
return { ...state, enterprises: action.payload };
|
|
||||||
case 'SET_SELECTED_USER':
|
case 'SET_SELECTED_USER':
|
||||||
return { ...state, selectedUser: action.payload };
|
return { ...state, selectedUser: action.payload };
|
||||||
case 'TOGGLE_DETAIL_DIALOG':
|
case 'TOGGLE_DETAIL_DIALOG':
|
||||||
@@ -78,7 +77,6 @@ const userManagementReducer = (state: UserManagementState, action: UserManagemen
|
|||||||
|
|
||||||
const initialState: UserManagementState = {
|
const initialState: UserManagementState = {
|
||||||
users: [],
|
users: [],
|
||||||
enterprises: [],
|
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
pagination: {
|
pagination: {
|
||||||
@@ -102,11 +100,178 @@ const initialState: UserManagementState = {
|
|||||||
|
|
||||||
export default function TenantUserManagementPage() {
|
export default function TenantUserManagementPage() {
|
||||||
const [state, dispatch] = useReducer(userManagementReducer, initialState);
|
const [state, dispatch] = useReducer(userManagementReducer, initialState);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 搜索字段配置
|
||||||
|
const searchFields: SearchFieldConfig[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'search',
|
||||||
|
label: '搜索',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '搜索用户名、姓名、邮箱...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: '用户状态',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'all',
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '全部状态' },
|
||||||
|
{ value: 'active', label: '活跃' },
|
||||||
|
{ value: 'inactive', label: '未激活' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
label: '用户类型',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'all',
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '全部类型' },
|
||||||
|
{ value: 'admin', label: '管理员' },
|
||||||
|
{ value: 'user', label: '普通用户' },
|
||||||
|
{ value: 'staff', label: '员工' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
// 表格列配置
|
||||||
|
const columns: TableColumnConfig[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'username',
|
||||||
|
label: '用户名',
|
||||||
|
sortable: true,
|
||||||
|
render: (value: string, user: User) => (
|
||||||
|
<div className="font-medium">{value}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fullName',
|
||||||
|
label: '姓名',
|
||||||
|
sortable: true,
|
||||||
|
render: (value: string) => value || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'email',
|
||||||
|
label: '邮箱',
|
||||||
|
sortable: true,
|
||||||
|
render: (value: string) => value || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isActive',
|
||||||
|
label: '状态',
|
||||||
|
sortable: true,
|
||||||
|
render: (value: boolean) => (
|
||||||
|
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
value
|
||||||
|
? 'bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400'
|
||||||
|
: 'bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400'
|
||||||
|
}`}>
|
||||||
|
<div className={`w-2 h-2 rounded-full ${value ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
|
{value ? '活跃' : '未激活'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isSuperuser',
|
||||||
|
label: '角色',
|
||||||
|
sortable: true,
|
||||||
|
render: (value: boolean, user: User) => {
|
||||||
|
if (value) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-purple-50 dark:bg-purple-950 text-purple-600 dark:text-purple-400">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-purple-500" />
|
||||||
|
超级管理员
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-50 dark:bg-blue-950 text-blue-600 dark:text-blue-400">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||||
|
普通用户
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isVerified',
|
||||||
|
label: '验证',
|
||||||
|
sortable: true,
|
||||||
|
render: (value: boolean) => (
|
||||||
|
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
value
|
||||||
|
? 'bg-orange-50 dark:bg-orange-950 text-orange-600 dark:text-orange-400'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{value ? '已验证' : '未验证'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastLoginAt',
|
||||||
|
label: '最后登录',
|
||||||
|
sortable: true,
|
||||||
|
render: (value: string) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
try {
|
||||||
|
const date = new Date(value);
|
||||||
|
return date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: '操作',
|
||||||
|
render: (_, user: User) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleViewDetail(user)}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEdit(user)}
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleToggleStatus(user)}
|
||||||
|
>
|
||||||
|
{user.isActive ? (
|
||||||
|
<UserX className="w-4 h-4 text-orange-600" />
|
||||||
|
) : (
|
||||||
|
<UserCheck className="w-4 h-4 text-green-600" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleResetPassword(user)}
|
||||||
|
>
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
// 加载用户数据
|
// 加载用户数据
|
||||||
const loadUsers = async (resetPage = false) => {
|
const loadUsers = useCallback(async (resetPage = false) => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
dispatch({ type: 'SET_LOADING', payload: true });
|
dispatch({ type: 'SET_LOADING', payload: true });
|
||||||
|
|
||||||
const params: UsersQueryParams = {
|
const params: UsersQueryParams = {
|
||||||
@@ -114,6 +279,26 @@ export default function TenantUserManagementPage() {
|
|||||||
size: state.pagination.size,
|
size: state.pagination.size,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 添加搜索条件
|
||||||
|
if (searchFilters.search) {
|
||||||
|
params.search = searchFilters.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchFilters.status && searchFilters.status !== 'all') {
|
||||||
|
params.is_active = searchFilters.status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchFilters.type && searchFilters.type !== 'all') {
|
||||||
|
// For user type filtering, we'll need to handle this differently based on the API
|
||||||
|
// For now, we'll filter on the client side if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.sortBy) {
|
||||||
|
params.order_by = state.sortBy;
|
||||||
|
params.sort_order = state.sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
const response: UsersApiResponse = await fetchUsers(params);
|
const response: UsersApiResponse = await fetchUsers(params);
|
||||||
const transformedUsers = response.data.map(transformUserData);
|
const transformedUsers = response.data.map(transformUserData);
|
||||||
|
|
||||||
@@ -138,50 +323,33 @@ export default function TenantUserManagementPage() {
|
|||||||
payload: error instanceof Error ? error.message : '加载用户数据失败'
|
payload: error instanceof Error ? error.message : '加载用户数据失败'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, [state.pagination.page, state.pagination.size, state.sortBy, state.sortOrder, searchFilters]);
|
||||||
|
|
||||||
// 加载企业数据(这里暂时使用mock数据,后续可以添加企业API)
|
|
||||||
// const loadEnterprises = () => {
|
|
||||||
// // 这里可以添加企业API调用,现在使用mock数据
|
|
||||||
// const mockEnterprises: Enterprise[] = [
|
|
||||||
// { id: 'ent-1', name: '丰收现代农业集团' },
|
|
||||||
// { id: 'ent-2', name: '绿色种植科技有限公司' },
|
|
||||||
// { id: 'ent-3', name: '智慧农业示范区' },
|
|
||||||
// ];
|
|
||||||
// dispatch({ type: 'SET_ENTERPRISES', payload: mockEnterprises });
|
|
||||||
// };
|
|
||||||
|
|
||||||
// 搜索处理
|
// 搜索处理
|
||||||
const handleSearch = (value: string) => {
|
const handleSearch = useCallback((filters: Record<string, string>) => {
|
||||||
dispatch({ type: 'SET_FILTERS', payload: { searchKeyword: value } });
|
setSearchFilters(filters);
|
||||||
};
|
dispatch({ type: 'SET_PAGINATION', payload: { page: 1 } });
|
||||||
|
}, []);
|
||||||
// 状态筛选
|
|
||||||
const handleStatusFilter = (value: string) => {
|
|
||||||
dispatch({ type: 'SET_FILTERS', payload: { statusFilter: value } });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 类型筛选
|
|
||||||
const handleTypeFilter = (value: string) => {
|
|
||||||
dispatch({ type: 'SET_FILTERS', payload: { typeFilter: value } });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 排序处理
|
// 排序处理
|
||||||
const handleSort = (sortBy: string) => {
|
const handleSort = useCallback((sortBy: string, sortOrder: 'asc' | 'desc') => {
|
||||||
const newSortOrder = state.sortBy === sortBy && state.sortOrder === 'desc' ? 'asc' : 'desc';
|
dispatch({ type: 'SET_SORT', payload: { sortBy, sortOrder } });
|
||||||
dispatch({ type: 'SET_SORT', payload: { sortBy, sortOrder: newSortOrder } });
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
// 分页处理
|
// 分页处理
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = useCallback((page: number) => {
|
||||||
// 边界检查,确保页码在有效范围内
|
|
||||||
if (page < 1) {
|
if (page < 1) {
|
||||||
page = 1;
|
page = 1;
|
||||||
} else if (page > state.pagination.totalPages && state.pagination.totalPages > 0) {
|
} else if (page > state.pagination.totalPages && state.pagination.totalPages > 0) {
|
||||||
page = state.pagination.totalPages;
|
page = state.pagination.totalPages;
|
||||||
}
|
}
|
||||||
dispatch({ type: 'SET_PAGINATION', payload: { page } });
|
dispatch({ type: 'SET_PAGINATION', payload: { page } });
|
||||||
};
|
}, [state.pagination.totalPages]);
|
||||||
|
|
||||||
|
// 每页条数变化处理
|
||||||
|
const handleSizeChange = useCallback((size: number) => {
|
||||||
|
dispatch({ type: 'SET_PAGINATION', payload: { size, page: 1 } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 查看详情
|
// 查看详情
|
||||||
const handleViewDetail = (user: User) => {
|
const handleViewDetail = (user: User) => {
|
||||||
@@ -191,108 +359,86 @@ export default function TenantUserManagementPage() {
|
|||||||
|
|
||||||
// 编辑用户
|
// 编辑用户
|
||||||
const handleEdit = (user: User) => {
|
const handleEdit = (user: User) => {
|
||||||
// 这里可以添加编辑逻辑,比如打开编辑对话框
|
|
||||||
toast.info('编辑功能开发中...');
|
toast.info('编辑功能开发中...');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除用户
|
|
||||||
const handleDelete = (user: User) => {
|
|
||||||
if (!confirm(`确定要删除用户 ${user.fullName || user.username} 吗?`)) return;
|
|
||||||
// 这里可以添加删除逻辑,调用API删除用户
|
|
||||||
toast.info('删除功能开发中...');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换用户状态
|
// 切换用户状态
|
||||||
const handleToggleStatus = (user: User) => {
|
const handleToggleStatus = (user: User) => {
|
||||||
const newStatus = !user.isActive;
|
const newStatus = !user.isActive;
|
||||||
const statusText = newStatus ? '激活' : '停用';
|
const statusText = newStatus ? '激活' : '停用';
|
||||||
if (!confirm(`确定要${statusText}用户 ${user.fullName || user.username} 吗?`)) return;
|
if (!confirm(`确定要${statusText}用户 ${user.fullName || user.username} 吗?`)) return;
|
||||||
// 这里可以添加状态切换逻辑,调用API更新用户状态
|
|
||||||
toast.info(`${statusText}功能开发中...`);
|
toast.info(`${statusText}功能开发中...`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重置密码
|
// 重置密码
|
||||||
const handleResetPassword = (user: User) => {
|
const handleResetPassword = (user: User) => {
|
||||||
if (!confirm(`确定要重置用户 ${user.fullName || user.username} 的密码吗?`)) return;
|
if (!confirm(`确定要重置用户 ${user.fullName || user.username} 的密码吗?`)) return;
|
||||||
// 这里可以添加重置密码逻辑,调用API重置密码
|
|
||||||
toast.info('重置密码功能开发中...');
|
toast.info('重置密码功能开发中...');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// 统计数据计算
|
// 统计数据计算
|
||||||
const stats = [
|
const stats = useMemo(() => [
|
||||||
{
|
{
|
||||||
label: '总用户数',
|
label: '总用户数',
|
||||||
value: state.pagination.total,
|
value: state.pagination.total,
|
||||||
color: 'text-blue-600',
|
color: 'text-blue-600 dark:text-blue-400',
|
||||||
bg: 'bg-blue-100',
|
bg: 'bg-blue-50 dark:bg-blue-950',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '活跃用户',
|
label: '活跃用户',
|
||||||
value: state.users.filter(u => u.isActive).length,
|
value: state.users.filter(u => u.isActive).length,
|
||||||
color: 'text-green-600',
|
color: 'text-green-600 dark:text-green-400',
|
||||||
bg: 'bg-green-100',
|
bg: 'bg-green-50 dark:bg-green-950',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '管理员',
|
label: '管理员',
|
||||||
value: state.users.filter(u => u.isSuperuser).length,
|
value: state.users.filter(u => u.isSuperuser).length,
|
||||||
color: 'text-purple-600',
|
color: 'text-purple-600 dark:text-purple-400',
|
||||||
bg: 'bg-purple-100',
|
bg: 'bg-purple-50 dark:bg-purple-950',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '已验证',
|
label: '已验证',
|
||||||
value: state.users.filter(u => u.isVerified).length,
|
value: state.users.filter(u => u.isVerified).length,
|
||||||
color: 'text-orange-600',
|
color: 'text-orange-600 dark:text-orange-400',
|
||||||
bg: 'bg-orange-100',
|
bg: 'bg-orange-50 dark:bg-orange-950',
|
||||||
},
|
},
|
||||||
];
|
], [state.users, state.pagination.total]);
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
}, [state.filters.searchKeyword, state.filters.statusFilter, state.filters.typeFilter, state.sortBy, state.sortOrder]);
|
}, [loadUsers]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (state.pagination.page > 1) {
|
|
||||||
loadUsers();
|
|
||||||
}
|
|
||||||
}, [state.pagination.page]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 页面标题和统计 */}
|
{/* 页面标题 */}
|
||||||
<UserManagementHeader stats={stats} />
|
<UserManagementHeader stats={stats} />
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* 统计卡片 */}
|
||||||
<UserManagementStatsCards stats={stats} />
|
<UserManagementStatsCards stats={stats} />
|
||||||
|
|
||||||
{/* 搜索和筛选 */}
|
{/* 搜索表单、数据表格和分页 */}
|
||||||
<UserManagementFilters
|
<SearchFormPagination
|
||||||
filters={state.filters}
|
formTitle="用户列表"
|
||||||
onSearchChange={handleSearch}
|
formRightContent={
|
||||||
onStatusFilterChange={handleStatusFilter}
|
<Button onClick={() => toast.info('新建用户功能开发中...')}>
|
||||||
onTypeFilterChange={handleTypeFilter}
|
新建用户
|
||||||
/>
|
</Button>
|
||||||
|
}
|
||||||
{/* 错误显示 */}
|
searchFields={searchFields}
|
||||||
{state.error && (
|
columns={columns}
|
||||||
<div className="p-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg">
|
data={state.users}
|
||||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
|
||||||
<span>{state.error}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 用户列表 */}
|
|
||||||
<UserList
|
|
||||||
users={state.users}
|
|
||||||
loading={state.loading}
|
loading={state.loading}
|
||||||
|
error={state.error}
|
||||||
pagination={state.pagination}
|
pagination={state.pagination}
|
||||||
|
sortBy={state.sortBy}
|
||||||
|
sortOrder={state.sortOrder}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onViewDetail={handleViewDetail}
|
onSizeChange={handleSizeChange}
|
||||||
onEdit={handleEdit}
|
onSearch={handleSearch}
|
||||||
onDelete={handleDelete}
|
onSort={handleSort}
|
||||||
onToggleStatus={handleToggleStatus}
|
emptyText="暂无用户数据"
|
||||||
onResetPassword={handleResetPassword}
|
sizeOptions={[10, 20, 50, 100]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 用户详情对话框 */}
|
{/* 用户详情对话框 */}
|
||||||
|
|||||||
@@ -53,16 +53,22 @@ export function SearchFormComponent({
|
|||||||
setLocalFilters(filters);
|
setLocalFilters(filters);
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
// 处理输入变化 - 防抖搜索避免频繁刷新导致失焦
|
// 处理输入变化 - 区分文本输入和下拉选择
|
||||||
const handleInputChange = (key: string, value: string) => {
|
const handleInputChange = (key: string, value: string, fieldType: 'text' | 'select') => {
|
||||||
const newFilters = {
|
const newFilters = {
|
||||||
...localFilters,
|
...localFilters,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
};
|
};
|
||||||
setLocalFilters(newFilters);
|
setLocalFilters(newFilters);
|
||||||
|
|
||||||
|
// 下拉框选择立即触发查询,文本输入使用防抖
|
||||||
|
if (fieldType === 'select') {
|
||||||
|
onFiltersChangeRef.current(newFilters);
|
||||||
|
}
|
||||||
|
// 文本输入的防抖在useEffect中处理
|
||||||
};
|
};
|
||||||
|
|
||||||
// 使用防抖来减少搜索频率,避免频繁刷新导致失焦
|
// 使用防抖来减少搜索频率,仅针对文本输入
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
// 使用ref引用最新的onFiltersChange函数,避免依赖变化导致重复触发
|
// 使用ref引用最新的onFiltersChange函数,避免依赖变化导致重复触发
|
||||||
@@ -89,7 +95,7 @@ export function SearchFormComponent({
|
|||||||
<div key={field.key}>
|
<div key={field.key}>
|
||||||
<Select
|
<Select
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={(newValue) => handleInputChange(field.key, newValue)}
|
onValueChange={(newValue) => handleInputChange(field.key, newValue, 'select')}
|
||||||
disabled={false} // 始终允许选择,不因加载而禁用
|
disabled={false} // 始终允许选择,不因加载而禁用
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-40">
|
<SelectTrigger className="w-40">
|
||||||
@@ -114,7 +120,7 @@ export function SearchFormComponent({
|
|||||||
<Input
|
<Input
|
||||||
placeholder={field.placeholder || placeholder}
|
placeholder={field.placeholder || placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => handleInputChange(field.key, e.target.value)}
|
onChange={(e) => handleInputChange(field.key, e.target.value, 'text')}
|
||||||
disabled={false} // 始终允许输入,不因加载而禁用
|
disabled={false} // 始终允许输入,不因加载而禁用
|
||||||
className="pl-10 w-64"
|
className="pl-10 w-64"
|
||||||
/>
|
/>
|
||||||
@@ -130,7 +136,7 @@ export function SearchFormComponent({
|
|||||||
<Input
|
<Input
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={localFilters.search || ''}
|
value={localFilters.search || ''}
|
||||||
onChange={(e) => handleInputChange('search', e.target.value)}
|
onChange={(e) => handleInputChange('search', e.target.value, 'text')}
|
||||||
disabled={false} // 始终允许输入,不因加载而禁用
|
disabled={false} // 始终允许输入,不因加载而禁用
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
/**
|
|
||||||
* filekorolheader: 搜索表单分页组件使用示例 - 展示如何使用该组件
|
|
||||||
* 功能:使用示例、配置示例、最佳实践展示
|
|
||||||
* 路径:/components/common/searchFormPagination/components/example
|
|
||||||
* 规范:遵循crop-x/docs/开发项目规范.md,提供完整的使用示例
|
|
||||||
*/
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { SearchFormPagination, SearchFieldConfig, TableColumnConfig } from '../index';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Building2, Eye, Power, PowerOff, Plus } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
// 模拟数据类型
|
|
||||||
interface MockEnterprise {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
registrant?: string;
|
|
||||||
contactPhone?: string;
|
|
||||||
createdAt: string;
|
|
||||||
auditStatus: 'draft' | 'pending' | 'approved' | 'rejected';
|
|
||||||
status: 'active' | 'inactive';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 示例使用
|
|
||||||
export function EnterpriseManagementExample() {
|
|
||||||
// 搜索字段配置
|
|
||||||
const searchFields: SearchFieldConfig[] = [
|
|
||||||
{
|
|
||||||
key: 'search',
|
|
||||||
label: '企业搜索',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: '搜索企业名称、编码...',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'audit_status',
|
|
||||||
label: '审核状态',
|
|
||||||
type: 'select',
|
|
||||||
placeholder: '选择审核状态',
|
|
||||||
options: [
|
|
||||||
{ value: '', label: '全部状态' },
|
|
||||||
{ value: '草稿', label: '草稿' },
|
|
||||||
{ value: '待审核', label: '待审核' },
|
|
||||||
{ value: '已通过', label: '审核通过' },
|
|
||||||
{ value: '已拒绝', label: '已拒绝' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 表格列配置
|
|
||||||
const columns: TableColumnConfig[] = [
|
|
||||||
{
|
|
||||||
key: 'code',
|
|
||||||
label: '企业编码',
|
|
||||||
sortable: true,
|
|
||||||
width: '120px',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
label: '企业名称',
|
|
||||||
sortable: true,
|
|
||||||
render: (value: string, row: MockEnterprise) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Building2 className="w-4 h-4 text-blue-500" />
|
|
||||||
<span className="font-medium">{value}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'type',
|
|
||||||
label: '企业类型',
|
|
||||||
render: (value: string) => (
|
|
||||||
<Badge variant="outline" className="font-light">{value}</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'registrant',
|
|
||||||
label: '登记人',
|
|
||||||
render: (value?: string) => value || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'contactPhone',
|
|
||||||
label: '联系电话',
|
|
||||||
render: (value?: string) => value || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'createdAt',
|
|
||||||
label: '创建时间',
|
|
||||||
sortable: true,
|
|
||||||
width: '160px',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'auditStatus',
|
|
||||||
label: '审核状态',
|
|
||||||
render: (value: MockEnterprise['auditStatus']) => {
|
|
||||||
const getAuditStatusBadge = (status: MockEnterprise['auditStatus']) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'draft':
|
|
||||||
return <Badge className="bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-800 font-light">草稿</Badge>;
|
|
||||||
case 'pending':
|
|
||||||
return <Badge className="bg-yellow-50 dark:bg-yellow-950 text-yellow-600 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800 font-light">待审核</Badge>;
|
|
||||||
case 'approved':
|
|
||||||
return <Badge className="bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800 font-light">审核通过</Badge>;
|
|
||||||
case 'rejected':
|
|
||||||
return <Badge className="bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 font-light">已拒绝</Badge>;
|
|
||||||
default:
|
|
||||||
return <Badge className="bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-800 font-light">草稿</Badge>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return getAuditStatusBadge(value);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
label: '状态',
|
|
||||||
render: (value: MockEnterprise['status']) => {
|
|
||||||
const getStatusBadge = (status: MockEnterprise['status']) => {
|
|
||||||
if (status === 'active') {
|
|
||||||
return <Badge className="bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800 font-light">启用</Badge>;
|
|
||||||
}
|
|
||||||
return <Badge className="bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-800 font-light">禁用</Badge>;
|
|
||||||
};
|
|
||||||
return getStatusBadge(value);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
label: '操作',
|
|
||||||
render: (_: any, row: MockEnterprise) => (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => toast.success(`查看企业: ${row.name}`)}
|
|
||||||
>
|
|
||||||
<Eye className="w-3 h-3 mr-1" />
|
|
||||||
查看
|
|
||||||
</Button>
|
|
||||||
{row.status === 'active' ? (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600"
|
|
||||||
onClick={() => toast.success(`禁用企业: ${row.name}`)}
|
|
||||||
>
|
|
||||||
<PowerOff className="w-3 h-3 mr-1" />
|
|
||||||
禁用
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="text-green-600 dark:text-green-400 border-green-300 dark:border-green-600"
|
|
||||||
onClick={() => toast.success(`启用企业: ${row.name}`)}
|
|
||||||
>
|
|
||||||
<Power className="w-3 h-3 mr-1" />
|
|
||||||
启用
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 模拟数据
|
|
||||||
const mockData: MockEnterprise[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
code: 'ENT001',
|
|
||||||
name: '示例科技有限公司',
|
|
||||||
type: '科技有限公司',
|
|
||||||
registrant: '张三',
|
|
||||||
contactPhone: '13800138000',
|
|
||||||
createdAt: '2024-01-15 10:30:00',
|
|
||||||
auditStatus: 'approved',
|
|
||||||
status: 'active',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
code: 'ENT002',
|
|
||||||
name: '测试农业发展有限公司',
|
|
||||||
type: '农业发展有限公司',
|
|
||||||
registrant: '李四',
|
|
||||||
contactPhone: '13900139000',
|
|
||||||
createdAt: '2024-01-16 14:20:00',
|
|
||||||
auditStatus: 'pending',
|
|
||||||
status: 'active',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 模拟分页配置
|
|
||||||
const mockPagination = {
|
|
||||||
page: 1,
|
|
||||||
size: 10,
|
|
||||||
total: 2,
|
|
||||||
totalPages: 1,
|
|
||||||
hasNext: false,
|
|
||||||
hasPrev: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理搜索
|
|
||||||
const handleSearch = (filters: Record<string, string>) => {
|
|
||||||
console.log('搜索条件:', filters);
|
|
||||||
toast.success('搜索条件已更新');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理排序
|
|
||||||
const handleSort = (sortBy: string, sortOrder: 'asc' | 'desc') => {
|
|
||||||
console.log('排序:', { sortBy, sortOrder });
|
|
||||||
toast.success(`排序: ${sortBy} ${sortOrder}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理分页
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
console.log('切换到页面:', page);
|
|
||||||
toast.success(`切换到第 ${page} 页`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 操作按钮
|
|
||||||
const actionButtons = (
|
|
||||||
<Button onClick={() => toast.success('新建企业')}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
新建企业
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SearchFormPagination
|
|
||||||
title="企业管理"
|
|
||||||
description="管理平台所有企业信息,支持查询、查看详情、启用/禁用企业"
|
|
||||||
searchFields={searchFields}
|
|
||||||
onSearch={handleSearch}
|
|
||||||
columns={columns}
|
|
||||||
data={mockData}
|
|
||||||
pagination={mockPagination}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
onSort={handleSort}
|
|
||||||
actionButtons={actionButtons}
|
|
||||||
emptyIcon={<Building2 className="w-12 h-12 mx-auto mb-4 opacity-20" />}
|
|
||||||
emptyText="暂无企业数据"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EnterpriseManagementExample;
|
|
||||||
@@ -33,7 +33,6 @@ export interface SearchFieldConfig {
|
|||||||
export interface TableColumnConfig {
|
export interface TableColumnConfig {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
sortable?: boolean;
|
|
||||||
width?: string;
|
width?: string;
|
||||||
render?: (value: any, row: any, index: number) => React.ReactNode;
|
render?: (value: any, row: any, index: number) => React.ReactNode;
|
||||||
}
|
}
|
||||||
@@ -68,11 +67,6 @@ export interface SearchFormPaginationProps<T = any> {
|
|||||||
onPageChange?: (page: number) => void;
|
onPageChange?: (page: number) => void;
|
||||||
onSizeChange?: (size: number) => void;
|
onSizeChange?: (size: number) => void;
|
||||||
|
|
||||||
// 排序配置
|
|
||||||
sortBy?: string;
|
|
||||||
sortOrder?: 'asc' | 'desc';
|
|
||||||
onSort?: (sortBy: string, sortOrder: 'asc' | 'desc') => void;
|
|
||||||
|
|
||||||
// 空状态配置
|
// 空状态配置
|
||||||
emptyIcon?: React.ReactNode;
|
emptyIcon?: React.ReactNode;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
@@ -109,9 +103,6 @@ export function SearchFormPagination<T = any>({
|
|||||||
pagination,
|
pagination,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onSizeChange,
|
onSizeChange,
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
onSort,
|
|
||||||
emptyIcon,
|
emptyIcon,
|
||||||
emptyText = '暂无数据',
|
emptyText = '暂无数据',
|
||||||
showSizeSelector = true,
|
showSizeSelector = true,
|
||||||
@@ -130,12 +121,6 @@ export function SearchFormPagination<T = any>({
|
|||||||
}, {} as Record<string, string>)
|
}, {} as Record<string, string>)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 同步外部排序状态
|
|
||||||
const [currentSort, setCurrentSort] = useState<{ sortBy?: string; sortOrder: 'asc' | 'desc' }>({
|
|
||||||
sortBy,
|
|
||||||
sortOrder: sortOrder || 'asc'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 数据更新回调 - 通知父组件数据变化
|
// 数据更新回调 - 通知父组件数据变化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDataUpdate?.({
|
onDataUpdate?.({
|
||||||
@@ -159,23 +144,6 @@ export function SearchFormPagination<T = any>({
|
|||||||
onSearch?.(newFilters);
|
onSearch?.(newFilters);
|
||||||
}, [onSearch]);
|
}, [onSearch]);
|
||||||
|
|
||||||
const handleSort = useCallback((columnKey: string) => {
|
|
||||||
const column = columns.find(col => col.key === columnKey);
|
|
||||||
if (!column?.sortable) return;
|
|
||||||
|
|
||||||
// 计算新的排序状态
|
|
||||||
let newSortOrder: 'asc' | 'desc';
|
|
||||||
if (currentSort.sortBy === columnKey) {
|
|
||||||
newSortOrder = currentSort.sortOrder === 'desc' ? 'asc' : 'desc';
|
|
||||||
} else {
|
|
||||||
newSortOrder = 'asc';
|
|
||||||
}
|
|
||||||
|
|
||||||
const newSort = { sortBy: columnKey, sortOrder: newSortOrder };
|
|
||||||
setCurrentSort(newSort);
|
|
||||||
onSort?.(columnKey, newSortOrder);
|
|
||||||
}, [columns, currentSort, onSort]);
|
|
||||||
|
|
||||||
const handlePageChange = useCallback((page: number) => {
|
const handlePageChange = useCallback((page: number) => {
|
||||||
onPageChange?.(page);
|
onPageChange?.(page);
|
||||||
}, [onPageChange]);
|
}, [onPageChange]);
|
||||||
@@ -210,7 +178,6 @@ export function SearchFormPagination<T = any>({
|
|||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={column.key}
|
key={column.key}
|
||||||
className={column.sortable ? 'cursor-pointer hover:bg-muted' : ''}
|
|
||||||
style={{
|
style={{
|
||||||
width: getColumnWidth(column),
|
width: getColumnWidth(column),
|
||||||
minWidth: column.render ? '120px' : '100px',
|
minWidth: column.render ? '120px' : '100px',
|
||||||
@@ -219,14 +186,10 @@ export function SearchFormPagination<T = any>({
|
|||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap'
|
whiteSpace: 'nowrap'
|
||||||
}}
|
}}
|
||||||
onClick={() => column.sortable && handleSort(column.key)}
|
|
||||||
>
|
>
|
||||||
<div className="truncate" title={column.label}>
|
<div className="truncate" title={column.label}>
|
||||||
{column.label}
|
{column.label}
|
||||||
</div>
|
</div>
|
||||||
{column.sortable && currentSort.sortBy === column.key && (
|
|
||||||
<span className="ml-1 flex-shrink-0">{currentSort.sortOrder === 'asc' ? '↑' : '↓'}</span>
|
|
||||||
)}
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
Reference in New Issue
Block a user