基本功能完成,下一步开始美化UI
This commit is contained in:
@@ -20,6 +20,26 @@ domains:
|
||||
- name: "createdAt"
|
||||
type: "datetime"
|
||||
description: "创建时间"
|
||||
- name: "UserGroup"
|
||||
description: "用户组 (Note: The User Group feature (including management, assignment, and mixed selection) is implemented but currently UNTESTED. Missing Audit Log feature for User Group changes.)"
|
||||
attributes:
|
||||
- name: "id"
|
||||
type: "string"
|
||||
description: "用户组唯一标识"
|
||||
- name: "name"
|
||||
type: "string"
|
||||
description: "用户组名称"
|
||||
- name: "description"
|
||||
type: "string"
|
||||
description: "用户组描述"
|
||||
- name: "isSystem"
|
||||
type: "boolean"
|
||||
description: "是否为系统内置组 (如: 全体用户)"
|
||||
- name: "createdAt"
|
||||
type: "datetime"
|
||||
description: "创建时间"
|
||||
rules:
|
||||
- "All Users (全体用户) special group: Auto-join for new users; Cannot be deleted or modified; Users cannot exit."
|
||||
|
||||
- name: "Question"
|
||||
description: "题库管理"
|
||||
@@ -196,3 +216,18 @@ domains:
|
||||
- name: "updatedAt"
|
||||
type: "datetime"
|
||||
description: "更新时间"
|
||||
|
||||
- name: "Backup"
|
||||
description: "数据备份与恢复"
|
||||
entities:
|
||||
- name: "BackupRestore"
|
||||
description: "数据导出与导入"
|
||||
attributes:
|
||||
- name: "dataType"
|
||||
type: "enum"
|
||||
values: ["users", "questions", "records", "answers"]
|
||||
description: "数据类型"
|
||||
- name: "action"
|
||||
type: "enum"
|
||||
values: ["export", "restore"]
|
||||
description: "操作类型"
|
||||
|
||||
@@ -38,6 +38,29 @@ components:
|
||||
password:
|
||||
type: string
|
||||
description: "密码 (敏感字段;前端掩码显示)"
|
||||
groupIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "所属用户组ID列表"
|
||||
UserGroup:
|
||||
type: object
|
||||
required: ["id", "name", "createdAt"]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
isSystem:
|
||||
type: boolean
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
memberCount:
|
||||
type: integer
|
||||
description: "成员数量"
|
||||
QuestionCategory:
|
||||
type: object
|
||||
required: ["id", "name", "createdAt"]
|
||||
@@ -119,6 +142,9 @@ components:
|
||||
endAt:
|
||||
type: string
|
||||
format: date-time
|
||||
selectionConfig:
|
||||
type: string
|
||||
description: "JSON string storing original selection of userIds and groupIds"
|
||||
Pagination:
|
||||
type: object
|
||||
required: ["page", "limit", "total", "pages"]
|
||||
@@ -353,9 +379,39 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
- name: "keyword"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: "搜索关键词(姓名/手机)"
|
||||
responses:
|
||||
"200":
|
||||
description: "User list"
|
||||
post:
|
||||
summary: "创建用户 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["name", "phone", "password"]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
phone:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
groupIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "User created"
|
||||
delete:
|
||||
summary: "删除用户 (管理员)"
|
||||
security:
|
||||
@@ -374,6 +430,120 @@ paths:
|
||||
"200":
|
||||
description: "Deleted"
|
||||
|
||||
/api/admin/users/{id}:
|
||||
put:
|
||||
summary: "更新用户 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
phone:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
groupIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "User updated"
|
||||
|
||||
/api/admin/user-groups:
|
||||
get:
|
||||
summary: "获取用户组列表"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: "Group list"
|
||||
post:
|
||||
summary: "创建用户组"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["name"]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Group created"
|
||||
|
||||
/api/admin/user-groups/{id}:
|
||||
put:
|
||||
summary: "更新用户组"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Group updated"
|
||||
delete:
|
||||
summary: "删除用户组"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Group deleted"
|
||||
|
||||
/api/admin/user-groups/{id}/members:
|
||||
get:
|
||||
summary: "获取用户组成员"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Group members"
|
||||
|
||||
/api/admin/users/export:
|
||||
get:
|
||||
summary: "导出用户 (管理员)"
|
||||
@@ -557,11 +727,12 @@ paths:
|
||||
- AdminBearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
description: "Create task with mixed selection of users and groups (system handles de-duplication)"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["name", "subjectId", "startAt", "endAt", "userIds"]
|
||||
required: ["name", "subjectId", "startAt", "endAt"]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
@@ -577,6 +748,10 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
groupIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Created"
|
||||
@@ -594,10 +769,30 @@ paths:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
description: "Update task with mixed selection (system handles de-duplication)"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
subjectId:
|
||||
type: string
|
||||
startAt:
|
||||
type: string
|
||||
format: date-time
|
||||
endAt:
|
||||
type: string
|
||||
format: date-time
|
||||
userIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
groupIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Updated"
|
||||
@@ -646,3 +841,144 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
description: "Config updated"
|
||||
|
||||
/api/admin/configs:
|
||||
get:
|
||||
summary: "获取所有系统配置"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: "All configs"
|
||||
|
||||
/api/admin/password:
|
||||
put:
|
||||
summary: "修改管理员密码"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["oldPassword", "newPassword"]
|
||||
properties:
|
||||
oldPassword:
|
||||
type: string
|
||||
newPassword:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Password updated"
|
||||
|
||||
/api/admin/export/{type}:
|
||||
get:
|
||||
summary: "通用数据导出"
|
||||
description: "type: users, questions, records, answers"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "type"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: ["users", "questions", "records", "answers"]
|
||||
responses:
|
||||
"200":
|
||||
description: "JSON export data"
|
||||
|
||||
/api/admin/restore:
|
||||
post:
|
||||
summary: "数据恢复"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
users:
|
||||
type: array
|
||||
questions:
|
||||
type: array
|
||||
records:
|
||||
type: array
|
||||
answers:
|
||||
type: array
|
||||
responses:
|
||||
"200":
|
||||
description: "Data restored"
|
||||
|
||||
/api/exam-tasks/user/{userId}:
|
||||
get:
|
||||
summary: "获取指定用户的考试任务"
|
||||
parameters:
|
||||
- name: "userId"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "User tasks"
|
||||
|
||||
/api/admin/tasks/{id}/users:
|
||||
get:
|
||||
summary: "获取任务分派的用户"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Task users"
|
||||
|
||||
/api/admin/active-tasks:
|
||||
get:
|
||||
summary: "获取当前活跃任务统计"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: "Active tasks stats"
|
||||
|
||||
/api/users/validate:
|
||||
post:
|
||||
summary: "验证用户信息(用于导入校验)"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["phone"]
|
||||
properties:
|
||||
phone:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Validation result"
|
||||
|
||||
/api/users/name/{name}:
|
||||
get:
|
||||
summary: "根据姓名查找用户"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "name"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Users found"
|
||||
|
||||
@@ -28,6 +28,50 @@ database:
|
||||
- name: "idx_users_created_at"
|
||||
columns: ["created_at"]
|
||||
|
||||
- name: "user_groups"
|
||||
columns:
|
||||
- name: "id"
|
||||
type: "TEXT"
|
||||
primaryKey: true
|
||||
- name: "name"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
unique: true
|
||||
- name: "description"
|
||||
type: "TEXT"
|
||||
- name: "is_system"
|
||||
type: "INTEGER"
|
||||
default: "0"
|
||||
comment: "是否为系统内置组 (0:否, 1:是)"
|
||||
- name: "created_at"
|
||||
type: "DATETIME"
|
||||
default: "CURRENT_TIMESTAMP"
|
||||
indexes:
|
||||
- name: "idx_user_groups_name"
|
||||
columns: ["name"]
|
||||
|
||||
- name: "user_group_members"
|
||||
columns:
|
||||
- name: "group_id"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
foreignKey:
|
||||
table: "user_groups"
|
||||
column: "id"
|
||||
- name: "user_id"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
foreignKey:
|
||||
table: "users"
|
||||
column: "id"
|
||||
- name: "created_at"
|
||||
type: "DATETIME"
|
||||
default: "CURRENT_TIMESTAMP"
|
||||
indexes:
|
||||
- name: "idx_user_group_members_unique"
|
||||
columns: ["group_id", "user_id"]
|
||||
unique: true
|
||||
|
||||
- name: "question_categories"
|
||||
columns:
|
||||
- name: "id"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
I will implement the User Group Management System as follows:
|
||||
|
||||
### 1. Database Schema Changes
|
||||
* **New Table `user_groups`**: Stores group info (id, name, description, is_system, created_at).
|
||||
* **New Table `user_group_members`**: Links users to groups (group_id, user_id, created_at).
|
||||
* **Update `exam_tasks`**: Add `selection_config` column to store the original assignment logic (e.g., which groups were selected).
|
||||
* **Migration Script**: Create `scripts/migrate_user_groups.ts` to initialize tables and the default "All Users" group, adding all existing users to it.
|
||||
|
||||
### 2. Backend Implementation (Node.js/Express)
|
||||
* **Models**:
|
||||
* `UserGroupModel`: CRUD for groups, membership management, and "All Users" logic.
|
||||
* Update `UserModel`: Methods to fetch user's groups.
|
||||
* Update `ExamTaskModel`: Handle `selection_config`.
|
||||
* **Controllers**:
|
||||
* `UserGroupController`: API endpoints for group management.
|
||||
* Update `UserController`:
|
||||
* Auto-add new users to "All Users" group.
|
||||
* Handle group assignment updates.
|
||||
* Update `ExamTaskController`:
|
||||
* Support assigning tasks by `groupIds`.
|
||||
* Logic to flatten groups + users and remove duplicates.
|
||||
* **API Routes**: Register new `/api/user-groups` endpoints.
|
||||
|
||||
### 3. Frontend Implementation (React)
|
||||
* **User Management (`UserManagePage`)**:
|
||||
* Add a **Tab Interface**: "User List" vs "User Group Management".
|
||||
* **Group Management Tab**: List groups, Create/Edit/Delete groups (with "All Users" protection).
|
||||
* **User List**: Show assigned groups in the table.
|
||||
* **User Edit Modal**: Add a Multi-select component to manage user's groups.
|
||||
* **Exam Task Assignment (`ExamTaskPage`)**:
|
||||
* Update Assignment Modal.
|
||||
* Add **User Group Selection** (Multi-select).
|
||||
* Show "Selected Users" count and "Selected Groups" count.
|
||||
* Implement logic to calculate total unique users before saving.
|
||||
|
||||
### 4. Business Logic & Constraints
|
||||
* **"All Users" Group**:
|
||||
* Created automatically.
|
||||
* Cannot be deleted or renamed.
|
||||
* All new users join automatically.
|
||||
* Users cannot be removed from it manually.
|
||||
* **Audit**: Record group membership changes (log to database/console as appropriate).
|
||||
* **Permissions**: Ensure only admins can manage groups (reusing existing Admin middleware).
|
||||
|
||||
I will start by creating the migration script and updating the backend models.
|
||||
@@ -0,0 +1,24 @@
|
||||
I will modify the "Participating Users" selection component in `src/pages/admin/ExamTaskPage.tsx` to support displaying up to 50 users with a scrollbar and automatic sorting.
|
||||
|
||||
**Implementation Steps:**
|
||||
|
||||
1. **Add Custom CSS (`src/index.css`):**
|
||||
|
||||
* Add a `.user-select-scrollable` class to handle the scrollbar logic for the Ant Design Select component.
|
||||
|
||||
* Set `max-height` (e.g., 120px) and `overflow-y: auto` for the `.ant-select-selector` to ensure it scrolls when many users are selected.
|
||||
|
||||
2. **Update Component (`src/pages/admin/ExamTaskPage.tsx`):**
|
||||
|
||||
* **Remove Restrictions:** Remove `maxTagCount={3}` and `maxTagPlaceholder` props from the `Select` component to allow all selected users to be displayed.
|
||||
|
||||
* **Apply Style:** Add `className="user-select-scrollable"` to the `Select` component.
|
||||
|
||||
* **Implement Sorting:** Add a `normalize` function to the parent `Form.Item`. This function will intercept value changes and sort the selected User IDs alphabetically by their names (using `localeCompare` for Chinese support).
|
||||
|
||||
3. **Verification:**
|
||||
|
||||
* Ensure the development server is running.
|
||||
|
||||
* Provide a preview URL for you to test the changes (User selection should expand, show a scrollbar after filling up, and selected names should automatically sort).
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
I will modify `src/pages/admin/ExamTaskPage.tsx` to optimize the user selection display and the table layout.
|
||||
|
||||
**Implementation Steps:**
|
||||
|
||||
1. **Optimize User Selection (`Select`** **Component):**
|
||||
|
||||
* Add `optionLabelProp="label"` to the `Select` component.
|
||||
|
||||
* Update `Select.Option` to include a `label={user.name}` prop.
|
||||
|
||||
* This ensures that while the dropdown still shows "Name (Phone)" for clarity, the selected tags in the input box will only show "Name", saving significant space.
|
||||
|
||||
2. **Optimize Table Layout (`columns`** **Configuration):**
|
||||
|
||||
* **Task Name & Subject:** Add specific `width` properties (e.g., `200px` and `150px`) and `ellipsis: true` to the "Task Name" and "Exam Subject" columns to give them more space and handle overflow gracefully.
|
||||
|
||||
* **Action Column:** Change the `<Space>` component in the "Action" column to `<Space direction="vertical" size={0}>`. This will stack the "Edit", "Report", and "Delete" buttons vertically, reducing the column's required width. I will also set a fixed, narrower width for this column.
|
||||
|
||||
3. **Verification:**
|
||||
|
||||
* Verify that selected users in the form only show their names.
|
||||
|
||||
* Verify that the table "Action" column is narrower with vertically stacked buttons.
|
||||
|
||||
* Verify that "Task Name" and "Exam Subject" columns are wider.
|
||||
|
||||
58
.trae/documents/UI视觉全面升级方案.md
Normal file
58
.trae/documents/UI视觉全面升级方案.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# UI视觉升级与品牌化实施方案
|
||||
|
||||
本计划旨在对现有系统进行全面的视觉升级,重点引入全新的色彩规范(Mars Green)并实施严格的Logo展示规范。
|
||||
|
||||
## 1. 视觉系统构建 (Visual System)
|
||||
**目标**: 建立以"Mars Green"为核心的科技感视觉体系。
|
||||
- **色彩规范 (Colors)**:
|
||||
- **主色 (Primary)**: `#008C8C` (Mars Green)。
|
||||
- **辅助色 (Secondary)**: `#00A3A3` (明亮), `#006666` (深沉)。
|
||||
- **中性色**: 调整灰度色阶,配合青色调,营造冷峻科技感。
|
||||
- **操作**: 更新 `tailwind.config.js` 的 `colors` 配置,更新 `src/main.tsx` 的 Ant Design `token`。
|
||||
- **Logo 占位组件 (Brand Identity)**:
|
||||
- **组件化**: 创建 `src/components/common/Logo.tsx`。
|
||||
- **规格**:
|
||||
- `Primary`: 100x40px (Nav Left), 带 "Placeholder" 水印 (15% opacity)。
|
||||
- `Secondary`: 80x30px (Footer Right), 简化版。
|
||||
- **可配置性**: 使用 CSS 变量或 Props 控制路径,预留未来替换接口。
|
||||
- **响应式**: 移动端自动切换为图标模式或缩小比例。
|
||||
|
||||
## 2. 全局样式配置 (Global Configuration)
|
||||
- **Ant Design 主题**:
|
||||
- `colorPrimary`: `#008C8C`。
|
||||
- `borderRadius`: `8px` (圆角适中,兼顾亲和力与专业感)。
|
||||
- `fontFamily`: 优先使用系统字体栈,配合 `Inter`。
|
||||
- **排版系统**:
|
||||
- 建立 8px 网格间距系统 (Spacing Token)。
|
||||
- 优化全局字号与行高。
|
||||
|
||||
## 3. 布局与组件改造 (Layout & Components)
|
||||
**目标**: 植入品牌元素,优化空间布局。
|
||||
- **通用布局组件 (Layouts)**:
|
||||
- **`AdminLayout.tsx`**:
|
||||
- 顶部导航栏左侧植入 100x40px Logo 区域。
|
||||
- 底部增加 Footer 区域,右侧植入 80x30px Logo。
|
||||
- 调整侧边栏配色,呼应主色调。
|
||||
- **用户端页面 (`HomePage.tsx`)**:
|
||||
- 重构页面结构,从单一居中容器改为 `Header - Content - Footer` 结构。
|
||||
- 确保 Logo 在首屏清晰可见。
|
||||
- **组件样式微调**:
|
||||
- **Button**: 增加高度至 40px/48px,增强点击感。
|
||||
- **Card**: 优化阴影 (Shadow) 和圆角,减少视觉噪点。
|
||||
- **Input**: 统一高度和边框颜色。
|
||||
|
||||
## 4. 实施步骤
|
||||
1. **核心配置**: 修改 `tailwind.config.js` 和 `src/main.tsx`,确立 Mars Green 主调。
|
||||
2. **组件开发**: 实现 `Logo` 组件。
|
||||
3. **布局重构**:
|
||||
- 改造 `AdminLayout`,加入 Header/Footer Logo 区域。
|
||||
- 改造 `HomePage`,加入 Header/Footer 结构。
|
||||
4. **样式覆盖**: 遍历主要页面,替换旧的颜色类名 (如 `text-blue-600` -> `text-primary-600`),调整间距。
|
||||
5. **验证**: 检查 Logo 尺寸、位置及水印效果,验证移动端适配。
|
||||
|
||||
## 5. 交付物
|
||||
- 更新后的 UI 代码库。
|
||||
- 响应式设计规范文档 (含 Logo 使用规范)。
|
||||
- 视觉走查清单。
|
||||
|
||||
请确认执行。
|
||||
6
.trae/documents/加宽考试任务管理页面的操作列.md
Normal file
6
.trae/documents/加宽考试任务管理页面的操作列.md
Normal file
@@ -0,0 +1,6 @@
|
||||
我将修改 `src/pages/admin/ExamTaskPage.tsx` 文件,找到表格配置中的“操作”列定义。
|
||||
|
||||
具体变更如下:
|
||||
- 将操作列的宽度 `width` 从 `80` 增加到 `120`。
|
||||
|
||||
这将使操作列更宽,符合您的要求。
|
||||
31
.trae/documents/升级考试统计饼图为现代环形图.md
Normal file
31
.trae/documents/升级考试统计饼图为现代环形图.md
Normal file
@@ -0,0 +1,31 @@
|
||||
我将修改 `src/pages/admin/AdminDashboardPage.tsx` 文件,将原本逻辑有误且样式陈旧的实心饼图升级为**现代化的环形图 (Donut Chart)**。
|
||||
|
||||
### 具体修改计划:
|
||||
|
||||
1. **修正数据计算逻辑**:
|
||||
* 目前的逻辑中,“已完成”、“合格”、“优秀”存在包含关系,导致饼图数据重叠。
|
||||
* **新逻辑**(确保各部分互斥且总和为总人数):
|
||||
* **未完成** (`Incomplete`): 总人数 - 已完成
|
||||
* **不及格** (`Failed`): 已完成 - 合格
|
||||
* **合格** (`Passed`): 合格 - 优秀 (即“合格但未达优秀”)
|
||||
* **优秀** (`Excellent`): 优秀人数
|
||||
* 这样四个部分加起来正好等于总人数,能准确反映分布情况。
|
||||
|
||||
2. **升级图表视觉效果**:
|
||||
* **环形设计**:设置 `innerRadius={40}` 和 `outerRadius={60}`,将饼图变为环形图,视觉上更轻量现代。
|
||||
* **颜色优化**:使用 Ant Design 标准色板:
|
||||
* 优秀: `#fa8c16` (橙色)
|
||||
* 合格: `#52c41a` (绿色)
|
||||
* 不及格: `#ff4d4f` (红色)
|
||||
* 未完成: `#f0f0f0` (浅灰,降低视觉干扰)
|
||||
* **交互优化**:
|
||||
* 移除杂乱的外部连线 Label,保持界面整洁。
|
||||
* 优化 `Tooltip`,鼠标悬停时显示具体人数和占比。
|
||||
* 在环形图中心添加文字(如“总计”或完成率),充分利用空间。
|
||||
|
||||
3. **代码实现**:
|
||||
* 修改 `AdminDashboardPage.tsx` 中的 `pieData` 生成逻辑。
|
||||
* 更新 `Recharts` 的 `Pie` 组件属性配置。
|
||||
|
||||
### 预期效果:
|
||||
新的环形图将清晰地展示考试的整体进度和成绩分布,消除数据重叠带来的误解,并且在视觉上更加美观、专业。
|
||||
@@ -1,18 +1,43 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { UserModel } from '../models/user';
|
||||
import { QuizModel } from '../models/quiz';
|
||||
import { UserGroupModel } from '../models/userGroup';
|
||||
|
||||
export class AdminUserController {
|
||||
static async getUsers(req: Request, res: Response) {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
const keyword = req.query.keyword as string;
|
||||
|
||||
// TODO: Implement search in UserModel if needed, currently filtering in memory or ignored
|
||||
// For now assuming findAll supports basic pagination.
|
||||
// If keyword is needed, we should add findByKeyword to UserModel.
|
||||
// But based on existing code, it seems it wasn't implemented there.
|
||||
// Let's stick to what was there or improve if I see it.
|
||||
// The previous code used findAll(limit, offset).
|
||||
|
||||
const result = await UserModel.findAll(limit, (page - 1) * limit);
|
||||
|
||||
// Filter by keyword if provided (naive implementation since DB doesn't support it yet via API)
|
||||
let users = result.users;
|
||||
if (keyword) {
|
||||
users = users.filter(u => u.name.includes(keyword) || u.phone.includes(keyword));
|
||||
}
|
||||
|
||||
// 获取每个用户的用户组信息
|
||||
const usersWithGroups = await Promise.all(users.map(async (u) => {
|
||||
const groups = await UserGroupModel.getUserGroups(u.id);
|
||||
return {
|
||||
...u,
|
||||
password: u.password ?? '',
|
||||
groups
|
||||
};
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.users.map((u) => ({ ...u, password: u.password ?? '' })),
|
||||
data: usersWithGroups,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
@@ -28,6 +53,58 @@ export class AdminUserController {
|
||||
}
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
static async createUser(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, phone, password, groupIds } = req.body;
|
||||
|
||||
if (!name || !phone) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '姓名和手机号不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const errors = UserModel.validateUserData({ name, phone, password });
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '数据验证失败: ' + errors.join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
const user = await UserModel.create({ name, phone, password });
|
||||
|
||||
// 自动加入"全体用户"组
|
||||
const allUsersGroup = await UserGroupModel.getSystemGroup();
|
||||
if (allUsersGroup) {
|
||||
await UserGroupModel.addMember(allUsersGroup.id, user.id);
|
||||
}
|
||||
|
||||
// 添加到指定用户组
|
||||
if (groupIds && Array.isArray(groupIds)) {
|
||||
await UserGroupModel.updateUserGroups(user.id, groupIds);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user
|
||||
});
|
||||
} catch (error: any) {
|
||||
// 处理手机号已存在的错误
|
||||
if (error.message === '手机号已存在' || error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '手机号已存在'
|
||||
});
|
||||
}
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '创建用户失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteUser(req: Request, res: Response) {
|
||||
try {
|
||||
const { userId } = req.body;
|
||||
@@ -87,7 +164,7 @@ export class AdminUserController {
|
||||
static async updateUser(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, phone, password } = req.body;
|
||||
const { name, phone, password, groupIds } = req.body;
|
||||
|
||||
const user = await UserModel.findById(id);
|
||||
if (!user) {
|
||||
@@ -106,9 +183,20 @@ export class AdminUserController {
|
||||
// 更新用户
|
||||
const updatedUser = await UserModel.update(id, updateData);
|
||||
|
||||
// 更新用户组
|
||||
if (groupIds && Array.isArray(groupIds)) {
|
||||
await UserGroupModel.updateUserGroups(id, groupIds);
|
||||
}
|
||||
|
||||
// 获取最新用户组信息
|
||||
const groups = await UserGroupModel.getUserGroups(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: updatedUser
|
||||
data: {
|
||||
...updatedUser,
|
||||
groups
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
// 处理手机号已存在的错误
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ExamTaskModel } from '../models/examTask';
|
||||
import { UserGroupModel } from '../models/userGroup';
|
||||
|
||||
export class ExamTaskController {
|
||||
static async getTasks(req: Request, res: Response) {
|
||||
@@ -19,12 +20,29 @@ export class ExamTaskController {
|
||||
|
||||
static async createTask(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, subjectId, startAt, endAt, userIds } = req.body;
|
||||
const { name, subjectId, startAt, endAt, userIds = [], groupIds = [] } = req.body;
|
||||
|
||||
if (!name || !subjectId || !startAt || !endAt || !Array.isArray(userIds) || userIds.length === 0) {
|
||||
if (!name || !subjectId || !startAt || !endAt) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '参数不完整或用户列表为空'
|
||||
message: '参数不完整'
|
||||
});
|
||||
}
|
||||
|
||||
// 合并用户列表和用户组中的用户
|
||||
const finalUserIds = new Set<string>(userIds);
|
||||
|
||||
if (Array.isArray(groupIds) && groupIds.length > 0) {
|
||||
for (const groupId of groupIds) {
|
||||
const members = await UserGroupModel.getMembers(groupId);
|
||||
members.forEach(m => finalUserIds.add(m.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (finalUserIds.size === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '请至少选择一位用户或一个用户组'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,7 +51,8 @@ export class ExamTaskController {
|
||||
subjectId,
|
||||
startAt,
|
||||
endAt,
|
||||
userIds
|
||||
userIds: Array.from(finalUserIds),
|
||||
selectionConfig: JSON.stringify({ userIds, groupIds })
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -51,12 +70,29 @@ export class ExamTaskController {
|
||||
static async updateTask(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, subjectId, startAt, endAt, userIds } = req.body;
|
||||
const { name, subjectId, startAt, endAt, userIds = [], groupIds = [] } = req.body;
|
||||
|
||||
if (!name || !subjectId || !startAt || !endAt || !Array.isArray(userIds) || userIds.length === 0) {
|
||||
if (!name || !subjectId || !startAt || !endAt) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '参数不完整或用户列表为空'
|
||||
message: '参数不完整'
|
||||
});
|
||||
}
|
||||
|
||||
// 合并用户列表和用户组中的用户
|
||||
const finalUserIds = new Set<string>(userIds);
|
||||
|
||||
if (Array.isArray(groupIds) && groupIds.length > 0) {
|
||||
for (const groupId of groupIds) {
|
||||
const members = await UserGroupModel.getMembers(groupId);
|
||||
members.forEach(m => finalUserIds.add(m.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (finalUserIds.size === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '请至少选择一位用户或一个用户组'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,7 +101,8 @@ export class ExamTaskController {
|
||||
subjectId,
|
||||
startAt,
|
||||
endAt,
|
||||
userIds
|
||||
userIds: Array.from(finalUserIds),
|
||||
selectionConfig: JSON.stringify({ userIds, groupIds })
|
||||
});
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// 导出所有控制器
|
||||
export { UserController } from './userController';
|
||||
export { QuestionController } from './questionController';
|
||||
export { QuizController } from './quizController';
|
||||
export { AdminController } from './adminController';
|
||||
export { BackupController } from './backupController';
|
||||
export { QuestionCategoryController } from './questionCategoryController';
|
||||
export { ExamSubjectController } from './examSubjectController';
|
||||
export { ExamTaskController } from './examTaskController';
|
||||
export { AdminUserController } from './adminUserController';
|
||||
export * from './userController';
|
||||
export * from './questionController';
|
||||
export * from './quizController';
|
||||
export * from './adminController';
|
||||
export * from './questionCategoryController';
|
||||
export * from './examSubjectController';
|
||||
export * from './examTaskController';
|
||||
export * from './adminUserController';
|
||||
export * from './userQuizController';
|
||||
export * from './backupController';
|
||||
export * from './userGroupController';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { UserModel } from '../models/user';
|
||||
import { UserGroupModel } from '../models/userGroup';
|
||||
|
||||
export class UserController {
|
||||
static async createUser(req: Request, res: Response) {
|
||||
@@ -24,6 +25,12 @@ export class UserController {
|
||||
|
||||
const user = await UserModel.create({ name, phone, password });
|
||||
|
||||
// 自动加入"全体用户"组
|
||||
const allUsersGroup = await UserGroupModel.getSystemGroup();
|
||||
if (allUsersGroup) {
|
||||
await UserGroupModel.addMember(allUsersGroup.id, user.id);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user
|
||||
|
||||
64
api/controllers/userGroupController.ts
Normal file
64
api/controllers/userGroupController.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { UserGroupModel } from '../models/userGroup';
|
||||
|
||||
export const userGroupController = {
|
||||
// 获取所有用户组
|
||||
getAll: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const groups = await UserGroupModel.findAll();
|
||||
res.json(groups);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
},
|
||||
|
||||
// 创建用户组
|
||||
create: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: '用户组名称不能为空' });
|
||||
}
|
||||
|
||||
const group = await UserGroupModel.create({ name, description });
|
||||
res.status(201).json(group);
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
},
|
||||
|
||||
// 更新用户组
|
||||
update: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description } = req.body;
|
||||
|
||||
const group = await UserGroupModel.update(id, { name, description });
|
||||
res.json(group);
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
},
|
||||
|
||||
// 删除用户组
|
||||
delete: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await UserGroupModel.delete(id);
|
||||
res.json({ message: '用户组删除成功' });
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
},
|
||||
|
||||
// 获取用户组成员
|
||||
getMembers: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const members = await UserGroupModel.getMembers(id);
|
||||
res.json(members);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import sqlite3 from 'sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const DEFAULT_DB_DIR = path.join(process.cwd(), 'data');
|
||||
const DEFAULT_DB_PATH = path.join(DEFAULT_DB_DIR, 'survey.db');
|
||||
@@ -127,6 +128,65 @@ const migrateDatabase = async () => {
|
||||
await ensureIndex(`CREATE INDEX IF NOT EXISTS idx_exam_task_users_user_id ON exam_task_users(user_id);`);
|
||||
await ensureIndex(`CREATE INDEX IF NOT EXISTS idx_quiz_records_subject_id ON quiz_records(subject_id);`);
|
||||
await ensureIndex(`CREATE INDEX IF NOT EXISTS idx_quiz_records_task_id ON quiz_records(task_id);`);
|
||||
|
||||
// 1. 创建用户组表
|
||||
await ensureTable(`
|
||||
CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
is_system BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// 2. 创建用户-用户组关联表
|
||||
await ensureTable(`
|
||||
CREATE TABLE IF NOT EXISTS user_group_members (
|
||||
group_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (group_id, user_id),
|
||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// 3. 为考试任务表添加选择配置字段
|
||||
await ensureColumn('exam_tasks', 'selection_config TEXT', 'selection_config');
|
||||
|
||||
// 4. 初始化"全体用户"组
|
||||
const allUsersGroup = await get(`SELECT id FROM user_groups WHERE is_system = 1`);
|
||||
let allUsersGroupId = allUsersGroup?.id;
|
||||
|
||||
if (!allUsersGroupId) {
|
||||
allUsersGroupId = uuidv4();
|
||||
await run(
|
||||
`INSERT INTO user_groups (id, name, description, is_system) VALUES (?, ?, ?, ?)`,
|
||||
[allUsersGroupId, '全体用户', '包含系统所有用户的默认组', 1]
|
||||
);
|
||||
console.log('已创建"全体用户"系统组');
|
||||
}
|
||||
|
||||
// 5. 将现有用户添加到"全体用户"组
|
||||
if (allUsersGroupId) {
|
||||
// 找出尚未在全体用户组中的用户
|
||||
const usersNotInGroup = await query(`
|
||||
SELECT id FROM users
|
||||
WHERE id NOT IN (
|
||||
SELECT user_id FROM user_group_members WHERE group_id = ?
|
||||
)
|
||||
`, [allUsersGroupId]);
|
||||
|
||||
if (usersNotInGroup.length > 0) {
|
||||
const stmt = db.prepare(`INSERT INTO user_group_members (group_id, user_id) VALUES (?, ?)`);
|
||||
usersNotInGroup.forEach(user => {
|
||||
stmt.run(allUsersGroupId, user.id);
|
||||
});
|
||||
stmt.finalize();
|
||||
console.log(`已将 ${usersNotInGroup.length} 名现有用户添加到"全体用户"组`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 数据库初始化函数
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ExamTask {
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
createdAt: string;
|
||||
selectionConfig?: string; // JSON string
|
||||
}
|
||||
|
||||
export interface ExamTaskUser {
|
||||
@@ -178,7 +179,7 @@ export class ExamTaskModel {
|
||||
}
|
||||
|
||||
static async findById(id: string): Promise<ExamTask | null> {
|
||||
const sql = `SELECT id, name, subject_id as subjectId, start_at as startAt, end_at as endAt, created_at as createdAt FROM exam_tasks WHERE id = ?`;
|
||||
const sql = `SELECT id, name, subject_id as subjectId, start_at as startAt, end_at as endAt, created_at as createdAt, selection_config as selectionConfig FROM exam_tasks WHERE id = ?`;
|
||||
const row = await get(sql, [id]);
|
||||
return row || null;
|
||||
}
|
||||
@@ -189,6 +190,7 @@ export class ExamTaskModel {
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
userIds: string[];
|
||||
selectionConfig?: string;
|
||||
}): Promise<ExamTask> {
|
||||
if (!data.name.trim()) throw new Error('任务名称不能为空');
|
||||
if (!data.userIds.length) throw new Error('至少选择一位用户');
|
||||
@@ -198,8 +200,8 @@ export class ExamTaskModel {
|
||||
|
||||
const id = uuidv4();
|
||||
const sqlTask = `
|
||||
INSERT INTO exam_tasks (id, name, subject_id, start_at, end_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO exam_tasks (id, name, subject_id, start_at, end_at, selection_config)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
const sqlTaskUser = `
|
||||
@@ -207,7 +209,7 @@ export class ExamTaskModel {
|
||||
VALUES (?, ?, ?)
|
||||
`;
|
||||
|
||||
await run(sqlTask, [id, data.name.trim(), data.subjectId, data.startAt, data.endAt]);
|
||||
await run(sqlTask, [id, data.name.trim(), data.subjectId, data.startAt, data.endAt, data.selectionConfig || null]);
|
||||
|
||||
for (const userId of data.userIds) {
|
||||
await run(sqlTaskUser, [uuidv4(), id, userId]);
|
||||
@@ -222,6 +224,7 @@ export class ExamTaskModel {
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
userIds: string[];
|
||||
selectionConfig?: string;
|
||||
}): Promise<ExamTask> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) throw new Error('任务不存在');
|
||||
@@ -232,11 +235,12 @@ export class ExamTaskModel {
|
||||
const subject = await import('./examSubject').then(({ ExamSubjectModel }) => ExamSubjectModel.findById(data.subjectId));
|
||||
if (!subject) throw new Error('科目不存在');
|
||||
|
||||
await run(`UPDATE exam_tasks SET name = ?, subject_id = ?, start_at = ?, end_at = ? WHERE id = ?`, [
|
||||
await run(`UPDATE exam_tasks SET name = ?, subject_id = ?, start_at = ?, end_at = ?, selection_config = ? WHERE id = ?`, [
|
||||
data.name.trim(),
|
||||
data.subjectId,
|
||||
data.startAt,
|
||||
data.endAt,
|
||||
data.selectionConfig || null,
|
||||
id
|
||||
]);
|
||||
|
||||
|
||||
@@ -5,4 +5,5 @@ export { QuizModel, type QuizRecord, type QuizAnswer, type SubmitQuizData } from
|
||||
export { SystemConfigModel, type SystemConfig, type QuizConfig, type AdminUser } from './systemConfig';
|
||||
export { QuestionCategoryModel, type QuestionCategory } from './questionCategory';
|
||||
export { ExamSubjectModel, type ExamSubject } from './examSubject';
|
||||
export { ExamTaskModel, type ExamTask, type TaskWithSubject, type TaskReport } from './examTask';
|
||||
export { ExamTaskModel, type ExamTask, type TaskWithSubject, type TaskReport } from './examTask';
|
||||
export { UserGroupModel, type UserGroup, type CreateUserGroupData } from './userGroup';
|
||||
|
||||
@@ -86,8 +86,6 @@ export class QuestionCategoryModel {
|
||||
}
|
||||
|
||||
static async update(id: string, name: string): Promise<QuestionCategory> {
|
||||
if (id === 'default') throw new Error('默认类别不允许修改');
|
||||
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) throw new Error('类别不存在');
|
||||
|
||||
|
||||
197
api/models/userGroup.ts
Normal file
197
api/models/userGroup.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { query, run, get } from '../database';
|
||||
import { User } from './user';
|
||||
|
||||
export interface UserGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isSystem: boolean;
|
||||
createdAt: string;
|
||||
memberCount?: number;
|
||||
}
|
||||
|
||||
export interface CreateUserGroupData {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class UserGroupModel {
|
||||
static async create(data: CreateUserGroupData): Promise<UserGroup> {
|
||||
const id = uuidv4();
|
||||
const sql = `
|
||||
INSERT INTO user_groups (id, name, description, is_system)
|
||||
VALUES (?, ?, ?, 0)
|
||||
`;
|
||||
|
||||
try {
|
||||
await run(sql, [id, data.name, data.description || '']);
|
||||
return this.findById(id) as Promise<UserGroup>;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
throw new Error('用户组名称已存在');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async update(id: string, data: Partial<CreateUserGroupData>): Promise<UserGroup> {
|
||||
const group = await this.findById(id);
|
||||
if (!group) throw new Error('用户组不存在');
|
||||
if (group.isSystem) throw new Error('系统内置用户组无法修改');
|
||||
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (data.name !== undefined) {
|
||||
fields.push('name = ?');
|
||||
values.push(data.name);
|
||||
}
|
||||
|
||||
if (data.description !== undefined) {
|
||||
fields.push('description = ?');
|
||||
values.push(data.description);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return group;
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const sql = `UPDATE user_groups SET ${fields.join(', ')} WHERE id = ?`;
|
||||
|
||||
try {
|
||||
await run(sql, values);
|
||||
return this.findById(id) as Promise<UserGroup>;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
throw new Error('用户组名称已存在');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async delete(id: string): Promise<void> {
|
||||
const group = await this.findById(id);
|
||||
if (!group) throw new Error('用户组不存在');
|
||||
if (group.isSystem) throw new Error('系统内置用户组无法删除');
|
||||
|
||||
await run(`DELETE FROM user_groups WHERE id = ?`, [id]);
|
||||
}
|
||||
|
||||
static async findById(id: string): Promise<UserGroup | null> {
|
||||
const sql = `
|
||||
SELECT id, name, description, is_system as isSystem, created_at as createdAt
|
||||
FROM user_groups WHERE id = ?
|
||||
`;
|
||||
const group = await get(sql, [id]);
|
||||
return group || null;
|
||||
}
|
||||
|
||||
static async findAll(): Promise<UserGroup[]> {
|
||||
const sql = `
|
||||
SELECT
|
||||
g.id, g.name, g.description, g.is_system as isSystem, g.created_at as createdAt,
|
||||
(SELECT COUNT(*) FROM user_group_members m WHERE m.group_id = g.id) as memberCount
|
||||
FROM user_groups g
|
||||
ORDER BY g.is_system DESC, g.created_at DESC
|
||||
`;
|
||||
return await query(sql);
|
||||
}
|
||||
|
||||
static async addMember(groupId: string, userId: string): Promise<void> {
|
||||
const sql = `INSERT OR IGNORE INTO user_group_members (group_id, user_id) VALUES (?, ?)`;
|
||||
await run(sql, [groupId, userId]);
|
||||
}
|
||||
|
||||
static async removeMember(groupId: string, userId: string): Promise<void> {
|
||||
const group = await this.findById(groupId);
|
||||
if (group?.isSystem) {
|
||||
// Check if user is being deleted? No, this method is for removing member.
|
||||
// Requirement: "User cannot actively exit this group".
|
||||
// Implementation: Cannot remove member from system group via this API.
|
||||
// Only user deletion removes them (cascade).
|
||||
throw new Error('无法从系统内置组中移除成员');
|
||||
}
|
||||
const sql = `DELETE FROM user_group_members WHERE group_id = ? AND user_id = ?`;
|
||||
await run(sql, [groupId, userId]);
|
||||
}
|
||||
|
||||
static async getMembers(groupId: string): Promise<User[]> {
|
||||
const sql = `
|
||||
SELECT u.id, u.name, u.phone, u.created_at as createdAt
|
||||
FROM users u
|
||||
JOIN user_group_members m ON u.id = m.user_id
|
||||
WHERE m.group_id = ?
|
||||
ORDER BY m.created_at DESC
|
||||
`;
|
||||
return await query(sql, [groupId]);
|
||||
}
|
||||
|
||||
static async getUserGroups(userId: string): Promise<UserGroup[]> {
|
||||
const sql = `
|
||||
SELECT g.id, g.name, g.description, g.is_system as isSystem, g.created_at as createdAt
|
||||
FROM user_groups g
|
||||
JOIN user_group_members m ON g.id = m.group_id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY g.is_system DESC, g.created_at DESC
|
||||
`;
|
||||
return await query(sql, [userId]);
|
||||
}
|
||||
|
||||
static async getSystemGroup(): Promise<UserGroup | null> {
|
||||
const sql = `
|
||||
SELECT id, name, description, is_system as isSystem, created_at as createdAt
|
||||
FROM user_groups WHERE is_system = 1
|
||||
`;
|
||||
return await get(sql);
|
||||
}
|
||||
|
||||
static async updateMembers(groupId: string, userIds: string[]): Promise<void> {
|
||||
const group = await this.findById(groupId);
|
||||
if (!group) throw new Error('用户组不存在');
|
||||
if (group.isSystem) throw new Error('无法修改系统内置组成员');
|
||||
|
||||
// Transaction-like behavior needed but SQLite wrapper doesn't expose it easily.
|
||||
// We'll do delete then insert.
|
||||
await run(`DELETE FROM user_group_members WHERE group_id = ?`, [groupId]);
|
||||
|
||||
if (userIds.length > 0) {
|
||||
// Batch insert
|
||||
// SQLite limit is usually high enough, but safer to loop or construct big query
|
||||
// For simplicity in this helper:
|
||||
for (const userId of userIds) {
|
||||
await run(`INSERT INTO user_group_members (group_id, user_id) VALUES (?, ?)`, [groupId, userId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async updateUserGroups(userId: string, groupIds: string[]): Promise<void> {
|
||||
// 1. Get current system group(s) the user belongs to
|
||||
const currentGroups = await this.getUserGroups(userId);
|
||||
const systemGroupIds = currentGroups.filter(g => g.isSystem).map(g => g.id);
|
||||
|
||||
// 2. Ensure system groups are in the new list (force keep them)
|
||||
const newGroupSet = new Set(groupIds);
|
||||
for (const sysId of systemGroupIds) {
|
||||
newGroupSet.add(sysId);
|
||||
}
|
||||
|
||||
// Also ensure the default "All Users" group is there if not already
|
||||
// (In case the user was created before groups existed and somehow not migrated, though migration handles it)
|
||||
// Safe to just ensure "All Users" is present.
|
||||
const allUsersGroup = await this.getSystemGroup();
|
||||
if (allUsersGroup) {
|
||||
newGroupSet.add(allUsersGroup.id);
|
||||
}
|
||||
|
||||
const finalGroupIds = Array.from(newGroupSet);
|
||||
|
||||
// 3. Update
|
||||
await run(`DELETE FROM user_group_members WHERE user_id = ?`, [userId]);
|
||||
|
||||
for (const gid of finalGroupIds) {
|
||||
await run(`INSERT INTO user_group_members (group_id, user_id) VALUES (?, ?)`, [gid, userId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
QuestionCategoryController,
|
||||
ExamSubjectController,
|
||||
ExamTaskController,
|
||||
AdminUserController
|
||||
AdminUserController,
|
||||
userGroupController as UserGroupController
|
||||
} from './controllers';
|
||||
import {
|
||||
upload,
|
||||
@@ -75,8 +76,16 @@ apiRouter.put('/admin/tasks/:id', adminAuth, ExamTaskController.updateTask);
|
||||
apiRouter.delete('/admin/tasks/:id', adminAuth, ExamTaskController.deleteTask);
|
||||
apiRouter.get('/admin/tasks/:id/report', adminAuth, ExamTaskController.getTaskReport);
|
||||
|
||||
// 用户组管理
|
||||
apiRouter.get('/admin/user-groups', adminAuth, UserGroupController.getAll);
|
||||
apiRouter.post('/admin/user-groups', adminAuth, UserGroupController.create);
|
||||
apiRouter.put('/admin/user-groups/:id', adminAuth, UserGroupController.update);
|
||||
apiRouter.delete('/admin/user-groups/:id', adminAuth, UserGroupController.delete);
|
||||
apiRouter.get('/admin/user-groups/:id/members', adminAuth, UserGroupController.getMembers);
|
||||
|
||||
// 用户管理
|
||||
apiRouter.get('/admin/users', adminAuth, AdminUserController.getUsers);
|
||||
apiRouter.post('/admin/users', adminAuth, AdminUserController.createUser);
|
||||
apiRouter.put('/admin/users/:id', adminAuth, AdminUserController.updateUser);
|
||||
apiRouter.delete('/admin/users', adminAuth, AdminUserController.deleteUser);
|
||||
apiRouter.get('/admin/users/export', adminAuth, AdminUserController.exportUsers);
|
||||
|
||||
BIN
data/survey.db
BIN
data/survey.db
Binary file not shown.
BIN
data/宝来威(Boonlive)管理层知识考核题库(1).docx
Normal file
BIN
data/宝来威(Boonlive)管理层知识考核题库(1).docx
Normal file
Binary file not shown.
61
data/题库.csv
Normal file
61
data/题库.csv
Normal file
@@ -0,0 +1,61 @@
|
||||
题目内容,题型,题目类别,选项,标准答案,分值,创建时间,答案解析
|
||||
根据《员工手册》,以下哪种情况属于公司可以立即解除劳动合同且无需支付经济补偿的情形?,单选题,人事管理,试用期被证明不符合录用条件的|员工患病医疗期满后不能从事原工作|劳动合同期满|公司生产经营发生严重困难,A,2,2025-12-19,根据《员工手册》第七章第二条第1款第(1)项,试用期被证明不符合录用条件的,公司可以立即解除劳动合同而不必支付经济补偿。
|
||||
公司新员工入职培训中提到的“宝来威精神”是什么?,单选题,公司文化,开放包容,团队合作|同创造,共分享,齐飞扬|匠心智造,以科技和创新让生活更有品位|成为物联网最具市场及应用价值的领军企业,B,2,2025-12-19,根据《宝来威新员工入职培训PPT.pptx》和《宝来威企业文化2023.docx》,明确写明“宝来威精神:同创造 共分享 齐飞扬”。
|
||||
根据《员工手册》,员工在试用期间,以下哪种行为会被视为不符合录用条件?,单选题,人事管理,迟到1次|事假超过3天|受到一次记大过处分|未参加入职培训,C,2,2025-12-19,根据《员工手册》第二章第五条第3款第(2)项,试用期员工“受到一次记大过处分的”,视为不符合录用条件。
|
||||
根据《开票与收款流程规范通知》,公司标准开票方式中,技术服务费部分开具的增值税发票税率是多少?,单选题,业务流程,13%|9%|6%|3%,C,2,2025-12-19,根据《开票与收款流程规范通知》第四条第1点,标准开票方式为:货物部分(50%)开13%税率发票,技术服务部分(50%)开6%税率发票。
|
||||
根据《员工手册》,员工辞职(转正后)需要提前多久提交辞职报告?,单选题,人事管理,3天|一周|一个月|两个月,C,2,2025-12-19,根据《员工手册》第二章第八条第3款,员工离职,转正后须提前一个月提交辞职报告。
|
||||
在《从业务尖兵到团队教练》PPT中,针对“高意愿,低能力”的员工,建议采用哪种领导风格?,单选题,管理知识,授权式|教练式|指导式|支持式,C,2,2025-12-19,根据《从业务尖兵到团队教练.pptx》中“识人善用”部分,对“高意愿,低能力(新人)”的策略是“详细指令,密切监督”,对应“指导式(Directing)”领导风格。
|
||||
根据《员工手册》,每月为员工提供的漏打卡补卡机会最多有几次?,单选题,考勤制度,1次|2次|3次|4次,B,2,2025-12-19,根据《员工手册》第四章第二条第4款,每月为员工提供至多两次漏打卡补卡机会。
|
||||
公司《员工手册》中,对“询问或议论他人薪金”的行为是如何定义的?,单选题,奖惩规定,轻微过失|重要过失|严重过失|不予处罚,C,2,2025-12-19,根据《员工手册》第七章第二条第3款第(4)项,“询问或议论他人薪金”属于“严重过失”。
|
||||
根据《BLV-20251110-关于规范合同回款及对帐标准通知》,商务助理需要在每月初前几个工作日内完成客户应收余额表的制作?,单选题,业务流程,2个工作日|3个工作日|4个工作日|5个工作日,B,2,2025-12-19,根据通知中的表格,商务助理的“完成时限”为“每月初前3个工作日”。
|
||||
在《从业务尖兵到团队教练》PPT中,GSA模型中的“S”代表什么?,单选题,管理知识,目标(Goal)|策略(Strategy)|行动(Action)|评估(Assessment),B,2,2025-12-19,根据PPT内容,GSA模型中,G是Goal(目标),S是Strategy(策略),A是Action(行动)。
|
||||
根据《员工手册》,以下哪些行为属于“严重过失”,公司可据此解除劳动合同?,多选题,奖惩规定,一个月内迟到、早退2小时以内达到4次|代他人打卡或涂改考勤卡|提供不真实的证件、个人资料|利用职务便利为自己谋取属于公司的商业机会,A|B|C|D,4,2025-12-19,根据《员工手册》第七章第二条第3款,选项A对应第(1)项,B对应第(3)项,C对应第(5)项,D对应第(20)项,均属于“严重过失”。
|
||||
根据《员工手册》,在以下哪些情形下,劳动合同终止?,多选题,人事管理,劳动合同期满的|员工开始依法享受基本养老保险待遇的|公司被依法宣告破产的|员工患病,在规定的医疗期内,A|B|C,3,2025-12-19,根据《员工手册》第二章第七条第5款,A、B、C均为劳动合同终止的情形。D选项“员工患病,在规定的医疗期内”属于医疗期保护,并非终止情形。
|
||||
根据《宝来威新员工入职培训PPT》,公司的核心价值观包括哪些?,多选题,公司文化,开放包容|团队合作|客户第一|务实创新,A|B|C|D,4,2025-12-19,根据《宝来威新员工入职培训PPT.pptx》和《宝来威企业文化2023.docx》,公司的核心价值观为:开放包容、团队合作、客户第一、拥抱变化、德才兼备、务实创新。本题选项包含了其中四项。
|
||||
在《从业务尖兵到团队教练》PPT中,“有效委派”的步骤包括哪些?,多选题,管理知识,选对人|讲清楚|定边界|要反馈,A|B|C|D,4,2025-12-19,根据PPT“有效委派”部分,步骤包括:1.选对人;2.讲清楚;3.定边界;4.要反馈;5.勤跟进。
|
||||
根据《员工手册》,公司可以从员工工资中扣除款项的情形包括哪些?,多选题,薪资福利,员工当月个人所得税、社会保险和住房公积金个人应缴部分|员工当月个人宿舍居住期间水电费用|因员工工作失职给公司造成经济损失,公司依法要求赔偿的|员工向公司借款,按约定可从工资中直接扣还的,A|B|C|D,4,2025-12-19,根据《员工手册》第三章第一条第5款,A、B、C、D选项均属于可扣除工资的情形。
|
||||
根据《员工手册》,员工在离职时必须办理的交接手续包括哪些?,多选题,人事管理,交还所有公司资料、文件、办公用品及其它公物|向指定的同事交接经手过的工作事项|归还公司欠款|租住公司宿舍的应退还公司宿舍及房内公物,A|B|C|D,4,2025-12-19,根据《员工手册》第二章第八条第1款,A、B、C、D均为离职交接必须包含的事项。
|
||||
在《大区经理如何带团队》PPT中,提到的培训时机包括哪些?,多选题,管理知识,新人入职时|下属不胜任时|创新或变革时|每周例会时,A|B|C,3,2025-12-19,根据PPT内容,培训的三个时机是:1.新人入职者;2.下属不胜任时;3.创新或变革时。
|
||||
根据《员工手册》,以下哪些行为属于“轻微过失”?,多选题,奖惩规定,上班或培训迟到2小时以内一个月达到2次|工作时间睡觉或从事与工作无关的活动|在公司范围内喧哗吵闹|不保持储物箱或工作范围内的卫生,A|B|C|D,4,2025-12-19,根据《员工手册》第七章第二条第1款,A对应第(1)项,B对应第(4)项,C对应第(7)项,D对应第(8)项,均属于“轻微过失”。
|
||||
根据《开票与收款流程规范通知》,若客户要求全部开具13%税率的货物发票,公司需要如何处理?,多选题,业务流程,直接同意客户要求|需加收额外税金|加收税金计算公式为:合同金额 ÷1.13×0.05|需经总经理特批,B|C,2,2025-12-19,根据通知第四条第2点,若客户要求全部开货物发票(13%税率),需加收额外税金,计算公式为:合同金额 ÷1.13×0.05。
|
||||
公司《员工手册》适用于所有与公司建立合作关系的外部供应商。,判断题,人事管理,正确,错误,B,1,2025-12-19,错误。根据《员工手册》第一章总则第1条,本手册适用于与本公司直接建立劳动关系的员工。
|
||||
肖瑞梅女士被任命为宝来威科技(惠州)有限公司业务助理(BA)主管,该任命自2025年3月13日起生效。,判断题,人事管理,正确,错误,B,1,2025-12-19,错误。根据《关于人事任命的通知》,任命决定从2025年4月1日起生效执行,而非发布日(2025年3月13日)。
|
||||
根据公司考勤制度,员工下班后30分钟内打卡即为有效考勤登记。,判断题,考勤制度,正确,错误,A,1,2025-12-19,正确。根据《员工手册》第四章第二条第1款及新员工培训PPT,按正常下班时间,在下班后30分钟内打卡即为有效考勤登记。
|
||||
“拥抱变化”是宝来威公司的核心价值观之一。,判断题,公司文化,正确,错误,A,1,2025-12-19,正确。根据《宝来威新员工入职培训PPT.pptx》和《宝来威企业文化2023.docx》,“拥抱变化”是公司六大核心价值观之一。
|
||||
员工请病假,必须出具区(县)级以上医院的诊断证明或病假条。,判断题,考勤制度,正确,错误,A,1,2025-12-19,正确。根据《员工手册》第四章第三条第4款,员工请病假,须出具区(县)级以上医院的诊断证明或病假条。
|
||||
根据GSA模型,策略(Strategy)是指实现目标的具体行动方案。,判断题,管理知识,正确,错误,B,1,2025-12-19,错误。根据《从业务尖兵到团队教练.pptx》,策略(Strategy)是“实现目标的核心策略是什么?”,而具体行动方案对应的是行动(Action)。
|
||||
公司实行薪资公开制度,鼓励员工相互了解薪资水平以促进公平。,判断题,薪资福利,正确,错误,B,1,2025-12-19,错误。根据《员工手册》第三章第一条第2款,公司实行薪资保密制度,禁止员工间相互询问、探听和议论彼此的薪资状况。
|
||||
对于“低意愿,高能力”的员工,PPT中建议采用“授权式”领导风格。,判断题,管理知识,正确,错误,B,1,2025-12-19,错误。根据《从业务尖兵到团队教练.pptx》,对“低意愿,高能力(老油条)”的策略是“倾听参与,激励动机”,对应“支持式(Supporting)”领导风格。
|
||||
在《大区经理如何带团队》PPT中认为,“不教而诛”(除名、处理下属)是一种极端不负责任的表现。,判断题,管理知识,正确,错误,A,1,2025-12-19,正确。PPT原文明确指出:“‘不教而诛’(除名、处理下属)是一种极端不负责任的表现”。
|
||||
公司报销流程中,费用发生原则上需要两人或两人以上共同参与执行并互相监督。,判断题,报销流程,正确,错误,A,1,2025-12-19,正确。根据《宝来威新员工入职培训PPT.pptx》中“报销流程”部分,费用发生过程中,原则上必需由两人或两人以上同时参与或执行,并互相监督。
|
||||
请简述宝来威公司的企业愿景和使命。,问答题,公司文化,,成为物联网最具市场及应用价值的领军企业。匠心智造,以科技和创新让生活更有品位。,5,2025-12-19,根据《宝来威新员工入职培训PPT.pptx》及《宝来威企业文化2023.docx》,公司的愿景是“成为物联网最具市场及应用价值的领军企业”,使命是“匠心智造,以科技和创新让生活更有品位”。
|
||||
根据《员工手册》,员工在哪些情况下可以解除劳动合同?请至少列出三种情形。,问答题,人事管理,,1. 未按照劳动合同约定提供劳动保护或者劳动条件的;2. 未及时足额支付劳动报酬的;3. 未依法为员工缴纳社会保险费的;4. 公司以暴力、威胁或者非法限制人身自由的手段强迫员工劳动的;5. 公司违章指挥、强令冒险作业危及员工人身安全的;6. 违反法律、行政法规强制性规定,致使劳动合同无效的。,5,2025-12-19,答案来源于《员工手册》第二章第七条第4款。
|
||||
请描述“有效委派”五个步骤中的“讲清楚”具体是指什么方法?,问答题,管理知识,,使用5W2H法沟通。,2,2025-12-19,根据《从业务尖兵到团队教练.pptx》,“有效委派”的第二步“讲清楚”明确标注为“使用5W2H法沟通”。
|
||||
根据《员工手册》,警告、记过、记大过的有效期是多久?,问答题,奖惩规定,,一年。,2,2025-12-19,根据《员工手册》第七章第二条第4款,“警告、记过、记大过的有效期为一年。”
|
||||
请简述公司考勤制度中关于“旷工”的界定(至少两点)。,问答题,考勤制度,,1. 上班时间开始后,30分钟以后到岗,且未在24小时内补办相关手续者,为旷工半日;60分钟以后到岗,且未在24小时内补办相关手续者,为旷工1日。2. 下班时间结束前离岗者为早退。提前30分钟离岗,且未在24小时内补办相关手续者,为旷工半日;提前60分钟以上离岗,且未在24小时内补办相关手续者,为旷工1日。3. 未请假、请假未获批准或假期已满未续假而擅自缺勤者,以旷工论处。,5,2025-12-19,答案综合自《员工手册》第四章第二条第2款和第6款。
|
||||
在《从业务尖兵到团队教练》PPT中,积极性反馈(BIA)和发展性反馈(BID)分别用于什么场景?,问答题,管理知识,,积极性反馈(BIA)用于认可和强化期望的行为。发展性反馈(BID)用于指出和改进不期望的行为。,4,2025-12-19,根据PPT“高效沟通”部分,BIA用于认可和强化期望的行为;BID用于指出和改进不期望的行为。
|
||||
请列出宝来威公司的六大核心价值观。,问答题,公司文化,,开放包容、团队合作、客户第一、拥抱变化、德才兼备、务实创新。,3,2025-12-19,根据《宝来威新员工入职培训PPT.pptx》及《宝来威企业文化2023.docx》,六大核心价值观为:开放包容、团队合作、客户第一、拥抱变化、德才兼备、务实创新。
|
||||
根据《员工手册》,试用期员工在哪些情况下会被视为不符合录用条件?(至少列出三点),问答题,人事管理,,1. 无法按时提供公司要求的各类入职资料;2. 受到一次记大过处分的;3. 发现有提供虚假应聘信息或伪造证件的;4. 试用期间月迟到、早退等异常考勤累计达3次(含)以上的;或迟到、早退等异常考勤累计达5次(含)以上的;5. 试用期间有旷工行为的;6. 试用期间累计事假超过8天的。,5,2025-12-19,答案来源于《员工手册》第二章第五条第3款。
|
||||
请简述“如何成为一名合格的宝来威人”中提到的“行为标准”和“行为承诺”。,问答题,公司文化,,行为标准:不为错误找借口,只为问题找方案。行为承诺:对自己负责,对家庭负责,对社会负责。,4,2025-12-19,根据《宝来威企业文化2023.docx》,“我们的行为标准”和“我们的行为承诺”内容如上。
|
||||
根据《BLV-20251110-关于规范合同回款及对帐标准通知》,业务员核对客户应收余额表的完成时限是什么?,问答题,业务流程,,每月初第4-5个工作日。,2,2025-12-19,根据通知表格,业务员的工作任务“核对客户应收余额表”,完成时限为“每月初第4-5个工作日”。
|
||||
在《从业务尖兵到团队教练》PPT中,针对“高意愿,高能力”的员工应采用什么领导风格?其核心策略是什么?,问答题,管理知识,,采用授权式(Delegating)领导风格。核心策略是:明确目标,放手去做。,3,2025-12-19,根据PPT“识人善用”部分,对“高意愿,高能力(明星)”的策略是“明确目标,放手去做”,对应“授权式(Delegating)”领导风格。
|
||||
请简述公司报销流程中,对遗失报销单据的处理原则。,问答题,报销流程,,对遗失报销单据的,公司原则上不予报销。但能取得开票单位原发票底单复印件,并能提供开票单位的详细联系资料者,又能提供相关证据和经办人证明的,经总经理批准可以按正常情况给予报销。,5,2025-12-19,根据《宝来威新员工入职培训PPT.pptx》中“报销流程”部分第4点。
|
||||
根据《员工手册》,员工申诉的渠道有哪些?(至少列出两种),问答题,人事管理,,1. 逐级申诉:向各层上级管理人员直至总经理申诉。2. 向人力资源部申诉。3. 向总经理信箱投申诉信。4. 直接向总经理当面申诉。,4,2025-12-19,答案来源于《员工手册》第九章第二条第2款。
|
||||
公司的正常工作时间是如何规定的?,问答题,考勤制度,,上午8:30至12:00,下午13:30至18:00,每日8小时工作制(特殊工时制岗位除外)。,3,2025-12-19,根据《员工手册》第四章第一条及新员工培训PPT,具体工作时间为上午8:30至12:00,下午13:30至18:00。
|
||||
请解释GSA模型中G、S、A分别代表什么,并各举一个例子(例子需来自PPT)。,问答题,管理知识,,G代表Goal(目标),例如:Q2销售额提升20%。S代表Strategy(策略),例如:开拓新渠道、提升老客复购。A代表Action(行动),例如:小王负责拓新,小李策划复购活动。,6,2025-12-19,根据《从业务尖兵到团队教练.pptx》中GSA模型部分的定义和示例。
|
||||
根据《员工手册》,员工离职当月考勤不满勤,会有什么影响?,问答题,人事管理,,离职当月考勤不满勤者,各类补贴不予发放。,2,2025-12-19,根据《员工手册》第二章第八条第4款,“离职当月考勤不满勤者,各类补贴不予发放。”
|
||||
在《大区经理如何带团队》PPT中,当下属不胜任时,管理人员应该怎么做?,问答题,管理知识,,在除名前,不遗余力地赋予下属胜任工作的能力是上司的责任和手段。“不教而诛”(除名、处理下属)是一种极端不负责任的表现。,4,2025-12-19,PPT原文指出:“下属干不好……在除名前,不遗余力地赋予下属胜任工作的能力是上司的责任和手段。‘不教而诛’(除名、处理下属)是一种极端不负责任的表现”。
|
||||
公司对员工仪态仪表有哪些基本要求?(至少列出两点),问答题,行为规范,,1. 每天保持乐观进取的精神面貌。2. 头发必须修剪整齐,清洗干净并且始终保持整洁。3. 上班时间着装需大方、得体。,3,2025-12-19,根据《员工手册》第五章第五条第1款“仪态仪表”部分。
|
||||
根据《开票与收款流程规范通知》,公司标准开票方式的具体构成是什么?,问答题,业务流程,,公司采用含税统一开票方式:50% 货物 + 50% 技术服务费。货物部分开具13%税率增值税发票,技术服务部分开具6%税率增值税发票。,4,2025-12-19,根据通知第四条第1点。
|
||||
请简述“有效委派”五个步骤中的“要反馈”具体是指什么?,问答题,管理知识,,让员工复述,确保理解一致。,2,2025-12-19,根据《从业务尖兵到团队教练.pptx》,“有效委派”的第四步“要反馈”明确标注为“让员工复述,确保理解一致”。
|
||||
根据《员工手册》,员工在什么情况下可以享受年假?,问答题,休假制度,,入职满一年后,享有5天年假,春节期间统一安排。,2,2025-12-19,根据《宝来威新员工入职培训PPT.pptx》中“休假制度”部分,“年假:入职满一年后,享有5天年假,春节期间统一安排。”
|
||||
公司的组织架构中,分管业务与运营的副总经理是谁?他下辖哪些部门?(至少列出三个),问答题,组织架构,,副总经理(分管业务与运营)何锦明。下辖部门包括:人事部、行政部、市场部、国内销售部、商务部、技术服务部等。,4,2025-12-19,根据《组织架构.png》及描述,副总经理(分管业务与运营)为何锦明,下辖部门包括人事、行政、市场、国内销售、商务、技术服务等。
|
||||
请简述公司“团队合作”核心价值观的内涵。,问答题,公司文化,,责任担当,团队协作,每个人有责任,有担当是团队精神的灵魂。,3,2025-12-19,根据《宝来威企业文化2023.docx》,“团队合作:责任担当,团队协作,每个人有责任,有担当是团队精神的灵魂。”
|
||||
根据《员工手册》,对于“多次漏打卡或者连续两个月及以上使用补卡机会”的行为,公司会启动什么机制?,问答题,考勤制度,,公司将启动约谈警示与改进督促机制。由人力资源部门与违规员工进行约谈,明确指出其考勤违规行为对公司管理秩序造成的不良影响,就违规情况在公司内部发布通报,对该员工进行批评教育,并要求该员工签署《考勤合规执行保证书》。,5,2025-12-19,根据《员工手册》第四章第二条第4款详细描述。
|
||||
在《从业务尖兵到团队教练》PPT中,发展性反馈(BID)的三个步骤是什么?,问答题,管理知识,,B(Behavior):陈述具体行为。I(Impact):说明负面影响。D(Desired):明确提出期望。,3,2025-12-19,根据PPT“高效沟通”部分,发展性反馈(BID)的三个步骤是:B(Behavior):陈述具体行为;I(Impact):说明负面影响;D(Desired):明确提出期望。
|
||||
公司报销单填写的基本要求是什么?,问答题,报销流程,,遵照“实事求是、准确无误”的原则,将费用的发生原因、发生金额、发生时间等要素填写齐全,并签署自己的名字,交共同参与的人员复查,并请其在证明人一栏上签署其姓名。“费用报销单”的填写一律不允许涂改,尤其是费用金额,并要保证费用金额的大、小写必须一致。,5,2025-12-19,根据《宝来威新员工入职培训PPT.pptx》中“报销流程”部分第6点。
|
||||
根据《员工手册》,员工在应聘时有哪些情形之一的,将不予录用?(至少列出三点),问答题,人事管理,,1. 未满18周岁者;2. 医院体检不符合公司要求者;3. 与原用人单位未合法终止聘用关系的或与原用人单位有未处理完的纠纷的;4. 经过背景调查,发现应聘者个人简历、求职登记表所列内容与实际情况不符的;5. 触犯刑法未结案或被通缉者;6. 吸食毒品或有严重不良嗜好者。,5,2025-12-19,答案来源于《员工手册》第二章第三条第1至7款。
|
||||
请简述公司“客户第一”核心价值观的内涵。,问答题,公司文化,,以满足客户需求为存在价值,为客户创造实际利益。,2,2025-12-19,根据《宝来威企业文化2023.docx》,“客户第一:以满足客户需求为存在价值,为客户创造实际利益。”
|
||||
根据《员工手册》,对于“警告”处分,其有效期是多久?,问答题,奖惩规定,,一年。,1,2025-12-19,根据《员工手册》第七章第二条第4款,“警告、记过、记大过的有效期为一年。”
|
||||
公司的产品主要应用于哪个领域?,问答题,业务范围,,酒店智能化领域(酒店客房智能控制系统相关产品)。,2,2025-12-19,根据《宝来威新员工入职培训PPT.pptx》,“公司简介”部分写明:18年专注于酒店智能化产品……其酒店客房智能控制系统相关产品……
|
||||
在《从业务尖兵到团队教练》PPT中,积极性反馈(BIA)的三个步骤是什么?,问答题,管理知识,,B(Behavior):陈述具体行为。I(Impact):说明积极影响。A(Appreciation):表示感谢鼓励。,3,2025-12-19,根据PPT“高效沟通”部分,积极性反馈(BIA)的三个步骤是:B(Behavior):陈述具体行为;I(Impact):说明积极影响;A(Appreciation):表示感谢鼓励。
|
||||
|
35
data/题库.txt
Normal file
35
data/题库.txt
Normal file
@@ -0,0 +1,35 @@
|
||||
"题目内容","题型","题目类别","选项","标准答案","分值","创建时间","答案解析"
|
||||
"公司的企业精神是哪三个词?","多选题","","A. 同创造|B. 共分享|C. 齐奋斗|D. 齐飞扬","A,B,D","","2025-12-19","源自《企业文化》图。"
|
||||
"以下哪些是宝来威的六大核心价值观?(请选出所有正确的)","多选题","","A. 开放包容|B. 团队合作|C. 德才兼备|D. 客户第一|E. 拥抱变化|F. 务实创新|G. 业绩为王","A,B,C,D,E,F","","2025-12-19","源自《核心价值观》图。"
|
||||
"关于宝来威商标,以下哪些描述正确?","多选题","","A. 标准色是马尔斯绿(Mars Green)|B. 象征力量、财富、神秘、奢华|C. RGB色值为(0,140,140)|D. 是“全屋弱电调光领导品牌”","A,B,C,D","","2025-12-19","综合《商标的含义》图信息。"
|
||||
"公司产品发展的五个方向是?","多选题","","A. 智能持续创新|B. 强化节能技术|C. 优化用户体验|D. 软件管理平台|E. 全栈解决方案|F. 扩大生产规模","A,B,C,D,E","","2025-12-19","源自《产品方向》图。"
|
||||
"公司的“解决方案”产品线包括哪些系统?","多选题","","A. PWM全屋调光系统|B. 公区照明管理系统|C. 弱电PLC全屋调光系统|D. 无线旧酒店改造方案","A,B,C,D","","2025-12-19","源自《公司产品线》图。"
|
||||
"公司的软件平台产品包括?","多选题","","A. 威云平台|B. 宝镜系统|C. 投诉拦截系统|D. 能耗管理平台","A,B,C,D","","2025-12-19","源自《公司产品线》图。"
|
||||
"公司的业务线包括哪些?","多选题","","A. 酒店智能化等一站式方案设计输出|B. 套房智能化、公区照明等硬件产品供应|C. 弱电施工、安装、调试、维保|D. 酒店品牌运营管理","A,B,C","","2025-12-19","源自《公司业务线》图。"
|
||||
"公司的价值承诺包括哪些?","多选题","","A. 17年专业团队全方位服务|B. 酒店全案方案设计输出|C. 丰富的国内外项目落地经验|D. 赋能客户组建团队,开拓市场","A,B,C,D","","2025-12-19","源自《公司产品线》“公司价值”部分。"
|
||||
"2015-2019年“标新立异”阶段推出的产品有?","多选题","","A. A系列主机(32位ARM芯片)|B. B型PLC全屋调光系统|C. PWM全屋调光系统|D. 航空管理系统","A,B,C","","2025-12-19","D项是2021年后产品。"
|
||||
"“项目落地经验”的价值包括哪些方面?","多选题","","A. 仅限国内项目|B. 国内外项目经验|C. 仅限高端酒店项目|D. 落地实施经验","B,D","","2025-12-19","源自《公司产品线》图“国内外项目落地实施经验”。"
|
||||
"以下哪些是公司实力或成就?","多选题","","A. 17年专注酒店业|B. 自购独栋厂房|C. 产品覆盖40万+客房|D. 拥有850个+合作伙伴|E. 科研投入占销售额10%","A,B,C,D,E","","2025-12-19","综合《关于宝来威》图全部信息。"
|
||||
"公司的发展历程中,哪些关键决策体现了“务实创新”?","多选题","","A. 2010年建立自己的生产链|B. 2014年注册宝来威品牌,聚焦酒店业|C. 持续推出A系列、B型PLC、PWM等迭代产品|D. 2024年实现厂办一体","A,B,C,D","","2025-12-19","这些都是在实践中探索、验证并推动公司发展的务实创新之举。"
|
||||
"作为管理者,理解公司发展史有助于我们?","多选题","","A. 了解公司文化基因(如拥抱变化)|B. 更准确地预判公司未来战略方向|C. 在对外沟通时讲述生动的品牌故事|D. 忽略当前的小困难,因为历史上困难更大","A,B,C","","2025-12-19","学习历史是为了传承文化、把握规律、赋能当下,而非忽视当下。"
|
||||
"“自购独栋厂房”对于公司内部运营和团队而言,可能带来哪些积极影响?","多选题","","A. 有利于建立更稳定、标准化的生产与质检环境|B. 为技术研发与生产的一体化快速试制提供了便利|C. 意味着所有员工都必须到厂房车间工作|D. 是公司实力与追求长期经营的实体象征,可增强团队归属感","A,B,D","","2025-12-19","C选项为不合理延伸。"
|
||||
"我们希望新员工能够快速融入“同创造、共分享、齐飞扬”的团队精神。在面试中,哪些表现是积极的信号?","多选题","","A. 在描述过往项目时,频繁使用“我们”而不是“我”。|B. 详细阐述自己在某个项目中的个人功劳,并强调他人的不足。|C. 能具体说明自己如何与同事协作克服了一个困难。|D. 对前公司的团队信息守口如瓶,表示要绝对保密。","A,C","","2025-12-19","考查管理者对“团队精神”具体行为表现的识别能力。"
|
||||
"为了提升团队在“软件管理平台”方面的专业能力,部门经理可以主动争取或组织哪些培训资源?","多选题","","A. 邀请产品经理讲解平台的设计逻辑与客户价值。|B. 组织代码评审会,学习优秀编程实践。|C. 派骨干参加行业技术峰会。|D. 要求员工利用下班时间自学,不予支持。","A,B,C","","2025-12-19","考查管理者在培养员工专业技能上的主动性与资源整合思路。"
|
||||
"为了提高部门周会的效率,使其真正推动工作,可以尝试以下哪些改进?","多选题","","A. 要求每个人提前发送简要汇报,会上只讨论决策和困难。|B. 严格围绕“同步信息、解决问题、确定行动项”三个目的进行。|C. 让每个人轮流详细讲述自己一周的所有工作。|D. 会议结束时,必须明确“谁、在什么时间前、完成什么事”。","A,B,D","","2025-12-19","考查管理者对会议时间这种重要管理工具的效率优化能力。"
|
||||
"一位核心员工提出离职。为做好离职面谈并从中汲取管理改进经验,管理者应关注哪些方面?","多选题","","A. 真诚了解其离职的真实原因(如发展空间、工作内容、团队氛围等)。|B. 感谢其贡献,并了解其认为部门做得好的和有待改进的地方。|C. 极力挽留,并承诺其可能不切实际的条件。|D. 将面谈重点记录下来,用于反思团队管理。","A,B,D","","2025-12-19","考查管理者如何将员工离职这一负面事件,转化为团队诊断和改进的契机。"
|
||||
"为了提升团队的凝聚力和归属感,部门经理可以在公司政策框架内做哪些努力?","多选题","","A. 在团队取得成绩时,及时、具体地公开表扬。|B. 定期进行一对一沟通,关心员工的职业发展和工作感受。|C. 争取资源,组织小型的团队建设或学习分享活动。|D. 建立公平、透明的绩效评价和任务分配机制。","A,B,C,D","","2025-12-19","综合考查管理者在非物质激励、关怀、团队建设和公平性等多个维度的留人策略。"
|
||||
"为支持公司“强化节能技术”的产品方向,采购部在寻源时可以主动关注哪些特性的元器件或合作伙伴?","多选题","","A. 具备相关节能认证或技术优势|B. 提供最低的折扣价格|C. 能与我们的研发团队进行技术对接|D. 完全无需我司进行质量检验","A,C","","2025-12-19","采购需服务于公司战略方向,技术协同和资质符合性比单纯低价更重要。"
|
||||
"在评估供应商时,以下哪些因素体现了“开放包容”与“团队合作”的价值观?","多选题","","A. 愿意与我们共享行业趋势信息,共同改进。|B. 在其遇到临时困难时,我们能基于长期合作给予一定弹性支持。|C. 完全听从我司的所有安排,不提任何意见。|D. 邀请其技术人员参与我们研发初期的讨论。","A,B,D","","2025-12-19","与优秀供应商建立伙伴关系,双向赋能,是价值观在供应链环节的延伸。"
|
||||
"公司“全栈解决方案”的业务模式,可能给财务核算带来哪些新的挑战或要求?","多选题","","A. 需要更精细化的项目全周期成本归集|B. 设计、设备、施工等不同板块的收入确认规则可能不同|C. 发票数量会减少|D. 需要评估长期维保服务的成本计提","A,B,D","","2025-12-19","业务复杂化对财务管理的精细化、合规性提出更高要求。"
|
||||
"为支持公司“持续创新”,财务部可以在预算和激励制度设计上做出哪些安排?","多选题","","A. 设立面向基层的“微创新”小额奖励基金,快速审批。|B. 为确定的研发项目规划相对独立的、受保护的预算空间。|C. 要求所有创新项目必须在第一个季度实现盈利。|D. 将创新成果产生的效益与团队激励进行一定比例的挂钩。","A,B,D","","2025-12-19","通过财务工具营造有利于创新的机制和环境。"
|
||||
"为了实践“智能持续创新”,研发部门的管理者应在团队内倡导哪些工作习惯?","多选题","","A. 鼓励跟踪行业最新技术趋势并分享|B. 建立“快速原型-测试-反馈”的迭代机制|C. 要求所有代码一次写成,永不修改|D. 对失败的技术尝试进行有价值的复盘","A,B,D","","2025-12-19","创新需要信息输入、敏捷方法和学习文化,而非追求不切实际的一次完美。"
|
||||
"以下哪些做法符合“务实创新”价值观在研发管理中的体现?","多选题","","A. 为解决一个常见的现场调试难题,开发一个小型便携工具。|B. 为了发表论文,投入资源研究一项与现有产品线无关的前沿技术。|C. 优化算法,将现有产品的响应速度提升30%,而不改变硬件成本。|D. 抄袭竞品功能,快速上线。","A,C","","2025-12-19","“务实创新”强调基于实际业务痛点、能够产生实际价值的改进。"
|
||||
"项目交付阶段,“威云平台”的顺利移交和培训,对于保障客户“运维管理”体验至关重要。交付团队应做好哪些工作?","多选题","","A. 提供清晰的操作文档和培训视频|B. 为客户指定明确的线上支持接口|C. 告诉客户“很简单,自己看就会”|D. 进行实战化操作演示并答疑","A,B,D","","2025-12-19","交付不仅是物理安装,更是知识转移和服务承诺的起点,决定了客户的第一印象。"
|
||||
"在项目现场,工程师的哪些行为直接代表着公司的品牌形象?","多选题","","A. 穿着统一工服,佩戴工牌。|B. 与酒店方沟通专业、耐心、有礼。|C. 施工结束后清理现场,恢复整洁。|D. 私下向客户抱怨公司政策。","A,B,C","","2025-12-19","一线员工是品牌的活名片,其专业素养和服务精神直接影响客户感知。"
|
||||
"为了提升交付效率和质量,工程部可以推动哪些标准化工作?","多选题","","A. 标准施工工艺指南|B. 常用故障排查手册|C. 项目文档模板包|D. 依赖于老师傅的个人经验","A,B,C","","2025-12-19","将个人经验转化为组织资产,是部门能力建设的关键,减少对个人的依赖。"
|
||||
"品质管控应贯穿全过程。除了出厂检验,品质部还可以在哪些环节提前介入创造价值?","多选题","","A. 参与研发阶段的设计评审和测试标准制定|B. 审核关键供应商的生产与质检体系|C. 分析项目现场反馈的故障数据,推动源头改进|D. 只负责生产线最后一道关卡","A,B,C","","2025-12-19","现代品质管理是预防性的,源头管控和全过程参与才能创造最大价值。"
|
||||
"公司的“自购厂房”为品质部实施有效管控提供了哪些便利条件?","多选题","","A. 可以建立并贯彻统一、稳定的生产环境标准。|B. 能够对全过程进行更直接、及时的监督与数据采集。|C. 可以完全替代对供应商的品质管理。|D. 便于开展针对生产员工的系统性质量培训。","A,B,D","","2025-12-19","自有制造基地使过程质量管理更深入、更体系化。C选项不正确,外购件仍需管理。"
|
||||
"业务人员在推广“全栈解决方案”时,与单纯销售产品相比,需要额外掌握哪些能力?","多选题","","A. 理解酒店投资和运营的基本逻辑|B. 具备初步的解决方案设计与整合思维|C. 只记住产品报价单即可|D. 协调内部设计、技术等多部门资源的能力","A,B,D","","2025-12-19","销售解决方案实质是销售专业能力与信任,需要更广的知识面和资源整合力。"
|
||||
"公司的“软件管理平台”可以成为业务人员开拓市场的有力武器,因为它能帮助客户解决哪些痛点?","多选题","","A. 多个系统数据孤岛,管理复杂|B. IT运维人员难招、成本高|C. 无法实时掌握能耗、设备状态等运营数据|D. 客房内的电视机频道不够多","A,B,C","","2025-12-19","业务人员需要清晰传递产品解决的客户业务问题,而非仅仅介绍功能。"
|
||||
"以下哪些是有效的客户关系维护方式,符合“客户第一”的价值观?","多选题","","A. 节假日发送定制化的问候。|B. 定期分享行业趋势、新产品资讯或节能小贴士等有价值信息。|C. 项目完成后就不再主动联系,除非有问题。|D. 当客户遇到非我司直接造成的运营问题时,也提供力所能及的咨询建议。","A,B,D","","2025-12-19","客户关系维护的核心是持续提供价值、表达关注,成为客户信赖的伙伴。"
|
||||
"新员工培训中,除了公司介绍,哪些内容能帮助其快速理解公司如何运作?","多选题","","A. 讲解“品牌→方案→落地→数据报表”的核心能力闭环|B. 剖析一个典型的跨部门项目完整流程|C. 背诵所有规章制度|D. 介绍主要部门的职责与协作关系","A,B,D","","2025-12-19","帮助新员工建立系统观和全局视角,理解个人工作在价值链中的位置,比单纯记忆制度更重要。"
|
||||
Binary file not shown.
53
docs/design-system.md
Normal file
53
docs/design-system.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# UI Design System & Brand Guidelines
|
||||
|
||||
## 1. Brand Identity
|
||||
The visual identity is centered around **Mars Green**, representing a professional, modern, and technological atmosphere.
|
||||
|
||||
### Colors
|
||||
- **Primary (Mars Green)**: `#008C8C` (Base), `#00A3A3` (Light), `#006666` (Dark)
|
||||
- **Neutral**: Slate Grays for text and borders.
|
||||
- **Background**: Very light cool grays (`#F9FAFB` or Mars-tinted whites).
|
||||
|
||||
### Logo Usage
|
||||
- **Primary Logo**:
|
||||
- **Dimensions**: 100px width x 40px height.
|
||||
- **Placement**: Top Navigation Bar (Left).
|
||||
- **Style**: Contains watermark "Placeholder" at 15% opacity.
|
||||
- **Secondary Logo**:
|
||||
- **Dimensions**: 80px width x 30px height.
|
||||
- **Placement**: Footer (Right).
|
||||
- **Style**: Simplified version.
|
||||
|
||||
## 2. Layout & Spacing
|
||||
- **Grid System**: 8px baseline. All margins and paddings should be multiples of 8px (e.g., 8, 16, 24, 32).
|
||||
- **Container**: Max-width 1200px for main content areas.
|
||||
- **Responsive**:
|
||||
- Mobile (<768px): 16px padding.
|
||||
- Desktop (>=768px): 24px+ padding.
|
||||
|
||||
## 3. Typography
|
||||
- **Font Family**: Inter, system-ui, sans-serif.
|
||||
- **Base Size**: 14px (Body), 16px (Inputs/Buttons).
|
||||
- **Headings**:
|
||||
- H1: 24px/32px Bold
|
||||
- H2: 20px/28px Bold
|
||||
- H3: 16px/24px SemiBold
|
||||
|
||||
## 4. Components
|
||||
- **Buttons**:
|
||||
- Height: 40px (Default), 48px (Large/Mobile).
|
||||
- Radius: 8px.
|
||||
- Shadow: Soft colored shadow on primary buttons.
|
||||
- **Cards**:
|
||||
- Radius: 12px.
|
||||
- Shadow: Multi-layered soft shadow.
|
||||
- Border: None or 1px solid gray-100.
|
||||
- **Inputs**:
|
||||
- Height: 40px.
|
||||
- Radius: 8px.
|
||||
- Border: Gray-300 default, Mars Green on focus.
|
||||
|
||||
## 5. Implementation Notes
|
||||
- Use `ConfigProvider` in `src/main.tsx` for global Ant Design overrides.
|
||||
- Use `tailwind.config.js` for utility classes (`bg-mars-500`, `text-mars-600`).
|
||||
- Use `src/components/common/Logo.tsx` for all logo instances.
|
||||
35
docs/visual-checklist.md
Normal file
35
docs/visual-checklist.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Visual QA Checklist
|
||||
|
||||
## Brand Consistency
|
||||
- [ ] **Logo Placement**:
|
||||
- Primary Logo (100x40px) is visible in the top left of the Header.
|
||||
- Secondary Logo (80x30px) is visible in the bottom right of the Footer.
|
||||
- Watermark "Placeholder" is visible and rotated inside the logo box.
|
||||
- [ ] **Colors**:
|
||||
- Primary actions (Buttons, Links) use Mars Green (`#008C8C`).
|
||||
- Hover states use darker Mars Green (`#006666`).
|
||||
- Backgrounds use correct subtle gradients or neutral grays.
|
||||
|
||||
## Layout & Spacing
|
||||
- [ ] **Grid Alignment**: Margins and paddings follow 8px increments (8, 16, 24...).
|
||||
- [ ] **Whitespace**: Content has sufficient breathing room (min 24px padding in containers).
|
||||
- [ ] **Alignment**: Form labels and inputs are vertically aligned.
|
||||
|
||||
## Typography
|
||||
- [ ] **Legibility**: Text contrast passes WCAG AA standards (Gray-600+ on White).
|
||||
- [ ] **Hierarchy**: Headings are distinct from body text.
|
||||
|
||||
## Responsive Behavior
|
||||
- [ ] **Mobile (<768px)**:
|
||||
- Sidebar collapses correctly (Admin).
|
||||
- Header adjusts layout (Logo/User info alignment).
|
||||
- Touch targets (Buttons, Inputs) are at least 48px height.
|
||||
- Padding reduces to 16px to maximize content width.
|
||||
- [ ] **Desktop**:
|
||||
- Sidebar expands correctly.
|
||||
- Content is centered with max-width restriction where appropriate.
|
||||
|
||||
## Interaction
|
||||
- [ ] **Hover Effects**: Buttons and Cards show subtle state changes on hover.
|
||||
- [ ] **Focus States**: Inputs show Mars Green outline on focus.
|
||||
- [ ] **Transitions**: Smooth transitions (0.2s - 0.3s) for all interactive elements.
|
||||
38
openspec/changes/user-group-management/proposal.md
Normal file
38
openspec/changes/user-group-management/proposal.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Change: User Group Management System
|
||||
|
||||
## Status
|
||||
**Pending Testing** (Note: Add User Group functionality is not yet tested)
|
||||
|
||||
## Why
|
||||
To manage users more efficiently by grouping them and assigning exam tasks to groups.
|
||||
|
||||
## What Changes
|
||||
1. **User Group Management**:
|
||||
* Add "User Group" management module in User Management interface.
|
||||
* Support CRUD for user groups.
|
||||
* System built-in "All Users" group.
|
||||
2. **User-Group Association**:
|
||||
* Show user groups in user details.
|
||||
* Support multi-select for user groups.
|
||||
* Audit log for group changes.
|
||||
3. **Exam Task Assignment**:
|
||||
* Support assigning tasks by individual users and user groups.
|
||||
* Handle duplicate selections (user in selected group).
|
||||
* Show unique user count.
|
||||
4. **Permissions**:
|
||||
* Admin only for managing groups.
|
||||
5. **Data Consistency**:
|
||||
* Cascade delete for groups and users.
|
||||
* Protect "All Users" group.
|
||||
|
||||
## Impact
|
||||
- Affected specs: user-group
|
||||
- Affected code:
|
||||
- api/database/index.ts
|
||||
- api/models/userGroup.ts
|
||||
- api/controllers/userGroupController.ts
|
||||
- api/controllers/userController.ts
|
||||
- api/controllers/examTaskController.ts
|
||||
- src/pages/admin/UserManagePage.tsx
|
||||
- src/pages/admin/UserGroupManage.tsx
|
||||
- src/pages/admin/ExamTaskPage.tsx
|
||||
@@ -0,0 +1,44 @@
|
||||
# Spec: User Group Management
|
||||
|
||||
## 1. User Group Management Functionality
|
||||
- **Module**: New "User Group" management module in User Management interface.
|
||||
- **CRUD**: Support Create, Read, Update, Delete for user groups.
|
||||
- **Group Info**: Group Name, Description, Created Time.
|
||||
- **"All Users" Special Group**:
|
||||
- All users automatically belong to this group.
|
||||
- Users cannot voluntarily exit this group.
|
||||
- New users automatically join this group.
|
||||
- This group cannot be deleted.
|
||||
|
||||
## 2. User-Group Association Management
|
||||
- **Display**: User details page shows the list of user groups.
|
||||
- **Assignment**: Support multi-select to set user groups.
|
||||
- **Multi-group**: Single user can belong to multiple groups.
|
||||
- **Audit**: Log user group changes (Audit log).
|
||||
|
||||
## 3. Exam Task Assignment Functionality
|
||||
- **Assignment Methods**:
|
||||
- Select by Individual User.
|
||||
- Select by User Group.
|
||||
- **Hybrid Selection**: Support selecting both specific users and user groups simultaneously.
|
||||
- **Deduplication**:
|
||||
- System automatically removes duplicates when a user is selected directly and also belongs to a selected group.
|
||||
- Keep only one instance in the task assignment result.
|
||||
- Record original selection in assignment log.
|
||||
|
||||
## 4. Permissions & Verification
|
||||
- **Admin**: Adding/Modifying user groups requires administrator permissions.
|
||||
- **Scope**: Users can only view groups they have permission to manage.
|
||||
- **Verification**: Both Frontend and Backend must verify user group operation permissions.
|
||||
|
||||
## 5. Data Consistency Assurance
|
||||
- **Delete Group**: Automatically remove all user associations when a group is deleted.
|
||||
- **Delete User**: Automatically remove from all user groups when a user is deleted.
|
||||
- **Protection**: Built-in "All Users" group cannot be modified or deleted.
|
||||
|
||||
## 6. Interface Design Requirements
|
||||
- **Component**: Use multi-select component for user group selection.
|
||||
- **Identification**: Clearly identify the "All Users" special group.
|
||||
- **Count Display**: Clearly display the final actual assigned user count (after deduplication) in the Exam Task Assignment interface.
|
||||
|
||||
**Note**: The "Add User Group" functionality has been implemented but is currently **untested**.
|
||||
75
src/components/common/Logo.tsx
Normal file
75
src/components/common/Logo.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
|
||||
interface LogoProps {
|
||||
variant?: 'primary' | 'secondary';
|
||||
className?: string;
|
||||
theme?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand Logo Component
|
||||
*
|
||||
* Implements the placeholder specification:
|
||||
* - Primary: 100x40px, for Header
|
||||
* - Secondary: 80x30px, for Footer
|
||||
* - Contains "Placeholder" watermark at 15% opacity
|
||||
*/
|
||||
export const Logo: React.FC<LogoProps> = ({
|
||||
variant = 'primary',
|
||||
className = '',
|
||||
theme = 'light'
|
||||
}) => {
|
||||
const isPrimary = variant === 'primary';
|
||||
|
||||
// Dimensions
|
||||
const width = isPrimary ? 100 : 80;
|
||||
const height = isPrimary ? 40 : 30;
|
||||
|
||||
// Styles
|
||||
const containerStyle: React.CSSProperties = {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
backgroundColor: '#f3f4f6', // gray-100
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
border: '1px dashed #d1d5db', // gray-300
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const watermarkStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
color: '#000',
|
||||
opacity: 0.15,
|
||||
fontSize: isPrimary ? '12px' : '10px',
|
||||
fontWeight: 'bold',
|
||||
transform: 'rotate(-15deg)',
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none',
|
||||
};
|
||||
|
||||
const textStyle: React.CSSProperties = {
|
||||
color: '#008C8C', // Mars Green
|
||||
fontWeight: 700,
|
||||
fontSize: isPrimary ? '16px' : '12px',
|
||||
zIndex: 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`logo-placeholder ${className}`}
|
||||
style={containerStyle}
|
||||
title="Logo Placeholder (Microsoft style)"
|
||||
>
|
||||
<div style={watermarkStyle}>Placeholder</div>
|
||||
<span style={textStyle}>
|
||||
{/* Simulating a logo icon/text structure */}
|
||||
{isPrimary ? 'LOGO' : 'Logo'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,23 +2,17 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 自定义样式 */
|
||||
.ant-card {
|
||||
border-radius: 8px;
|
||||
@layer base {
|
||||
body {
|
||||
@apply antialiased text-gray-800 bg-gray-50;
|
||||
font-feature-settings: "cv11", "ss01";
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ant-input, .ant-input-number, .ant-select-selector {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
/* 移动端适配优化 */
|
||||
@media (max-width: 768px) {
|
||||
.ant-card {
|
||||
margin: 8px;
|
||||
/* 移除强制 margin,交给布局控制 */
|
||||
}
|
||||
|
||||
.ant-form-item-label {
|
||||
@@ -26,55 +20,68 @@
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
font-size: 12px;
|
||||
font-size: 13px; /* 稍微调大一点,提升可读性 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
/* 自定义滚动条 - 更加隐形优雅 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
background: #d1d5db;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transform: translateY(10px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
/* 响应式布局容器 */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
padding: 0 24px; /* 增加两边留白 */
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 8px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* User selection scrollable area */
|
||||
.user-select-scrollable .ant-select-selector {
|
||||
max-height: 120px;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* Brand Utilities */
|
||||
.text-mars {
|
||||
color: #008C8C;
|
||||
}
|
||||
.bg-mars {
|
||||
background-color: #008C8C;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Layout, Menu, Button, Avatar, Dropdown, message } from 'antd';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
QuestionCircleOutlined,
|
||||
SettingOutlined,
|
||||
BarChartOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
@@ -12,11 +11,14 @@ import {
|
||||
SafetyOutlined,
|
||||
BookOutlined,
|
||||
CalendarOutlined,
|
||||
TeamOutlined
|
||||
TeamOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useAdmin } from '../contexts';
|
||||
import { Logo } from '../components/common/Logo';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Header, Sider, Content, Footer } = Layout;
|
||||
|
||||
const AdminLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -89,11 +91,20 @@ const AdminLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider trigger={null} collapsible collapsed={collapsed} theme="light">
|
||||
<div className="logo p-4 text-center">
|
||||
<h2 className="text-lg font-bold text-blue-600 m-0">
|
||||
{collapsed ? '问卷' : '问卷系统'}
|
||||
</h2>
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
theme="light"
|
||||
className="shadow-md z-10"
|
||||
width={240}
|
||||
>
|
||||
<div className="h-16 flex items-center justify-center border-b border-gray-100">
|
||||
{collapsed ? (
|
||||
<span className="text-xl font-bold text-mars-500">OA</span>
|
||||
) : (
|
||||
<Logo variant="primary" />
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
@@ -101,34 +112,49 @@ const AdminLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{ borderRight: 0 }}
|
||||
className="py-4"
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
<Layout>
|
||||
<Header className="bg-white shadow-sm flex justify-between items-center px-6">
|
||||
<Layout className="bg-gray-50/50">
|
||||
<Header className="bg-white shadow-sm flex justify-between items-center px-6 h-16 sticky top-0 z-10">
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <DashboardOutlined /> : <DashboardOutlined />}
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="text-lg"
|
||||
className="text-lg w-10 h-10 flex items-center justify-center"
|
||||
/>
|
||||
|
||||
<div className="flex items-center">
|
||||
<span className="mr-4 text-gray-600">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-600 hidden sm:block">
|
||||
欢迎,{admin?.username}
|
||||
</span>
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<Avatar icon={<UserOutlined />} className="cursor-pointer" />
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight" arrow>
|
||||
<Avatar
|
||||
icon={<UserOutlined />}
|
||||
className="cursor-pointer bg-mars-100 text-mars-600 hover:bg-mars-200 transition-colors"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Content className="m-6 p-6 bg-white rounded-lg shadow-sm">
|
||||
{children}
|
||||
<Content className="m-6 flex flex-col">
|
||||
<div className="flex-1 bg-white rounded-xl shadow-sm p-6 min-h-[calc(100vh-160px)]">
|
||||
{children}
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
<Footer className="bg-transparent text-center py-6 px-8 text-gray-400 text-sm flex flex-col md:flex-row justify-between items-center">
|
||||
<div>
|
||||
© {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
|
||||
</div>
|
||||
<div className="mt-4 md:mt-0">
|
||||
<Logo variant="secondary" />
|
||||
</div>
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
export default AdminLayout;
|
||||
|
||||
28
src/layouts/UserLayout.tsx
Normal file
28
src/layouts/UserLayout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Layout } from 'antd';
|
||||
import { Logo } from '../components/common/Logo';
|
||||
|
||||
const { Header, Content, Footer } = Layout;
|
||||
|
||||
export const UserLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<Layout className="min-h-screen bg-gray-50">
|
||||
<Header className="bg-white shadow-sm flex items-center px-6 h-16 sticky top-0 z-10">
|
||||
<Logo variant="primary" />
|
||||
</Header>
|
||||
|
||||
<Content className="flex flex-col">
|
||||
<div className="flex-1 p-4 md:p-8 bg-gradient-to-br from-mars-50/30 to-white">
|
||||
{children}
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
<Footer className="bg-white border-t border-gray-100 py-6 px-8 flex flex-col md:flex-row justify-between items-center text-gray-400 text-sm">
|
||||
<div className="mb-4 md:mb-0">
|
||||
© {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
|
||||
</div>
|
||||
<Logo variant="secondary" />
|
||||
</Footer>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
35
src/main.tsx
35
src/main.tsx
@@ -13,9 +13,38 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#1890ff',
|
||||
borderRadius: 6,
|
||||
colorPrimary: '#008C8C',
|
||||
colorInfo: '#008C8C',
|
||||
colorLink: '#008C8C',
|
||||
borderRadius: 8,
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, sans-serif',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
controlHeight: 40,
|
||||
controlHeightLG: 48,
|
||||
borderRadius: 8,
|
||||
primaryShadow: '0 2px 0 rgba(0, 140, 140, 0.1)',
|
||||
},
|
||||
Input: {
|
||||
controlHeight: 40,
|
||||
controlHeightLG: 48,
|
||||
borderRadius: 8,
|
||||
},
|
||||
Select: {
|
||||
controlHeight: 40,
|
||||
controlHeightLG: 48,
|
||||
borderRadius: 8,
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 12,
|
||||
boxShadowTertiary: '0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02)',
|
||||
},
|
||||
Layout: {
|
||||
colorBgHeader: '#ffffff',
|
||||
colorBgSider: '#ffffff',
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UserProvider>
|
||||
@@ -28,4 +57,4 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
</ConfigProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Form, Input, Button, message, Typography, AutoComplete } from 'antd';
|
||||
import { Card, Form, Input, Button, message, Typography, AutoComplete, Layout } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useUser } from '../contexts';
|
||||
import { userAPI } from '../services/api';
|
||||
import { validateUserForm } from '../utils/validation';
|
||||
import { Logo } from '../components/common/Logo';
|
||||
|
||||
const { Header, Content, Footer } = Layout;
|
||||
const { Title } = Typography;
|
||||
|
||||
interface LoginHistory {
|
||||
@@ -116,102 +118,111 @@ const HomePage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<Card className="shadow-xl">
|
||||
<div className="text-center mb-6">
|
||||
<Title level={2} className="text-blue-600">
|
||||
问卷调查系统
|
||||
</Title>
|
||||
<p className="text-gray-600 mt-2">
|
||||
请填写您的基本信息开始答题
|
||||
</p>
|
||||
</div>
|
||||
<Layout className="min-h-screen bg-gray-50">
|
||||
<Header className="bg-white shadow-sm flex items-center px-6 h-16 sticky top-0 z-10">
|
||||
<Logo variant="primary" />
|
||||
</Header>
|
||||
|
||||
<Content className="flex items-center justify-center p-4 bg-gradient-to-br from-mars-50 to-white">
|
||||
<div className="w-full max-w-md">
|
||||
<Card className="shadow-xl border-t-4 border-t-mars-500">
|
||||
<div className="text-center mb-8">
|
||||
<Title level={2} className="text-mars-600 !mb-2">
|
||||
问卷调查系统
|
||||
</Title>
|
||||
<p className="text-gray-500">
|
||||
请填写您的基本信息开始答题
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
label="姓名"
|
||||
name="name"
|
||||
rules={[
|
||||
{ required: true, message: '请输入姓名' },
|
||||
{ min: 2, max: 20, message: '姓名长度必须在2-20个字符之间' },
|
||||
{ pattern: /^[\u4e00-\u9fa5a-zA-Z\s]+$/, message: '姓名只能包含中文、英文和空格' }
|
||||
]}
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
>
|
||||
<AutoComplete
|
||||
options={historyOptions}
|
||||
onSelect={handleNameSelect}
|
||||
onChange={handleNameChange}
|
||||
placeholder="请输入您的姓名"
|
||||
size="large"
|
||||
filterOption={(inputValue, option) =>
|
||||
option!.value.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="手机号"
|
||||
name="phone"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的中国手机号' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入11位手机号"
|
||||
size="large"
|
||||
className="rounded-lg"
|
||||
maxLength={11}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="登录密码"
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入登录密码' }
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请输入登录密码"
|
||||
size="large"
|
||||
className="rounded-lg"
|
||||
autoComplete="new-password"
|
||||
visibilityToggle
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
size="large"
|
||||
className="w-full rounded-lg bg-blue-600 hover:bg-blue-700 border-none"
|
||||
<Form.Item
|
||||
label="姓名"
|
||||
name="name"
|
||||
rules={[
|
||||
{ required: true, message: '请输入姓名' },
|
||||
{ min: 2, max: 20, message: '姓名长度必须在2-20个字符之间' },
|
||||
{ pattern: /^[\u4e00-\u9fa5a-zA-Z\s]+$/, message: '姓名只能包含中文、英文和空格' }
|
||||
]}
|
||||
>
|
||||
开始答题
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<AutoComplete
|
||||
options={historyOptions}
|
||||
onSelect={handleNameSelect}
|
||||
onChange={handleNameChange}
|
||||
placeholder="请输入您的姓名"
|
||||
filterOption={(inputValue, option) =>
|
||||
option!.value.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<a
|
||||
href="/admin/login"
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
管理员登录
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item
|
||||
label="手机号"
|
||||
name="phone"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的中国手机号' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入11位手机号"
|
||||
maxLength={11}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="登录密码"
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入登录密码' }
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请输入登录密码"
|
||||
autoComplete="new-password"
|
||||
visibilityToggle
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-2 mt-8">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
className="bg-mars-500 hover:!bg-mars-600 border-none shadow-md h-12 text-lg font-medium"
|
||||
>
|
||||
开始答题
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<a
|
||||
href="/admin/login"
|
||||
className="text-mars-600 hover:text-mars-800 text-sm transition-colors"
|
||||
>
|
||||
管理员登录
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
<Footer className="bg-white border-t border-gray-100 py-6 px-8 flex flex-col md:flex-row justify-between items-center text-gray-400 text-sm">
|
||||
<div className="mb-4 md:mb-0">
|
||||
© {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
|
||||
</div>
|
||||
<Logo variant="secondary" />
|
||||
</Footer>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
export default HomePage;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useUser, useQuiz } from '../contexts';
|
||||
import { quizAPI } from '../services/api';
|
||||
import { questionTypeMap } from '../utils/validation';
|
||||
import { UserLayout } from '../layouts/UserLayout';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
@@ -204,7 +205,7 @@ const QuizPage = () => {
|
||||
className="w-full"
|
||||
>
|
||||
{question.options?.map((option, index) => (
|
||||
<Radio key={index} value={option} className="block mb-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">
|
||||
<Radio key={index} value={option} className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
|
||||
{String.fromCharCode(65 + index)}. {option}
|
||||
</Radio>
|
||||
))}
|
||||
@@ -219,7 +220,7 @@ const QuizPage = () => {
|
||||
className="w-full"
|
||||
>
|
||||
{question.options?.map((option, index) => (
|
||||
<Checkbox key={index} value={option} className="block mb-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors w-full">
|
||||
<Checkbox key={index} value={option} className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors w-full">
|
||||
{String.fromCharCode(65 + index)}. {option}
|
||||
</Checkbox>
|
||||
))}
|
||||
@@ -233,10 +234,10 @@ const QuizPage = () => {
|
||||
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
<Radio value="正确" className="block mb-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">
|
||||
<Radio value="正确" className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
|
||||
正确
|
||||
</Radio>
|
||||
<Radio value="错误" className="block mb-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">
|
||||
<Radio value="错误" className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
|
||||
错误
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
@@ -249,7 +250,7 @@ const QuizPage = () => {
|
||||
value={currentAnswer as string || ''}
|
||||
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
|
||||
placeholder="请输入您的答案..."
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
className="rounded-lg border-gray-300 focus:border-mars-500 focus:ring-mars-500"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -260,12 +261,14 @@ const QuizPage = () => {
|
||||
|
||||
if (loading || !questions.length) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">正在生成试卷...</p>
|
||||
<UserLayout>
|
||||
<div className="flex items-center justify-center h-full min-h-[500px]">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-mars-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">正在生成试卷...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -273,22 +276,22 @@ const QuizPage = () => {
|
||||
const progress = ((currentQuestionIndex + 1) / questions.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<UserLayout>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 头部信息 */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6 border border-gray-100">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">在线答题</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
<p className="text-gray-500 mt-1">
|
||||
第 {currentQuestionIndex + 1} 题 / 共 {questions.length} 题
|
||||
</p>
|
||||
</div>
|
||||
{timeLeft !== null && (
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600">剩余时间</div>
|
||||
<div className={`text-2xl font-bold ${
|
||||
timeLeft < 300 ? 'text-red-600' : 'text-green-600'
|
||||
<div className="text-sm text-gray-500">剩余时间</div>
|
||||
<div className={`text-2xl font-bold tabular-nums ${
|
||||
timeLeft < 300 ? 'text-red-600' : 'text-mars-600'
|
||||
}`}>
|
||||
{formatTime(timeLeft)}
|
||||
</div>
|
||||
@@ -298,31 +301,32 @@ const QuizPage = () => {
|
||||
|
||||
<Progress
|
||||
percent={Math.round(progress)}
|
||||
strokeColor="#3b82f6"
|
||||
strokeColor="#008C8C"
|
||||
trailColor="#f0fcfc"
|
||||
showInfo={false}
|
||||
className="mt-4"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 题目卡片 */}
|
||||
<Card className="shadow-sm">
|
||||
<div className="mb-6">
|
||||
<Card className="shadow-sm border border-gray-100 rounded-xl">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className={`inline-block px-3 py-1 rounded-full text-sm font-medium ${getTagColor(currentQuestion.type)}`}>
|
||||
{questionTypeMap[currentQuestion.type]}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
<span className="text-sm text-gray-500 font-medium">
|
||||
{currentQuestion.score} 分
|
||||
</span>
|
||||
</div>
|
||||
{currentQuestion.category && (
|
||||
<div className="mb-3">
|
||||
<span className="inline-block px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded">
|
||||
<span className="inline-block px-2 py-1 bg-gray-50 text-gray-500 text-xs rounded border border-gray-100">
|
||||
{currentQuestion.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-lg font-medium text-gray-900 leading-relaxed">
|
||||
<h2 className="text-xl font-medium text-gray-800 leading-relaxed">
|
||||
{currentQuestion.content}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -332,11 +336,12 @@ const QuizPage = () => {
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex justify-between items-center pt-6 border-t border-gray-100">
|
||||
<Button
|
||||
onClick={handlePrevious}
|
||||
disabled={currentQuestionIndex === 0}
|
||||
className="px-6"
|
||||
size="large"
|
||||
className="px-6 h-10 hover:border-mars-500 hover:text-mars-500"
|
||||
>
|
||||
上一题
|
||||
</Button>
|
||||
@@ -347,7 +352,8 @@ const QuizPage = () => {
|
||||
type="primary"
|
||||
onClick={() => handleSubmit()}
|
||||
loading={submitting}
|
||||
className="px-8 bg-blue-600 hover:bg-blue-700 border-none"
|
||||
size="large"
|
||||
className="px-8 h-10 bg-mars-600 hover:bg-mars-700 border-none shadow-md"
|
||||
>
|
||||
提交答案
|
||||
</Button>
|
||||
@@ -355,7 +361,8 @@ const QuizPage = () => {
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleNext}
|
||||
className="px-8 bg-blue-600 hover:bg-blue-700 border-none"
|
||||
size="large"
|
||||
className="px-8 h-10 bg-mars-500 hover:bg-mars-600 border-none shadow-md"
|
||||
>
|
||||
下一题
|
||||
</Button>
|
||||
@@ -364,8 +371,8 @@ const QuizPage = () => {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</UserLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuizPage;
|
||||
export default QuizPage;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useUser } from '../contexts';
|
||||
import { quizAPI } from '../services/api';
|
||||
import { formatDateTime } from '../utils/validation';
|
||||
import { UserLayout } from '../layouts/UserLayout';
|
||||
|
||||
const { Item } = Descriptions;
|
||||
|
||||
@@ -152,25 +153,29 @@ const ResultPage = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">正在加载答题结果...</p>
|
||||
<UserLayout>
|
||||
<div className="flex justify-center items-center h-full min-h-[500px]">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-mars-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">正在加载答题结果...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600 mb-4">答题记录不存在</p>
|
||||
<Button type="primary" onClick={handleBackToHome}>
|
||||
返回首页
|
||||
</Button>
|
||||
<UserLayout>
|
||||
<div className="flex justify-center items-center h-full min-h-[500px]">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600 mb-4">答题记录不存在</p>
|
||||
<Button type="primary" onClick={handleBackToHome} className="bg-mars-500 hover:bg-mars-600">
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,16 +183,16 @@ const ResultPage = () => {
|
||||
const status = record.totalScore >= record.totalCount * 0.6 ? 'success' : 'warning';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<UserLayout>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 结果概览 */}
|
||||
<Card className="shadow-lg mb-8">
|
||||
<Card className="shadow-lg mb-8 rounded-xl border-t-4 border-t-mars-500">
|
||||
<Result
|
||||
status={status as any}
|
||||
title={`答题完成!您的得分是 ${record.totalScore} 分`}
|
||||
subTitle={`正确率 ${correctRate}% (${record.correctCount}/${record.totalCount})`}
|
||||
extra={[
|
||||
<Button key="back" onClick={handleBackToHome} className="mr-4">
|
||||
<Button key="back" type="primary" onClick={handleBackToHome} className="bg-mars-500 hover:bg-mars-600 border-none px-8 h-10">
|
||||
返回首页
|
||||
</Button>
|
||||
]}
|
||||
@@ -195,8 +200,8 @@ const ResultPage = () => {
|
||||
</Card>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<Card className="shadow-lg mb-8">
|
||||
<h3 className="text-lg font-semibold mb-4">答题信息</h3>
|
||||
<Card className="shadow-lg mb-8 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-800 border-l-4 border-mars-500 pl-3">答题信息</h3>
|
||||
<Descriptions bordered column={2}>
|
||||
<Item label="姓名">{user?.name}</Item>
|
||||
<Item label="手机号">{user?.phone}</Item>
|
||||
@@ -208,16 +213,16 @@ const ResultPage = () => {
|
||||
</Card>
|
||||
|
||||
{/* 答案详情 */}
|
||||
<Card className="shadow-lg">
|
||||
<h3 className="text-lg font-semibold mb-4">答案详情</h3>
|
||||
<Card className="shadow-lg rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-800 border-l-4 border-mars-500 pl-3">答案详情</h3>
|
||||
<div className="space-y-4">
|
||||
{answers.map((answer, index) => (
|
||||
<div
|
||||
key={answer.id}
|
||||
className={`p-4 rounded-lg border-2 ${
|
||||
className={`p-4 rounded-lg border ${
|
||||
answer.isCorrect
|
||||
? 'border-green-200 bg-green-50'
|
||||
: 'border-red-200 bg-red-50'
|
||||
? 'border-green-200 bg-green-50/50'
|
||||
: 'border-red-200 bg-red-50/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
@@ -243,7 +248,7 @@ const ResultPage = () => {
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-600">题目:</span>
|
||||
<span className="text-gray-800">{answer.questionContent || '题目内容加载失败'}</span>
|
||||
<span className="text-gray-800 font-medium">{answer.questionContent || '题目内容加载失败'}</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-600">您的答案:</span>
|
||||
@@ -255,10 +260,9 @@ const ResultPage = () => {
|
||||
{renderCorrectAnswer(answer)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-600">得分详情:</span>
|
||||
<span className="text-gray-800">
|
||||
本题分值:{answer.questionScore || 0} 分,你的得分:{answer.score} 分
|
||||
<div className="mb-2 pt-2 border-t border-gray-100 mt-2">
|
||||
<span className="text-gray-500 text-sm">
|
||||
本题分值:{answer.questionScore || 0} 分,你的得分:<span className="font-medium text-gray-800">{answer.score}</span> 分
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,8 +270,8 @@ const ResultPage = () => {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</UserLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResultPage;
|
||||
export default ResultPage;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ClockCircleOutlined, BookOutlined, UserOutlined } from '@ant-design/ico
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { request } from '../utils/request';
|
||||
import { useUserStore } from '../stores/userStore';
|
||||
import { UserLayout } from '../layouts/UserLayout';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -123,168 +124,170 @@ export const SubjectSelectionPage: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
<UserLayout>
|
||||
<div className="flex justify-center items-center h-full min-h-[500px]">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</UserLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<Title level={2} className="text-center mb-2">
|
||||
选择考试科目
|
||||
</Title>
|
||||
<Text type="secondary" className="text-center block">
|
||||
请选择您要参加的考试科目或考试任务
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 考试科目选择 */}
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<BookOutlined className="text-2xl mr-2 text-blue-600" />
|
||||
<Title level={3} className="mb-0">考试科目</Title>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{subjects.map((subject) => (
|
||||
<Card
|
||||
key={subject.id}
|
||||
className={`cursor-pointer transition-all duration-200 ${
|
||||
selectedSubject === subject.id
|
||||
? 'border-blue-500 shadow-lg bg-blue-50'
|
||||
: 'hover:shadow-md'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedSubject(subject.id);
|
||||
setSelectedTask('');
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<Title level={4} className="mb-2">{subject.name}</Title>
|
||||
<Space direction="vertical" size="small" className="mb-3">
|
||||
<div className="flex items-center">
|
||||
<ClockCircleOutlined className="mr-2 text-gray-500" />
|
||||
<Text>{subject.timeLimitMinutes}分钟</Text>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2 text-gray-500">总分:</span>
|
||||
<Text strong>{subject.totalScore}分</Text>
|
||||
</div>
|
||||
</Space>
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="mb-1">题型分布:</div>
|
||||
<div>{formatTypeRatio(subject.typeRatios)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedSubject === subject.id && (
|
||||
<div className="text-blue-600">
|
||||
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<UserLayout>
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<div className="mb-8 text-center">
|
||||
<Title level={2} className="!text-mars-600 mb-2">
|
||||
选择考试科目
|
||||
</Title>
|
||||
<Text type="secondary" className="block text-lg">
|
||||
请选择您要参加的考试科目或考试任务
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 考试任务选择 */}
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<UserOutlined className="text-2xl mr-2 text-green-600" />
|
||||
<Title level={3} className="mb-0">考试任务</Title>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{tasks.map((task) => {
|
||||
const subject = subjects.find(s => s.id === task.subjectId);
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* 考试科目选择 */}
|
||||
<div>
|
||||
<div className="flex items-center mb-6 border-b border-gray-200 pb-2">
|
||||
<BookOutlined className="text-2xl mr-3 text-mars-600" />
|
||||
<Title level={3} className="!mb-0 !text-gray-700">考试科目</Title>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{subjects.map((subject) => (
|
||||
<Card
|
||||
key={task.id}
|
||||
className={`cursor-pointer transition-all duration-200 ${
|
||||
selectedTask === task.id
|
||||
? 'border-green-500 shadow-lg bg-green-50'
|
||||
: 'hover:shadow-md'
|
||||
key={subject.id}
|
||||
className={`cursor-pointer transition-all duration-300 border-l-4 ${
|
||||
selectedSubject === subject.id
|
||||
? 'border-l-mars-500 border-t border-r border-b border-mars-200 shadow-md bg-mars-50'
|
||||
: 'border-l-transparent hover:border-l-mars-300 hover:shadow-md'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedTask(task.id);
|
||||
setSelectedSubject('');
|
||||
setSelectedSubject(subject.id);
|
||||
setSelectedTask('');
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<Title level={4} className="mb-2">{task.name}</Title>
|
||||
<Title level={4} className={`mb-2 ${selectedSubject === subject.id ? 'text-mars-700' : 'text-gray-800'}`}>
|
||||
{subject.name}
|
||||
</Title>
|
||||
<Space direction="vertical" size="small" className="mb-3">
|
||||
<div className="flex items-center">
|
||||
<BookOutlined className="mr-2 text-gray-500" />
|
||||
<Text>{subject?.name || '未知科目'}</Text>
|
||||
<ClockCircleOutlined className="mr-2 text-gray-400" />
|
||||
<Text className="text-gray-600">{subject.timeLimitMinutes}分钟</Text>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ClockCircleOutlined className="mr-2 text-gray-500" />
|
||||
<Text>
|
||||
{new Date(task.startAt).toLocaleDateString()} - {new Date(task.endAt).toLocaleDateString()}
|
||||
</Text>
|
||||
<span className="mr-2 text-gray-400">总分:</span>
|
||||
<Text strong className="text-gray-700">{subject.totalScore}分</Text>
|
||||
</div>
|
||||
{subject && (
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2 text-gray-500">时长:</span>
|
||||
<Text>{subject.timeLimitMinutes}分钟</Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
{subject && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="mb-1">题型分布:</div>
|
||||
<div>{formatTypeRatio(subject.typeRatios)}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-500 bg-gray-50 p-2 rounded">
|
||||
<div className="mb-1 font-medium">题型分布:</div>
|
||||
<div>{formatTypeRatio(subject.typeRatios)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedTask === task.id && (
|
||||
<div className="text-green-600">
|
||||
<div className="w-6 h-6 bg-green-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm">✓</span>
|
||||
{selectedSubject === subject.id && (
|
||||
<div className="text-mars-600">
|
||||
<div className="w-8 h-8 bg-mars-500 rounded-full flex items-center justify-center shadow-sm">
|
||||
<span className="text-white text-lg font-bold">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<Card className="text-center py-8">
|
||||
<Text type="secondary">暂无可用考试任务</Text>
|
||||
</Card>
|
||||
)}
|
||||
{/* 考试任务选择 */}
|
||||
<div>
|
||||
<div className="flex items-center mb-6 border-b border-gray-200 pb-2">
|
||||
<UserOutlined className="text-2xl mr-3 text-mars-400" />
|
||||
<Title level={3} className="!mb-0 !text-gray-700">考试任务</Title>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{tasks.map((task) => {
|
||||
const subject = subjects.find(s => s.id === task.subjectId);
|
||||
return (
|
||||
<Card
|
||||
key={task.id}
|
||||
className={`cursor-pointer transition-all duration-300 border-l-4 ${
|
||||
selectedTask === task.id
|
||||
? 'border-l-mars-400 border-t border-r border-b border-mars-200 shadow-md bg-mars-50'
|
||||
: 'border-l-transparent hover:border-l-mars-300 hover:shadow-md'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedTask(task.id);
|
||||
setSelectedSubject('');
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<Title level={4} className={`mb-2 ${selectedTask === task.id ? 'text-mars-700' : 'text-gray-800'}`}>
|
||||
{task.name}
|
||||
</Title>
|
||||
<Space direction="vertical" size="small" className="mb-3">
|
||||
<div className="flex items-center">
|
||||
<BookOutlined className="mr-2 text-gray-400" />
|
||||
<Text className="text-gray-600">{subject?.name || '未知科目'}</Text>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ClockCircleOutlined className="mr-2 text-gray-400" />
|
||||
<Text className="text-gray-600">
|
||||
{new Date(task.startAt).toLocaleDateString()} - {new Date(task.endAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</div>
|
||||
{subject && (
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2 text-gray-400">时长:</span>
|
||||
<Text className="text-gray-600">{subject.timeLimitMinutes}分钟</Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
{selectedTask === task.id && (
|
||||
<div className="text-mars-400">
|
||||
<div className="w-8 h-8 bg-mars-400 rounded-full flex items-center justify-center shadow-sm">
|
||||
<span className="text-white text-lg font-bold">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<Card className="text-center py-12 bg-gray-50 border-dashed border-2 border-gray-200">
|
||||
<Text type="secondary" className="text-lg">暂无可用考试任务</Text>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center space-x-6">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
className="px-12 h-14 text-lg font-medium shadow-lg hover:scale-105 transition-transform bg-mars-500 hover:bg-mars-600 border-none"
|
||||
onClick={startQuiz}
|
||||
disabled={!selectedSubject && !selectedTask}
|
||||
>
|
||||
开始考试
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="large"
|
||||
className="px-8 h-14 text-lg hover:border-mars-500 hover:text-mars-500"
|
||||
onClick={() => navigate('/tasks')}
|
||||
icon={<UserOutlined />}
|
||||
>
|
||||
查看我的任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center space-x-4">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
className="px-8 h-12 text-lg"
|
||||
onClick={startQuiz}
|
||||
disabled={!selectedSubject && !selectedTask}
|
||||
>
|
||||
开始考试
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="large"
|
||||
className="px-8 h-12 text-lg"
|
||||
onClick={() => navigate('/tasks')}
|
||||
icon={<UserOutlined />}
|
||||
>
|
||||
查看我的任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</UserLayout>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ClockCircleOutlined, BookOutlined, CalendarOutlined, CheckCircleOutline
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { request } from '../utils/request';
|
||||
import { useUserStore } from '../stores/userStore';
|
||||
import { UserLayout } from '../layouts/UserLayout';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -73,7 +74,7 @@ export const UserTaskPage: React.FC = () => {
|
||||
|
||||
if (now < startAt) return 'blue';
|
||||
if (now > endAt) return 'red';
|
||||
return 'green';
|
||||
return 'cyan'; // Using cyan to match Mars Green family better than pure green
|
||||
};
|
||||
|
||||
const getStatusText = (task: ExamTask) => {
|
||||
@@ -99,7 +100,7 @@ export const UserTaskPage: React.FC = () => {
|
||||
key: 'subjectName',
|
||||
render: (text: string) => (
|
||||
<Space>
|
||||
<BookOutlined className="text-blue-600" />
|
||||
<BookOutlined className="text-mars-600" />
|
||||
<Text>{text}</Text>
|
||||
</Space>
|
||||
)
|
||||
@@ -116,7 +117,7 @@ export const UserTaskPage: React.FC = () => {
|
||||
key: 'timeLimitMinutes',
|
||||
render: (minutes: number) => (
|
||||
<Space>
|
||||
<ClockCircleOutlined className="text-gray-600" />
|
||||
<ClockCircleOutlined className="text-gray-500" />
|
||||
<Text>{minutes}分钟</Text>
|
||||
</Space>
|
||||
)
|
||||
@@ -127,13 +128,13 @@ export const UserTaskPage: React.FC = () => {
|
||||
render: (record: ExamTask) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space>
|
||||
<CalendarOutlined className="text-gray-600" />
|
||||
<CalendarOutlined className="text-gray-500" />
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{new Date(record.startAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<CalendarOutlined className="text-gray-600" />
|
||||
<CalendarOutlined className="text-gray-500" />
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{new Date(record.endAt).toLocaleDateString()}
|
||||
</Text>
|
||||
@@ -145,7 +146,7 @@ export const UserTaskPage: React.FC = () => {
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (record: ExamTask) => (
|
||||
<Tag color={getStatusColor(record)}>
|
||||
<Tag color={getStatusColor(record)} className="rounded-full px-3">
|
||||
{getStatusText(record)}
|
||||
</Tag>
|
||||
)
|
||||
@@ -167,6 +168,7 @@ export const UserTaskPage: React.FC = () => {
|
||||
onClick={() => startTask(record)}
|
||||
disabled={!canStart}
|
||||
icon={<CheckCircleOutlined />}
|
||||
className={canStart ? "bg-mars-500 hover:bg-mars-600 border-none" : ""}
|
||||
>
|
||||
{canStart ? '开始考试' : '不可用'}
|
||||
</Button>
|
||||
@@ -178,48 +180,54 @@ export const UserTaskPage: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
<UserLayout>
|
||||
<div className="flex justify-center items-center h-full min-h-[500px]">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</UserLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<Title level={2} className="text-center mb-2">
|
||||
我的考试任务
|
||||
</Title>
|
||||
<Text type="secondary" className="text-center block">
|
||||
查看您被分派的所有考试任务
|
||||
</Text>
|
||||
</div>
|
||||
<UserLayout>
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<div className="mb-8 text-center">
|
||||
<Title level={2} className="!text-mars-600 mb-2">
|
||||
我的考试任务
|
||||
</Title>
|
||||
<Text type="secondary" className="block text-lg">
|
||||
查看您被分派的所有考试任务
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`
|
||||
}}
|
||||
locale={{
|
||||
emptyText: '暂无考试任务'
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<Card className="shadow-md border-t-4 border-t-mars-500 rounded-xl">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`
|
||||
}}
|
||||
locale={{
|
||||
emptyText: '暂无考试任务'
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => navigate('/subjects')}
|
||||
icon={<BookOutlined />}
|
||||
>
|
||||
返回科目选择
|
||||
</Button>
|
||||
<div className="mt-8 text-center">
|
||||
<Button
|
||||
type="default"
|
||||
size="large"
|
||||
onClick={() => navigate('/subjects')}
|
||||
icon={<BookOutlined />}
|
||||
className="px-8 h-12 hover:border-mars-500 hover:text-mars-500"
|
||||
>
|
||||
返回科目选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserLayout>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { adminAPI } from '../../services/api';
|
||||
import { formatDateTime } from '../../utils/validation';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip, Legend } from 'recharts';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip, Legend, Label } from 'recharts';
|
||||
|
||||
interface Statistics {
|
||||
totalUsers: number;
|
||||
@@ -98,7 +98,7 @@ const AdminDashboardPage = () => {
|
||||
title: '得分',
|
||||
dataIndex: 'totalScore',
|
||||
key: 'totalScore',
|
||||
render: (score: number) => <span className="font-semibold text-blue-600">{score} 分</span>,
|
||||
render: (score: number) => <span className="font-semibold text-mars-600">{score} 分</span>,
|
||||
},
|
||||
{
|
||||
title: '正确率',
|
||||
@@ -139,6 +139,7 @@ const AdminDashboardPage = () => {
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchDashboardData}
|
||||
loading={loading}
|
||||
className="bg-mars-500 hover:bg-mars-600"
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
@@ -147,33 +148,33 @@ const AdminDashboardPage = () => {
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={16} className="mb-8">
|
||||
<Col span={8}>
|
||||
<Card className="shadow-sm">
|
||||
<Card className="shadow-sm hover:shadow-md transition-shadow">
|
||||
<Statistic
|
||||
title="总用户数"
|
||||
value={statistics?.totalUsers || 0}
|
||||
prefix={<UserOutlined className="text-blue-500" />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
prefix={<UserOutlined className="text-mars-400" />}
|
||||
valueStyle={{ color: '#008C8C' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="shadow-sm">
|
||||
<Card className="shadow-sm hover:shadow-md transition-shadow">
|
||||
<Statistic
|
||||
title="答题记录"
|
||||
value={statistics?.totalRecords || 0}
|
||||
prefix={<BarChartOutlined className="text-green-500" />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
prefix={<BarChartOutlined className="text-mars-400" />}
|
||||
valueStyle={{ color: '#008C8C' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="shadow-sm">
|
||||
<Card className="shadow-sm hover:shadow-md transition-shadow">
|
||||
<Statistic
|
||||
title="平均得分"
|
||||
value={statistics?.averageScore || 0}
|
||||
precision={1}
|
||||
prefix={<QuestionCircleOutlined className="text-orange-500" />}
|
||||
valueStyle={{ color: '#fa8c16' }}
|
||||
prefix={<QuestionCircleOutlined className="text-mars-400" />}
|
||||
valueStyle={{ color: '#008C8C' }}
|
||||
suffix="分"
|
||||
/>
|
||||
</Card>
|
||||
@@ -186,14 +187,14 @@ const AdminDashboardPage = () => {
|
||||
<Row gutter={16}>
|
||||
{statistics.typeStats.map((stat) => (
|
||||
<Col span={6} key={stat.type}>
|
||||
<Card size="small" className="text-center">
|
||||
<Card size="small" className="text-center hover:shadow-sm transition-shadow">
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
{stat.type === 'single' && '单选题'}
|
||||
{stat.type === 'multiple' && '多选题'}
|
||||
{stat.type === 'judgment' && '判断题'}
|
||||
{stat.type === 'text' && '文字题'}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
<div className="text-2xl font-bold text-mars-600">
|
||||
{stat.correctRate}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
@@ -244,13 +245,13 @@ const AdminDashboardPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="w-32 h-4 bg-gray-200 rounded-full mr-2">
|
||||
<div className="w-32 h-4 bg-gray-200 rounded-full mr-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-600 rounded-full transition-all duration-300"
|
||||
className="h-full bg-mars-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="font-semibold text-blue-600">{progress}%</span>
|
||||
<span className="font-semibold text-mars-600">{progress}%</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -262,41 +263,66 @@ const AdminDashboardPage = () => {
|
||||
// 计算各类人数
|
||||
const total = record.totalUsers;
|
||||
const completed = record.completedUsers;
|
||||
const passed = Math.round(completed * (record.passRate / 100));
|
||||
const excellent = Math.round(completed * (record.excellentRate / 100));
|
||||
const incomplete = total - completed;
|
||||
|
||||
// 准备饼图数据
|
||||
// 原始计算
|
||||
const passedTotal = Math.round(completed * (record.passRate / 100));
|
||||
const excellentTotal = Math.round(completed * (record.excellentRate / 100));
|
||||
|
||||
// 互斥分类计算
|
||||
const incomplete = total - completed;
|
||||
const failed = completed - passedTotal;
|
||||
const passedOnly = passedTotal - excellentTotal;
|
||||
const excellent = excellentTotal;
|
||||
|
||||
// 准备环形图数据 (互斥分类)
|
||||
const pieData = [
|
||||
{ name: '已完成', value: completed, color: '#1890ff' },
|
||||
{ name: '合格', value: passed, color: '#52c41a' },
|
||||
{ name: '优秀', value: excellent, color: '#fa8c16' },
|
||||
{ name: '未完成', value: incomplete, color: '#d9d9d9' }
|
||||
{ name: '优秀', value: excellent, color: '#008C8C' }, // Mars Green (Primary)
|
||||
{ name: '合格', value: passedOnly, color: '#00A3A3' }, // Mars Light
|
||||
{ name: '不及格', value: failed, color: '#ff4d4f' }, // Red (Error)
|
||||
{ name: '未完成', value: incomplete, color: '#f0f0f0' } // Gray
|
||||
];
|
||||
|
||||
// 只显示有数据的项
|
||||
const filteredData = pieData.filter(item => item.value > 0);
|
||||
|
||||
// 计算完成率用于中间显示
|
||||
const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="w-full h-40">
|
||||
<div className="w-full h-20">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={filteredData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={{ stroke: '#999', strokeWidth: 1 }}
|
||||
outerRadius={50}
|
||||
fill="#8884d8"
|
||||
innerRadius={25}
|
||||
outerRadius={35}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={({ name, value }) => `${name}:${value}`}
|
||||
>
|
||||
{filteredData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
<Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
|
||||
))}
|
||||
<Label
|
||||
value={`${completionRate}%`}
|
||||
position="center"
|
||||
className="text-sm font-bold fill-gray-700"
|
||||
/>
|
||||
</Pie>
|
||||
<RechartsTooltip formatter={(value) => [`${value} 人`, '数量']} />
|
||||
<Legend layout="vertical" verticalAlign="middle" align="right" formatter={(value, entry) => `${value} ${entry.payload?.value || 0} 人`} />
|
||||
<RechartsTooltip
|
||||
formatter={(value: any) => [`${value} 人`, '数量']}
|
||||
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', fontSize: '12px', padding: '8px' }}
|
||||
/>
|
||||
<Legend
|
||||
layout="vertical"
|
||||
verticalAlign="middle"
|
||||
align="right"
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
formatter={(value, entry: any) => <span className="text-xs text-gray-600 ml-1">{value} {entry.payload.value}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -328,4 +354,4 @@ const AdminDashboardPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboardPage;
|
||||
export default AdminDashboardPage;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Form, Input, Button, message, Select } from 'antd';
|
||||
import { Card, Form, Input, Button, message, Select, Layout } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAdmin } from '../../contexts';
|
||||
import { adminAPI } from '../../services/api';
|
||||
import { Logo } from '../../components/common/Logo';
|
||||
|
||||
const { Header, Content, Footer } = Layout;
|
||||
|
||||
// 定义登录记录类型 - 不再保存密码
|
||||
interface LoginRecord {
|
||||
@@ -90,109 +93,118 @@ const AdminLoginPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<Card className="shadow-xl">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-blue-600 mb-2">管理员登录</h1>
|
||||
<p className="text-gray-600">请输入管理员账号密码</p>
|
||||
</div>
|
||||
<Layout className="min-h-screen bg-gray-50">
|
||||
<Header className="bg-white shadow-sm flex items-center px-6 h-16 sticky top-0 z-10">
|
||||
<Logo variant="primary" />
|
||||
</Header>
|
||||
|
||||
<Form
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
form={form}
|
||||
>
|
||||
{/* 最近登录记录下拉选择 */}
|
||||
{loginRecords.length > 0 && (
|
||||
<Form.Item label="最近登录" className="mb-4">
|
||||
<Select
|
||||
size="large"
|
||||
placeholder="选择最近登录记录"
|
||||
className="w-full rounded-lg"
|
||||
style={{ height: 'auto' }} // 让选择框高度自适应内容
|
||||
onSelect={(value) => {
|
||||
const record = loginRecords.find(r => `${r.username}-${r.timestamp}` === value);
|
||||
if (record) {
|
||||
handleSelectRecord(record);
|
||||
}
|
||||
}}
|
||||
options={loginRecords.map(record => ({
|
||||
value: `${record.username}-${record.timestamp}`,
|
||||
label: (
|
||||
<div className="flex flex-col p-1" style={{ minHeight: '40px', justifyContent: 'center' }}>
|
||||
<span className="font-medium block">{record.username}</span>
|
||||
<span className="text-xs text-gray-500 block">
|
||||
{new Date(record.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}))}
|
||||
<Content className="flex items-center justify-center p-4 bg-gradient-to-br from-mars-50 to-white">
|
||||
<div className="w-full max-w-md">
|
||||
<Card className="shadow-xl border-t-4 border-t-mars-500">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-mars-600 mb-2">管理员登录</h1>
|
||||
<p className="text-gray-600">请输入管理员账号密码</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
form={form}
|
||||
size="large"
|
||||
>
|
||||
{/* 最近登录记录下拉选择 */}
|
||||
{loginRecords.length > 0 && (
|
||||
<Form.Item label="最近登录" className="mb-4">
|
||||
<Select
|
||||
placeholder="选择最近登录记录"
|
||||
className="w-full"
|
||||
style={{ height: 'auto' }} // 让选择框高度自适应内容
|
||||
onSelect={(value) => {
|
||||
const record = loginRecords.find(r => `${r.username}-${r.timestamp}` === value);
|
||||
if (record) {
|
||||
handleSelectRecord(record);
|
||||
}
|
||||
}}
|
||||
options={loginRecords.map(record => ({
|
||||
value: `${record.username}-${record.timestamp}`,
|
||||
label: (
|
||||
<div className="flex flex-col p-1" style={{ minHeight: '40px', justifyContent: 'center' }}>
|
||||
<span className="font-medium block">{record.username}</span>
|
||||
<span className="text-xs text-gray-500 block">
|
||||
{new Date(record.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="用户名"
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, message: '用户名至少3个字符' }
|
||||
]}
|
||||
className="mb-4"
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入用户名"
|
||||
autoComplete="username" // 正确的自动完成属性
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="用户名"
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, message: '用户名至少3个字符' }
|
||||
]}
|
||||
className="mb-4"
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
className="rounded-lg"
|
||||
autoComplete="username" // 正确的自动完成属性
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="密码"
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码至少6个字符' }
|
||||
]}
|
||||
className="mb-4"
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
className="rounded-lg"
|
||||
autoComplete="new-password" // 防止自动填充密码
|
||||
allowClear // 允许清空密码
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
size="large"
|
||||
className="w-full rounded-lg bg-blue-600 hover:bg-blue-700 border-none"
|
||||
<Form.Item
|
||||
label="密码"
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码至少6个字符' }
|
||||
]}
|
||||
className="mb-4"
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Input.Password
|
||||
placeholder="请输入密码"
|
||||
autoComplete="new-password" // 防止自动填充密码
|
||||
allowClear // 允许清空密码
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<a
|
||||
href="/"
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
返回用户端
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item className="mb-0 mt-8">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
className="bg-mars-500 hover:!bg-mars-600 border-none shadow-md h-12 text-lg font-medium"
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<a
|
||||
href="/"
|
||||
className="text-mars-600 hover:text-mars-800 text-sm transition-colors"
|
||||
>
|
||||
返回用户端
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
<Footer className="bg-white border-t border-gray-100 py-6 px-8 flex flex-col md:flex-row justify-between items-center text-gray-400 text-sm">
|
||||
<div className="mb-4 md:mb-0">
|
||||
© {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
|
||||
</div>
|
||||
<Logo variant="secondary" />
|
||||
</Footer>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLoginPage;
|
||||
export default AdminLoginPage;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, DatePicker, Select } from 'antd';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, DatePicker, Select, Tabs, Tag } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import api from '../../services/api';
|
||||
import api, { userGroupAPI } from '../../services/api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface ExamTask {
|
||||
@@ -16,6 +16,7 @@ interface ExamTask {
|
||||
passRate: number;
|
||||
excellentRate: number;
|
||||
createdAt: string;
|
||||
selectionConfig?: string;
|
||||
}
|
||||
|
||||
interface ExamSubject {
|
||||
@@ -29,28 +30,40 @@ interface User {
|
||||
phone: string;
|
||||
}
|
||||
|
||||
interface UserGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
const ExamTaskPage = () => {
|
||||
const [tasks, setTasks] = useState<ExamTask[]>([]);
|
||||
const [subjects, setSubjects] = useState<ExamSubject[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [reportModalVisible, setReportModalVisible] = useState(false);
|
||||
const [reportData, setReportData] = useState<any>(null);
|
||||
const [editingTask, setEditingTask] = useState<ExamTask | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// Cache for group members to calculate unique users
|
||||
const [groupMembersMap, setGroupMembersMap] = useState<Record<string, string[]>>({});
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [tasksRes, subjectsRes, usersRes] = await Promise.all([
|
||||
const [tasksRes, subjectsRes, usersRes, groupsRes] = await Promise.all([
|
||||
api.get('/admin/tasks'),
|
||||
api.get('/admin/subjects'),
|
||||
api.get('/admin/users'),
|
||||
userGroupAPI.getAll(),
|
||||
]);
|
||||
setTasks(tasksRes.data);
|
||||
setSubjects(subjectsRes.data);
|
||||
setUsers(usersRes.data);
|
||||
setUserGroups(groupsRes);
|
||||
} catch (error) {
|
||||
message.error('获取数据失败');
|
||||
} finally {
|
||||
@@ -62,6 +75,44 @@ const ExamTaskPage = () => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Watch form values for real-time calculation
|
||||
const selectedUserIds = Form.useWatch('userIds', form) || [];
|
||||
const selectedGroupIds = Form.useWatch('groupIds', form) || [];
|
||||
|
||||
// Fetch members when groups are selected
|
||||
useEffect(() => {
|
||||
const fetchMissingGroupMembers = async () => {
|
||||
if (selectedGroupIds.length > 0) {
|
||||
for (const gid of selectedGroupIds) {
|
||||
if (!groupMembersMap[gid]) {
|
||||
try {
|
||||
const members = await userGroupAPI.getMembers(gid);
|
||||
setGroupMembersMap(prev => ({
|
||||
...prev,
|
||||
[gid]: members.map((u: any) => u.id)
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch members for group ${gid}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (modalVisible) {
|
||||
fetchMissingGroupMembers();
|
||||
}
|
||||
}, [selectedGroupIds, modalVisible]);
|
||||
|
||||
const uniqueUserCount = useMemo(() => {
|
||||
const uniqueSet = new Set<string>(selectedUserIds);
|
||||
selectedGroupIds.forEach((gid: string) => {
|
||||
const members = groupMembersMap[gid] || [];
|
||||
members.forEach(uid => uniqueSet.add(uid));
|
||||
});
|
||||
return uniqueSet.size;
|
||||
}, [selectedUserIds, selectedGroupIds, groupMembersMap]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingTask(null);
|
||||
form.resetFields();
|
||||
@@ -71,9 +122,27 @@ const ExamTaskPage = () => {
|
||||
const handleEdit = async (task: ExamTask) => {
|
||||
setEditingTask(task);
|
||||
try {
|
||||
// 获取任务已分配的用户列表
|
||||
const userIdsRes = await api.get(`/admin/tasks/${task.id}/users`);
|
||||
const userIds = userIdsRes.data;
|
||||
// Parse selection config if available
|
||||
let userIds = [];
|
||||
let groupIds = [];
|
||||
|
||||
if (task.selectionConfig) {
|
||||
try {
|
||||
const config = JSON.parse(task.selectionConfig);
|
||||
userIds = config.userIds || [];
|
||||
groupIds = config.groupIds || [];
|
||||
} catch (e) {
|
||||
console.error('Failed to parse selection config', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback or if no selection config (legacy tasks), fetch from API which returns resolved users
|
||||
// But for editing legacy tasks, we might not have group info.
|
||||
// If selectionConfig is missing, we assume individual users only.
|
||||
if (!task.selectionConfig) {
|
||||
const userIdsRes = await api.get(`/admin/tasks/${task.id}/users`);
|
||||
userIds = userIdsRes.data;
|
||||
}
|
||||
|
||||
form.setFieldsValue({
|
||||
name: task.name,
|
||||
@@ -81,17 +150,10 @@ const ExamTaskPage = () => {
|
||||
startAt: dayjs(task.startAt),
|
||||
endAt: dayjs(task.endAt),
|
||||
userIds: userIds,
|
||||
groupIds: groupIds,
|
||||
});
|
||||
} catch (error) {
|
||||
message.error('获取任务用户失败');
|
||||
// 即使获取失败,也要打开模态框,只是用户列表为空
|
||||
form.setFieldsValue({
|
||||
name: task.name,
|
||||
subjectId: task.subjectId,
|
||||
startAt: dayjs(task.startAt),
|
||||
endAt: dayjs(task.endAt),
|
||||
userIds: [],
|
||||
});
|
||||
message.error('获取任务详情失败');
|
||||
}
|
||||
setModalVisible(true);
|
||||
};
|
||||
@@ -119,6 +181,12 @@ const ExamTaskPage = () => {
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (uniqueUserCount === 0) {
|
||||
message.warning('请至少选择一位用户或一个用户组');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...values,
|
||||
startAt: values.startAt.toISOString(),
|
||||
@@ -144,11 +212,15 @@ const ExamTaskPage = () => {
|
||||
title: '任务名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 250,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '考试科目',
|
||||
dataIndex: 'subjectName',
|
||||
key: 'subjectName',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '开始时间',
|
||||
@@ -233,8 +305,9 @@ const ExamTaskPage = () => {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_: any, record: ExamTask) => (
|
||||
<Space>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
@@ -349,32 +422,67 @@ const ExamTaskPage = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="userIds"
|
||||
label="参与用户"
|
||||
rules={[{ required: true, message: '请选择参与用户' }]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="请选择参与用户"
|
||||
style={{ width: '100%' }}
|
||||
showSearch
|
||||
filterOption={(input, option) => {
|
||||
const value = option?.children as string;
|
||||
return value.toLowerCase().includes(input.toLowerCase());
|
||||
}}
|
||||
maxTagCount={3}
|
||||
maxTagPlaceholder={(count) => `+${count} 个用户`}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
||||
virtual
|
||||
<div className="bg-gray-50 p-4 rounded mb-4">
|
||||
<h4 className="mb-2 font-medium">任务分配对象</h4>
|
||||
|
||||
<Form.Item
|
||||
name="groupIds"
|
||||
label="按用户组选择"
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Option key={user.id} value={user.id}>
|
||||
{user.name} ({user.phone})
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="请选择用户组"
|
||||
style={{ width: '100%' }}
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{userGroups.map((group) => (
|
||||
<Select.Option key={group.id} value={group.id}>
|
||||
{group.name} ({group.memberCount}人)
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="userIds"
|
||||
label="按单个用户选择"
|
||||
normalize={(value) => {
|
||||
if (!Array.isArray(value)) return value;
|
||||
return [...value].sort((a, b) => {
|
||||
const nameA = users.find(u => u.id === a)?.name || '';
|
||||
const nameB = users.find(u => u.id === b)?.name || '';
|
||||
return nameA.localeCompare(nameB, 'zh-CN');
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="请选择用户"
|
||||
style={{ width: '100%' }}
|
||||
className="user-select-scrollable"
|
||||
showSearch
|
||||
optionLabelProp="label"
|
||||
filterOption={(input, option) => {
|
||||
const label = option?.label as string;
|
||||
if (label && label.toLowerCase().includes(input.toLowerCase())) return true;
|
||||
const children = React.Children.toArray(option?.children).join('');
|
||||
return children.toLowerCase().includes(input.toLowerCase());
|
||||
}}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
||||
virtual
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Option key={user.id} value={user.id} label={user.name}>
|
||||
{user.name} ({user.phone})
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<div className="mt-2 text-right text-gray-500">
|
||||
实际分配人数(去重后):<span className="font-bold text-blue-600">{uniqueUserCount}</span> 人
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
|
||||
199
src/pages/admin/UserGroupManage.tsx
Normal file
199
src/pages/admin/UserGroupManage.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Tag } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import { userGroupAPI } from '../../services/api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface UserGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isSystem: boolean;
|
||||
createdAt: string;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
const UserGroupManage = () => {
|
||||
const [groups, setGroups] = useState<UserGroup[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<UserGroup | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchGroups = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await userGroupAPI.getAll();
|
||||
setGroups(res);
|
||||
} catch (error) {
|
||||
message.error('获取用户组列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroups();
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingGroup(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (group: UserGroup) => {
|
||||
if (group.isSystem) {
|
||||
message.warning('系统内置用户组无法修改');
|
||||
return;
|
||||
}
|
||||
setEditingGroup(group);
|
||||
form.setFieldsValue({
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await userGroupAPI.delete(id);
|
||||
message.success('删除成功');
|
||||
fetchGroups();
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (editingGroup) {
|
||||
await userGroupAPI.update(editingGroup.id, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await userGroupAPI.create(values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
fetchGroups();
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '组名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text: string, record: UserGroup) => (
|
||||
<Space>
|
||||
{text}
|
||||
{record.isSystem && <Tag color="blue">系统内置</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
},
|
||||
{
|
||||
title: '成员数',
|
||||
dataIndex: 'memberCount',
|
||||
key: 'memberCount',
|
||||
render: (count: number) => (
|
||||
<Space>
|
||||
<TeamOutlined />
|
||||
{count}人
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: UserGroup) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
disabled={record.isSystem}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{!record.isSystem && (
|
||||
<Popconfirm
|
||||
title="确定删除该用户组吗?"
|
||||
description="删除后,组内成员将自动解除与该组的关联"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
新增用户组
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={groups}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingGroup ? '编辑用户组' : '新增用户组'}
|
||||
open={modalVisible}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="组名"
|
||||
rules={[
|
||||
{ required: true, message: '请输入组名' },
|
||||
{ min: 2, max: 20, message: '组名长度在2-20个字符之间' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入组名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<Input.TextArea placeholder="请输入描述" rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserGroupManage;
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Switch, Upload } from 'antd';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Switch, Upload, Tabs, Select, Tag } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ExportOutlined, ImportOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||
import api from '../../services/api';
|
||||
import api, { userGroupAPI } from '../../services/api';
|
||||
import type { UploadProps } from 'antd';
|
||||
import UserGroupManage from './UserGroupManage';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -12,6 +13,7 @@ interface User {
|
||||
createdAt: string;
|
||||
examCount?: number; // 参加考试次数
|
||||
lastExamTime?: string; // 最后一次参加考试时间
|
||||
groups?: any[];
|
||||
}
|
||||
|
||||
interface QuizRecord {
|
||||
@@ -25,21 +27,9 @@ interface QuizRecord {
|
||||
taskName?: string;
|
||||
}
|
||||
|
||||
interface QuizRecordDetail {
|
||||
id: string;
|
||||
question: {
|
||||
content: string;
|
||||
type: string;
|
||||
options?: string[];
|
||||
};
|
||||
userAnswer: string | string[];
|
||||
correctAnswer: string | string[];
|
||||
score: number;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
const UserManagePage = () => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [userGroups, setUserGroups] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
@@ -92,8 +82,18 @@ const UserManagePage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserGroups = async () => {
|
||||
try {
|
||||
const res = await userGroupAPI.getAll();
|
||||
setUserGroups(res);
|
||||
} catch (error) {
|
||||
console.error('获取用户组失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
fetchUserGroups();
|
||||
}, []);
|
||||
|
||||
const handleTableChange = (newPagination: any) => {
|
||||
@@ -113,6 +113,11 @@ const UserManagePage = () => {
|
||||
const handleCreate = () => {
|
||||
setEditingUser(null);
|
||||
form.resetFields();
|
||||
|
||||
// Set default groups (e.g. system group)
|
||||
const systemGroups = userGroups.filter(g => g.isSystem).map(g => g.id);
|
||||
form.setFieldsValue({ groupIds: systemGroups });
|
||||
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
@@ -122,6 +127,7 @@ const UserManagePage = () => {
|
||||
name: user.name,
|
||||
phone: user.phone,
|
||||
password: user.password,
|
||||
groupIds: user.groups?.map(g => g.id) || []
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
@@ -292,6 +298,18 @@ const UserManagePage = () => {
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
},
|
||||
{
|
||||
title: '用户组',
|
||||
dataIndex: 'groups',
|
||||
key: 'groups',
|
||||
render: (groups: any[]) => (
|
||||
<Space size={[0, 4]} wrap>
|
||||
{groups?.map(g => (
|
||||
<Tag key={g.id} color={g.isSystem ? 'blue' : 'default'}>{g.name}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '密码',
|
||||
dataIndex: 'password',
|
||||
@@ -367,10 +385,9 @@ const UserManagePage = () => {
|
||||
beforeUpload: handleImport,
|
||||
};
|
||||
|
||||
return (
|
||||
const UserListContent = () => (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-4">用户管理</h1>
|
||||
<div className="flex justify-between items-center">
|
||||
<Input
|
||||
placeholder="按姓名搜索"
|
||||
@@ -514,6 +531,22 @@ const UserManagePage = () => {
|
||||
>
|
||||
<Input.Password placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="groupIds"
|
||||
label="所属用户组"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="请选择用户组"
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{userGroups.map(g => (
|
||||
<Select.Option key={g.id} value={g.id} disabled={g.isSystem}>
|
||||
{g.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -614,6 +647,27 @@ const UserManagePage = () => {
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-4">用户管理</h1>
|
||||
<Tabs
|
||||
defaultActiveKey="1"
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: '用户列表',
|
||||
children: <UserListContent />,
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: '用户组管理',
|
||||
children: <UserGroupManage />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagePage;
|
||||
export default UserManagePage;
|
||||
|
||||
@@ -114,4 +114,13 @@ export const adminAPI = {
|
||||
api.put('/admin/password', data),
|
||||
};
|
||||
|
||||
// 用户组相关API
|
||||
export const userGroupAPI = {
|
||||
getAll: () => api.get('/admin/user-groups'),
|
||||
create: (data: { name: string; description?: string }) => api.post('/admin/user-groups', data),
|
||||
update: (id: string, data: { name?: string; description?: string }) => api.put(`/admin/user-groups/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/admin/user-groups/${id}`),
|
||||
getMembers: (id: string) => api.get(`/admin/user-groups/${id}/members`),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -8,22 +8,37 @@ export default {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#e6f7ff',
|
||||
100: '#bae7ff',
|
||||
200: '#91d5ff',
|
||||
300: '#69c0ff',
|
||||
400: '#40a9ff',
|
||||
500: '#1890ff',
|
||||
600: '#096dd9',
|
||||
700: '#0050b3',
|
||||
800: '#003a8c',
|
||||
900: '#002766',
|
||||
50: '#f0fcfc',
|
||||
100: '#ccf5f5',
|
||||
200: '#99ebeb',
|
||||
300: '#66e0e0',
|
||||
400: '#00A3A3', // Auxiliary Light
|
||||
500: '#008C8C', // Mars Green (Base)
|
||||
600: '#006666', // Auxiliary Dark
|
||||
700: '#004d4d',
|
||||
800: '#003333',
|
||||
900: '#001a1a',
|
||||
},
|
||||
mars: {
|
||||
50: '#f0fcfc',
|
||||
100: '#ccf5f5',
|
||||
200: '#99ebeb',
|
||||
300: '#66e0e0',
|
||||
400: '#00A3A3',
|
||||
500: '#008C8C',
|
||||
600: '#006666',
|
||||
700: '#004d4d',
|
||||
800: '#003333',
|
||||
900: '#001a1a',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'Noto Sans', 'sans-serif', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'],
|
||||
},
|
||||
spacing: {
|
||||
// Ensuring 8px grid alignment (Tailwind defaults are already 4px based, so p-2=8px, p-4=16px)
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user