From 48ee2628d44c7e6642e605b44f53508a095fc116 Mon Sep 17 00:00:00 2001 From: Boen_Shi Date: Mon, 29 Jun 2026 14:05:27 +0800 Subject: [PATCH] Normalize admin API docs and install defaults --- README.md | 8 +- app/Console/Commands/QuickQuizInstall.php | 4 +- .../Controllers/Api/Admin/ClassController.php | 6 +- .../Controllers/Api/Admin/LogController.php | 2 +- .../Controllers/Api/Admin/PaperController.php | 10 +- .../Api/Admin/PermissionController.php | 4 +- .../Api/Admin/QuestionBankController.php | 12 +- .../Api/Admin/QuestionController.php | 22 +- .../Api/Admin/ReportController.php | 12 +- .../Api/Admin/SettingController.php | 4 +- .../Api/Admin/TaxonomyController.php | 8 +- .../Controllers/Api/Admin/UserController.php | 18 +- .../Api/App/ClassroomController.php | 6 +- .../Controllers/Api/App/QuizController.php | 27 ++- app/Http/Controllers/Api/AuthController.php | 72 +------ .../Controllers/Api/InstallController.php | 82 -------- app/Models/InviteCode.php | 2 +- app/Services/QuestionImportService.php | 82 +++++++- bootstrap/app.php | 3 +- config/jwt.php | 2 +- ...24_000100_create_quickquiz_core_tables.php | 1 + ...0001_add_assigned_name_to_invite_codes.php | 30 +++ database/seeders/DatabaseSeeder.php | 4 +- frontend/index.html | 2 +- frontend/src/api/admin.ts | 6 +- frontend/src/api/auth.ts | 21 -- frontend/src/api/http.ts | 6 +- frontend/src/api/quiz.ts | 4 + frontend/src/layouts/AdminLayout.vue | 18 +- frontend/src/layouts/QuizLayout.vue | 30 ++- frontend/src/router/index.ts | 5 +- frontend/src/views/InstallView.vue | 67 ------ frontend/src/views/LoginView.vue | 12 +- frontend/src/views/RegisterView.vue | 9 +- frontend/src/views/admin/BanksView.vue | 192 +++++++++++++++++- frontend/src/views/admin/UsersView.vue | 79 ++++++- frontend/src/views/app/QuizView.vue | 24 +++ frontend/src/views/app/ResourcesView.vue | 5 +- frontend/vite.config.ts | 6 +- tests/Feature/AuthFlowTest.php | 27 --- 40 files changed, 564 insertions(+), 370 deletions(-) delete mode 100644 app/Http/Controllers/Api/InstallController.php create mode 100644 database/migrations/2026_06_26_000001_add_assigned_name_to_invite_codes.php delete mode 100644 frontend/src/views/InstallView.vue diff --git a/README.md b/README.md index 636fe25..32dabee 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ QuickQuiz 是一个前后端分离题库系统。后端为 Laravel + MySQL + JWT + hg/apidoc 注解路由,前端为 Vue 3 + Vite + TypeScript + Element Plus + UnoCSS + Pinia。 -## Requirements + ## Requirements - PHP 8.3+ - Composer 2+ @@ -39,14 +39,14 @@ DB_PASSWORD=root 创建数据库后执行: ```bash -php artisan quickquiz:install --admin-email=admin@quickquiz.local --admin-password=password --fresh +php artisan quickquiz:install --fresh php artisan serve ``` 默认管理员: -- 邮箱:`admin@quickquiz.local` -- 密码:`password` +- 邮箱:`admin@example.com` +- 密码:`123456` ## Frontend Setup diff --git a/app/Console/Commands/QuickQuizInstall.php b/app/Console/Commands/QuickQuizInstall.php index 344ce67..da934aa 100644 --- a/app/Console/Commands/QuickQuizInstall.php +++ b/app/Console/Commands/QuickQuizInstall.php @@ -13,8 +13,8 @@ use Illuminate\Support\Facades\Storage; final class QuickQuizInstall extends Command { protected $signature = 'quickquiz:install - {--admin-email=admin@quickquiz.local : 首个管理员邮箱} - {--admin-password=password : 首个管理员密码} + {--admin-email=admin@example.com : 首个管理员邮箱} + {--admin-password=123456 : 首个管理员密码} {--fresh : 使用 migrate:fresh 重建数据库}'; protected $description = 'Install QuickQuiz by running migrations, seeders, and creating the first administrator.'; diff --git a/app/Http/Controllers/Api/Admin/ClassController.php b/app/Http/Controllers/Api/Admin/ClassController.php index b5153e4..6a55159 100644 --- a/app/Http/Controllers/Api/Admin/ClassController.php +++ b/app/Http/Controllers/Api/Admin/ClassController.php @@ -18,7 +18,7 @@ use Illuminate\Support\Str; final class ClassController extends Controller { #[Apidoc\Title('班级列表')] - #[Apidoc\Url('/api/admin/classes')] + #[Apidoc\Url('/admin/classes')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:classes'])] public function index(Request $request): JsonResponse @@ -32,7 +32,7 @@ final class ClassController extends Controller } #[Apidoc\Title('创建班级')] - #[Apidoc\Url('/api/admin/classes')] + #[Apidoc\Url('/admin/classes')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:classes'])] public function store(Request $request): JsonResponse @@ -52,7 +52,7 @@ final class ClassController extends Controller } #[Apidoc\Title('分配成员')] - #[Apidoc\Url('/api/admin/classes/{class}/members')] + #[Apidoc\Url('/admin/classes/{class}/members')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:classes'])] public function addMember(Request $request, mixed $class): JsonResponse diff --git a/app/Http/Controllers/Api/Admin/LogController.php b/app/Http/Controllers/Api/Admin/LogController.php index 3a74f7b..5998a8e 100644 --- a/app/Http/Controllers/Api/Admin/LogController.php +++ b/app/Http/Controllers/Api/Admin/LogController.php @@ -17,7 +17,7 @@ use Illuminate\Http\Request; final class LogController extends Controller { #[Apidoc\Title('日志列表')] - #[Apidoc\Url('/api/admin/logs')] + #[Apidoc\Url('/admin/logs')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:logs'])] public function index(Request $request): JsonResponse diff --git a/app/Http/Controllers/Api/Admin/PaperController.php b/app/Http/Controllers/Api/Admin/PaperController.php index f271f67..60faea4 100644 --- a/app/Http/Controllers/Api/Admin/PaperController.php +++ b/app/Http/Controllers/Api/Admin/PaperController.php @@ -23,7 +23,7 @@ final class PaperController extends Controller use AuthorizesOwnedResources; #[Apidoc\Title('试卷列表')] - #[Apidoc\Url('/api/admin/papers')] + #[Apidoc\Url('/admin/papers')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:papers'])] public function index(Request $request): JsonResponse @@ -37,7 +37,7 @@ final class PaperController extends Controller } #[Apidoc\Title('试卷详情')] - #[Apidoc\Url('/api/admin/papers/{paper}')] + #[Apidoc\Url('/admin/papers/{paper}')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:papers'])] public function show(Request $request, mixed $paper): JsonResponse @@ -49,7 +49,7 @@ final class PaperController extends Controller } #[Apidoc\Title('创建固定试卷')] - #[Apidoc\Url('/api/admin/papers')] + #[Apidoc\Url('/admin/papers')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:papers'])] public function store(Request $request): JsonResponse @@ -87,7 +87,7 @@ final class PaperController extends Controller } #[Apidoc\Title('更新固定试卷')] - #[Apidoc\Url('/api/admin/papers/{paper}')] + #[Apidoc\Url('/admin/papers/{paper}')] #[Apidoc\Method('PUT')] #[Apidoc\RouteMiddleware(['permission:papers'])] public function update(Request $request, mixed $paper): JsonResponse @@ -118,7 +118,7 @@ final class PaperController extends Controller } #[Apidoc\Title('删除固定试卷')] - #[Apidoc\Url('/api/admin/papers/{paper}')] + #[Apidoc\Url('/admin/papers/{paper}')] #[Apidoc\Method('DELETE')] #[Apidoc\RouteMiddleware(['permission:papers'])] public function destroy(Request $request, mixed $paper): JsonResponse diff --git a/app/Http/Controllers/Api/Admin/PermissionController.php b/app/Http/Controllers/Api/Admin/PermissionController.php index b91f9c4..159cb0f 100644 --- a/app/Http/Controllers/Api/Admin/PermissionController.php +++ b/app/Http/Controllers/Api/Admin/PermissionController.php @@ -19,7 +19,7 @@ use Illuminate\Support\Facades\DB; final class PermissionController extends Controller { #[Apidoc\Title('权限菜单列表')] - #[Apidoc\Url('/api/admin/permissions')] + #[Apidoc\Url('/admin/permissions')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:permissions'])] public function index(): JsonResponse @@ -36,7 +36,7 @@ final class PermissionController extends Controller } #[Apidoc\Title('保存角色权限')] - #[Apidoc\Url('/api/admin/roles/{role}/permissions')] + #[Apidoc\Url('/admin/roles/{role}/permissions')] #[Apidoc\Method('PUT')] #[Apidoc\RouteMiddleware(['permission:permissions'])] public function syncRole(Request $request, string $role): JsonResponse diff --git a/app/Http/Controllers/Api/Admin/QuestionBankController.php b/app/Http/Controllers/Api/Admin/QuestionBankController.php index 5cdb37e..148b5db 100644 --- a/app/Http/Controllers/Api/Admin/QuestionBankController.php +++ b/app/Http/Controllers/Api/Admin/QuestionBankController.php @@ -24,7 +24,7 @@ final class QuestionBankController extends Controller use AuthorizesOwnedResources; #[Apidoc\Title('题库列表')] - #[Apidoc\Url('/api/admin/banks')] + #[Apidoc\Url('/admin/banks')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:banks'])] public function index(Request $request): JsonResponse @@ -38,7 +38,7 @@ final class QuestionBankController extends Controller } #[Apidoc\Title('创建题库')] - #[Apidoc\Url('/api/admin/banks')] + #[Apidoc\Url('/admin/banks')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:banks.create'])] public function store(Request $request): JsonResponse @@ -63,7 +63,7 @@ final class QuestionBankController extends Controller } #[Apidoc\Title('更新题库')] - #[Apidoc\Url('/api/admin/banks/{bank}')] + #[Apidoc\Url('/admin/banks/{bank}')] #[Apidoc\Method('PUT')] #[Apidoc\RouteMiddleware(['permission:banks.update'])] public function update(Request $request, mixed $bank): JsonResponse @@ -83,7 +83,7 @@ final class QuestionBankController extends Controller } #[Apidoc\Title('删除题库')] - #[Apidoc\Url('/api/admin/banks/{bank}')] + #[Apidoc\Url('/admin/banks/{bank}')] #[Apidoc\Method('DELETE')] #[Apidoc\RouteMiddleware(['permission:banks.delete'])] public function destroy(Request $request, mixed $bank): JsonResponse @@ -103,7 +103,7 @@ final class QuestionBankController extends Controller } #[Apidoc\Title('题库授权')] - #[Apidoc\Url('/api/admin/banks/{bank}/shares')] + #[Apidoc\Url('/admin/banks/{bank}/shares')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:banks.share'])] public function share(Request $request, mixed $bank): JsonResponse @@ -133,7 +133,7 @@ final class QuestionBankController extends Controller } #[Apidoc\Title('题库导出')] - #[Apidoc\Url('/api/admin/banks/{bank}/export')] + #[Apidoc\Url('/admin/banks/{bank}/export')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:questions.export'])] public function export(Request $request, mixed $bank): JsonResponse diff --git a/app/Http/Controllers/Api/Admin/QuestionController.php b/app/Http/Controllers/Api/Admin/QuestionController.php index 7949293..6413c91 100644 --- a/app/Http/Controllers/Api/Admin/QuestionController.php +++ b/app/Http/Controllers/Api/Admin/QuestionController.php @@ -24,7 +24,7 @@ final class QuestionController extends Controller use AuthorizesOwnedResources; #[Apidoc\Title('题目列表')] - #[Apidoc\Url('/api/admin/questions')] + #[Apidoc\Url('/admin/questions')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:questions'])] public function index(Request $request): JsonResponse @@ -50,7 +50,7 @@ final class QuestionController extends Controller } #[Apidoc\Title('创建题目')] - #[Apidoc\Url('/api/admin/questions')] + #[Apidoc\Url('/admin/questions')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:questions.import'])] public function store(Request $request, QuestionImportService $service): JsonResponse @@ -78,7 +78,7 @@ final class QuestionController extends Controller } #[Apidoc\Title('批量导入题目')] - #[Apidoc\Url('/api/admin/banks/{bank}/imports')] + #[Apidoc\Url('/admin/banks/{bank}/imports')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:questions.import'])] public function import(Request $request, mixed $bank, QuestionImportService $service): JsonResponse @@ -103,7 +103,7 @@ final class QuestionController extends Controller } #[Apidoc\Title('校验导入题目')] - #[Apidoc\Url('/api/admin/banks/{bank}/imports/validate')] + #[Apidoc\Url('/admin/banks/{bank}/imports/validate')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:questions.import'])] public function validateImport(Request $request, mixed $bank, QuestionImportService $service): JsonResponse @@ -117,14 +117,14 @@ final class QuestionController extends Controller $prepared = $service->prepareUploadedFile($request->file('file')); return ApiResponse::success([ - ...$service->validateRows($prepared['rows']), + ...$service->validateRows($prepared['rows'], $bank), 'type' => $prepared['type'], 'file_path' => $prepared['path'], ], '校验完成'); } #[Apidoc\Title('提交已校验题目')] - #[Apidoc\Url('/api/admin/banks/{bank}/imports/rows')] + #[Apidoc\Url('/admin/banks/{bank}/imports/rows')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:questions.import'])] public function importRows(Request $request, mixed $bank, QuestionImportService $service): JsonResponse @@ -137,7 +137,7 @@ final class QuestionController extends Controller 'file_path' => ['nullable', 'string'], ]); - $validation = $service->validateRows($data['rows']); + $validation = $service->validateRows($data['rows'], $bank); if (! $validation['valid']) { return ApiResponse::success($validation, '校验未通过'); } @@ -148,7 +148,7 @@ final class QuestionController extends Controller } #[Apidoc\Title('校验已编辑题目')] - #[Apidoc\Url('/api/admin/banks/{bank}/imports/rows/validate')] + #[Apidoc\Url('/admin/banks/{bank}/imports/rows/validate')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:questions.import'])] public function validateRows(Request $request, mixed $bank, QuestionImportService $service): JsonResponse @@ -159,7 +159,7 @@ final class QuestionController extends Controller 'rows' => ['required', 'array'], ]); - return ApiResponse::success($service->validateRows($data['rows']), '校验完成'); + return ApiResponse::success($service->validateRows($data['rows'], $bank), '校验完成'); } private function resolveBank(mixed $bank): QuestionBank @@ -172,7 +172,7 @@ final class QuestionController extends Controller } #[Apidoc\Title('更新题目状态')] - #[Apidoc\Url('/api/admin/questions/{question}')] + #[Apidoc\Url('/admin/questions/{question}')] #[Apidoc\Method('PUT')] #[Apidoc\RouteMiddleware(['permission:banks.update'])] public function update(Request $request, mixed $question): JsonResponse @@ -220,7 +220,7 @@ final class QuestionController extends Controller } #[Apidoc\Title('删除题目')] - #[Apidoc\Url('/api/admin/questions/{question}')] + #[Apidoc\Url('/admin/questions/{question}')] #[Apidoc\Method('DELETE')] #[Apidoc\RouteMiddleware(['permission:banks.update'])] public function destroy(Request $request, mixed $question): JsonResponse diff --git a/app/Http/Controllers/Api/Admin/ReportController.php b/app/Http/Controllers/Api/Admin/ReportController.php index 6f0d637..14c088d 100644 --- a/app/Http/Controllers/Api/Admin/ReportController.php +++ b/app/Http/Controllers/Api/Admin/ReportController.php @@ -23,7 +23,7 @@ use Illuminate\Support\Facades\Storage; final class ReportController extends Controller { #[Apidoc\Title('报表概览')] - #[Apidoc\Url('/api/admin/reports/overview')] + #[Apidoc\Url('/admin/reports/overview')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:reports'])] public function overview(): JsonResponse @@ -43,7 +43,7 @@ final class ReportController extends Controller } #[Apidoc\Title('练习趋势')] - #[Apidoc\Url('/api/admin/reports/trends')] + #[Apidoc\Url('/admin/reports/trends')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:reports'])] public function trends(): JsonResponse @@ -59,7 +59,7 @@ final class ReportController extends Controller } #[Apidoc\Title('题目错误率')] - #[Apidoc\Url('/api/admin/reports/question-errors')] + #[Apidoc\Url('/admin/reports/question-errors')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:reports'])] public function questionErrors(Request $request): JsonResponse @@ -75,7 +75,7 @@ final class ReportController extends Controller } #[Apidoc\Title('班级排行')] - #[Apidoc\Url('/api/admin/reports/class-ranking')] + #[Apidoc\Url('/admin/reports/class-ranking')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:reports'])] public function classRanking(Request $request): JsonResponse @@ -107,7 +107,7 @@ final class ReportController extends Controller } #[Apidoc\Title('题库和分类掌握度')] - #[Apidoc\Url('/api/admin/reports/mastery')] + #[Apidoc\Url('/admin/reports/mastery')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:reports'])] public function mastery(Request $request): JsonResponse @@ -143,7 +143,7 @@ final class ReportController extends Controller } #[Apidoc\Title('报表导出')] - #[Apidoc\Url('/api/admin/reports/export')] + #[Apidoc\Url('/admin/reports/export')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:reports'])] public function export(Request $request): JsonResponse diff --git a/app/Http/Controllers/Api/Admin/SettingController.php b/app/Http/Controllers/Api/Admin/SettingController.php index 08e84ea..04209eb 100644 --- a/app/Http/Controllers/Api/Admin/SettingController.php +++ b/app/Http/Controllers/Api/Admin/SettingController.php @@ -18,7 +18,7 @@ use Illuminate\Http\Request; final class SettingController extends Controller { #[Apidoc\Title('配置列表')] - #[Apidoc\Url('/api/admin/settings')] + #[Apidoc\Url('/admin/settings')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:settings'])] public function index(): JsonResponse @@ -27,7 +27,7 @@ final class SettingController extends Controller } #[Apidoc\Title('保存配置')] - #[Apidoc\Url('/api/admin/settings')] + #[Apidoc\Url('/admin/settings')] #[Apidoc\Method('PUT')] #[Apidoc\RouteMiddleware(['permission:settings'])] public function update(Request $request): JsonResponse diff --git a/app/Http/Controllers/Api/Admin/TaxonomyController.php b/app/Http/Controllers/Api/Admin/TaxonomyController.php index 12c0ce3..fa3e1d0 100644 --- a/app/Http/Controllers/Api/Admin/TaxonomyController.php +++ b/app/Http/Controllers/Api/Admin/TaxonomyController.php @@ -22,7 +22,7 @@ final class TaxonomyController extends Controller use AuthorizesOwnedResources; #[Apidoc\Title('分类列表')] - #[Apidoc\Url('/api/admin/banks/{bank}/categories')] + #[Apidoc\Url('/admin/banks/{bank}/categories')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:questions'])] public function categories(Request $request, mixed $bank): JsonResponse @@ -34,7 +34,7 @@ final class TaxonomyController extends Controller } #[Apidoc\Title('创建分类')] - #[Apidoc\Url('/api/admin/banks/{bank}/categories')] + #[Apidoc\Url('/admin/banks/{bank}/categories')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:banks.update'])] public function createCategory(Request $request, mixed $bank): JsonResponse @@ -54,7 +54,7 @@ final class TaxonomyController extends Controller } #[Apidoc\Title('标签列表')] - #[Apidoc\Url('/api/admin/banks/{bank}/tags')] + #[Apidoc\Url('/admin/banks/{bank}/tags')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:questions'])] public function tags(Request $request, mixed $bank): JsonResponse @@ -66,7 +66,7 @@ final class TaxonomyController extends Controller } #[Apidoc\Title('创建标签')] - #[Apidoc\Url('/api/admin/banks/{bank}/tags')] + #[Apidoc\Url('/admin/banks/{bank}/tags')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:banks.update'])] public function createTag(Request $request, mixed $bank): JsonResponse diff --git a/app/Http/Controllers/Api/Admin/UserController.php b/app/Http/Controllers/Api/Admin/UserController.php index 99c3a16..aa0fa8c 100644 --- a/app/Http/Controllers/Api/Admin/UserController.php +++ b/app/Http/Controllers/Api/Admin/UserController.php @@ -20,7 +20,7 @@ use Illuminate\Support\Str; final class UserController extends Controller { #[Apidoc\Title('用户列表')] - #[Apidoc\Url('/api/admin/users')] + #[Apidoc\Url('/admin/users')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:users'])] public function index(Request $request): JsonResponse @@ -37,7 +37,7 @@ final class UserController extends Controller } #[Apidoc\Title('创建用户')] - #[Apidoc\Url('/api/admin/users')] + #[Apidoc\Url('/admin/users')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:users.create'])] public function store(Request $request): JsonResponse @@ -55,7 +55,7 @@ final class UserController extends Controller } #[Apidoc\Title('更新用户')] - #[Apidoc\Url('/api/admin/users/{user}')] + #[Apidoc\Url('/admin/users/{user}')] #[Apidoc\Method('PUT')] #[Apidoc\RouteMiddleware(['permission:users.update'])] public function update(Request $request, mixed $user): JsonResponse @@ -89,7 +89,7 @@ final class UserController extends Controller } #[Apidoc\Title('邀请码列表')] - #[Apidoc\Url('/api/admin/invite-codes')] + #[Apidoc\Url('/admin/invite-codes')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['permission:users'])] public function invites(Request $request): JsonResponse @@ -98,16 +98,24 @@ final class UserController extends Controller } #[Apidoc\Title('创建邀请码')] - #[Apidoc\Url('/api/admin/invite-codes')] + #[Apidoc\Url('/admin/invite-codes')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['permission:users.create'])] public function createInvite(Request $request): JsonResponse { $data = $request->validate([ 'role' => ['required', 'in:teacher,user'], + 'assigned_name' => ['nullable', 'string', 'max:50'], 'max_uses' => ['required', 'integer', 'min:1', 'max:10000'], 'expires_at' => ['nullable', 'date'], ]); + $data['assigned_name'] = isset($data['assigned_name']) ? trim((string) $data['assigned_name']) : null; + if ($data['assigned_name'] === '') { + $data['assigned_name'] = null; + } + if ($data['assigned_name'] !== null) { + $data['max_uses'] = 1; + } $invite = InviteCode::create($data + [ 'created_by' => $request->user()->id, diff --git a/app/Http/Controllers/Api/App/ClassroomController.php b/app/Http/Controllers/Api/App/ClassroomController.php index 90b4cf0..c2540cb 100644 --- a/app/Http/Controllers/Api/App/ClassroomController.php +++ b/app/Http/Controllers/Api/App/ClassroomController.php @@ -19,7 +19,7 @@ use Illuminate\Http\Request; final class ClassroomController extends Controller { #[Apidoc\Title('我的班级')] - #[Apidoc\Url('/api/app/classes')] + #[Apidoc\Url('/app/classes')] #[Apidoc\Method('GET')] public function myClasses(Request $request): JsonResponse { @@ -32,7 +32,7 @@ final class ClassroomController extends Controller } #[Apidoc\Title('通过班级码加入')] - #[Apidoc\Url('/api/app/classes/join')] + #[Apidoc\Url('/app/classes/join')] #[Apidoc\Method('POST')] public function join(Request $request): JsonResponse { @@ -44,7 +44,7 @@ final class ClassroomController extends Controller } #[Apidoc\Title('可学习资源')] - #[Apidoc\Url('/api/app/resources')] + #[Apidoc\Url('/app/resources')] #[Apidoc\Method('GET')] public function resources(Request $request, LearningAccessService $access): JsonResponse { diff --git a/app/Http/Controllers/Api/App/QuizController.php b/app/Http/Controllers/Api/App/QuizController.php index 0ee9839..8a1f6b3 100644 --- a/app/Http/Controllers/Api/App/QuizController.php +++ b/app/Http/Controllers/Api/App/QuizController.php @@ -25,7 +25,7 @@ use Tymon\JWTAuth\Facades\JWTAuth; final class QuizController extends Controller { #[Apidoc\Title('开始背题/刷题/抽题')] - #[Apidoc\Url('/api/app/banks/{bank}/attempts')] + #[Apidoc\Url('/app/banks/{bank}/attempts')] #[Apidoc\Method('POST')] public function startBank(Request $request, mixed $bank, QuizService $service, LearningAccessService $access): JsonResponse { @@ -45,8 +45,19 @@ final class QuizController extends Controller return ApiResponse::success($service->startPractice($this->currentUser($request), $bank, $data['mode'], $data), '已开始'); } + #[Apidoc\Title('题库标签')] + #[Apidoc\Url('/app/banks/{bank}/tags')] + #[Apidoc\Method('GET')] + public function tags(Request $request, mixed $bank, LearningAccessService $access): JsonResponse + { + $bank = $this->resolveBank($bank); + abort_if(! $access->canAccessBank($this->currentUser($request), $bank), 403); + + return ApiResponse::success($bank->tags()->orderBy('name')->get()); + } + #[Apidoc\Title('开始整卷测试')] - #[Apidoc\Url('/api/app/papers/{paper}/attempts')] + #[Apidoc\Url('/app/papers/{paper}/attempts')] #[Apidoc\Method('POST')] public function startPaper(Request $request, mixed $paper, QuizService $service, LearningAccessService $access): JsonResponse { @@ -57,7 +68,7 @@ final class QuizController extends Controller } #[Apidoc\Title('继续作答')] - #[Apidoc\Url('/api/app/attempts/{attempt}')] + #[Apidoc\Url('/app/attempts/{attempt}')] #[Apidoc\Method('GET')] public function show(Request $request, mixed $attempt): JsonResponse { @@ -68,7 +79,7 @@ final class QuizController extends Controller } #[Apidoc\Title('提交单题答案')] - #[Apidoc\Url('/api/app/attempts/{attempt}/answer')] + #[Apidoc\Url('/app/attempts/{attempt}/answer')] #[Apidoc\Method('POST')] public function answer(Request $request, mixed $attempt, QuizService $service): JsonResponse { @@ -86,7 +97,7 @@ final class QuizController extends Controller } #[Apidoc\Title('保存作答位置')] - #[Apidoc\Url('/api/app/attempts/{attempt}/position')] + #[Apidoc\Url('/app/attempts/{attempt}/position')] #[Apidoc\Method('PUT')] public function updatePosition(Request $request, mixed $attempt): JsonResponse { @@ -107,7 +118,7 @@ final class QuizController extends Controller } #[Apidoc\Title('交卷')] - #[Apidoc\Url('/api/app/attempts/{attempt}/submit')] + #[Apidoc\Url('/app/attempts/{attempt}/submit')] #[Apidoc\Method('POST')] public function submit(Request $request, mixed $attempt, QuizService $service): JsonResponse { @@ -117,7 +128,7 @@ final class QuizController extends Controller } #[Apidoc\Title('错题列表')] - #[Apidoc\Url('/api/app/wrong-questions')] + #[Apidoc\Url('/app/wrong-questions')] #[Apidoc\Method('GET')] public function wrongQuestions(Request $request): JsonResponse { @@ -136,7 +147,7 @@ final class QuizController extends Controller } #[Apidoc\Title('收藏和笔记')] - #[Apidoc\Url('/api/app/favorites')] + #[Apidoc\Url('/app/favorites')] #[Apidoc\Method('POST')] public function favorite(Request $request): JsonResponse { diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php index 52bed0a..0641cab 100644 --- a/app/Http/Controllers/Api/AuthController.php +++ b/app/Http/Controllers/Api/AuthController.php @@ -13,10 +13,7 @@ use hg\apidoc\annotation as Apidoc; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\RateLimiter; -use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Tymon\JWTAuth\Facades\JWTAuth; @@ -25,7 +22,7 @@ use Tymon\JWTAuth\Facades\JWTAuth; final class AuthController extends Controller { #[Apidoc\Title('邀请码注册')] - #[Apidoc\Url('/api/auth/register')] + #[Apidoc\Url('/auth/register')] #[Apidoc\Method('POST')] public function register(Request $request): JsonResponse { @@ -40,6 +37,9 @@ final class AuthController extends Controller if (! $invite || ! $invite->available()) { throw ValidationException::withMessages(['invite_code' => '邀请码无效']); } + if ($invite->assigned_name !== null && trim($data['name']) !== $invite->assigned_name) { + throw ValidationException::withMessages(['name' => '姓名与邀请码指定使用人不一致']); + } $user = User::create([ 'name' => $data['name'], @@ -55,7 +55,7 @@ final class AuthController extends Controller } #[Apidoc\Title('验证码')] - #[Apidoc\Url('/api/auth/captcha')] + #[Apidoc\Url('/auth/captcha')] #[Apidoc\Method('GET')] public function captcha(Request $request): JsonResponse { @@ -69,7 +69,7 @@ final class AuthController extends Controller } #[Apidoc\Title('登录')] - #[Apidoc\Url('/api/auth/login')] + #[Apidoc\Url('/auth/login')] #[Apidoc\Method('POST')] public function login(Request $request): JsonResponse { @@ -120,7 +120,7 @@ final class AuthController extends Controller } #[Apidoc\Title('刷新Token')] - #[Apidoc\Url('/api/auth/refresh')] + #[Apidoc\Url('/auth/refresh')] #[Apidoc\Method('POST')] public function refresh(): JsonResponse { @@ -132,7 +132,7 @@ final class AuthController extends Controller } #[Apidoc\Title('当前用户')] - #[Apidoc\Url('/api/auth/me')] + #[Apidoc\Url('/auth/me')] #[Apidoc\Method('GET')] #[Apidoc\RouteMiddleware(['jwt.auth'])] public function me(Request $request): JsonResponse @@ -141,7 +141,7 @@ final class AuthController extends Controller } #[Apidoc\Title('退出登录')] - #[Apidoc\Url('/api/auth/logout')] + #[Apidoc\Url('/auth/logout')] #[Apidoc\Method('POST')] #[Apidoc\RouteMiddleware(['jwt.auth'])] public function logout(): JsonResponse @@ -151,60 +151,6 @@ final class AuthController extends Controller return ApiResponse::success(null, '已退出'); } - #[Apidoc\Title('发送找回密码邮件')] - #[Apidoc\Url('/api/auth/forgot-password')] - #[Apidoc\Method('POST')] - public function forgotPassword(Request $request): JsonResponse - { - $data = $request->validate(['email' => ['required', 'email']]); - $user = User::query()->where('email', $data['email'])->first(); - - if (! $user) { - return ApiResponse::success(null, '如果邮箱存在,系统会发送重置邮件'); - } - - $token = Password::broker()->createToken($user); - - if (config('mail.default') !== 'smtp' || ! config('mail.mailers.smtp.host')) { - OperationLog::create([ - 'user_id' => $user->id, - 'action' => 'auth.password_reset_token_created', - 'payload' => ['token' => $token], - ]); - - return ApiResponse::success(['token' => $token], '邮件未配置,已返回重置 token'); - } - - Mail::raw("QuickQuiz 密码重置 Token:{$token}", fn ($message) => $message->to($user->email)->subject('QuickQuiz 密码重置')); - - return ApiResponse::success(null, '重置邮件已发送'); - } - - #[Apidoc\Title('重置密码')] - #[Apidoc\Url('/api/auth/reset-password')] - #[Apidoc\Method('POST')] - public function resetPassword(Request $request): JsonResponse - { - $data = $request->validate([ - 'email' => ['required', 'email'], - 'token' => ['required', 'string'], - 'password' => ['required', 'string', 'min:6', 'confirmed'], - ]); - - $status = Password::broker()->reset($data, function (User $user, string $password): void { - $user->forceFill([ - 'password' => Hash::make($password), - 'remember_token' => Str::random(60), - ])->save(); - }); - - if ($status !== Password::PASSWORD_RESET) { - return ApiResponse::error('重置失败', 422, 422, ['status' => $status]); - } - - return ApiResponse::success(null, '密码已重置'); - } - private function tokenPayload(User $user): array { return [ diff --git a/app/Http/Controllers/Api/InstallController.php b/app/Http/Controllers/Api/InstallController.php deleted file mode 100644 index 4fd1c34..0000000 --- a/app/Http/Controllers/Api/InstallController.php +++ /dev/null @@ -1,82 +0,0 @@ - Storage::exists('installed.lock'), - 'database' => config('database.default'), - ]); - } - - #[Apidoc\Title('测试数据库连接')] - #[Apidoc\Url('/api/install/database-test')] - #[Apidoc\Method('POST')] - public function databaseTest(Request $request): JsonResponse - { - $data = $request->validate([ - 'host' => ['required', 'string'], - 'port' => ['required', 'integer'], - 'database' => ['required', 'string'], - 'username' => ['required', 'string'], - 'password' => ['nullable', 'string'], - ]); - - config()->set('database.connections.install_test', [ - 'driver' => 'mysql', - 'host' => $data['host'], - 'port' => $data['port'], - 'database' => $data['database'], - 'username' => $data['username'], - 'password' => $data['password'] ?? '', - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - ]); - - DB::connection('install_test')->select('select 1'); - - return ApiResponse::success(['ok' => true], '数据库连接成功'); - } - - #[Apidoc\Title('执行安装')] - #[Apidoc\Url('/api/install/run')] - #[Apidoc\Method('POST')] - public function run(Request $request): JsonResponse - { - $data = $request->validate([ - 'admin_email' => ['required', 'email'], - 'admin_password' => ['required', 'string', 'min:6'], - 'fresh' => ['boolean'], - ]); - - Artisan::call('quickquiz:install', [ - '--admin-email' => $data['admin_email'], - '--admin-password' => $data['admin_password'], - '--fresh' => (bool) ($data['fresh'] ?? false), - ]); - - return ApiResponse::success([ - 'output' => Artisan::output(), - 'installed' => true, - ], '安装完成'); - } -} diff --git a/app/Models/InviteCode.php b/app/Models/InviteCode.php index 463facd..42c6477 100644 --- a/app/Models/InviteCode.php +++ b/app/Models/InviteCode.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model; final class InviteCode extends Model { - protected $fillable = ['created_by', 'code', 'role', 'max_uses', 'used_count', 'expires_at', 'is_active']; + protected $fillable = ['created_by', 'code', 'role', 'assigned_name', 'max_uses', 'used_count', 'expires_at', 'is_active']; protected $casts = [ 'expires_at' => 'datetime', diff --git a/app/Services/QuestionImportService.php b/app/Services/QuestionImportService.php index bc6d259..4a0d3d7 100644 --- a/app/Services/QuestionImportService.php +++ b/app/Services/QuestionImportService.php @@ -68,14 +68,38 @@ final class QuestionImportService /** * @param array> $rows - * @return array{valid:bool,rows:array>,errors:array} + * @return array{valid:bool,rows:array>,errors:array,duplicates:array,importable_count:int} */ - public function validateRows(array $rows): array + public function validateRows(array $rows, ?QuestionBank $bank = null): array { $errors = []; + $duplicates = []; + $seenHashes = []; foreach ($rows as $index => $row) { try { - $this->normalizeQuestionRow($row, $index + 1); + $normalized = $this->normalizeQuestionRow($row, $index + 1); + $hash = $this->dedupHash($normalized['content'], $normalized['options']); + if (isset($seenHashes[$hash])) { + $duplicates[] = [ + 'row' => $index + 1, + 'message' => "与本次上传第 {$seenHashes[$hash]} 行重复,确认导入时会跳过", + 'content' => $normalized['content'], + 'type' => $normalized['type'], + ]; + + continue; + } + + $seenHashes[$hash] = $index + 1; + + if ($bank !== null && $this->isDuplicate($bank, $normalized)) { + $duplicates[] = [ + 'row' => $index + 1, + 'message' => '与题库已有题目重复,确认导入时会跳过', + 'content' => $normalized['content'], + 'type' => $normalized['type'], + ]; + } } catch (ValidationException $exception) { $errors[] = [ 'row' => $index + 1, @@ -88,6 +112,8 @@ final class QuestionImportService 'valid' => $errors === [], 'rows' => $rows, 'errors' => $errors, + 'duplicates' => $duplicates, + 'importable_count' => max(0, count($rows) - count($errors) - count($duplicates)), ]; } @@ -110,19 +136,36 @@ final class QuestionImportService $report = []; $success = 0; $skipped = 0; + $seenHashes = []; foreach ($rows as $index => $row) { $normalized = $this->normalizeQuestionRow($row, $index + 1); $hash = $this->dedupHash($normalized['content'], $normalized['options']); - $exists = Question::query() - ->where('question_bank_id', $bank->id) - ->where('dedup_hash', $hash) - ->exists(); - - if ($exists) { + if (isset($seenHashes[$hash])) { $skipped++; - $report[] = ['row' => $index + 1, 'status' => 'skipped', 'message' => '重复题目已跳过']; + $report[] = [ + 'row' => $index + 1, + 'status' => 'skipped', + 'message' => "与本次导入第 {$seenHashes[$hash]} 行重复,已跳过", + 'content' => $normalized['content'], + 'type' => $normalized['type'], + ]; + + continue; + } + + $seenHashes[$hash] = $index + 1; + + if ($this->isDuplicate($bank, $normalized)) { + $skipped++; + $report[] = [ + 'row' => $index + 1, + 'status' => 'skipped', + 'message' => '与题库已有题目重复,已跳过', + 'content' => $normalized['content'], + 'type' => $normalized['type'], + ]; continue; } @@ -149,7 +192,13 @@ final class QuestionImportService } $success++; - $report[] = ['row' => $index + 1, 'status' => 'success', 'message' => '导入成功']; + $report[] = [ + 'row' => $index + 1, + 'status' => 'success', + 'message' => '导入成功', + 'content' => $normalized['content'], + 'type' => $normalized['type'], + ]; } $job->update([ @@ -288,6 +337,17 @@ final class QuestionImportService ], JSON_UNESCAPED_UNICODE)); } + /** + * @param array{content:string,options:array} $normalized + */ + private function isDuplicate(QuestionBank $bank, array $normalized): bool + { + return Question::query() + ->where('question_bank_id', $bank->id) + ->where('dedup_hash', $this->dedupHash($normalized['content'], $normalized['options'])) + ->exists(); + } + /** * @param array> $rows * @return array> diff --git a/bootstrap/app.php b/bootstrap/app.php index 2f4bc4e..3eac6ee 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,6 +13,7 @@ return Application::configure(basePath: dirname(__DIR__)) api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', + apiPrefix: '', ) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ @@ -22,6 +23,6 @@ return Application::configure(basePath: dirname(__DIR__)) }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( - fn (Request $request) => $request->is('api/*'), + fn (Request $request) => $request->is('auth/*', 'admin/*', 'app/*', 'health'), ); })->create(); diff --git a/config/jwt.php b/config/jwt.php index aa62554..f910fb2 100644 --- a/config/jwt.php +++ b/config/jwt.php @@ -105,7 +105,7 @@ return [ | */ - 'ttl' => env('JWT_TTL', 60), + 'ttl' => env('JWT_TTL', 1440), /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2026_06_24_000100_create_quickquiz_core_tables.php b/database/migrations/2026_06_24_000100_create_quickquiz_core_tables.php index 1b9b861..6ad2365 100644 --- a/database/migrations/2026_06_24_000100_create_quickquiz_core_tables.php +++ b/database/migrations/2026_06_24_000100_create_quickquiz_core_tables.php @@ -130,6 +130,7 @@ return new class extends Migration $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->string('code')->unique(); $table->string('role')->default('user'); + $table->string('assigned_name', 50)->nullable(); $table->unsignedInteger('max_uses')->default(1); $table->unsignedInteger('used_count')->default(0); $table->timestamp('expires_at')->nullable(); diff --git a/database/migrations/2026_06_26_000001_add_assigned_name_to_invite_codes.php b/database/migrations/2026_06_26_000001_add_assigned_name_to_invite_codes.php new file mode 100644 index 0000000..59ca58b --- /dev/null +++ b/database/migrations/2026_06_26_000001_add_assigned_name_to_invite_codes.php @@ -0,0 +1,30 @@ +string('assigned_name', 50)->nullable()->after('role'); + }); + } + + public function down(): void + { + if (! Schema::hasColumn('invite_codes', 'assigned_name')) { + return; + } + + Schema::table('invite_codes', function (Blueprint $table): void { + $table->dropColumn('assigned_name'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 163d902..e4cc455 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -19,12 +19,12 @@ class DatabaseSeeder extends Seeder public function run(): void { $admin = User::query()->firstOrCreate( - ['email' => 'admin@quickquiz.local'], + ['email' => 'admin@example.com'], [ 'name' => '系统管理员', 'role' => 'admin', 'is_active' => true, - 'password' => Hash::make('password'), + 'password' => Hash::make('123456'), ], ); diff --git a/frontend/index.html b/frontend/index.html index 096d706..2490b7c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - frontend + QuickQuiz
diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index c829249..7649a71 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -50,6 +50,8 @@ export function validateQuestionImport(bankId: number, file: File) { valid: boolean rows: Array> errors: Array<{ row: number; message: string }> + duplicates: Array<{ row: number; message: string; content: string; type: string }> + importable_count: number type: string file_path: string }>(`/api/admin/banks/${bankId}/imports/validate`, form) @@ -70,6 +72,8 @@ export function validateQuestionRows(bankId: number, payload: { valid: boolean rows: Array> errors: Array<{ row: number; message: string }> + duplicates: Array<{ row: number; message: string; content: string; type: string }> + importable_count: number }>(`/api/admin/banks/${bankId}/imports/rows/validate`, payload) } @@ -162,7 +166,7 @@ export function fetchInvites(params?: Record) { return apiGet>>('/api/admin/invite-codes', params) } -export function createInvite(payload: { role: string; max_uses: number; expires_at?: string }) { +export function createInvite(payload: { role: string; assigned_name?: string; max_uses: number; expires_at?: string }) { return apiPost('/api/admin/invite-codes', payload) } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 8c883d1..a68e6d0 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -35,24 +35,3 @@ export function me() { export function captcha() { return apiGet<{ captcha: string; expires_in: number }>('/api/auth/captcha') } - -export function forgotPassword(email: string) { - return apiPost<{ token?: string }>('/api/auth/forgot-password', { email }) -} - -export function resetPassword(payload: { - email: string - token: string - password: string - password_confirmation: string -}) { - return apiPost('/api/auth/reset-password', payload) -} - -export function installStatus() { - return apiGet<{ installed: boolean; database: string }>('/api/install/status') -} - -export function runInstall(payload: { admin_email: string; admin_password: string; fresh?: boolean }) { - return apiPost('/api/install/run', payload) -} diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index e8ba328..894809c 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -19,8 +19,12 @@ http.interceptors.response.use( (response) => response.data, async (error) => { const auth = useAuthStore() - if (error.response?.status === 401 && auth.token) { + if (error.response?.status === 401) { auth.clearSession() + if (!window.location.hash.startsWith('#/login')) { + const redirect = window.location.hash.slice(1) || '/' + window.location.href = `/#/login?redirect=${encodeURIComponent(redirect)}` + } } return Promise.reject(error) }, diff --git a/frontend/src/api/quiz.ts b/frontend/src/api/quiz.ts index 113a0be..43788cf 100644 --- a/frontend/src/api/quiz.ts +++ b/frontend/src/api/quiz.ts @@ -5,6 +5,10 @@ export function fetchResources() { return apiGet('/api/app/resources') } +export function fetchBankTags(bankId: number) { + return apiGet>(`/api/app/banks/${bankId}/tags`) +} + export function startBankAttempt(bankId: number, payload: Record) { return apiPost(`/api/app/banks/${bankId}/attempts`, payload) } diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index b870692..e7f5781 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -1,7 +1,8 @@